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
+59 -19
View File
@@ -8,7 +8,7 @@ import {
useState,
type ReactNode,
} from "react";
import { Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
import { Eye, EyeOff, Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { channelUiPresentation } from "@/channel-plugins/registry";
import { Sidebar } from "@/components/Sidebar";
@@ -316,13 +316,23 @@ function AuthForm({
onSecret: (secret: string) => void;
}) {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState("");
const [passwordVisible, setPasswordVisible] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [validationError, setValidationError] = useState<"required" | "invalid" | null>(
failed ? "invalid" : null,
);
const errorMessage = validationError ? t(`app.auth.${validationError}`) : null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const secret = value.trim();
if (!secret) return;
if (!secret) {
setValidationError("required");
inputRef.current?.focus();
return;
}
setSubmitting(true);
onSecret(secret);
};
@@ -333,27 +343,57 @@ function AuthForm({
onSubmit={handleSubmit}
className="flex w-full max-w-sm flex-col gap-4"
>
<div className="flex flex-col items-center gap-1 text-center">
<p className="text-lg font-semibold">{t("app.auth.title")}</p>
<p className="text-sm text-muted-foreground">{t("app.auth.hint")}</p>
<div className="space-y-2">
<h1 className="text-sm font-medium text-foreground">
<label htmlFor="webui-access-password">{t("app.auth.label")}</label>
</h1>
<div className="relative">
<Input
ref={inputRef}
id="webui-access-password"
name="webui-access-password"
type={passwordVisible ? "text" : "password"}
autoComplete="current-password"
value={value}
onChange={(e) => {
setValue(e.target.value);
setValidationError(null);
}}
disabled={submitting}
aria-invalid={validationError ? true : undefined}
aria-describedby={validationError ? "webui-auth-error" : undefined}
className="pr-10 focus-visible:ring-1 focus-visible:ring-ring/30 focus-visible:ring-offset-0"
autoFocus
/>
<Button
type="button"
variant="ghost"
size="icon"
disabled={submitting}
aria-label={t(
passwordVisible ? "app.auth.hidePassword" : "app.auth.showPassword",
)}
aria-controls="webui-access-password"
onClick={() => setPasswordVisible((visible) => !visible)}
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{passwordVisible ? (
<EyeOff className="h-4 w-4" strokeWidth={1.75} aria-hidden />
) : (
<Eye className="h-4 w-4" strokeWidth={1.75} aria-hidden />
)}
</Button>
</div>
{errorMessage ? (
<p id="webui-auth-error" role="alert" className="text-sm text-destructive">
{errorMessage}
</p>
) : null}
</div>
{failed && (
<p className="text-center text-sm text-destructive">
{t("app.auth.invalid")}
</p>
)}
<Input
type="password"
placeholder={t("app.auth.placeholder")}
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={submitting}
autoFocus
/>
<Button
type="submit"
className="w-full"
disabled={!value.trim() || submitting}
disabled={submitting}
>
{t("app.auth.submit")}
</Button>
+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>
);
}
+17 -7
View File
@@ -10,11 +10,12 @@
"gatewayHint": "Make sure the gateway is running (`nanobot gateway`) and that this page is open on the same machine."
},
"auth": {
"title": "Authentication required",
"hint": "Enter the secret configured as tokenIssueSecret in your gateway config.",
"placeholder": "Password",
"label": "Password",
"showPassword": "Show password",
"hidePassword": "Hide password",
"submit": "Connect",
"invalid": "Invalid password. Try again."
"required": "Enter your password.",
"invalid": "Incorrect password. Try again."
},
"account": {
"section": "Account",
@@ -595,7 +596,14 @@
"searchPlaceholder": "Search tools",
"featured": "Tools",
"loading": "Loading Apps...",
"empty": "No tools match this view.",
"empty": "No tools match your search.",
"emptyApps": "No apps available.",
"emptyIntegrations": "No integrations available.",
"emptyReady": "No tools are ready yet.",
"clearSearch": "Clear search",
"browseApps": "Browse apps",
"browseIntegrations": "Browse integrations",
"emptyIntegrationsHint": "Add a custom integration below.",
"restartRequired": "Restart nanobot to apply updated apps and integrations."
},
"channels": {
@@ -698,7 +706,9 @@
"loading": "Loading automations...",
"noMatches": "No automations match this view.",
"empty": "No automations yet.",
"emptyHint": "Create one from where it should run so nanobot keeps the right context.",
"emptyHint": "Create automations in a chat so they keep the right context.",
"emptyAction": "Open a chat",
"clearFilters": "Clear filters",
"oneShot": "One-time",
"systemTask": "System-managed automation",
"localTrigger": "Local trigger",
@@ -1381,7 +1391,7 @@
},
"workspaceScopeRejected": {
"title": "Workspace was not changed",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
"body": "The gateway rejected this project or access mode. Choose an existing project or a different access mode, then try again."
},
"turnRejected": {
"title": "Message was not sent",
+17 -7
View File
@@ -10,11 +10,12 @@
"gatewayHint": "Asegúrate de que la gateway esté en ejecución (`nanobot gateway`) y de que esta página esté abierta en la misma máquina."
},
"auth": {
"title": "Autenticación requerida",
"hint": "Introduce el secreto configurado como tokenIssueSecret en la configuración del gateway.",
"placeholder": "Contraseña",
"label": "Contraseña",
"showPassword": "Mostrar contraseña",
"hidePassword": "Ocultar contraseña",
"submit": "Conectar",
"invalid": "Contraseña no válida. Inténtalo de nuevo."
"required": "Introduce la contraseña.",
"invalid": "Contraseña incorrecta. Inténtalo de nuevo."
},
"account": {
"section": "Cuenta",
@@ -582,7 +583,14 @@
"searchPlaceholder": "Buscar aplicaciones",
"featured": "Herramientas",
"loading": "Cargando aplicaciones...",
"empty": "Ninguna herramienta coincide con esta vista.",
"empty": "Ninguna herramienta coincide con tu búsqueda.",
"emptyApps": "No hay aplicaciones disponibles.",
"emptyIntegrations": "No hay integraciones disponibles.",
"emptyReady": "Todavía no hay herramientas listas.",
"clearSearch": "Borrar búsqueda",
"browseApps": "Explorar aplicaciones",
"browseIntegrations": "Explorar integraciones",
"emptyIntegrationsHint": "Añade una integración personalizada abajo.",
"restartRequired": "Reinicia nanobot para aplicar las aplicaciones y funciones actualizadas."
},
"channels": {
@@ -685,7 +693,9 @@
"loading": "Cargando automatizaciones...",
"noMatches": "No hay automatizaciones que coincidan con esta vista.",
"empty": "Aún no hay automatizaciones.",
"emptyHint": "Créala desde donde debe ejecutarse para que nanobot conserve el contexto correcto.",
"emptyHint": "Crea automatizaciones en un chat para que conserven el contexto correcto.",
"emptyAction": "Abrir un chat",
"clearFilters": "Borrar filtros",
"oneShot": "Una vez",
"systemTask": "Automatización administrada por el sistema",
"localTrigger": "Activador local",
@@ -1368,7 +1378,7 @@
},
"workspaceScopeRejected": {
"title": "El espacio de trabajo no cambió",
"body": "El gateway rechazó el proyecto o modo de acceso solicitado, así que Nanobot conservó el espacio de trabajo anterior."
"body": "El gateway rechazó este proyecto o modo de acceso. Elige un proyecto existente u otro modo de acceso e inténtalo de nuevo."
},
"turnRejected": {
"title": "El mensaje no se envió",
+17 -7
View File
@@ -10,11 +10,12 @@
"gatewayHint": "Assurez-vous que la gateway est en cours dexécution (`nanobot gateway`) et que cette page est ouverte sur la même machine."
},
"auth": {
"title": "Authentification requise",
"hint": "Saisissez le secret configuré comme tokenIssueSecret dans la configuration de votre gateway.",
"placeholder": "Mot de passe",
"label": "Mot de passe",
"showPassword": "Afficher le mot de passe",
"hidePassword": "Masquer le mot de passe",
"submit": "Se connecter",
"invalid": "Mot de passe invalide. Réessayez."
"required": "Saisissez le mot de passe.",
"invalid": "Mot de passe incorrect. Réessayez."
},
"account": {
"section": "Compte",
@@ -581,7 +582,14 @@
"searchPlaceholder": "Rechercher des applications",
"featured": "Outils",
"loading": "Chargement des applications...",
"empty": "Aucun outil ne correspond à cette vue.",
"empty": "Aucun outil ne correspond à votre recherche.",
"emptyApps": "Aucune application disponible.",
"emptyIntegrations": "Aucune intégration disponible.",
"emptyReady": "Aucun outil nest encore prêt.",
"clearSearch": "Effacer la recherche",
"browseApps": "Parcourir les applications",
"browseIntegrations": "Parcourir les intégrations",
"emptyIntegrationsHint": "Ajoutez une intégration personnalisée ci-dessous.",
"restartRequired": "Redémarrez nanobot pour appliquer les applications et fonctions mises à jour."
},
"channels": {
@@ -684,7 +692,9 @@
"loading": "Chargement des automatisations...",
"noMatches": "Aucune automatisation ne correspond à cette vue.",
"empty": "Aucune automatisation pour le moment.",
"emptyHint": "Créez-la depuis son point d'exécution pour que nanobot conserve le bon contexte.",
"emptyHint": "Créez les automatisations dans un chat afin de conserver le bon contexte.",
"emptyAction": "Ouvrir un chat",
"clearFilters": "Effacer les filtres",
"oneShot": "Ponctuelle",
"systemTask": "Automatisation gérée par le système",
"localTrigger": "Déclencheur local",
@@ -1367,7 +1377,7 @@
},
"workspaceScopeRejected": {
"title": "Lespace de travail na pas changé",
"body": "La passerelle a refusé le projet ou le mode daccès demandé ; Nanobot a conservé lespace de travail précédent."
"body": "La passerelle a refusé ce projet ou ce mode daccès. Choisissez un projet existant ou un autre mode daccès, puis réessayez."
},
"turnRejected": {
"title": "Le message na pas été envoyé",
+17 -7
View File
@@ -10,11 +10,12 @@
"gatewayHint": "Pastikan gateway sedang berjalan (`nanobot gateway`) dan halaman ini dibuka pada mesin yang sama."
},
"auth": {
"title": "Autentikasi diperlukan",
"hint": "Masukkan secret yang dikonfigurasi sebagai tokenIssueSecret di konfigurasi gateway.",
"placeholder": "Kata sandi",
"label": "Kata sandi",
"showPassword": "Tampilkan kata sandi",
"hidePassword": "Sembunyikan kata sandi",
"submit": "Hubungkan",
"invalid": "Kata sandi tidak valid. Coba lagi."
"required": "Masukkan kata sandi.",
"invalid": "Kata sandi salah. Coba lagi."
},
"account": {
"section": "Akun",
@@ -581,7 +582,14 @@
"searchPlaceholder": "Cari aplikasi",
"featured": "Alat",
"loading": "Memuat aplikasi...",
"empty": "Tidak ada alat yang cocok dengan tampilan ini.",
"empty": "Tidak ada alat yang cocok dengan pencarian Anda.",
"emptyApps": "Tidak ada aplikasi yang tersedia.",
"emptyIntegrations": "Tidak ada integrasi yang tersedia.",
"emptyReady": "Belum ada alat yang siap.",
"clearSearch": "Hapus pencarian",
"browseApps": "Jelajahi aplikasi",
"browseIntegrations": "Jelajahi integrasi",
"emptyIntegrationsHint": "Tambahkan integrasi khusus di bawah.",
"restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan fitur yang diperbarui."
},
"channels": {
@@ -684,7 +692,9 @@
"loading": "Memuat otomasi...",
"noMatches": "Tidak ada otomasi yang cocok dengan tampilan ini.",
"empty": "Belum ada otomasi.",
"emptyHint": "Buat dari tempat tugas ini berjalan agar nanobot menyimpan konteks yang tepat.",
"emptyHint": "Buat otomatisasi di chat agar konteks yang tepat tetap tersimpan.",
"emptyAction": "Buka chat",
"clearFilters": "Hapus filter",
"oneShot": "Satu kali",
"systemTask": "Automasi yang dikelola sistem",
"localTrigger": "Pemicu lokal",
@@ -1367,7 +1377,7 @@
},
"workspaceScopeRejected": {
"title": "Ruang kerja tidak berubah",
"body": "Gateway menolak proyek atau mode akses yang diminta, jadi Nanobot tetap memakai ruang kerja sebelumnya."
"body": "Gateway menolak proyek atau mode akses ini. Pilih proyek yang sudah ada atau mode akses lain, lalu coba lagi."
},
"turnRejected": {
"title": "Pesan tidak terkirim",
+17 -7
View File
@@ -10,11 +10,12 @@
"gatewayHint": "gateway`nanobot gateway`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
},
"auth": {
"title": "認証が必要です",
"hint": "gateway 設定の tokenIssueSecret に指定されたシークレットを入力してください。",
"placeholder": "パスワード",
"label": "パスワード",
"showPassword": "パスワードを表示",
"hidePassword": "パスワードを隠す",
"submit": "接続",
"invalid": "パスワードが無効です。もう一度お試しください。"
"required": "パスワードを入力してください。",
"invalid": "パスワードが正しくありません。もう一度お試しください。"
},
"account": {
"section": "アカウント",
@@ -581,7 +582,14 @@
"searchPlaceholder": "アプリを検索",
"featured": "ツール",
"loading": "アプリを読み込み中...",
"empty": "この表示に一致するツールはありません。",
"empty": "検索条件に一致するツールはありません。",
"emptyApps": "利用できるアプリはありません。",
"emptyIntegrations": "利用できる連携はありません。",
"emptyReady": "使用可能なツールはまだありません。",
"clearSearch": "検索をクリア",
"browseApps": "アプリを見る",
"browseIntegrations": "連携を見る",
"emptyIntegrationsHint": "下からカスタム連携を追加できます。",
"restartRequired": "更新したアプリと機能を反映するには nanobot を再起動してください。"
},
"channels": {
@@ -684,7 +692,9 @@
"loading": "自動タスクを読み込み中...",
"noMatches": "この表示に一致する自動タスクはありません。",
"empty": "自動タスクはまだありません。",
"emptyHint": "実行元から作成すると、nanobot が正しいコンテキストを保持できます。",
"emptyHint": "正しいコンテキストを保持するには、チャットで自動化を作成してください。",
"emptyAction": "チャットを開く",
"clearFilters": "フィルターをクリア",
"oneShot": "一回限り",
"systemTask": "システム管理の自動タスク",
"localTrigger": "ローカルトリガー",
@@ -1367,7 +1377,7 @@
},
"workspaceScopeRejected": {
"title": "ワークスペースは変更されませんでした",
"body": "要求されたプロジェクトまたはアクセスモードゲートウェイ拒否されたため、Nanobot は以前のワークスペースをそのまま使用しています。"
"body": "このプロジェクトまたはアクセスモードゲートウェイ拒否されました。既存のプロジェクトまたは別のアクセスモードを選択して、もう一度お試しください。"
},
"turnRejected": {
"title": "メッセージは送信されませんでした",
+16 -6
View File
@@ -10,10 +10,11 @@
"gatewayHint": "gateway(`nanobot gateway`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
},
"auth": {
"title": "인증이 필요합니다",
"hint": "gateway 설정의 tokenIssueSecret에 구성된 비밀 값을 입력하세요.",
"placeholder": "비밀번호",
"label": "비밀번호",
"showPassword": "비밀번호 표시",
"hidePassword": "비밀번호 숨기기",
"submit": "연결",
"required": "비밀번호를 입력하세요.",
"invalid": "비밀번호가 올바르지 않습니다. 다시 시도하세요."
},
"account": {
@@ -581,7 +582,14 @@
"searchPlaceholder": "앱 검색",
"featured": "도구",
"loading": "앱을 불러오는 중...",
"empty": "이 보기에 일치하는 도구가 없습니다.",
"empty": "검색과 일치하는 도구가 없습니다.",
"emptyApps": "사용 가능한 앱이 없습니다.",
"emptyIntegrations": "사용 가능한 연동이 없습니다.",
"emptyReady": "아직 준비된 도구가 없습니다.",
"clearSearch": "검색 지우기",
"browseApps": "앱 둘러보기",
"browseIntegrations": "연동 둘러보기",
"emptyIntegrationsHint": "아래에서 사용자 지정 연동을 추가하세요.",
"restartRequired": "업데이트된 앱과 기능을 적용하려면 nanobot을 다시 시작하세요."
},
"channels": {
@@ -684,7 +692,9 @@
"loading": "자동화를 불러오는 중...",
"noMatches": "이 보기와 일치하는 자동화가 없습니다.",
"empty": "아직 자동화가 없습니다.",
"emptyHint": "실행될 위치에서 만들면 nanobot이 올바른 컨텍스트를 유지합니다.",
"emptyHint": "올바른 컨텍스트를 유지하려면 채팅에서 자동화를 만드세요.",
"emptyAction": "채팅 열기",
"clearFilters": "필터 지우기",
"oneShot": "일회성",
"systemTask": "시스템 관리 자동화",
"localTrigger": "로컬 트리거",
@@ -1367,7 +1377,7 @@
},
"workspaceScopeRejected": {
"title": "작업공간이 변경되지 않았습니다",
"body": "요청한 프로젝트 또는 접근 모드가 게이트웨이에서 거부되어 Nanobot이 이전 작업공간을 계속 사용합니다."
"body": "게이트웨이가 이 프로젝트 또는 접근 모드를 거부했습니다. 기존 프로젝트나 다른 접근 모드를 선택한 후 다시 시도하세요."
},
"turnRejected": {
"title": "메시지가 전송되지 않았습니다",
+17 -7
View File
@@ -10,11 +10,12 @@
"gatewayHint": "Verifique se o gateway está em execução (`nanobot gateway`) e se esta página está aberta na mesma máquina."
},
"auth": {
"title": "Autenticação necessária",
"hint": "Informe o segredo configurado como tokenIssueSecret na configuração do gateway.",
"placeholder": "Senha",
"label": "Senha",
"showPassword": "Mostrar senha",
"hidePassword": "Ocultar senha",
"submit": "Conectar",
"invalid": "Senha inválida. Tente novamente."
"required": "Digite a senha.",
"invalid": "Senha incorreta. Tente novamente."
},
"account": {
"section": "Conta",
@@ -595,7 +596,14 @@
"searchPlaceholder": "Buscar ferramentas",
"featured": "Ferramentas",
"loading": "Carregando aplicativos...",
"empty": "Nenhuma ferramenta corresponde a esta visualização.",
"empty": "Nenhuma ferramenta corresponde à sua busca.",
"emptyApps": "Nenhum aplicativo disponível.",
"emptyIntegrations": "Nenhuma integração disponível.",
"emptyReady": "Ainda não há ferramentas prontas.",
"clearSearch": "Limpar busca",
"browseApps": "Explorar aplicativos",
"browseIntegrations": "Explorar integrações",
"emptyIntegrationsHint": "Adicione uma integração personalizada abaixo.",
"restartRequired": "Reinicie o nanobot para aplicar os aplicativos e integrações atualizados."
},
"channels": {
@@ -698,7 +706,9 @@
"loading": "Carregando automações...",
"noMatches": "Nenhuma automação corresponde a esta visualização.",
"empty": "Nenhuma automação ainda.",
"emptyHint": "Crie uma de onde ela deve rodar para que o nanobot mantenha o contexto correto.",
"emptyHint": "Crie automações em uma conversa para que mantenham o contexto correto.",
"emptyAction": "Abrir uma conversa",
"clearFilters": "Limpar filtros",
"oneShot": "Uma vez",
"systemTask": "Automação gerenciada pelo sistema",
"localTrigger": "Gatilho local",
@@ -1381,7 +1391,7 @@
},
"workspaceScopeRejected": {
"title": "O espaço de trabalho não foi alterado",
"body": "O nanobot manteve o espaço de trabalho anterior porque o projeto ou modo de acesso solicitado foi rejeitado pelo gateway."
"body": "O gateway rejeitou este projeto ou modo de acesso. Escolha um projeto existente ou outro modo de acesso e tente novamente."
},
"turnRejected": {
"title": "A mensagem não foi enviada",
+17 -7
View File
@@ -10,11 +10,12 @@
"gatewayHint": "Hãy chắc chắn gateway đang chạy (`nanobot gateway`) và trang này được mở trên cùng máy."
},
"auth": {
"title": "Cần xác thực",
"hint": "Nhập secret được cấu hình là tokenIssueSecret trong cấu hình gateway.",
"placeholder": "Mật khẩu",
"label": "Mật khẩu",
"showPassword": "Hiện mật khẩu",
"hidePassword": "Ẩn mật khẩu",
"submit": "Kết nối",
"invalid": "Mật khẩu không hợp lệ. Hãy thử lại."
"required": "Nhập mật khẩu.",
"invalid": "Mật khẩu không đúng. Hãy thử lại."
},
"account": {
"section": "Tài khoản",
@@ -581,7 +582,14 @@
"searchPlaceholder": "Tìm ứng dụng",
"featured": "Công cụ",
"loading": "Đang tải ứng dụng...",
"empty": "Không có công cụ phù hợp với chế độ xem này.",
"empty": "Không có công cụ phù hợp với tìm kiếm của bạn.",
"emptyApps": "Không có ứng dụng nào.",
"emptyIntegrations": "Không có tích hợp nào.",
"emptyReady": "Chưa có công cụ nào sẵn sàng.",
"clearSearch": "Xóa tìm kiếm",
"browseApps": "Xem ứng dụng",
"browseIntegrations": "Xem tích hợp",
"emptyIntegrationsHint": "Thêm tích hợp tùy chỉnh ở bên dưới.",
"restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và tính năng đã cập nhật."
},
"channels": {
@@ -684,7 +692,9 @@
"loading": "Đang tải tự động hóa...",
"noMatches": "Không có tự động hóa phù hợp với chế độ xem này.",
"empty": "Chưa có tự động hóa.",
"emptyHint": "Tạo từ nơi tác vụ sẽ chạy để nanobot giữ đúng ngữ cảnh.",
"emptyHint": "Tạo tác vụ tự động trong cuộc trò chuyện để giữ đúng ngữ cảnh.",
"emptyAction": "Mở cuộc trò chuyện",
"clearFilters": "Xóa bộ lọc",
"oneShot": "Một lần",
"systemTask": "Tự động hóa do hệ thống quản lý",
"localTrigger": "Trình kích hoạt cục bộ",
@@ -1367,7 +1377,7 @@
},
"workspaceScopeRejected": {
"title": "Không gian làm việc không thay đổi",
"body": "Gateway đã từ chối dự án hoặc chế độ truy cập được yêu cầu, nên Nanobot giữ không gian làm việc trước đó."
"body": "Gateway đã từ chối dự án hoặc chế độ truy cập này. Chọn dự án hiện có hoặc chế độ truy cập khác rồi thử lại."
},
"turnRejected": {
"title": "Tin nhắn chưa được gửi",
+17 -7
View File
@@ -10,11 +10,12 @@
"gatewayHint": "请确认网关已启动(`nanobot gateway`),并且当前页面与网关运行在同一台机器上。"
},
"auth": {
"title": "需要验证",
"hint": "请输入网关配置中的 tokenIssueSecret。",
"placeholder": "密码",
"label": "密码",
"showPassword": "显示密码",
"hidePassword": "隐藏密码",
"submit": "连接",
"invalid": "密码无效,请重试。"
"required": "请输入密码。",
"invalid": "密码错误,请重试。"
},
"account": {
"section": "账户",
@@ -595,7 +596,14 @@
"searchPlaceholder": "搜索工具",
"featured": "工具",
"loading": "正在加载应用...",
"empty": "当前视图没有匹配的工具。",
"empty": "没有与搜索条件匹配的工具。",
"emptyApps": "暂无可用应用。",
"emptyIntegrations": "暂无可用集成。",
"emptyReady": "还没有就绪的工具。",
"clearSearch": "清除搜索",
"browseApps": "浏览应用",
"browseIntegrations": "浏览集成",
"emptyIntegrationsHint": "可在下方添加自定义集成。",
"restartRequired": "重启 nanobot 以应用更新后的应用和集成。"
},
"channels": {
@@ -698,7 +706,9 @@
"loading": "正在加载自动任务...",
"noMatches": "当前视图没有匹配的自动任务。",
"empty": "暂无自动任务。",
"emptyHint": "请从它应该运行的来源处创建,这样 nanobot 才能保留正确上下文。",
"emptyHint": "请在对话中创建自动任务,以便保留正确上下文。",
"emptyAction": "打开对话",
"clearFilters": "清除筛选",
"oneShot": "一次性",
"systemTask": "系统管理的自动任务",
"localTrigger": "本地触发器",
@@ -1381,7 +1391,7 @@
},
"workspaceScopeRejected": {
"title": "工作区未更改",
"body": "网关拒绝了请求的项目或访问权限,Nanobot 已继续使用之前的工作区。"
"body": "网关拒绝了此项目或访问权限。请选择已存在的项目或其他访问权限,然后重试。"
},
"turnRejected": {
"title": "消息未发送",
+17 -7
View File
@@ -10,11 +10,12 @@
"gatewayHint": "請確認閘道已啟動(`nanobot gateway`),並且目前頁面與閘道在同一台機器上開啟。"
},
"auth": {
"title": "需要驗證",
"hint": "請輸入閘道設定中 tokenIssueSecret 所設定的金鑰。",
"placeholder": "密碼",
"label": "密碼",
"showPassword": "顯示密碼",
"hidePassword": "隱藏密碼",
"submit": "連線",
"invalid": "密碼無效,請再試一次。"
"required": "請輸入密碼。",
"invalid": "密碼錯誤,請再試一次。"
},
"account": {
"section": "帳戶",
@@ -581,7 +582,14 @@
"searchPlaceholder": "搜尋工具",
"featured": "工具",
"loading": "正在載入應用程式…",
"empty": "沒有符合條件的工具。",
"empty": "沒有符合搜尋條件的工具。",
"emptyApps": "沒有可用的應用程式。",
"emptyIntegrations": "沒有可用的整合服務。",
"emptyReady": "尚無就緒的工具。",
"clearSearch": "清除搜尋",
"browseApps": "瀏覽應用程式",
"browseIntegrations": "瀏覽整合服務",
"emptyIntegrationsHint": "可在下方新增自訂整合服務。",
"restartRequired": "重新啟動 nanobot 以套用更新後的應用程式與整合服務。"
},
"channels": {
@@ -684,7 +692,9 @@
"loading": "正在載入自動任務…",
"noMatches": "目前沒有符合條件的自動任務。",
"empty": "尚無自動任務。",
"emptyHint": "請從自動任務預定執行的對話中建立,讓 nanobot 保留正確的對話脈絡。",
"emptyHint": "請在聊天中建立自動任務,以保留正確的對話脈絡。",
"emptyAction": "開啟聊天",
"clearFilters": "清除篩選",
"oneShot": "單次",
"systemTask": "系統管理的自動任務",
"localTrigger": "本機觸發器",
@@ -1367,7 +1377,7 @@
},
"workspaceScopeRejected": {
"title": "工作區未變更",
"body": "閘道拒絕要求的專案或存取模式,因此 Nanobot 繼續使用先前的工作區。"
"body": "閘道拒絕了此專案或存取模式。請選擇現有專案或其他存取模式,然後再試一次。"
},
"turnRejected": {
"title": "訊息未傳送",
+137 -6
View File
@@ -315,11 +315,68 @@ describe("App layout", () => {
render(<App />);
expect(await screen.findByText("Authentication required")).toBeInTheDocument();
expect(screen.queryByText("Invalid password. Try again.")).not.toBeInTheDocument();
expect(await screen.findByRole("heading", { level: 1, name: "Password" }))
.toBeInTheDocument();
const password = screen.getByLabelText("Password");
expect(password).toHaveAttribute(
"autocomplete",
"current-password",
);
expect(password).not.toHaveAttribute("placeholder");
expect(screen.queryByText("Authentication required")).not.toBeInTheDocument();
expect(
screen.queryByText("Incorrect password. Try again."),
).not.toBeInTheDocument();
expect(connectSpy).not.toHaveBeenCalled();
});
it("toggles password visibility without changing the password", async () => {
vi.mocked(fetchBootstrap).mockRejectedValueOnce(
new Error("bootstrap failed: HTTP 401"),
);
const user = userEvent.setup();
render(<App />);
const password = await screen.findByLabelText("Password");
await user.type(password, "correct horse battery staple");
expect(password).toHaveAttribute("type", "password");
await user.click(screen.getByRole("button", { name: "Show password" }));
expect(password).toHaveAttribute("type", "text");
expect(password).toHaveValue("correct horse battery staple");
const hidePassword = screen.getByRole("button", { name: "Hide password" });
expect(hidePassword).toHaveFocus();
await user.click(hidePassword);
expect(password).toHaveAttribute("type", "password");
expect(password).toHaveValue("correct horse battery staple");
expect(screen.getByRole("button", { name: "Show password" })).toHaveFocus();
});
it("explains and focuses an empty auth password", async () => {
vi.mocked(fetchBootstrap).mockRejectedValue(
new Error("bootstrap failed: HTTP 401"),
);
render(<App />);
const password = await screen.findByLabelText("Password");
const connect = screen.getByRole("button", { name: "Connect" });
expect(connect).toBeEnabled();
fireEvent.click(connect);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Enter your password.",
);
expect(password).toHaveAttribute("aria-invalid", "true");
expect(password).toHaveAttribute("aria-describedby", "webui-auth-error");
expect(password).toHaveFocus();
expect(fetchBootstrap).toHaveBeenCalledTimes(1);
});
it("shows the auth form when bootstrap does not issue an API token", async () => {
vi.mocked(fetchBootstrap).mockRejectedValueOnce(
new BootstrapAuthRequiredError(
@@ -329,8 +386,11 @@ describe("App layout", () => {
render(<App />);
expect(await screen.findByText("Authentication required")).toBeInTheDocument();
expect(screen.queryByText("Invalid password. Try again.")).not.toBeInTheDocument();
expect(await screen.findByRole("heading", { level: 1, name: "Password" }))
.toBeInTheDocument();
expect(
screen.queryByText("Incorrect password. Try again."),
).not.toBeInTheDocument();
expect(connectSpy).not.toHaveBeenCalled();
});
@@ -341,11 +401,16 @@ describe("App layout", () => {
render(<App />);
const password = await screen.findByPlaceholderText("Password");
const password = await screen.findByLabelText("Password");
fireEvent.change(password, { target: { value: "wrong-password" } });
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
expect(await screen.findByText("Invalid password. Try again.")).toBeInTheDocument();
const retryPassword = await screen.findByLabelText("Password");
expect(await screen.findByRole("alert")).toHaveTextContent(
"Incorrect password. Try again.",
);
expect(retryPassword).toHaveAttribute("aria-invalid", "true");
expect(retryPassword).toHaveFocus();
expect(fetchBootstrap).toHaveBeenLastCalledWith("", "wrong-password");
expect(connectSpy).not.toHaveBeenCalled();
});
@@ -365,6 +430,21 @@ describe("App layout", () => {
expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true);
});
it("uses one main landmark and a page heading in desktop settings", async () => {
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
const { container } = render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
expect(
await screen.findByRole("navigation", { name: "Settings sections" }),
).toBeInTheDocument();
expect(container.querySelectorAll("main")).toHaveLength(1);
expect(screen.getByRole("heading", { level: 1, name: "Settings" })).toBeInTheDocument();
});
it("places Automations after Skills in the main sidebar", async () => {
render(<App />);
@@ -652,6 +732,57 @@ describe("App layout", () => {
});
});
it("preserves the first message when the gateway rejects a project", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
createChatSpy.mockRejectedValueOnce(
new Error("workspace_scope_rejected:project_path must be an existing directory"),
);
mockFetchRoutes({
"/api/workspaces": {
schema_version: 1,
default_access_mode: "restricted",
default_scope: {
project_path: "C:\\workspace",
project_name: "workspace",
access_mode: "restricted",
restrict_to_workspace: true,
},
controls: { can_change_project: true, can_use_full_access: true },
},
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
fireEvent.click(await screen.findByRole("button", { name: "Choose project" }));
fireEvent.change(await screen.findByLabelText("Paste path"), {
target: { value: "C:\\missing-project" },
});
fireEvent.click(screen.getByRole("button", { name: "Use Path" }));
const message = screen.getByLabelText("Message input");
fireEvent.change(message, { target: { value: "keep this first message" } });
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(createChatSpy).toHaveBeenCalledTimes(1));
expect(message).toHaveValue("keep this first message");
const projectButton = screen.getByRole("button", { name: "Choose project" });
await waitFor(() => expect(projectButton).toHaveFocus());
expect(screen.getByRole("alert")).toHaveTextContent(
"The gateway rejected this project or access mode. Choose an existing project or a different access mode, then try again.",
);
fireEvent.click(projectButton);
const projectPath = await screen.findByLabelText("Paste path");
expect(projectPath).toHaveValue("C:\\missing-project");
expect(projectPath).toHaveAttribute("aria-invalid", "true");
expect(projectPath).toHaveFocus();
expect(screen.getByRole("alert")).toHaveTextContent(
"The gateway rejected this project or access mode. Choose an existing project or a different access mode, then try again.",
);
expect(window.location.hash).toBe("");
consoleError.mockRestore();
});
it("restores the Settings route after a restart fallback hash", async () => {
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
+73 -2
View File
@@ -347,6 +347,7 @@ function renderSettingsView(
| "runtime";
initialSettings?: SettingsPayload;
showSidebar?: boolean;
onBackToChat?: () => void;
onSettingsChange?: (payload: SettingsPayload) => void;
onNativeEngineRestart?: () => Promise<string>;
} = {},
@@ -359,7 +360,7 @@ function renderSettingsView(
initialSettings={options.initialSettings}
showSidebar={options.showSidebar}
onToggleTheme={() => {}}
onBackToChat={() => {}}
onBackToChat={options.onBackToChat ?? (() => {})}
onModelNameChange={() => {}}
onSettingsChange={options.onSettingsChange}
onNativeEngineRestart={options.onNativeEngineRestart}
@@ -385,6 +386,9 @@ async function chooseProviderToConfigure(label: string) {
}
describe("SettingsView Apps catalog", () => {
const thirdPartyBrandNotice =
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.";
beforeEach(() => {
vi.stubGlobal(
"matchMedia",
@@ -428,7 +432,34 @@ describe("SettingsView Apps catalog", () => {
});
});
it("shows the third-party brand notice only with the brand logo preference", () => {
renderSettingsView({
initialSection: "appearance",
initialSettings: settingsPayload(),
showSidebar: true,
});
const brandLogosTitle = screen.getByText("Brand logos");
const brandLogosRow = brandLogosTitle.parentElement?.parentElement;
expect(brandLogosRow).not.toBeNull();
expect(
within(brandLogosRow as HTMLElement).getByText(thirdPartyBrandNotice),
).toBeInTheDocument();
expect(screen.getAllByText(thirdPartyBrandNotice)).toHaveLength(1);
});
it.each(["apps", "channels"] as const)(
"does not repeat the third-party brand notice in %s",
(initialSection) => {
renderSettingsView({ initialSection, initialSettings: settingsPayload() });
expect(screen.queryByText(thirdPartyBrandNotice)).not.toBeInTheDocument();
},
);
it("does not show the Settings kicker on the standalone Automations surface", async () => {
const onBackToChat = vi.fn();
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
@@ -440,11 +471,49 @@ describe("SettingsView Apps catalog", () => {
initialSection: "automations",
initialSettings: settingsPayload(),
showSidebar: false,
onBackToChat,
});
expect(screen.getByRole("heading", { name: "Automations" })).toBeInTheDocument();
expect(await screen.findByText("No automations yet.")).toBeInTheDocument();
expect(screen.queryByText("Settings")).not.toBeInTheDocument();
expect(
screen.queryByPlaceholderText("Search task, message, linked chat, or schedule"),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Open a chat" }));
expect(onBackToChat).toHaveBeenCalledTimes(1);
});
it("offers a way out of an empty automations filter", async () => {
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/webui/automations") {
return jsonResponse({
jobs: [{
id: "job-1",
name: "Daily summary",
enabled: true,
schedule: { kind: "cron", expr: "0 9 * * *" },
payload: { message: "Summarize the day" },
state: {},
}],
});
}
return jsonResponse({});
}));
renderSettingsView({
initialSection: "automations",
initialSettings: settingsPayload(),
showSidebar: false,
});
expect(await screen.findByRole("heading", { name: "Daily summary" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Paused 0" }));
expect(await screen.findByText("No automations match this view.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
expect(await screen.findByRole("heading", { name: "Daily summary" })).toBeInTheDocument();
});
it("coalesces focus refreshes while automations are already loading", async () => {
@@ -1963,8 +2032,10 @@ describe("SettingsView Apps catalog", () => {
renderSettingsView();
expect(await screen.findByText("No tools match this view.")).toBeInTheDocument();
expect(await screen.findByText("No apps available.")).toBeInTheDocument();
expect(screen.queryByText("Loading Apps...")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Browse integrations" }));
expect(await screen.findByText("Add integration")).toBeInTheDocument();
});
it("shows token activity on the overview", async () => {
+54
View File
@@ -350,6 +350,30 @@ function longPress(badge: HTMLElement, pointerId = 7) {
}
describe("ThreadComposer", () => {
it("locks an async send and keeps the draft when it is rejected", async () => {
let resolveSend!: (accepted: boolean) => void;
const onSend = vi.fn(() => new Promise<boolean>((resolve) => {
resolveSend = resolve;
}));
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "keep this pending draft" } });
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(input).toBeDisabled();
expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled();
await act(async () => resolveSend(false));
await waitFor(() => expect(input).toBeEnabled());
expect(input).toHaveValue("keep this pending draft");
});
it("dismisses the touch keyboard after a successful send", async () => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: query === "(hover: none) and (pointer: coarse)",
@@ -1113,6 +1137,36 @@ describe("ThreadComposer", () => {
}));
});
it.each([
["Windows", "D:\\Users\\test\\.nanobot\\workspace", "D:\\path\\to\\project"],
["macOS", "/Users/test/.nanobot/workspace", "/Users/name/project"],
["Linux", "/home/test/.nanobot/workspace", "/home/name/project"],
])("uses a %s path example for the project picker", async (_, projectPath, placeholder) => {
const user = userEvent.setup();
const defaultScope = {
project_path: projectPath,
project_name: "workspace",
access_mode: "restricted" as const,
restrict_to_workspace: true,
};
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Ask anything..."
variant="hero"
workspaceScope={defaultScope}
workspaceDefaultScope={defaultScope}
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
onWorkspaceScopeChange={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: "Choose project" }));
expect(await screen.findByLabelText("Paste path")).toHaveAttribute("placeholder", placeholder);
});
it("slides project controls closed without offering a compact replacement", () => {
const defaultScope = {
project_path: "/Users/test/.nanobot/workspace",