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:
@@ -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: "" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user