feat(webui): add project workspaces and access controls (#4007)

* feat(webui): add project workspaces and access controls

* feat(webui): add project workspaces and access controls

* refactor(tools): centralize workspace access resolution

* refactor(webui): remove unused workspace host state

* fix(webui): hide estimated file edit label

* fix(webui): clarify file edit deletion feedback

* fix(webui): label deleted file activity

* fix(webui): flatten file edit activity rows

* fix(core): remove path-only patch deletion

* fix(core): keep apply patch non-destructive

* refactor(webui): trim workspace host plumbing

* fix(tools): register exec with tools config
This commit is contained in:
Xubin Ren
2026-05-29 03:42:53 +08:00
committed by GitHub
parent 84428136e6
commit 3a420136bb
111 changed files with 9972 additions and 1822 deletions
@@ -48,6 +48,7 @@ interface ActivityCounts {
hasDiffStats: boolean;
hasEditingFiles: boolean;
hasFailedFiles: boolean;
hasDeletedFiles: boolean;
primaryFilePath?: string;
primaryFileTooltipPath?: string;
primaryCliName?: string;
@@ -66,6 +67,7 @@ interface FileEditSummary {
approximate: boolean;
binary: boolean;
status: UIFileEdit["status"];
operation?: UIFileEdit["operation"];
pending: boolean;
error?: string;
}
@@ -126,6 +128,7 @@ function countActivity(
let hasDiffStats = false;
let hasEditingFiles = false;
let failedFileCount = 0;
let deletedFileCount = 0;
let primaryFilePath: string | undefined;
let primaryFileTooltipPath: string | undefined;
for (const edit of fileEdits) {
@@ -137,6 +140,9 @@ function countActivity(
if (edit.status === "error") {
failedFileCount += 1;
}
if (edit.operation === "delete") {
deletedFileCount += 1;
}
if (edit.status === "error" || edit.binary) {
continue;
}
@@ -158,6 +164,7 @@ function countActivity(
hasDiffStats,
hasEditingFiles,
hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length,
hasDeletedFiles: fileEdits.length > 0 && deletedFileCount === fileEdits.length,
primaryFilePath,
primaryFileTooltipPath,
primaryCliName,
@@ -217,6 +224,7 @@ export function AgentActivityCluster({
hasDiffStats,
hasEditingFiles,
hasFailedFiles,
hasDeletedFiles,
primaryFilePath,
primaryFileTooltipPath,
primaryCliName,
@@ -245,6 +253,7 @@ export function AgentActivityCluster({
const singleFilePath = fileCount === 1 ? primaryFilePath : undefined;
const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined;
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
const hasOnlyFileActivity = fileCount > 0 && messages.every(messageHasOnlyFileActivity);
const durationMs = activityDurationMs(messages, isTurnStreaming, now, turnLatencyMs);
const activityDuration = formatActivityDuration(durationMs);
const thoughtLabel = isTurnStreaming
@@ -263,13 +272,13 @@ export function AgentActivityCluster({
? hasPendingFileEdit && !singleFilePath
? t("message.fileActivityPreparing", { defaultValue: "Preparing edit…" })
: singleFilePath
? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles), {
file: shortFileName(singleFilePath),
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{file}}`,
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles)} {{file}}`,
})
: t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
: t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles), {
count: fileCount,
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{count}} files`,
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles)} {{count}} files`,
})
: "";
@@ -410,6 +419,25 @@ export function AgentActivityCluster({
if (!hasVisibleActivity) return null;
if (hasOnlyFileActivity) {
return (
<FileEditFlatActivity
edits={fileEdits}
active={isTurnStreaming}
hasBodyBelow={hasBodyBelow}
summary={summary}
singleFilePath={singleFilePath}
singleFileTooltipPath={singleFileTooltipPath}
hasLiveEditingFiles={hasLiveEditingFiles}
hasFailedFiles={hasFailedFiles}
hasDeletedFiles={hasDeletedFiles}
added={added}
deleted={deleted}
hasDiffStats={hasDiffStats}
/>
);
}
return (
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
<button
@@ -426,7 +454,7 @@ export function AgentActivityCluster({
active={isTurnStreaming}
className="min-w-0"
>
{singleFilePath ? fileActivityVerb(hasLiveEditingFiles, hasFailedFiles) : thoughtLabel}
{singleFilePath ? fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles) : thoughtLabel}
</StreamingLabelSheen>
{singleFilePath ? (
<FileReferenceChip
@@ -502,6 +530,77 @@ export function AgentActivityCluster({
);
}
function messageHasOnlyFileActivity(message: UIMessage): boolean {
if (message.kind !== "trace" || !message.fileEdits?.length) return false;
return traceLines(message).every((line) => !line.trim() || isFileEditTraceLine(line));
}
function FileEditFlatActivity({
edits,
active,
hasBodyBelow,
summary,
singleFilePath,
singleFileTooltipPath,
hasLiveEditingFiles,
hasFailedFiles,
hasDeletedFiles,
added,
deleted,
hasDiffStats,
}: {
edits: FileEditSummary[];
active: boolean;
hasBodyBelow: boolean;
summary: string;
singleFilePath?: string;
singleFileTooltipPath?: string;
hasLiveEditingFiles: boolean;
hasFailedFiles: boolean;
hasDeletedFiles: boolean;
added: number;
deleted: number;
hasDiffStats: boolean;
}) {
const showRows = edits.length > 1 || edits.some((edit) => edit.status === "error" || edit.pending);
return (
<div className={cn("w-full", hasBodyBelow && "mb-2")} aria-label={summary}>
<div
className={cn(
"flex max-w-full items-center gap-1.5 px-1 py-1",
"text-[12.5px] text-muted-foreground/72",
)}
>
<StreamingLabelSheen active={active} className="min-w-0">
{singleFilePath
? fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles)
: summary}
</StreamingLabelSheen>
{singleFilePath ? (
<FileReferenceChip
path={singleFilePath}
tooltipPath={singleFileTooltipPath}
active={hasLiveEditingFiles}
className="-my-0.5 min-w-0"
textClassName="text-xs"
testId="activity-header-file-reference"
/>
) : null}
{hasDiffStats ? (
<span className="inline-flex min-w-0 items-center gap-1 text-muted-foreground/85">
<DiffPair added={added} deleted={deleted} />
</span>
) : null}
</div>
{showRows ? (
<div className="mt-0.5 pl-4">
<FileEditGroup edits={edits} />
</div>
) : null}
</div>
);
}
function shortFileName(path: string): string {
return path.split(/[\\/]/).pop() || path;
}
@@ -1039,6 +1138,10 @@ function isMcpRunTraceLine(line: string): boolean {
return MCP_TOOL_NAME_RE.test(line.trim().split("(", 1)[0] ?? "");
}
function isFileEditTraceLine(line: string): boolean {
return /^(write_file|edit_file|apply_patch)\(/.test(line.trim());
}
function parseCliRunTrace(line: string, status: CliRunStatus = "running"): CliRunSummary | null {
const match = /^(run_cli_app|cli_anything_run)\((.*)\)$/.exec(line.trim());
if (!match) return null;
@@ -1365,18 +1468,21 @@ function mcpRunLabelDefault(run: McpRunSummary, active: boolean): string {
return active && run.status === "running" ? "Using" : "Used";
}
function fileActivityVerb(editing: boolean, failed: boolean): string {
function fileActivityVerb(editing: boolean, failed: boolean, deleted: boolean): string {
if (failed) return "Failed";
if (deleted) return editing ? "Deleting" : "Deleted";
return editing ? "Editing" : "Edited";
}
function fileActivitySummaryKey(editing: boolean, failed: boolean): string {
function fileActivitySummaryKey(editing: boolean, failed: boolean, deleted: boolean): string {
if (failed) return "message.fileActivityFailedOne";
if (deleted) return editing ? "message.fileActivityDeletingOne" : "message.fileActivityDeletedOne";
return editing ? "message.fileActivityEditingOne" : "message.fileActivityEditedOne";
}
function fileActivityManySummaryKey(editing: boolean, failed: boolean): string {
function fileActivityManySummaryKey(editing: boolean, failed: boolean, deleted: boolean): string {
if (failed) return "message.fileActivityFailedMany";
if (deleted) return editing ? "message.fileActivityDeletingMany" : "message.fileActivityDeletedMany";
return editing ? "message.fileActivityEditingMany" : "message.fileActivityEditedMany";
}
@@ -1419,6 +1525,7 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
hasSuccessfulChange: boolean;
hasActiveEditing: boolean;
hasFailed: boolean;
operation?: UIFileEdit["operation"];
error?: string;
}
@@ -1440,6 +1547,7 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
hasSuccessfulChange: false,
hasActiveEditing: false,
hasFailed: false,
operation: undefined,
};
byPath.set(key, summary);
order.push(key);
@@ -1451,6 +1559,9 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
if (edit.absolute_path) {
summary.absolute_path = edit.absolute_path;
}
if (edit.operation === "delete") {
summary.operation = "delete";
}
summary.pending = summary.pending || !!edit.pending || !edit.path;
if (!edit.path && edit.pending) {
if (active && edit.status === "editing") {
@@ -1515,6 +1626,7 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
approximate: summary.approximate,
binary: summary.binary,
status,
operation: summary.operation,
pending: summary.pending && !summary.path,
error: summary.error,
}];
@@ -1525,6 +1637,23 @@ function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">):
return edit.added > 0 || edit.deleted > 0;
}
function formatFileEditError(error?: string): string {
const firstLine = (error || "").replace(/\s+/g, " ").trim();
if (!firstLine) return "";
const cleaned = firstLine
.replace(/^Error applying patch:\s*/i, "")
.replace(/^Error writing file:\s*/i, "")
.replace(/^Error editing file:\s*/i, "")
.replace(/^Error:\s*/i, "");
return cleaned
.replace(/^old_text not found in (.+)$/i, "Target text was not found in $1.")
.replace(/^old_text appears multiple times in (.+)$/i, "Target text matched multiple places in $1.")
.replace(/^file to (?:update|delete) does not exist: (.+)$/i, "File does not exist: $1.")
.replace(/^path to (?:update|delete) is not a file: (.+)$/i, "Path is not a file: $1.")
.slice(0, 180);
}
function CliRunGroup({
runs,
active,
@@ -1758,8 +1887,15 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
const editing = edit.status === "editing";
const failed = edit.status === "error";
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
const failureDetail = failed
? formatFileEditError(edit.error)
|| t("message.fileEditFailedFallback", { defaultValue: "File change was not applied." })
: "";
return (
<li className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 py-0.5 text-xs">
<li
className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 py-0.5 text-xs"
title={failureDetail || edit.absolute_path || edit.path}
>
<div className="flex min-w-0 items-center gap-2">
<span className="grid h-5 w-5 shrink-0 place-items-center text-muted-foreground/50">
{failed ? (
@@ -1789,13 +1925,8 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
/>
)}
{failed ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[10.5px] font-medium text-destructive/75">
{t("message.fileEditFailed", { defaultValue: "Failed" })}
</span>
) : null}
{edit.approximate && !failed ? (
<span className="shrink-0 text-[10.5px] font-medium text-muted-foreground/55">
{t("message.fileEditApproximate", { defaultValue: "estimated" })}
<span className="min-w-0 truncate text-[11px] leading-4 text-destructive/75">
{failureDetail}
</span>
) : null}
</div>
@@ -62,10 +62,15 @@ function resolveCopy(
title: t("errors.messageTooBig.title"),
body: t("errors.messageTooBig.body"),
};
case "workspace_scope_rejected":
return {
title: t("errors.workspaceScopeRejected.title"),
body: t("errors.workspaceScopeRejected.body"),
};
default: {
// Exhaustiveness guard: if a new StreamError kind is added, TS will
// complain here until we add a corresponding i18n branch.
const _exhaustive: never = error.kind;
const _exhaustive: never = error;
return { title: String(_exhaustive), body: "" };
}
}
+178 -101
View File
@@ -43,6 +43,10 @@ import {
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
WorkspaceAccessMenu,
WorkspaceProjectPicker,
} from "@/components/thread/WorkspaceControls";
import {
useAttachedImages,
type AttachedImage,
@@ -58,6 +62,8 @@ import type {
OutboundCliAppMention,
OutboundMcpPresetMention,
SlashCommand,
WorkspaceScopePayload,
WorkspacesPayload,
} from "@/lib/types";
import {
inferProviderFromModelName,
@@ -88,6 +94,7 @@ interface ThreadComposerProps {
slashCommands?: SlashCommand[];
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
imageGenerationEnabled?: boolean;
imageMode?: boolean;
onImageModeChange?: (enabled: boolean) => void;
onStop?: () => void;
@@ -95,6 +102,12 @@ interface ThreadComposerProps {
runStartedAt?: number | null;
/** Sustained objective for this chat (WebSocket ``goal_state``). */
goalState?: GoalStateWsPayload;
workspaceScope?: WorkspaceScopePayload | null;
workspaceDefaultScope?: WorkspaceScopePayload | null;
workspaceControls?: WorkspacesPayload["controls"] | null;
workspaceScopeDisabled?: boolean;
workspaceError?: string | null;
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
}
const COMMAND_ICONS: Record<string, LucideIcon> = {
@@ -471,11 +484,18 @@ export function ThreadComposer({
slashCommands = [],
cliApps = [],
mcpPresets = [],
imageGenerationEnabled = true,
imageMode: controlledImageMode,
onImageModeChange,
onStop,
runStartedAt = null,
goalState,
workspaceScope = null,
workspaceDefaultScope = null,
workspaceControls = null,
workspaceScopeDisabled = false,
workspaceError = null,
onWorkspaceScopeChange,
}: ThreadComposerProps) {
const { t } = useTranslation();
const [value, setValue] = useState("");
@@ -495,7 +515,13 @@ export function ThreadComposer({
const aspectControlRef = useRef<HTMLDivElement>(null);
const chipRefs = useRef(new Map<string, HTMLButtonElement>());
const isHero = variant === "hero";
const imageMode = controlledImageMode ?? uncontrolledImageMode;
const showProjectPicker =
isHero
&& !!workspaceDefaultScope
&& !!onWorkspaceScopeChange
&& workspaceControls?.can_change_project !== false;
const requestedImageMode = controlledImageMode ?? uncontrolledImageMode;
const imageMode = imageGenerationEnabled && requestedImageMode;
const setImageMode = useCallback(
(enabled: boolean) => {
if (controlledImageMode === undefined) {
@@ -505,6 +531,13 @@ export function ThreadComposer({
},
[controlledImageMode, onImageModeChange],
);
useEffect(() => {
if (imageGenerationEnabled || !requestedImageMode) return;
setImageMode(false);
setAspectMenuOpen(false);
}, [imageGenerationEnabled, requestedImageMode, setImageMode]);
const resolvedPlaceholder = isStreaming
? t("thread.composer.placeholderStreaming")
: imageMode
@@ -574,16 +607,17 @@ export function ThreadComposer({
}, [disabled, slashMenuDismissed, value]);
const visibleSlashCommands = useMemo(() => {
if (!(isStreaming && onStop)) return slashCommands;
if (slashCommands.some((command) => command.command === "/stop")) return slashCommands;
const baseCommands = slashCommands.filter((command) => command.command !== "/stop");
if (!(isStreaming && onStop)) return baseCommands;
const stopCommand = slashCommands.find((command) => command.command === "/stop") ?? {
command: "/stop",
title: "Stop current task",
description: "Cancel the active agent turn for this chat.",
icon: "square",
};
return [
{
command: "/stop",
title: "Stop current task",
description: "Cancel the active agent turn for this chat.",
icon: "square",
},
...slashCommands,
stopCommand,
...baseCommands,
];
}, [isStreaming, onStop, slashCommands]);
@@ -845,13 +879,6 @@ export function ThreadComposer({
const chooseSlashCommand = useCallback(
(command: SlashCommand) => {
const nextRecents = [
command.command,
...recentSlashCommands.filter((item) => item !== command.command),
].slice(0, SLASH_RECENTS_LIMIT);
setRecentSlashCommands(nextRecents);
storeSlashRecents(nextRecents);
if (command.command === "/stop" && isStreaming && onStop) {
onStop();
setValue("");
@@ -862,6 +889,13 @@ export function ThreadComposer({
return;
}
const nextRecents = [
command.command,
...recentSlashCommands.filter((item) => item !== command.command),
].slice(0, SLASH_RECENTS_LIMIT);
setRecentSlashCommands(nextRecents);
storeSlashRecents(nextRecents);
setValue(command.argHint ? `${command.command} ` : command.command);
setSlashMenuDismissed(true);
setCliAppMenuDismissed(false);
@@ -1051,10 +1085,15 @@ export function ThreadComposer({
const attachButtonDisabled = disabled || full;
const showStopButton = isStreaming && !!onStop;
const centerHeroPlaceholder =
isHero && value.length === 0 && images.length === 0 && !isStreaming;
const inputTextClasses = cn(
"w-full resize-none bg-transparent",
isHero
? "min-h-[78px] px-5 pb-2 pt-5 text-[15px] leading-6"
? cn(
"min-h-[78px] px-5 text-[15px] leading-6",
centerHeroPlaceholder ? "pb-2 pt-[27px]" : "pb-1.5 pt-4",
)
: "min-h-[50px] px-4 pb-1.5 pt-3 text-[13.5px] leading-5",
);
@@ -1093,11 +1132,12 @@ export function ThreadComposer({
) : null}
<div
className={cn(
"relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
"group/composer relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
"after:pointer-events-none after:absolute after:inset-[-1px] after:rounded-[inherit] after:border after:border-blue-300/75 after:opacity-0 after:transition-opacity after:duration-200 focus-within:after:opacity-100 dark:after:border-blue-400/55",
isHero
? "max-w-[58rem] rounded-[28px] border border-black/[0.035] bg-card shadow-[0_20px_55px_rgba(15,23,42,0.08)] dark:border-white/[0.06] dark:shadow-[0_24px_55px_rgba(0,0,0,0.34)]"
: "max-w-[49.5rem] rounded-[22px] border border-black/[0.035] bg-card shadow-[0_12px_30px_rgba(15,23,42,0.07)] dark:border-white/[0.06] dark:shadow-[0_16px_34px_rgba(0,0,0,0.28)]",
"focus-within:ring-1 focus-within:ring-foreground/8",
"focus-within:border-blue-300/75 dark:focus-within:border-blue-400/55",
disabled && "opacity-60",
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
goalState?.active &&
@@ -1184,11 +1224,11 @@ export function ThreadComposer({
) : null}
<div
className={cn(
"flex items-center justify-between gap-2",
isHero ? "px-4 pb-4" : "px-3 pb-2",
"flex items-center justify-between",
isHero ? cn("gap-1.5 px-4", showProjectPicker ? "pb-1.5" : "pb-3.5") : "gap-2 px-3 pb-2",
)}
>
<div className="flex min-w-0 items-center gap-2">
<div className={cn("flex min-w-0 flex-1 items-center", isHero ? "gap-1.5" : "gap-2")}>
<input
ref={fileInputRef}
type="file"
@@ -1207,36 +1247,46 @@ export function ThreadComposer({
className={cn(
"rounded-full text-muted-foreground hover:text-foreground",
isHero
? "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card"
? "h-8 w-8 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card"
: "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
)}
>
<Plus className={cn(isHero ? "h-5 w-5" : "h-4 w-4")} />
<Plus className={cn(isHero ? "h-[18px] w-[18px]" : "h-4 w-4")} />
</Button>
<div ref={aspectControlRef} className="relative flex items-center gap-1">
<Button
type="button"
variant="ghost"
disabled={disabled}
aria-pressed={imageMode}
aria-label={t("thread.composer.imageMode.toggle")}
onClick={() => {
setImageMode(!imageMode);
setAspectMenuOpen(false);
textareaRef.current?.focus();
}}
className={cn(
"rounded-full border border-border/55 px-2.5 font-medium shadow-[0_2px_8px_rgba(15,23,42,0.04)]",
"h-9 text-[12px]",
imageMode
? "border-primary/30 bg-primary/10 text-primary hover:bg-primary/12"
: "bg-card text-muted-foreground hover:bg-card hover:text-foreground",
)}
>
<ImageIcon className={cn("mr-1.5", isHero ? "h-4 w-4" : "h-3.5 w-3.5")} />
{t("thread.composer.imageMode.label")}
</Button>
{imageMode ? (
{workspaceScope ? (
<WorkspaceAccessMenu
scope={workspaceScope}
disabled={disabled || workspaceScopeDisabled}
canUseFullAccess={workspaceControls?.can_use_full_access !== false}
isHero={isHero}
onChange={onWorkspaceScopeChange}
/>
) : null}
{imageGenerationEnabled ? (
<div ref={aspectControlRef} className="relative flex items-center gap-1">
<Button
type="button"
variant="ghost"
disabled={disabled}
aria-pressed={imageMode}
aria-label={t("thread.composer.imageMode.toggle")}
onClick={() => {
setImageMode(!imageMode);
setAspectMenuOpen(false);
textareaRef.current?.focus();
}}
className={cn(
"max-w-[11rem] rounded-full border border-border/55 px-2.5 font-medium shadow-[0_2px_8px_rgba(15,23,42,0.04)]",
isHero ? "h-8 text-[11.5px]" : "h-9 text-[12px]",
imageMode
? "border-primary/30 bg-primary/10 text-primary hover:bg-primary/12"
: "bg-card text-muted-foreground hover:bg-card hover:text-foreground",
)}
>
<ImageIcon className={cn("mr-1.5", isHero ? "h-3.5 w-3.5" : "h-3.5 w-3.5")} />
<span className="truncate">{t("thread.composer.imageMode.label")}</span>
</Button>
{imageMode ? (
<Button
type="button"
variant="ghost"
@@ -1247,25 +1297,28 @@ export function ThreadComposer({
onClick={() => setAspectMenuOpen((open) => !open)}
className={cn(
"rounded-full border border-border/55 bg-card px-2.5 font-medium text-foreground/80 shadow-[0_2px_8px_rgba(15,23,42,0.04)] hover:bg-card",
"h-9 text-[12px]",
isHero ? "h-8 text-[11.5px]" : "h-9 text-[12px]",
)}
>
<span>{t(`thread.composer.imageMode.aspect.${imageAspectRatio.replace(":", "_")}`)}</span>
<ChevronDown className={cn("ml-1.5", isHero ? "h-3.5 w-3.5" : "h-3 w-3")} />
</Button>
) : null}
{imageMode && aspectMenuOpen ? (
<ImageAspectMenu
selected={imageAspectRatio}
isHero={isHero}
onSelect={(ratio) => {
setImageAspectRatio(ratio);
setAspectMenuOpen(false);
textareaRef.current?.focus();
}}
/>
) : null}
</div>
) : null}
{imageMode && aspectMenuOpen ? (
<ImageAspectMenu
selected={imageAspectRatio}
isHero={isHero}
onSelect={(ratio) => {
setImageAspectRatio(ratio);
setAspectMenuOpen(false);
textareaRef.current?.focus();
}}
/>
) : null}
</div>
) : null}
</div>
<div className={cn("flex shrink-0 items-center", isHero ? "gap-1.5" : "gap-2")}>
{modelLabel ? (
<ComposerModelBadge
label={modelLabel}
@@ -1274,39 +1327,42 @@ export function ThreadComposer({
isHero={isHero}
/>
) : null}
{!isHero ? (
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
{t("thread.composer.sendHint")}
</span>
) : null}
<Button
type={showStopButton ? "button" : "submit"}
size="icon"
disabled={showStopButton ? disabled : !canSend}
aria-label={showStopButton ? t("thread.composer.stop") : t("thread.composer.send")}
onClick={showStopButton ? onStop : undefined}
className={cn(
"rounded-full transition-transform",
showStopButton
? "border border-border/70 bg-card text-foreground/85 shadow-[0_3px_10px_rgba(15,23,42,0.08)] hover:bg-muted/65 hover:text-foreground disabled:text-muted-foreground/50"
: isHero
? "border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80"
: "border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80",
isHero ? "h-8 w-8" : "h-9 w-9",
(canSend || showStopButton) && "hover:scale-[1.03] active:scale-95",
)}
>
{showStopButton ? (
<Square className={cn("fill-current stroke-current", isHero ? "h-3 w-3" : "h-3.5 w-3.5")} />
) : isStreaming ? (
<Loader2 className={cn(isHero ? "h-4 w-4" : "h-4 w-4", "animate-spin")} />
) : (
<ArrowUp className={cn(isHero ? "h-4 w-4" : "h-4 w-4")} />
)}
</Button>
</div>
<span className={cn(isHero ? "hidden" : "sm:hidden")} aria-hidden />
<Button
type={showStopButton ? "button" : "submit"}
size="icon"
disabled={showStopButton ? disabled : !canSend}
aria-label={showStopButton ? t("thread.composer.stop") : t("thread.composer.send")}
onClick={showStopButton ? onStop : undefined}
className={cn(
"rounded-full transition-transform",
showStopButton
? "border border-border/70 bg-card text-foreground/85 shadow-[0_3px_10px_rgba(15,23,42,0.08)] hover:bg-muted/65 hover:text-foreground disabled:text-muted-foreground/50"
: isHero
? "border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80"
: "border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80",
"h-9 w-9",
(canSend || showStopButton) && "hover:scale-[1.03] active:scale-95",
)}
>
{showStopButton ? (
<Square className={cn("fill-current stroke-current", isHero ? "h-3 w-3" : "h-2.5 w-2.5")} />
) : isStreaming ? (
<Loader2 className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4", "animate-spin")} />
) : (
<ArrowUp className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4")} />
)}
</Button>
</div>
<WorkspaceProjectPicker
isHero={isHero}
disabled={disabled || workspaceScopeDisabled}
scope={workspaceScope}
defaultScope={workspaceDefaultScope}
controls={workspaceControls}
error={workspaceError}
onChange={onWorkspaceScopeChange}
/>
</div>
</form>
);
@@ -1338,14 +1394,14 @@ function ComposerModelBadge({
className={cn(
"inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82",
"shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
isHero ? "h-9 max-w-[13.5rem] gap-2 px-2.5 text-[12px]" : "h-9 max-w-[12rem] gap-2 px-2.5 text-[12px]",
isHero ? "h-8 max-w-[12.5rem] gap-1.5 px-2 text-[11.5px]" : "h-9 max-w-[12rem] gap-2 px-2.5 text-[12px]",
)}
>
<span
data-testid={inferredProvider ? `composer-model-logo-${inferredProvider}` : "composer-model-logo"}
className={cn(
"grid shrink-0 place-items-center overflow-hidden rounded-full border bg-background",
"h-5 w-5",
isHero ? "h-[18px] w-[18px]" : "h-5 w-5",
)}
style={{
borderColor: brand ? `${brand.color}28` : undefined,
@@ -1357,21 +1413,21 @@ function ComposerModelBadge({
<img
src={logoUrl}
alt=""
className="h-3.5 w-3.5 object-contain"
className={cn("object-contain", isHero ? "h-3 w-3" : "h-3.5 w-3.5")}
onError={() => setLogoIndex((index) => index + 1)}
/>
) : brand ? (
<span
className={cn(
"grid h-full w-full place-items-center rounded-full text-white",
"text-[8px]",
isHero ? "text-[7.5px]" : "text-[8px]",
)}
style={{ backgroundColor: brand.color }}
>
{brand.initials.slice(0, 2)}
</span>
) : (
<Sparkles className={cn("text-muted-foreground/65", isHero ? "h-3.5 w-3.5" : "h-3 w-3")} />
<Sparkles className={cn("text-muted-foreground/65", isHero ? "h-3 w-3" : "h-3 w-3")} />
)}
</span>
<span className="truncate">{label}</span>
@@ -1440,6 +1496,23 @@ interface CliAppMentionPaletteProps {
onChoose: (candidate: MentionCandidate) => void;
}
function useSelectedOptionScroll(selectedIndex: number) {
const containerRef = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const option = container.querySelector<HTMLElement>(
`[data-palette-index="${selectedIndex}"]`,
);
if (typeof option?.scrollIntoView === "function") {
option.scrollIntoView({ block: "nearest" });
}
}, [selectedIndex]);
return containerRef;
}
function ImageAspectMenu({
selected,
isHero,
@@ -1506,6 +1579,7 @@ function CliAppMentionPalette({
0,
layout.maxHeight - SLASH_PALETTE_CHROME_PX,
);
const listRef = useSelectedOptionScroll(selectedIndex);
return (
<div
role="listbox"
@@ -1522,7 +1596,7 @@ function CliAppMentionPalette({
<div className="px-2 pb-1.5 pt-0.5 text-[13px] font-semibold text-muted-foreground/78">
{t("thread.composer.mentions.label")}
</div>
<div className="overflow-y-auto" style={{ maxHeight: listMaxHeight }}>
<div ref={listRef} className="overflow-y-auto" style={{ maxHeight: listMaxHeight }}>
{candidates.map((candidate, index) => {
const selected = index === selectedIndex;
const name = candidate.name;
@@ -1540,6 +1614,7 @@ function CliAppMentionPalette({
key={`${candidate.kind}-${name}`}
type="button"
role="option"
data-palette-index={index}
aria-selected={selected}
aria-label={`${displayName} @${name} ${ariaDescription} ${typeLabel}`}
onMouseEnter={() => onHover(index)}
@@ -1640,6 +1715,7 @@ function SlashCommandPalette({
0,
layout.maxHeight - SLASH_PALETTE_CHROME_PX,
);
const listRef = useSelectedOptionScroll(selectedIndex);
return (
<div
role="listbox"
@@ -1653,7 +1729,7 @@ function SlashCommandPalette({
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
)}
>
<div className="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
<div ref={listRef} className="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
{commands.map((command, index) => {
const Icon = COMMAND_ICONS[command.icon] ?? CircleHelp;
const selected = index === selectedIndex;
@@ -1669,6 +1745,7 @@ function SlashCommandPalette({
key={command.command}
type="button"
role="option"
data-palette-index={index}
aria-selected={selected}
onMouseEnter={() => onHover(index)}
onMouseDown={(e) => {
+4 -4
View File
@@ -9,7 +9,7 @@ interface ThreadHeaderProps {
onToggleSidebar: () => void;
theme: "light" | "dark";
onToggleTheme: () => void;
hideSidebarToggleOnDesktop?: boolean;
hideSidebarToggleForHostChrome?: boolean;
minimal?: boolean;
}
@@ -18,7 +18,7 @@ export function ThreadHeader({
onToggleSidebar,
theme,
onToggleTheme,
hideSidebarToggleOnDesktop = false,
hideSidebarToggleForHostChrome = false,
minimal = false,
}: ThreadHeaderProps) {
const { t } = useTranslation();
@@ -32,7 +32,7 @@ export function ThreadHeader({
onClick={onToggleSidebar}
className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
hideSidebarToggleOnDesktop && "lg:hidden",
hideSidebarToggleForHostChrome && "lg:hidden",
)}
>
<Menu className="h-3.5 w-3.5" />
@@ -57,7 +57,7 @@ export function ThreadHeader({
onClick={onToggleSidebar}
className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
hideSidebarToggleOnDesktop && "lg:hidden",
hideSidebarToggleForHostChrome && "lg:hidden",
)}
>
<Menu className="h-3.5 w-3.5" />
+58 -3
View File
@@ -59,7 +59,7 @@ export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
cluster.push(current);
i += 1;
}
out.push({ type: "cluster", messages: cluster });
pushActivityCluster(out, cluster);
continue;
}
const previous = out[out.length - 1];
@@ -85,6 +85,42 @@ export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
return out;
}
function pushActivityCluster(out: DisplayUnit[], cluster: UIMessage[]) {
const previous = out[out.length - 1];
if (
previous?.type !== "single"
|| !shouldPlaceLateActivityBeforeAssistant(out, previous.message)
) {
out.push({ type: "cluster", messages: cluster });
return;
}
const beforeAssistant = out[out.length - 2];
if (beforeAssistant?.type === "cluster" && canMergeActivityClusters(beforeAssistant.messages, cluster)) {
beforeAssistant.messages.push(...cluster);
return;
}
out.splice(out.length - 1, 0, { type: "cluster", messages: cluster });
}
function shouldPlaceLateActivityBeforeAssistant(out: DisplayUnit[], message: UIMessage): boolean {
if (message.role !== "assistant" || message.kind === "trace") return false;
if (message.isStreaming) return true;
if (hasTurnLatency(message)) return true;
const beforeAssistant = out[out.length - 2];
return beforeAssistant?.type === "cluster";
}
function hasTurnLatency(message: UIMessage): boolean {
return (
typeof message.latencyMs === "number"
&& Number.isFinite(message.latencyMs)
&& message.latencyMs >= 0
);
}
function clusterSegmentId(messages: UIMessage[]): string | undefined {
return messages.find((message) => message.activitySegmentId)?.activitySegmentId;
}
@@ -115,6 +151,19 @@ function canFoldInlineReasoning(cluster: UIMessage[], message: UIMessage): boole
return segmentId === message.activitySegmentId;
}
function canMergeActivityClusters(target: UIMessage[], incoming: UIMessage[]): boolean {
let segmentId = clusterSegmentId(target);
let includesFileEdits = clusterHasFileEdits(target);
for (const message of incoming) {
if (!canJoinActivityCluster(segmentId, includesFileEdits, message)) return false;
if (!segmentId && message.activitySegmentId) {
segmentId = message.activitySegmentId;
}
includesFileEdits = includesFileEdits || hasFileEdits(message);
}
return true;
}
function assistantHasInlineReasoning(message: UIMessage): boolean {
return (
message.role === "assistant"
@@ -261,8 +310,14 @@ function activityClusterTurnLatencyMs(
}
function currentActivityClusterIndex(units: DisplayUnit[]): number {
const last = units.length - 1;
return units[last]?.type === "cluster" ? last : -1;
for (let i = units.length - 1; i >= 0; i -= 1) {
const unit = units[i];
if (unit.type === "cluster") return i;
if (unit.message.role === "assistant" && unit.message.isStreaming) continue;
if (unit.message.role === "user") break;
return -1;
}
return -1;
}
function unitKey(unit: DisplayUnit, index: number): string {
+97 -92
View File
@@ -1,16 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
BarChart3,
BookOpen,
ChevronRight,
Code2,
ImageIcon,
LayoutGrid,
Lightbulb,
MoreHorizontal,
Palette,
Sparkles,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
@@ -31,7 +19,16 @@ import {
isMcpPresetsPayload,
} from "@/lib/mcp-preset-events";
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
import type { ChatSummary, CliAppInfo, McpPresetInfo, SettingsPayload, SlashCommand, UIMessage } from "@/lib/types";
import type {
ChatSummary,
CliAppInfo,
McpPresetInfo,
SettingsPayload,
SlashCommand,
UIMessage,
WorkspaceScopePayload,
WorkspacesPayload,
} from "@/lib/types";
import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
import { useClient } from "@/providers/ClientProvider";
@@ -60,11 +57,19 @@ interface ThreadShellProps {
onToggleSidebar: () => void;
onGoHome?: () => void;
onNewChat?: () => void;
onCreateChat?: () => Promise<string | null>;
onCreateChat?: (workspaceScope?: WorkspaceScopePayload | null) => Promise<string | null>;
onTurnEnd?: () => void;
theme?: "light" | "dark";
onToggleTheme?: () => void;
hideSidebarToggleOnDesktop?: boolean;
hideSidebarToggleForHostChrome?: boolean;
hideHeader?: boolean;
workspaceScope?: WorkspaceScopePayload | null;
workspaceDefaultScope?: WorkspaceScopePayload | null;
workspaceControls?: WorkspacesPayload["controls"] | null;
workspaceScopeDisabled?: boolean;
workspaceError?: string | null;
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
settingsSnapshot?: SettingsPayload | null;
}
function toModelBadgeLabel(modelName: string | null): string | null {
@@ -110,23 +115,17 @@ function toModelBadgeInfo(modelName: string | null, settings: SettingsPayload |
};
}
const QUICK_ACTION_KEYS = [
{ key: "plan", icon: LayoutGrid, tone: "text-[#f25b8f]" },
{ key: "analyze", icon: BarChart3, tone: "text-[#4f9de8]" },
{ key: "brainstorm", icon: Lightbulb, tone: "text-[#53c59d]" },
{ key: "code", icon: Code2, tone: "text-[#eba45d]" },
{ key: "summarize", icon: BookOpen, tone: "text-[#a877e7]" },
{ key: "more", icon: MoreHorizontal, tone: "text-muted-foreground/65" },
const HERO_GREETING_KEYS = [
"thread.empty.greetings.workOn",
"thread.empty.greetings.start",
"thread.empty.greetings.build",
"thread.empty.greetings.tackle",
] as const;
const IMAGE_QUICK_ACTION_KEYS = [
{ key: "icon", icon: ImageIcon, tone: "text-[#4f9de8]" },
{ key: "sticker", icon: Sparkles, tone: "text-[#f25b8f]" },
{ key: "poster", icon: Palette, tone: "text-[#eba45d]" },
{ key: "product", icon: LayoutGrid, tone: "text-[#53c59d]" },
{ key: "portrait", icon: ImageIcon, tone: "text-[#a877e7]" },
{ key: "edit", icon: MoreHorizontal, tone: "text-muted-foreground/65" },
] as const;
function randomHeroGreetingKey(): (typeof HERO_GREETING_KEYS)[number] {
const index = Math.floor(Math.random() * HERO_GREETING_KEYS.length);
return HERO_GREETING_KEYS[index] ?? HERO_GREETING_KEYS[0];
}
interface PendingFirstMessage {
content: string;
@@ -142,7 +141,15 @@ export function ThreadShell({
onTurnEnd,
theme = "light",
onToggleTheme = () => {},
hideSidebarToggleOnDesktop = false,
hideSidebarToggleForHostChrome = false,
hideHeader = false,
workspaceScope = null,
workspaceDefaultScope = null,
workspaceControls = null,
workspaceScopeDisabled = false,
workspaceError = null,
onWorkspaceScopeChange,
settingsSnapshot = null,
}: ThreadShellProps) {
const { t } = useTranslation();
const chatId = session?.chatId ?? null;
@@ -159,8 +166,9 @@ export function ThreadShell({
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
const [mcpPresets, setMcpPresets] = useState<McpPresetInfo[]>([]);
const [settings, setSettings] = useState<SettingsPayload | null>(null);
const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot);
const [heroImageMode, setHeroImageMode] = useState(false);
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
@@ -198,22 +206,46 @@ export function ThreadShell({
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
const showHeroComposer = messages.length === 0 && !loading;
const wasShowingHeroComposerRef = useRef(showHeroComposer);
const modelBadge = useMemo(
() => toModelBadgeInfo(modelName, settings),
[modelName, settings],
);
const imageGenerationEnabled = settings?.image_generation.enabled === true;
useEffect(() => {
if (showHeroComposer && !wasShowingHeroComposerRef.current) {
setHeroGreetingKey(randomHeroGreetingKey());
}
wasShowingHeroComposerRef.current = showHeroComposer;
}, [showHeroComposer]);
const withWorkspaceScope = useCallback(
(options?: SendOptions): SendOptions | undefined => {
if (!workspaceScope) return options;
return {
...(options ?? {}),
workspaceScope,
};
},
[workspaceScope],
);
const refreshModelSettings = useCallback(async () => {
try {
setSettings(await fetchSettings(token));
} catch {
setSettings(null);
if (!settingsSnapshot) setSettings(null);
}
}, [token]);
}, [settingsSnapshot, token]);
useEffect(() => {
if (settingsSnapshot) {
setSettings(settingsSnapshot);
return;
}
void refreshModelSettings();
}, [refreshModelSettings]);
}, [refreshModelSettings, settingsSnapshot]);
useEffect(() => {
return client.onRuntimeModelUpdate(() => {
@@ -433,64 +465,22 @@ export function ThreadShell({
async (content: string, images?: SendImage[], options?: SendOptions) => {
if (booting) return;
setBooting(true);
pendingFirstRef.current = { content, images, options };
const newId = await onCreateChat?.();
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
const newId = await onCreateChat?.(workspaceScope);
if (!newId) {
pendingFirstRef.current = null;
setBooting(false);
}
},
[booting, onCreateChat],
[booting, onCreateChat, withWorkspaceScope, workspaceScope],
);
const handleThreadSend = useCallback(
(content: string, images?: SendImage[], options?: SendOptions) => {
setScrollToBottomSignal((value) => value + 1);
send(content, images, options);
send(content, images, withWorkspaceScope(options));
},
[send],
);
const handleQuickAction = useCallback(
(prompt: string) => {
const options: SendOptions | undefined = heroImageMode
? { imageGeneration: { enabled: true, aspect_ratio: null } }
: undefined;
if (session) {
handleThreadSend(prompt, undefined, options);
return;
}
void handleWelcomeSend(prompt, undefined, options);
},
[handleThreadSend, handleWelcomeSend, heroImageMode, session],
);
const quickActionItems = heroImageMode ? IMAGE_QUICK_ACTION_KEYS : QUICK_ACTION_KEYS;
const quickActionPrefix = heroImageMode
? "thread.empty.imageQuickActions"
: "thread.empty.quickActions";
const quickActions = (
<div className="mx-auto grid w-full max-w-[58rem] grid-cols-2 gap-3 pt-4 sm:grid-cols-3 lg:grid-cols-6 lg:gap-4">
{quickActionItems.map(({ key, icon: Icon, tone }) => {
const title = t(`${quickActionPrefix}.${key}.title`);
const prompt = t(`${quickActionPrefix}.${key}.prompt`);
return (
<button
key={key}
type="button"
onClick={() => handleQuickAction(prompt)}
disabled={booting || isStreaming}
className="group flex min-h-[136px] flex-col justify-between rounded-[20px] border border-black/[0.035] bg-card px-5 py-5 text-left shadow-[0_14px_34px_rgba(15,23,42,0.07)] transition-all hover:-translate-y-0.5 hover:shadow-[0_18px_42px_rgba(15,23,42,0.10)] disabled:pointer-events-none disabled:opacity-60 dark:border-white/[0.06] dark:shadow-[0_16px_34px_rgba(0,0,0,0.28)]"
>
<Icon className={`h-[18px] w-[18px] ${tone}`} strokeWidth={2} />
<span className="max-w-[7.5rem] text-[15px] font-medium leading-[1.28] tracking-[-0.01em] text-foreground/82">
{title}
</span>
<ChevronRight className="h-4 w-4 self-end text-muted-foreground/45 transition-colors group-hover:text-muted-foreground" />
</button>
);
})}
</div>
[send, withWorkspaceScope],
);
const composer = (
@@ -518,11 +508,18 @@ export function ThreadShell({
slashCommands={slashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
imageGenerationEnabled={imageGenerationEnabled}
imageMode={showHeroComposer ? heroImageMode : undefined}
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
onStop={stop}
runStartedAt={runStartedAt}
goalState={goalState}
workspaceScope={workspaceScope}
workspaceDefaultScope={workspaceDefaultScope}
workspaceControls={workspaceControls}
workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange}
/>
) : (
<ThreadComposer
@@ -541,13 +538,19 @@ export function ThreadShell({
slashCommands={slashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
imageGenerationEnabled={imageGenerationEnabled}
imageMode={heroImageMode}
onImageModeChange={setHeroImageMode}
runStartedAt={runStartedAt}
goalState={goalState}
workspaceScope={workspaceScope}
workspaceDefaultScope={workspaceDefaultScope}
workspaceControls={workspaceControls}
workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange}
/>
)}
{showHeroComposer ? quickActions : null}
</>
);
@@ -558,21 +561,23 @@ export function ThreadShell({
) : (
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<h1 className="text-balance text-[40px] font-normal leading-tight tracking-[-0.045em] text-foreground sm:text-[48px]">
{t("thread.empty.greeting")}
{t(heroGreetingKey)}
</h1>
</div>
);
return (
<section className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<ThreadHeader
title={title}
onToggleSidebar={onToggleSidebar}
theme={theme}
onToggleTheme={onToggleTheme}
hideSidebarToggleOnDesktop={hideSidebarToggleOnDesktop}
minimal={!session && !loading}
/>
{!hideHeader ? (
<ThreadHeader
title={title}
onToggleSidebar={onToggleSidebar}
theme={theme}
onToggleTheme={onToggleTheme}
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
minimal={!session && !loading}
/>
) : null}
<ThreadViewport
messages={displayMessages}
isStreaming={isStreaming}
@@ -271,9 +271,11 @@ export function ThreadViewport({
</div>
) : (
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[72rem] flex-col px-4">
<div className="flex w-full flex-1 items-center justify-center pb-[7vh] pt-8">
<div className="flex w-full max-w-[58rem] flex-col gap-6">
{emptyState}
<div className="flex w-full flex-1 items-center justify-center py-10 sm:py-12">
<div className="relative w-full max-w-[58rem]">
<div className="absolute inset-x-0 bottom-[calc(100%+1.5rem)] flex justify-center">
{emptyState}
</div>
<div className="w-full">{composer}</div>
</div>
</div>
@@ -0,0 +1,326 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { AlertTriangle, Check, ChevronDown, Folder, Hand } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import type {
WorkspaceAccessMode,
WorkspaceScopePayload,
WorkspacesPayload,
} from "@/lib/types";
import { getHostApi } from "@/lib/runtime";
import { cn } from "@/lib/utils";
import {
isAbsoluteWorkspacePath,
projectNameFromPath,
scopeWithAccessMode,
selectedProjectScope,
shortWorkspacePath,
} from "@/lib/workspace";
export function WorkspaceProjectPicker({
isHero,
disabled,
scope,
defaultScope,
controls,
error,
onChange,
}: {
isHero: boolean;
disabled?: boolean;
scope: WorkspaceScopePayload | null;
defaultScope: WorkspaceScopePayload | null;
controls: WorkspacesPayload["controls"] | null;
error?: string | null;
onChange?: (scope: WorkspaceScopePayload) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [pathDraft, setPathDraft] = useState("");
const [pathError, setPathError] = useState<string | null>(null);
const [pickingFolder, setPickingFolder] = useState(false);
const currentProjectScope = selectedProjectScope(scope, defaultScope);
const projectLabel = currentProjectScope
? currentProjectScope.project_name || projectNameFromPath(currentProjectScope.project_path)
: t("thread.composer.workspace.projectPlaceholder");
const visible = isHero
&& !!defaultScope
&& !!onChange
&& controls?.can_change_project !== false;
const hostApi = getHostApi();
const nativeProjectPicker = !!hostApi;
useEffect(() => {
if (!open) return;
setPathDraft(currentProjectScope?.project_path ?? "");
setPathError(null);
}, [currentProjectScope?.project_path, open]);
useEffect(() => {
if (error && visible) setOpen(true);
}, [error, visible]);
const applyProjectPath = useCallback(
(projectPath: string, projectName?: string) => {
const base = scope ?? defaultScope;
const trimmed = projectPath.trim();
if (!base || !onChange) return;
if (!trimmed || !isAbsoluteWorkspacePath(trimmed)) {
setPathError(t("workspace.dialog.absolutePathRequired"));
return;
}
onChange({
...base,
project_path: trimmed,
project_name: projectName || projectNameFromPath(trimmed),
restrict_to_workspace: base.access_mode === "restricted",
});
setPathError(null);
setOpen(false);
},
[defaultScope, onChange, scope, t],
);
const pickNativeFolder = useCallback(async () => {
if (!hostApi || disabled) return;
setPickingFolder(true);
try {
const picked = await hostApi.pickFolder();
if (picked) applyProjectPath(picked);
} catch (err) {
setPathError((err as Error).message);
} finally {
setPickingFolder(false);
}
}, [applyProjectPath, disabled, hostApi]);
if (!visible || !defaultScope || !onChange) return null;
if (nativeProjectPicker) {
return (
<div className="flex items-center border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
<button
type="button"
disabled={disabled || pickingFolder}
aria-label={t("thread.composer.workspace.projectAria")}
title={currentProjectScope?.project_path}
onClick={() => void pickNativeFolder()}
className={cn(
"inline-flex h-7 max-w-[18rem] items-center gap-2 rounded-full px-2.5",
"text-[12px] font-medium text-muted-foreground/90 transition-colors",
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
currentProjectScope && "text-foreground/82",
)}
>
<Folder className={cn("h-3.5 w-3.5 shrink-0", currentProjectScope && "text-primary")} />
<span className="truncate">{projectLabel}</span>
</button>
{pathError || error ? (
<span role="alert" className="ml-2 truncate text-[11.5px] font-medium text-destructive">
{pathError ?? error}
</span>
) : null}
</div>
);
}
return (
<div className="flex items-center border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={disabled}
aria-label={t("thread.composer.workspace.projectAria")}
className={cn(
"inline-flex h-7 max-w-[18rem] items-center gap-2 rounded-full px-2.5",
"text-[12px] font-medium text-muted-foreground/90 transition-colors",
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
currentProjectScope && "text-foreground/82",
)}
>
<Folder className={cn("h-3.5 w-3.5 shrink-0", currentProjectScope && "text-primary")} />
<span className="truncate">{projectLabel}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="bottom"
sideOffset={8}
className="w-[min(25rem,calc(100vw-2rem))] rounded-[22px]"
>
<DropdownMenuItem
onSelect={() => applyProjectPath(defaultScope.project_path, defaultScope.project_name)}
className="flex min-h-[48px] cursor-default gap-3 rounded-[16px] px-3 py-2.5 focus:bg-muted/55"
>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-[12px] bg-muted text-foreground/80">
<Folder className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-semibold text-foreground">
{t("workspace.dialog.defaultProject")}
</span>
<span className="block truncate text-[11.5px] text-muted-foreground">
{shortWorkspacePath(defaultScope.project_path)}
</span>
</span>
{!currentProjectScope ? <Check className="h-4 w-4 text-foreground/80" /> : null}
</DropdownMenuItem>
<div className="my-1 h-px bg-border/45" />
<div
className="space-y-1.5 px-1.5 py-1.5"
onKeyDown={(event) => {
if (event.key !== "Escape") event.stopPropagation();
}}
>
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
applyProjectPath(pathDraft);
}}
>
<Input
value={pathDraft}
disabled={disabled}
onChange={(event) => {
setPathDraft(event.target.value);
setPathError(null);
}}
placeholder={t("workspace.dialog.manualPlaceholder")}
aria-label={t("workspace.dialog.manual")}
className={cn(
"h-9 rounded-full border-border/55 bg-background/80 px-3 text-[12.5px]",
"focus-visible:ring-1 focus-visible:ring-foreground/10 focus-visible:ring-offset-0",
)}
/>
<Button
type="submit"
disabled={disabled || !pathDraft.trim()}
className="h-9 shrink-0 rounded-full px-3 text-[12px]"
>
{t("workspace.dialog.usePath")}
</Button>
</form>
{pathError || error ? (
<p role="alert" className="px-1 text-[11.5px] font-medium text-destructive">
{pathError ?? error}
</p>
) : null}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export function WorkspaceAccessMenu({
scope,
disabled,
canUseFullAccess,
isHero,
onChange,
}: {
scope: WorkspaceScopePayload;
disabled?: boolean;
canUseFullAccess: boolean;
isHero: boolean;
onChange?: (scope: WorkspaceScopePayload) => void;
}) {
const { t } = useTranslation();
const mode = scope.access_mode;
const isFull = mode === "full";
const setMode = (value: WorkspaceAccessMode) => {
if (value === "full" && !canUseFullAccess) return;
if (value === mode) return;
onChange?.(scopeWithAccessMode(scope, value));
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={disabled || !onChange}>
<Button
type="button"
variant="ghost"
aria-label={t("thread.composer.workspace.accessAria")}
className={cn(
"max-w-[12.5rem] rounded-[10px] border border-transparent font-semibold shadow-none",
isHero ? "h-8 px-2.5 text-[12px]" : "h-9 px-3 text-[12.5px]",
isFull
? "bg-transparent text-orange-600 hover:bg-orange-500/8 dark:text-orange-300 dark:hover:bg-orange-400/10"
: "bg-transparent text-muted-foreground hover:bg-foreground/[0.045] hover:text-foreground dark:hover:bg-white/[0.06]",
)}
>
{isFull ? (
<AlertTriangle className={cn("mr-1.5 shrink-0", isHero ? "h-3.5 w-3.5" : "h-3.5 w-3.5")} />
) : (
<Hand className={cn("mr-1.5 shrink-0", isHero ? "h-3.5 w-3.5" : "h-3.5 w-3.5")} />
)}
<span className="truncate">
{t(isFull ? "thread.composer.workspace.full" : "thread.composer.workspace.default")}
</span>
<ChevronDown className={cn("ml-1.5 shrink-0", isHero ? "h-3 w-3" : "h-3 w-3")} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<AccessMenuItem
icon={<Hand className="h-4 w-4" />}
label={t("thread.composer.workspace.default")}
selected={mode === "restricted"}
onSelect={() => setMode("restricted")}
/>
<AccessMenuItem
icon={<AlertTriangle className="h-4 w-4" />}
label={t("thread.composer.workspace.full")}
selected={mode === "full"}
disabled={!canUseFullAccess}
warning
onSelect={() => setMode("full")}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}
function AccessMenuItem({
icon,
label,
selected,
disabled,
warning,
onSelect,
}: {
icon: ReactNode;
label: string;
selected: boolean;
disabled?: boolean;
warning?: boolean;
onSelect: () => void;
}) {
return (
<DropdownMenuItem
disabled={disabled}
onSelect={onSelect}
className={cn(
"flex h-10 items-center gap-3 rounded-xl px-3 text-[13.5px] font-semibold",
warning && "text-orange-600 focus:text-orange-600 dark:text-orange-300 dark:focus:text-orange-300",
)}
>
<span className="grid h-5 w-5 shrink-0 place-items-center text-current" aria-hidden>
{icon}
</span>
<span className="min-w-0 flex-1 truncate">{label}</span>
{selected ? <Check className="h-4 w-4 shrink-0" aria-hidden /> : null}
</DropdownMenuItem>
);
}