fix(webui): improve UX recovery and empty states (#5315)
This commit is contained in:
@@ -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"] {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user