fix(webui): improve UX recovery and empty states (#5315)

This commit is contained in:
chengyongru
2026-08-10 15:22:25 +08:00
committed by GitHub
parent 43511decc9
commit 71a99b0780
18 changed files with 755 additions and 231 deletions
+154 -94
View File
@@ -2328,6 +2328,7 @@ export function SettingsView({
onAction={handleAutomationAction}
onRequestEdit={setAutomationPendingEdit}
onRequestDelete={setAutomationPendingDelete}
onBackToChat={onBackToChat}
/>
);
case "skills":
@@ -2448,7 +2449,7 @@ export function SettingsView({
onSave={handleAutomationEdit}
/>
<main
<div
className={cn(
"min-w-0 flex-1 bg-settings-canvas [scrollbar-gutter:stable]",
activeSection === "channels" ? "overflow-y-auto xl:overflow-hidden" : "overflow-y-auto",
@@ -2512,7 +2513,7 @@ export function SettingsView({
</div>
) : null}
</div>
</main>
</div>
</div>
);
}
@@ -2578,9 +2579,9 @@ function SettingsSidebar({
{t("settings.backToChat")}
</button>
<div className="mb-3 px-1 lg:mb-4 lg:px-2">
<h2 className="text-[18px] font-normal tracking-normal text-foreground">
<h1 className="text-[18px] font-normal tracking-normal text-foreground">
{t("settings.sidebar.title")}
</h2>
</h1>
</div>
<nav
@@ -3042,7 +3043,13 @@ function AppearanceSettings({
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.brandLogos", "Brand logos")}>
<SettingsRow
title={tx("settings.rows.brandLogos", "Brand logos")}
description={tx(
"settings.legal.thirdPartyBrands",
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.",
)}
>
<ToggleButton
checked={localPrefs.brandLogos}
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
@@ -5488,6 +5495,7 @@ function AutomationsSettings({
onAction,
onRequestEdit,
onRequestDelete,
onBackToChat,
}: {
payload: AutomationsPayload | null;
loading: boolean;
@@ -5502,6 +5510,7 @@ function AutomationsSettings({
onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise<void>;
onRequestEdit: (job: SessionAutomationJob) => void;
onRequestDelete: (job: SessionAutomationJob) => void;
onBackToChat: () => void;
}) {
const { t, i18n } = useTranslation();
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
@@ -5549,74 +5558,76 @@ function AutomationsSettings({
return (
<div className="space-y-5">
<section className="shrink-0">
<div className="mx-auto flex w-full max-w-[56rem] flex-col gap-3">
<div className="-mx-1 overflow-x-auto px-1 pb-0.5">
<div className="grid w-full min-w-[36rem] grid-cols-5 gap-1 rounded-[15px] bg-muted p-1">
{summaryOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onFilterChange(option.value)}
className={cn(
"inline-flex h-8 min-w-0 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-[11px] px-3 text-[12px] font-medium text-muted-foreground transition-colors",
filter === option.value && "bg-background text-foreground",
automationFilterToneClass(option.value, option.count, filter === option.value),
)}
>
<span>{option.label}</span>
<span
{jobs.length ? (
<section className="shrink-0">
<div className="mx-auto flex w-full max-w-[56rem] flex-col gap-3">
<div className="-mx-1 overflow-x-auto px-1 pb-0.5">
<div className="grid w-full min-w-[36rem] grid-cols-5 gap-1 rounded-[15px] bg-muted p-1">
{summaryOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onFilterChange(option.value)}
className={cn(
"min-w-5 shrink-0 rounded-full bg-background/75 px-1.5 py-0.5 text-center text-[11px] tabular-nums text-muted-foreground",
automationFilterCountClass(option.value, option.count),
"inline-flex h-8 min-w-0 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-[11px] px-3 text-[12px] font-medium text-muted-foreground transition-colors",
filter === option.value && "bg-background text-foreground",
automationFilterToneClass(option.value, option.count, filter === option.value),
)}
>
{option.count}
</span>
</button>
))}
</div>
</div>
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<div className="relative min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/70" />
<Input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx(
"settings.automations.search",
"Search task, message, linked chat, or schedule",
)}
className={cn(
"h-9 w-full rounded-[13px] pl-9 text-[13px]",
SETTINGS_SEARCH_INPUT_CLASS,
)}
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex h-9 min-w-[8.5rem] items-center justify-center gap-1.5 whitespace-nowrap rounded-[13px] border border-border/45 bg-settings-surface px-3 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:w-auto"
>
<ArrowUpDown className="h-3.5 w-3.5" aria-hidden />
<span>{sortLabel[sort]}</span>
<ChevronDown className="h-3.5 w-3.5" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
{(Object.keys(sortLabel) as AutomationSort[]).map((value) => (
<DropdownMenuItem key={value} onClick={() => onSortChange(value)}>
<span>{sortLabel[value]}</span>
{sort === value ? <Check className="ml-auto h-3.5 w-3.5" aria-hidden /> : null}
</DropdownMenuItem>
<span>{option.label}</span>
<span
className={cn(
"min-w-5 shrink-0 rounded-full bg-background/75 px-1.5 py-0.5 text-center text-[11px] tabular-nums text-muted-foreground",
automationFilterCountClass(option.value, option.count),
)}
>
{option.count}
</span>
</button>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<div className="relative min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/70" />
<Input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx(
"settings.automations.search",
"Search task, message, linked chat, or schedule",
)}
className={cn(
"h-9 w-full rounded-[13px] pl-9 text-[13px]",
SETTINGS_SEARCH_INPUT_CLASS,
)}
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex h-9 min-w-[8.5rem] items-center justify-center gap-1.5 whitespace-nowrap rounded-[13px] border border-border/45 bg-settings-surface px-3 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:w-auto"
>
<ArrowUpDown className="h-3.5 w-3.5" aria-hidden />
<span>{sortLabel[sort]}</span>
<ChevronDown className="h-3.5 w-3.5" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
{(Object.keys(sortLabel) as AutomationSort[]).map((value) => (
<DropdownMenuItem key={value} onClick={() => onSortChange(value)}>
<span>{sortLabel[value]}</span>
{sort === value ? <Check className="ml-auto h-3.5 w-3.5" aria-hidden /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</section>
</section>
) : null}
{error ? (
<div className="flex items-center gap-2 rounded-[18px] border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
@@ -5674,13 +5685,35 @@ function AutomationsSettings({
: tx("settings.automations.empty", "No automations yet.")}
</div>
{!jobs.length ? (
<div className="mx-auto mt-2 max-w-[28rem] text-[12px] leading-5">
{tx(
"settings.automations.emptyHint",
"Create one from where it should run so nanobot keeps the right context.",
)}
</div>
) : null}
<>
<div className="mx-auto mt-2 max-w-[28rem] text-[12px] leading-5">
{tx(
"settings.automations.emptyHint",
"Create automations in a chat so they keep the right context.",
)}
</div>
<Button
type="button"
variant="outline"
className="mt-4 rounded-full"
onClick={onBackToChat}
>
{tx("settings.automations.emptyAction", "Open a chat")}
</Button>
</>
) : (
<Button
type="button"
variant="outline"
className="mt-4 rounded-full"
onClick={() => {
onQueryChange("");
onFilterChange("all");
}}
>
{tx("settings.automations.clearFilters", "Clear filters")}
</Button>
)}
</div>
)}
</div>
@@ -7290,10 +7323,6 @@ function ChannelsSettings({
</div>
)}
</section>
<div className={cn("shrink-0 pt-2", showingCompactDetail && "hidden")}>
<ThirdPartyBrandNotice />
</div>
</div>
);
}
@@ -7396,6 +7425,23 @@ function AppsCatalogSettings({
(cliAppsLoading || mcpPresetsLoading) &&
!cliApps &&
!mcpPresets;
const cliAppCount = cliApps?.apps.length ?? 0;
const emptyTitle = normalizedQuery
? tx("settings.apps.empty", "No tools match your search.")
: filter === "cli"
? tx("settings.apps.emptyApps", "No apps available.")
: filter === "mcp"
? tx("settings.apps.emptyIntegrations", "No integrations available.")
: tx("settings.apps.emptyReady", "No tools are ready yet.");
const emptyBrowseTarget: AppsKindFilter | null = normalizedQuery
? null
: filter === "cli"
? "mcp"
: filter === "mcp"
? (cliAppCount ? "cli" : null)
: cliAppCount
? "cli"
: "mcp";
const statusMessage =
cliError ||
mcpError ||
@@ -7484,7 +7530,35 @@ function AppsCatalogSettings({
</div>
) : (
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
{tx("settings.apps.empty", "No tools match this view.")}
<p>{emptyTitle}</p>
{normalizedQuery ? (
<Button
type="button"
variant="outline"
className="mt-4 rounded-full"
onClick={() => onQueryChange("")}
>
{tx("settings.apps.clearSearch", "Clear search")}
</Button>
) : emptyBrowseTarget ? (
<Button
type="button"
variant="outline"
className="mt-4 rounded-full"
onClick={() => onFilterChange(emptyBrowseTarget)}
>
{emptyBrowseTarget === "cli"
? tx("settings.apps.browseApps", "Browse apps")
: tx("settings.apps.browseIntegrations", "Browse integrations")}
</Button>
) : (
<p className="mx-auto mt-2 max-w-[28rem] text-[12px] leading-5">
{tx(
"settings.apps.emptyIntegrationsHint",
"Add a custom integration below.",
)}
</p>
)}
</div>
)}
</section>
@@ -7500,8 +7574,6 @@ function AppsCatalogSettings({
onImportConfig={onImportMcpConfig}
/>
) : null}
<ThirdPartyBrandNotice />
</div>
);
}
@@ -9326,18 +9398,6 @@ function ProviderPickerIcon({
);
}
function ThirdPartyBrandNotice() {
const { t } = useTranslation();
return (
<p className="px-1 text-[11.5px] leading-5 text-muted-foreground/75">
{t("settings.legal.thirdPartyBrands", {
defaultValue:
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.",
})}
</p>
);
}
function orderUnconfiguredProviders(
providers: SettingsPayload["providers"],
): SettingsPayload["providers"] {
+66 -35
View File
@@ -179,7 +179,11 @@ function getVoiceShortcutLabel(): string {
}
interface ThreadComposerProps {
onSend: (content: string, images?: SendAttachment[], options?: SendOptions) => void;
onSend: (
content: string,
images?: SendAttachment[],
options?: SendOptions,
) => boolean | void | Promise<boolean | void>;
disabled?: boolean;
placeholder?: string;
isStreaming?: boolean;
@@ -981,6 +985,8 @@ export function ThreadComposer({
end: number;
} | null>(null);
const [inlineError, setInlineError] = useState<string | null>(null);
const [sendPending, setSendPending] = useState(false);
const interactionDisabled = !!disabled || sendPending;
const [voiceErrorFading, setVoiceErrorFading] = useState(false);
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
@@ -1071,7 +1077,7 @@ export function ThreadComposer({
const addFiles = useCallback(
(files: File[]) => {
if (files.length === 0) return;
if (interactionDisabled || files.length === 0) return;
secondEnterPromptIdRef.current = null;
const { rejected } = enqueue(files);
if (rejected.length > 0) {
@@ -1080,7 +1086,7 @@ export function ThreadComposer({
setInlineError(null);
}
},
[enqueue, formatRejection],
[enqueue, formatRejection, interactionDisabled],
);
const {
@@ -1093,18 +1099,20 @@ export function ThreadComposer({
} = useClipboardAndDrop(addFiles);
useEffect(() => {
if (disabled || hasTouchPrimaryPointer) return;
if (interactionDisabled || hasTouchPrimaryPointer || (workspaceError && showProjectPicker)) {
return;
}
const el = textareaRef.current;
if (!el) return;
const id = requestAnimationFrame(() => el.focus());
return () => cancelAnimationFrame(id);
}, [disabled, hasTouchPrimaryPointer]);
}, [hasTouchPrimaryPointer, interactionDisabled, showProjectPicker, workspaceError]);
useEffect(() => {
if (!focusRequest || disabled) return;
if (!focusRequest || interactionDisabled) return;
const id = requestAnimationFrame(() => textareaRef.current?.focus());
return () => cancelAnimationFrame(id);
}, [disabled, focusRequest]);
}, [focusRequest, interactionDisabled]);
const normalizedQuotedContext = quotedContext?.trim().slice(0, QUEUED_PROMPT_MAX_CHARS) || null;
@@ -1118,15 +1126,17 @@ export function ThreadComposer({
const hasComposerContent = value.trim().length > 0 || readyImages.length > 0;
const canSend =
!disabled
!interactionDisabled
&& !modelNeedsSetup
&& !encoding
&& !hasErrors
&& hasComposerContent;
const canOpenModelSettings = Boolean(modelNeedsSetup && onModelBadgeClick && !disabled);
const canOpenModelSettings = Boolean(
modelNeedsSetup && onModelBadgeClick && !interactionDisabled,
);
const canQueueGuidance =
isStreaming
&& !disabled
&& !interactionDisabled
&& !modelNeedsSetup
&& !encoding
&& !hasErrors
@@ -1134,14 +1144,14 @@ export function ThreadComposer({
&& !value.trimStart().startsWith("/");
const slashQuery = useMemo(() => {
if (disabled || slashMenuDismissed || !value.startsWith("/")) return null;
if (interactionDisabled || slashMenuDismissed || !value.startsWith("/")) return null;
const commandToken = value.slice(1);
if (/\s/.test(commandToken)) return null;
return commandToken.toLowerCase();
}, [disabled, slashMenuDismissed, value]);
}, [interactionDisabled, slashMenuDismissed, value]);
const skillQuery = useMemo(() => {
if (disabled || slashMenuDismissed) return null;
if (interactionDisabled || slashMenuDismissed) return null;
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
const beforeCaret = value.slice(0, caret);
const match = /\$([A-Za-z0-9_-]*)$/i.exec(beforeCaret);
@@ -1151,7 +1161,7 @@ export function ThreadComposer({
start: match.index,
text: match[1].toLowerCase(),
};
}, [cursorPosition, disabled, slashMenuDismissed, value]);
}, [cursorPosition, interactionDisabled, slashMenuDismissed, value]);
const visibleSlashCommands = useMemo(() => {
if (!(isStreaming && onStop)) return slashCommands;
@@ -1279,7 +1289,7 @@ export function ThreadComposer({
const showSlashMenu = filteredSlashCommands.length > 0;
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
if (disabled || cliAppMenuDismissed) return null;
if (interactionDisabled || cliAppMenuDismissed) return null;
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
const beforeCaret = value.slice(0, caret);
const match = /(?:^|\s)@([\p{L}\p{N}_-]*)$/iu.exec(beforeCaret);
@@ -1290,7 +1300,7 @@ export function ThreadComposer({
start: caret - query.length - 1,
end: caret,
};
}, [cliAppMenuDismissed, cursorPosition, disabled, value]);
}, [cliAppMenuDismissed, cursorPosition, interactionDisabled, value]);
const availableSessionMentions = useMemo(
() => sessionMentionOptions(
@@ -1580,7 +1590,7 @@ export function ThreadComposer({
}, VOICE_ERROR_VISIBLE_MS);
}, [clearVoiceErrorTimers, t]);
const voiceRecorder = useVoiceRecorder({
disabled,
disabled: interactionDisabled,
onClearError: clearInlineError,
onError: setVoiceError,
onTranscript: appendTranscription,
@@ -1714,7 +1724,7 @@ export function ThreadComposer({
clearDraggedSession();
const preview = sessionDragPreview;
setSessionDragPreview(null);
if (disabled) return true;
if (interactionDisabled) return true;
const sessionKey = readDraggedSession(event.dataTransfer);
const mention = availableSessionMentions.find(
(candidate) => candidate.session_key === (sessionKey ?? preview?.mention.session_key),
@@ -1732,11 +1742,17 @@ export function ThreadComposer({
preview?.end ?? textareaRef.current?.selectionEnd ?? caret,
);
return true;
}, [availableSessionMentions, disabled, insertMentionCandidate, sessionDragPreview, value.length]);
}, [
availableSessionMentions,
insertMentionCandidate,
interactionDisabled,
sessionDragPreview,
value.length,
]);
const previewSessionDrop = useCallback((event: React.DragEvent) => {
if (!hasDraggedSession(event.dataTransfer)) return false;
if (disabled) {
if (interactionDisabled) {
event.dataTransfer.dropEffect = "none";
setSessionDragPreview(null);
return true;
@@ -1765,7 +1781,7 @@ export function ThreadComposer({
: { mention, start, end }
));
return true;
}, [activeSessionMentions, availableSessionMentions, disabled, value.length]);
}, [activeSessionMentions, availableSessionMentions, interactionDisabled, value.length]);
useEffect(() => {
if (!sessionDragPreview) return;
@@ -2008,7 +2024,16 @@ export function ThreadComposer({
const isSlashSideChannel = isSideChannelLifecycle(slashLifecycle);
const finalizeActiveTurn =
slashLifecycle === "finalize_active_turn";
onSend(
const finishSend = () => {
if (hasTouchPrimaryPointer) textareaRef.current?.blur();
setQueuedPrompts([]);
// Bubble owns the data URL copy; safe to revoke every staged blob
// preview here without affecting the rendered message.
clear();
clearComposerText(!hasTouchPrimaryPointer);
onQuotedContextChange?.(null);
};
const result = onSend(
content,
payload,
isSlashSideChannel
@@ -2019,13 +2044,19 @@ export function ThreadComposer({
}
: options,
);
if (hasTouchPrimaryPointer) textareaRef.current?.blur();
setQueuedPrompts([]);
// Bubble owns the data URL copy; safe to revoke every staged blob
// preview here without affecting the rendered message.
clear();
clearComposerText(!hasTouchPrimaryPointer);
onQuotedContextChange?.(null);
if (result instanceof Promise) {
setSendPending(true);
void result
.then((accepted) => {
if (accepted !== false) finishSend();
})
.catch((error: unknown) => {
console.error("Failed to send message", error);
})
.finally(() => setSendPending(false));
return;
}
if (result !== false) finishSend();
}, [
activeCliMentionApps,
activeMcpPresetMentions,
@@ -2168,7 +2199,7 @@ export function ThreadComposer({
[removeChip],
);
const attachButtonDisabled = disabled || full;
const attachButtonDisabled = interactionDisabled || full;
const showVoiceButton = Boolean(onTranscribeAudio);
const voiceRecordingStatusLabel = t("thread.composer.voice.recordingStatus", {
time: voiceRecorder.elapsedLabel,
@@ -2253,7 +2284,7 @@ export function ThreadComposer({
isHero
? "max-w-[58rem] rounded-[28px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]"
: "max-w-[49.5rem] rounded-[22px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
disabled && "opacity-60",
interactionDisabled && "opacity-60",
sessionDragPreview && "ring-1 ring-primary/25",
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
goalState?.active &&
@@ -2368,7 +2399,7 @@ export function ThreadComposer({
onPaste={onPaste}
rows={1}
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
disabled={disabled}
disabled={interactionDisabled}
aria-label={t("thread.composer.inputAria")}
className={cn(
inputTextClasses,
@@ -2443,7 +2474,7 @@ export function ThreadComposer({
) : workspaceScope && !workspaceControlsHidden ? (
<WorkspaceAccessMenu
scope={workspaceScope}
disabled={disabled || workspaceScopeDisabled}
disabled={interactionDisabled || workspaceScopeDisabled}
canUseFullAccess={workspaceControls?.can_use_full_access !== false}
isHero={isHero}
onChange={onWorkspaceScopeChange}
@@ -2521,7 +2552,7 @@ export function ThreadComposer({
<Button
type={showStopButton || modelNeedsSetup ? "button" : "submit"}
size="icon"
disabled={showStopButton ? disabled : !canSend && !canOpenModelSettings}
disabled={showStopButton ? interactionDisabled : !canSend && !canOpenModelSettings}
aria-label={
showStopButton
? t("thread.composer.stop")
@@ -2562,7 +2593,7 @@ export function ThreadComposer({
<div className="composer-workspace-drawer-content">
<WorkspaceProjectPicker
isHero={isHero}
disabled={disabled || workspaceScopeDisabled || !showProjectPicker}
disabled={interactionDisabled || workspaceScopeDisabled || !showProjectPicker}
scope={workspaceScope}
defaultScope={workspaceDefaultScope}
controls={workspaceControls}
+3 -2
View File
@@ -1261,7 +1261,7 @@ export function ThreadShell({
const handleWelcomeSend = useCallback(
async (content: string, images?: SendAttachment[], options?: SendOptions) => {
if (booting) return;
if (booting) return false;
setBooting(true);
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
setPendingFirstTargetChatId(null);
@@ -1270,12 +1270,13 @@ export function ThreadShell({
pendingFirstRef.current = null;
setPendingFirstTargetChatId(null);
setBooting(false);
return;
return false;
}
if (localModelPreset) {
await client.sendSystemCommand(newId, `/model ${localModelPreset}`).catch(() => {});
}
setPendingFirstTargetChatId(newId);
return true;
},
[booting, client, localModelPreset, onCreateChat, withWorkspaceScope, workspaceScope],
);
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
import { AlertTriangle, Check, ChevronDown, Folder, Hand } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -34,6 +34,14 @@ import {
shortWorkspacePath,
} from "@/lib/workspace";
function workspacePathPlaceholder(defaultWorkspacePath: string, macPlaceholder: string): string {
const normalized = defaultWorkspacePath.trim().replace(/\\/g, "/");
const windowsDrive = normalized.match(/^([A-Za-z]):\//)?.[1];
if (windowsDrive) return `${windowsDrive.toUpperCase()}:\\path\\to\\project`;
if (normalized.startsWith("/Users/")) return macPlaceholder;
return "/home/name/project";
}
export function WorkspaceProjectPicker({
isHero,
compact = false,
@@ -60,6 +68,9 @@ export function WorkspaceProjectPicker({
const [pathDraft, setPathDraft] = useState("");
const [pathError, setPathError] = useState<string | null>(null);
const [pickingFolder, setPickingFolder] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const pathInputRef = useRef<HTMLInputElement>(null);
const pathErrorId = useId();
const currentProjectScope = selectedProjectScope(scope, defaultScope);
const projectLabel = currentProjectScope
? currentProjectScope.project_name || projectNameFromPath(currentProjectScope.project_path)
@@ -82,9 +93,17 @@ export function WorkspaceProjectPicker({
}, [disabled]);
useEffect(() => {
if (error && visible && !disabled) setOpen(true);
if (!error || !visible || disabled) return;
const frame = window.requestAnimationFrame(() => triggerRef.current?.focus());
return () => window.cancelAnimationFrame(frame);
}, [disabled, error, visible]);
useEffect(() => {
if (!open || !error) return;
const frame = window.requestAnimationFrame(() => pathInputRef.current?.focus());
return () => window.cancelAnimationFrame(frame);
}, [error, open]);
const applyProjectPath = useCallback(
(projectPath: string, projectName?: string) => {
const base = scope ?? defaultScope;
@@ -129,6 +148,7 @@ export function WorkspaceProjectPicker({
: "flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4",
)}>
<button
ref={triggerRef}
type="button"
disabled={disabled || pickingFolder}
aria-label={t("thread.composer.workspace.projectAria")}
@@ -164,6 +184,7 @@ export function WorkspaceProjectPicker({
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
ref={triggerRef}
type="button"
disabled={disabled}
aria-label={t("thread.composer.workspace.projectAria")}
@@ -221,14 +242,20 @@ export function WorkspaceProjectPicker({
}}
>
<Input
ref={pathInputRef}
value={pathDraft}
disabled={disabled}
onChange={(event) => {
setPathDraft(event.target.value);
setPathError(null);
}}
placeholder={t("workspace.dialog.manualPlaceholder")}
placeholder={workspacePathPlaceholder(
defaultScope.project_path,
t("workspace.dialog.manualPlaceholder"),
)}
aria-label={t("workspace.dialog.manual")}
aria-invalid={pathError || error ? true : undefined}
aria-describedby={pathError || error ? pathErrorId : undefined}
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",
@@ -243,13 +270,22 @@ export function WorkspaceProjectPicker({
</Button>
</form>
{pathError || error ? (
<p role="alert" className="px-1 text-[11.5px] font-medium text-destructive">
<p
id={pathErrorId}
role="alert"
className="px-1 text-[11.5px] font-medium text-destructive"
>
{pathError ?? error}
</p>
) : null}
</div>
</PopoverContent>
</Popover>
{!compact && error && !open ? (
<span role="alert" className="ml-2 min-w-0 truncate text-[11.5px] font-medium text-destructive">
{error}
</span>
) : null}
</div>
);
}