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, useState,
type ReactNode, type ReactNode,
} from "react"; } 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 { useTranslation } from "react-i18next";
import { channelUiPresentation } from "@/channel-plugins/registry"; import { channelUiPresentation } from "@/channel-plugins/registry";
import { Sidebar } from "@/components/Sidebar"; import { Sidebar } from "@/components/Sidebar";
@@ -316,13 +316,23 @@ function AuthForm({
onSecret: (secret: string) => void; onSecret: (secret: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const [passwordVisible, setPasswordVisible] = useState(false);
const [submitting, setSubmitting] = 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) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const secret = value.trim(); const secret = value.trim();
if (!secret) return; if (!secret) {
setValidationError("required");
inputRef.current?.focus();
return;
}
setSubmitting(true); setSubmitting(true);
onSecret(secret); onSecret(secret);
}; };
@@ -333,27 +343,57 @@ function AuthForm({
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="flex w-full max-w-sm flex-col gap-4" className="flex w-full max-w-sm flex-col gap-4"
> >
<div className="flex flex-col items-center gap-1 text-center"> <div className="space-y-2">
<p className="text-lg font-semibold">{t("app.auth.title")}</p> <h1 className="text-sm font-medium text-foreground">
<p className="text-sm text-muted-foreground">{t("app.auth.hint")}</p> <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> </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 <Button
type="submit" type="submit"
className="w-full" className="w-full"
disabled={!value.trim() || submitting} disabled={submitting}
> >
{t("app.auth.submit")} {t("app.auth.submit")}
</Button> </Button>
+154 -94
View File
@@ -2328,6 +2328,7 @@ export function SettingsView({
onAction={handleAutomationAction} onAction={handleAutomationAction}
onRequestEdit={setAutomationPendingEdit} onRequestEdit={setAutomationPendingEdit}
onRequestDelete={setAutomationPendingDelete} onRequestDelete={setAutomationPendingDelete}
onBackToChat={onBackToChat}
/> />
); );
case "skills": case "skills":
@@ -2448,7 +2449,7 @@ export function SettingsView({
onSave={handleAutomationEdit} onSave={handleAutomationEdit}
/> />
<main <div
className={cn( className={cn(
"min-w-0 flex-1 bg-settings-canvas [scrollbar-gutter:stable]", "min-w-0 flex-1 bg-settings-canvas [scrollbar-gutter:stable]",
activeSection === "channels" ? "overflow-y-auto xl:overflow-hidden" : "overflow-y-auto", activeSection === "channels" ? "overflow-y-auto xl:overflow-hidden" : "overflow-y-auto",
@@ -2512,7 +2513,7 @@ export function SettingsView({
</div> </div>
) : null} ) : null}
</div> </div>
</main> </div>
</div> </div>
); );
} }
@@ -2578,9 +2579,9 @@ function SettingsSidebar({
{t("settings.backToChat")} {t("settings.backToChat")}
</button> </button>
<div className="mb-3 px-1 lg:mb-4 lg:px-2"> <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")} {t("settings.sidebar.title")}
</h2> </h1>
</div> </div>
<nav <nav
@@ -3042,7 +3043,13 @@ function AppearanceSettings({
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")} label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/> />
</SettingsRow> </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 <ToggleButton
checked={localPrefs.brandLogos} checked={localPrefs.brandLogos}
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))} onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
@@ -5488,6 +5495,7 @@ function AutomationsSettings({
onAction, onAction,
onRequestEdit, onRequestEdit,
onRequestDelete, onRequestDelete,
onBackToChat,
}: { }: {
payload: AutomationsPayload | null; payload: AutomationsPayload | null;
loading: boolean; loading: boolean;
@@ -5502,6 +5510,7 @@ function AutomationsSettings({
onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise<void>; onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise<void>;
onRequestEdit: (job: SessionAutomationJob) => void; onRequestEdit: (job: SessionAutomationJob) => void;
onRequestDelete: (job: SessionAutomationJob) => void; onRequestDelete: (job: SessionAutomationJob) => void;
onBackToChat: () => void;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const tx = (key: string, fallback: string, values?: Record<string, unknown>) => const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
@@ -5549,74 +5558,76 @@ function AutomationsSettings({
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<section className="shrink-0"> {jobs.length ? (
<div className="mx-auto flex w-full max-w-[56rem] flex-col gap-3"> <section className="shrink-0">
<div className="-mx-1 overflow-x-auto px-1 pb-0.5"> <div className="mx-auto flex w-full max-w-[56rem] flex-col gap-3">
<div className="grid w-full min-w-[36rem] grid-cols-5 gap-1 rounded-[15px] bg-muted p-1"> <div className="-mx-1 overflow-x-auto px-1 pb-0.5">
{summaryOptions.map((option) => ( <div className="grid w-full min-w-[36rem] grid-cols-5 gap-1 rounded-[15px] bg-muted p-1">
<button {summaryOptions.map((option) => (
key={option.value} <button
type="button" key={option.value}
onClick={() => onFilterChange(option.value)} type="button"
className={cn( onClick={() => onFilterChange(option.value)}
"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
className={cn( 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", "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",
automationFilterCountClass(option.value, option.count), filter === option.value && "bg-background text-foreground",
automationFilterToneClass(option.value, option.count, filter === option.value),
)} )}
> >
{option.count} <span>{option.label}</span>
</span> <span
</button> 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",
</div> automationFilterCountClass(option.value, option.count),
</div> )}
>
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"> {option.count}
<div className="relative min-w-0"> </span>
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/70" /> </button>
<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> </div>
</DropdownMenu> </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>
</div> </section>
</section> ) : null}
{error ? ( {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"> <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.")} : tx("settings.automations.empty", "No automations yet.")}
</div> </div>
{!jobs.length ? ( {!jobs.length ? (
<div className="mx-auto mt-2 max-w-[28rem] text-[12px] leading-5"> <>
{tx( <div className="mx-auto mt-2 max-w-[28rem] text-[12px] leading-5">
"settings.automations.emptyHint", {tx(
"Create one from where it should run so nanobot keeps the right context.", "settings.automations.emptyHint",
)} "Create automations in a chat so they keep the right context.",
</div> )}
) : null} </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>
)} )}
</div> </div>
@@ -7290,10 +7323,6 @@ function ChannelsSettings({
</div> </div>
)} )}
</section> </section>
<div className={cn("shrink-0 pt-2", showingCompactDetail && "hidden")}>
<ThirdPartyBrandNotice />
</div>
</div> </div>
); );
} }
@@ -7396,6 +7425,23 @@ function AppsCatalogSettings({
(cliAppsLoading || mcpPresetsLoading) && (cliAppsLoading || mcpPresetsLoading) &&
!cliApps && !cliApps &&
!mcpPresets; !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 = const statusMessage =
cliError || cliError ||
mcpError || mcpError ||
@@ -7484,7 +7530,35 @@ function AppsCatalogSettings({
</div> </div>
) : ( ) : (
<div className="px-3 py-12 text-center text-sm text-muted-foreground"> <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> </div>
)} )}
</section> </section>
@@ -7500,8 +7574,6 @@ function AppsCatalogSettings({
onImportConfig={onImportMcpConfig} onImportConfig={onImportMcpConfig}
/> />
) : null} ) : null}
<ThirdPartyBrandNotice />
</div> </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( function orderUnconfiguredProviders(
providers: SettingsPayload["providers"], providers: SettingsPayload["providers"],
): SettingsPayload["providers"] { ): SettingsPayload["providers"] {
+66 -35
View File
@@ -179,7 +179,11 @@ function getVoiceShortcutLabel(): string {
} }
interface ThreadComposerProps { interface ThreadComposerProps {
onSend: (content: string, images?: SendAttachment[], options?: SendOptions) => void; onSend: (
content: string,
images?: SendAttachment[],
options?: SendOptions,
) => boolean | void | Promise<boolean | void>;
disabled?: boolean; disabled?: boolean;
placeholder?: string; placeholder?: string;
isStreaming?: boolean; isStreaming?: boolean;
@@ -981,6 +985,8 @@ export function ThreadComposer({
end: number; end: number;
} | null>(null); } | null>(null);
const [inlineError, setInlineError] = useState<string | null>(null); const [inlineError, setInlineError] = useState<string | null>(null);
const [sendPending, setSendPending] = useState(false);
const interactionDisabled = !!disabled || sendPending;
const [voiceErrorFading, setVoiceErrorFading] = useState(false); const [voiceErrorFading, setVoiceErrorFading] = useState(false);
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false); const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0); const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
@@ -1071,7 +1077,7 @@ export function ThreadComposer({
const addFiles = useCallback( const addFiles = useCallback(
(files: File[]) => { (files: File[]) => {
if (files.length === 0) return; if (interactionDisabled || files.length === 0) return;
secondEnterPromptIdRef.current = null; secondEnterPromptIdRef.current = null;
const { rejected } = enqueue(files); const { rejected } = enqueue(files);
if (rejected.length > 0) { if (rejected.length > 0) {
@@ -1080,7 +1086,7 @@ export function ThreadComposer({
setInlineError(null); setInlineError(null);
} }
}, },
[enqueue, formatRejection], [enqueue, formatRejection, interactionDisabled],
); );
const { const {
@@ -1093,18 +1099,20 @@ export function ThreadComposer({
} = useClipboardAndDrop(addFiles); } = useClipboardAndDrop(addFiles);
useEffect(() => { useEffect(() => {
if (disabled || hasTouchPrimaryPointer) return; if (interactionDisabled || hasTouchPrimaryPointer || (workspaceError && showProjectPicker)) {
return;
}
const el = textareaRef.current; const el = textareaRef.current;
if (!el) return; if (!el) return;
const id = requestAnimationFrame(() => el.focus()); const id = requestAnimationFrame(() => el.focus());
return () => cancelAnimationFrame(id); return () => cancelAnimationFrame(id);
}, [disabled, hasTouchPrimaryPointer]); }, [hasTouchPrimaryPointer, interactionDisabled, showProjectPicker, workspaceError]);
useEffect(() => { useEffect(() => {
if (!focusRequest || disabled) return; if (!focusRequest || interactionDisabled) return;
const id = requestAnimationFrame(() => textareaRef.current?.focus()); const id = requestAnimationFrame(() => textareaRef.current?.focus());
return () => cancelAnimationFrame(id); return () => cancelAnimationFrame(id);
}, [disabled, focusRequest]); }, [focusRequest, interactionDisabled]);
const normalizedQuotedContext = quotedContext?.trim().slice(0, QUEUED_PROMPT_MAX_CHARS) || null; 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 hasComposerContent = value.trim().length > 0 || readyImages.length > 0;
const canSend = const canSend =
!disabled !interactionDisabled
&& !modelNeedsSetup && !modelNeedsSetup
&& !encoding && !encoding
&& !hasErrors && !hasErrors
&& hasComposerContent; && hasComposerContent;
const canOpenModelSettings = Boolean(modelNeedsSetup && onModelBadgeClick && !disabled); const canOpenModelSettings = Boolean(
modelNeedsSetup && onModelBadgeClick && !interactionDisabled,
);
const canQueueGuidance = const canQueueGuidance =
isStreaming isStreaming
&& !disabled && !interactionDisabled
&& !modelNeedsSetup && !modelNeedsSetup
&& !encoding && !encoding
&& !hasErrors && !hasErrors
@@ -1134,14 +1144,14 @@ export function ThreadComposer({
&& !value.trimStart().startsWith("/"); && !value.trimStart().startsWith("/");
const slashQuery = useMemo(() => { const slashQuery = useMemo(() => {
if (disabled || slashMenuDismissed || !value.startsWith("/")) return null; if (interactionDisabled || slashMenuDismissed || !value.startsWith("/")) return null;
const commandToken = value.slice(1); const commandToken = value.slice(1);
if (/\s/.test(commandToken)) return null; if (/\s/.test(commandToken)) return null;
return commandToken.toLowerCase(); return commandToken.toLowerCase();
}, [disabled, slashMenuDismissed, value]); }, [interactionDisabled, slashMenuDismissed, value]);
const skillQuery = useMemo(() => { const skillQuery = useMemo(() => {
if (disabled || slashMenuDismissed) return null; if (interactionDisabled || slashMenuDismissed) return null;
const caret = Math.min(Math.max(cursorPosition, 0), value.length); const caret = Math.min(Math.max(cursorPosition, 0), value.length);
const beforeCaret = value.slice(0, caret); const beforeCaret = value.slice(0, caret);
const match = /\$([A-Za-z0-9_-]*)$/i.exec(beforeCaret); const match = /\$([A-Za-z0-9_-]*)$/i.exec(beforeCaret);
@@ -1151,7 +1161,7 @@ export function ThreadComposer({
start: match.index, start: match.index,
text: match[1].toLowerCase(), text: match[1].toLowerCase(),
}; };
}, [cursorPosition, disabled, slashMenuDismissed, value]); }, [cursorPosition, interactionDisabled, slashMenuDismissed, value]);
const visibleSlashCommands = useMemo(() => { const visibleSlashCommands = useMemo(() => {
if (!(isStreaming && onStop)) return slashCommands; if (!(isStreaming && onStop)) return slashCommands;
@@ -1279,7 +1289,7 @@ export function ThreadComposer({
const showSlashMenu = filteredSlashCommands.length > 0; const showSlashMenu = filteredSlashCommands.length > 0;
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => { 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 caret = Math.min(Math.max(cursorPosition, 0), value.length);
const beforeCaret = value.slice(0, caret); const beforeCaret = value.slice(0, caret);
const match = /(?:^|\s)@([\p{L}\p{N}_-]*)$/iu.exec(beforeCaret); const match = /(?:^|\s)@([\p{L}\p{N}_-]*)$/iu.exec(beforeCaret);
@@ -1290,7 +1300,7 @@ export function ThreadComposer({
start: caret - query.length - 1, start: caret - query.length - 1,
end: caret, end: caret,
}; };
}, [cliAppMenuDismissed, cursorPosition, disabled, value]); }, [cliAppMenuDismissed, cursorPosition, interactionDisabled, value]);
const availableSessionMentions = useMemo( const availableSessionMentions = useMemo(
() => sessionMentionOptions( () => sessionMentionOptions(
@@ -1580,7 +1590,7 @@ export function ThreadComposer({
}, VOICE_ERROR_VISIBLE_MS); }, VOICE_ERROR_VISIBLE_MS);
}, [clearVoiceErrorTimers, t]); }, [clearVoiceErrorTimers, t]);
const voiceRecorder = useVoiceRecorder({ const voiceRecorder = useVoiceRecorder({
disabled, disabled: interactionDisabled,
onClearError: clearInlineError, onClearError: clearInlineError,
onError: setVoiceError, onError: setVoiceError,
onTranscript: appendTranscription, onTranscript: appendTranscription,
@@ -1714,7 +1724,7 @@ export function ThreadComposer({
clearDraggedSession(); clearDraggedSession();
const preview = sessionDragPreview; const preview = sessionDragPreview;
setSessionDragPreview(null); setSessionDragPreview(null);
if (disabled) return true; if (interactionDisabled) return true;
const sessionKey = readDraggedSession(event.dataTransfer); const sessionKey = readDraggedSession(event.dataTransfer);
const mention = availableSessionMentions.find( const mention = availableSessionMentions.find(
(candidate) => candidate.session_key === (sessionKey ?? preview?.mention.session_key), (candidate) => candidate.session_key === (sessionKey ?? preview?.mention.session_key),
@@ -1732,11 +1742,17 @@ export function ThreadComposer({
preview?.end ?? textareaRef.current?.selectionEnd ?? caret, preview?.end ?? textareaRef.current?.selectionEnd ?? caret,
); );
return true; return true;
}, [availableSessionMentions, disabled, insertMentionCandidate, sessionDragPreview, value.length]); }, [
availableSessionMentions,
insertMentionCandidate,
interactionDisabled,
sessionDragPreview,
value.length,
]);
const previewSessionDrop = useCallback((event: React.DragEvent) => { const previewSessionDrop = useCallback((event: React.DragEvent) => {
if (!hasDraggedSession(event.dataTransfer)) return false; if (!hasDraggedSession(event.dataTransfer)) return false;
if (disabled) { if (interactionDisabled) {
event.dataTransfer.dropEffect = "none"; event.dataTransfer.dropEffect = "none";
setSessionDragPreview(null); setSessionDragPreview(null);
return true; return true;
@@ -1765,7 +1781,7 @@ export function ThreadComposer({
: { mention, start, end } : { mention, start, end }
)); ));
return true; return true;
}, [activeSessionMentions, availableSessionMentions, disabled, value.length]); }, [activeSessionMentions, availableSessionMentions, interactionDisabled, value.length]);
useEffect(() => { useEffect(() => {
if (!sessionDragPreview) return; if (!sessionDragPreview) return;
@@ -2008,7 +2024,16 @@ export function ThreadComposer({
const isSlashSideChannel = isSideChannelLifecycle(slashLifecycle); const isSlashSideChannel = isSideChannelLifecycle(slashLifecycle);
const finalizeActiveTurn = const finalizeActiveTurn =
slashLifecycle === "finalize_active_turn"; 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, content,
payload, payload,
isSlashSideChannel isSlashSideChannel
@@ -2019,13 +2044,19 @@ export function ThreadComposer({
} }
: options, : options,
); );
if (hasTouchPrimaryPointer) textareaRef.current?.blur(); if (result instanceof Promise) {
setQueuedPrompts([]); setSendPending(true);
// Bubble owns the data URL copy; safe to revoke every staged blob void result
// preview here without affecting the rendered message. .then((accepted) => {
clear(); if (accepted !== false) finishSend();
clearComposerText(!hasTouchPrimaryPointer); })
onQuotedContextChange?.(null); .catch((error: unknown) => {
console.error("Failed to send message", error);
})
.finally(() => setSendPending(false));
return;
}
if (result !== false) finishSend();
}, [ }, [
activeCliMentionApps, activeCliMentionApps,
activeMcpPresetMentions, activeMcpPresetMentions,
@@ -2168,7 +2199,7 @@ export function ThreadComposer({
[removeChip], [removeChip],
); );
const attachButtonDisabled = disabled || full; const attachButtonDisabled = interactionDisabled || full;
const showVoiceButton = Boolean(onTranscribeAudio); const showVoiceButton = Boolean(onTranscribeAudio);
const voiceRecordingStatusLabel = t("thread.composer.voice.recordingStatus", { const voiceRecordingStatusLabel = t("thread.composer.voice.recordingStatus", {
time: voiceRecorder.elapsedLabel, time: voiceRecorder.elapsedLabel,
@@ -2253,7 +2284,7 @@ export function ThreadComposer({
isHero 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-[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]", : "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", sessionDragPreview && "ring-1 ring-primary/25",
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary", isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
goalState?.active && goalState?.active &&
@@ -2368,7 +2399,7 @@ export function ThreadComposer({
onPaste={onPaste} onPaste={onPaste}
rows={1} rows={1}
placeholder={sessionDragPreview ? "" : resolvedPlaceholder} placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
disabled={disabled} disabled={interactionDisabled}
aria-label={t("thread.composer.inputAria")} aria-label={t("thread.composer.inputAria")}
className={cn( className={cn(
inputTextClasses, inputTextClasses,
@@ -2443,7 +2474,7 @@ export function ThreadComposer({
) : workspaceScope && !workspaceControlsHidden ? ( ) : workspaceScope && !workspaceControlsHidden ? (
<WorkspaceAccessMenu <WorkspaceAccessMenu
scope={workspaceScope} scope={workspaceScope}
disabled={disabled || workspaceScopeDisabled} disabled={interactionDisabled || workspaceScopeDisabled}
canUseFullAccess={workspaceControls?.can_use_full_access !== false} canUseFullAccess={workspaceControls?.can_use_full_access !== false}
isHero={isHero} isHero={isHero}
onChange={onWorkspaceScopeChange} onChange={onWorkspaceScopeChange}
@@ -2521,7 +2552,7 @@ export function ThreadComposer({
<Button <Button
type={showStopButton || modelNeedsSetup ? "button" : "submit"} type={showStopButton || modelNeedsSetup ? "button" : "submit"}
size="icon" size="icon"
disabled={showStopButton ? disabled : !canSend && !canOpenModelSettings} disabled={showStopButton ? interactionDisabled : !canSend && !canOpenModelSettings}
aria-label={ aria-label={
showStopButton showStopButton
? t("thread.composer.stop") ? t("thread.composer.stop")
@@ -2562,7 +2593,7 @@ export function ThreadComposer({
<div className="composer-workspace-drawer-content"> <div className="composer-workspace-drawer-content">
<WorkspaceProjectPicker <WorkspaceProjectPicker
isHero={isHero} isHero={isHero}
disabled={disabled || workspaceScopeDisabled || !showProjectPicker} disabled={interactionDisabled || workspaceScopeDisabled || !showProjectPicker}
scope={workspaceScope} scope={workspaceScope}
defaultScope={workspaceDefaultScope} defaultScope={workspaceDefaultScope}
controls={workspaceControls} controls={workspaceControls}
+3 -2
View File
@@ -1261,7 +1261,7 @@ export function ThreadShell({
const handleWelcomeSend = useCallback( const handleWelcomeSend = useCallback(
async (content: string, images?: SendAttachment[], options?: SendOptions) => { async (content: string, images?: SendAttachment[], options?: SendOptions) => {
if (booting) return; if (booting) return false;
setBooting(true); setBooting(true);
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) }; pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
setPendingFirstTargetChatId(null); setPendingFirstTargetChatId(null);
@@ -1270,12 +1270,13 @@ export function ThreadShell({
pendingFirstRef.current = null; pendingFirstRef.current = null;
setPendingFirstTargetChatId(null); setPendingFirstTargetChatId(null);
setBooting(false); setBooting(false);
return; return false;
} }
if (localModelPreset) { if (localModelPreset) {
await client.sendSystemCommand(newId, `/model ${localModelPreset}`).catch(() => {}); await client.sendSystemCommand(newId, `/model ${localModelPreset}`).catch(() => {});
} }
setPendingFirstTargetChatId(newId); setPendingFirstTargetChatId(newId);
return true;
}, },
[booting, client, localModelPreset, onCreateChat, withWorkspaceScope, workspaceScope], [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 { AlertTriangle, Check, ChevronDown, Folder, Hand } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -34,6 +34,14 @@ import {
shortWorkspacePath, shortWorkspacePath,
} from "@/lib/workspace"; } 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({ export function WorkspaceProjectPicker({
isHero, isHero,
compact = false, compact = false,
@@ -60,6 +68,9 @@ export function WorkspaceProjectPicker({
const [pathDraft, setPathDraft] = useState(""); const [pathDraft, setPathDraft] = useState("");
const [pathError, setPathError] = useState<string | null>(null); const [pathError, setPathError] = useState<string | null>(null);
const [pickingFolder, setPickingFolder] = useState(false); const [pickingFolder, setPickingFolder] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const pathInputRef = useRef<HTMLInputElement>(null);
const pathErrorId = useId();
const currentProjectScope = selectedProjectScope(scope, defaultScope); const currentProjectScope = selectedProjectScope(scope, defaultScope);
const projectLabel = currentProjectScope const projectLabel = currentProjectScope
? currentProjectScope.project_name || projectNameFromPath(currentProjectScope.project_path) ? currentProjectScope.project_name || projectNameFromPath(currentProjectScope.project_path)
@@ -82,9 +93,17 @@ export function WorkspaceProjectPicker({
}, [disabled]); }, [disabled]);
useEffect(() => { 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]); }, [disabled, error, visible]);
useEffect(() => {
if (!open || !error) return;
const frame = window.requestAnimationFrame(() => pathInputRef.current?.focus());
return () => window.cancelAnimationFrame(frame);
}, [error, open]);
const applyProjectPath = useCallback( const applyProjectPath = useCallback(
(projectPath: string, projectName?: string) => { (projectPath: string, projectName?: string) => {
const base = scope ?? defaultScope; 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", : "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 <button
ref={triggerRef}
type="button" type="button"
disabled={disabled || pickingFolder} disabled={disabled || pickingFolder}
aria-label={t("thread.composer.workspace.projectAria")} aria-label={t("thread.composer.workspace.projectAria")}
@@ -164,6 +184,7 @@ export function WorkspaceProjectPicker({
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
ref={triggerRef}
type="button" type="button"
disabled={disabled} disabled={disabled}
aria-label={t("thread.composer.workspace.projectAria")} aria-label={t("thread.composer.workspace.projectAria")}
@@ -221,14 +242,20 @@ export function WorkspaceProjectPicker({
}} }}
> >
<Input <Input
ref={pathInputRef}
value={pathDraft} value={pathDraft}
disabled={disabled} disabled={disabled}
onChange={(event) => { onChange={(event) => {
setPathDraft(event.target.value); setPathDraft(event.target.value);
setPathError(null); setPathError(null);
}} }}
placeholder={t("workspace.dialog.manualPlaceholder")} placeholder={workspacePathPlaceholder(
defaultScope.project_path,
t("workspace.dialog.manualPlaceholder"),
)}
aria-label={t("workspace.dialog.manual")} aria-label={t("workspace.dialog.manual")}
aria-invalid={pathError || error ? true : undefined}
aria-describedby={pathError || error ? pathErrorId : undefined}
className={cn( className={cn(
"h-9 rounded-full border-border/55 bg-background/80 px-3 text-[12.5px]", "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", "focus-visible:ring-1 focus-visible:ring-foreground/10 focus-visible:ring-offset-0",
@@ -243,13 +270,22 @@ export function WorkspaceProjectPicker({
</Button> </Button>
</form> </form>
{pathError || error ? ( {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} {pathError ?? error}
</p> </p>
) : null} ) : null}
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </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> </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." "gatewayHint": "Make sure the gateway is running (`nanobot gateway`) and that this page is open on the same machine."
}, },
"auth": { "auth": {
"title": "Authentication required", "label": "Password",
"hint": "Enter the secret configured as tokenIssueSecret in your gateway config.", "showPassword": "Show password",
"placeholder": "Password", "hidePassword": "Hide password",
"submit": "Connect", "submit": "Connect",
"invalid": "Invalid password. Try again." "required": "Enter your password.",
"invalid": "Incorrect password. Try again."
}, },
"account": { "account": {
"section": "Account", "section": "Account",
@@ -595,7 +596,14 @@
"searchPlaceholder": "Search tools", "searchPlaceholder": "Search tools",
"featured": "Tools", "featured": "Tools",
"loading": "Loading Apps...", "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." "restartRequired": "Restart nanobot to apply updated apps and integrations."
}, },
"channels": { "channels": {
@@ -698,7 +706,9 @@
"loading": "Loading automations...", "loading": "Loading automations...",
"noMatches": "No automations match this view.", "noMatches": "No automations match this view.",
"empty": "No automations yet.", "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", "oneShot": "One-time",
"systemTask": "System-managed automation", "systemTask": "System-managed automation",
"localTrigger": "Local trigger", "localTrigger": "Local trigger",
@@ -1381,7 +1391,7 @@
}, },
"workspaceScopeRejected": { "workspaceScopeRejected": {
"title": "Workspace was not changed", "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": { "turnRejected": {
"title": "Message was not sent", "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." "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": { "auth": {
"title": "Autenticación requerida", "label": "Contraseña",
"hint": "Introduce el secreto configurado como tokenIssueSecret en la configuración del gateway.", "showPassword": "Mostrar contraseña",
"placeholder": "Contraseña", "hidePassword": "Ocultar contraseña",
"submit": "Conectar", "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": { "account": {
"section": "Cuenta", "section": "Cuenta",
@@ -582,7 +583,14 @@
"searchPlaceholder": "Buscar aplicaciones", "searchPlaceholder": "Buscar aplicaciones",
"featured": "Herramientas", "featured": "Herramientas",
"loading": "Cargando aplicaciones...", "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." "restartRequired": "Reinicia nanobot para aplicar las aplicaciones y funciones actualizadas."
}, },
"channels": { "channels": {
@@ -685,7 +693,9 @@
"loading": "Cargando automatizaciones...", "loading": "Cargando automatizaciones...",
"noMatches": "No hay automatizaciones que coincidan con esta vista.", "noMatches": "No hay automatizaciones que coincidan con esta vista.",
"empty": "Aún no hay automatizaciones.", "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", "oneShot": "Una vez",
"systemTask": "Automatización administrada por el sistema", "systemTask": "Automatización administrada por el sistema",
"localTrigger": "Activador local", "localTrigger": "Activador local",
@@ -1368,7 +1378,7 @@
}, },
"workspaceScopeRejected": { "workspaceScopeRejected": {
"title": "El espacio de trabajo no cambió", "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": { "turnRejected": {
"title": "El mensaje no se envió", "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." "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": { "auth": {
"title": "Authentification requise", "label": "Mot de passe",
"hint": "Saisissez le secret configuré comme tokenIssueSecret dans la configuration de votre gateway.", "showPassword": "Afficher le mot de passe",
"placeholder": "Mot de passe", "hidePassword": "Masquer le mot de passe",
"submit": "Se connecter", "submit": "Se connecter",
"invalid": "Mot de passe invalide. Réessayez." "required": "Saisissez le mot de passe.",
"invalid": "Mot de passe incorrect. Réessayez."
}, },
"account": { "account": {
"section": "Compte", "section": "Compte",
@@ -581,7 +582,14 @@
"searchPlaceholder": "Rechercher des applications", "searchPlaceholder": "Rechercher des applications",
"featured": "Outils", "featured": "Outils",
"loading": "Chargement des applications...", "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." "restartRequired": "Redémarrez nanobot pour appliquer les applications et fonctions mises à jour."
}, },
"channels": { "channels": {
@@ -684,7 +692,9 @@
"loading": "Chargement des automatisations...", "loading": "Chargement des automatisations...",
"noMatches": "Aucune automatisation ne correspond à cette vue.", "noMatches": "Aucune automatisation ne correspond à cette vue.",
"empty": "Aucune automatisation pour le moment.", "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", "oneShot": "Ponctuelle",
"systemTask": "Automatisation gérée par le système", "systemTask": "Automatisation gérée par le système",
"localTrigger": "Déclencheur local", "localTrigger": "Déclencheur local",
@@ -1367,7 +1377,7 @@
}, },
"workspaceScopeRejected": { "workspaceScopeRejected": {
"title": "Lespace de travail na pas changé", "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": { "turnRejected": {
"title": "Le message na pas été envoyé", "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." "gatewayHint": "Pastikan gateway sedang berjalan (`nanobot gateway`) dan halaman ini dibuka pada mesin yang sama."
}, },
"auth": { "auth": {
"title": "Autentikasi diperlukan", "label": "Kata sandi",
"hint": "Masukkan secret yang dikonfigurasi sebagai tokenIssueSecret di konfigurasi gateway.", "showPassword": "Tampilkan kata sandi",
"placeholder": "Kata sandi", "hidePassword": "Sembunyikan kata sandi",
"submit": "Hubungkan", "submit": "Hubungkan",
"invalid": "Kata sandi tidak valid. Coba lagi." "required": "Masukkan kata sandi.",
"invalid": "Kata sandi salah. Coba lagi."
}, },
"account": { "account": {
"section": "Akun", "section": "Akun",
@@ -581,7 +582,14 @@
"searchPlaceholder": "Cari aplikasi", "searchPlaceholder": "Cari aplikasi",
"featured": "Alat", "featured": "Alat",
"loading": "Memuat aplikasi...", "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." "restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan fitur yang diperbarui."
}, },
"channels": { "channels": {
@@ -684,7 +692,9 @@
"loading": "Memuat otomasi...", "loading": "Memuat otomasi...",
"noMatches": "Tidak ada otomasi yang cocok dengan tampilan ini.", "noMatches": "Tidak ada otomasi yang cocok dengan tampilan ini.",
"empty": "Belum ada otomasi.", "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", "oneShot": "Satu kali",
"systemTask": "Automasi yang dikelola sistem", "systemTask": "Automasi yang dikelola sistem",
"localTrigger": "Pemicu lokal", "localTrigger": "Pemicu lokal",
@@ -1367,7 +1377,7 @@
}, },
"workspaceScopeRejected": { "workspaceScopeRejected": {
"title": "Ruang kerja tidak berubah", "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": { "turnRejected": {
"title": "Pesan tidak terkirim", "title": "Pesan tidak terkirim",
+17 -7
View File
@@ -10,11 +10,12 @@
"gatewayHint": "gateway`nanobot gateway`)が起動しており、このページが同じマシン上で開かれていることを確認してください。" "gatewayHint": "gateway`nanobot gateway`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
}, },
"auth": { "auth": {
"title": "認証が必要です", "label": "パスワード",
"hint": "gateway 設定の tokenIssueSecret に指定されたシークレットを入力してください。", "showPassword": "パスワードを表示",
"placeholder": "パスワード", "hidePassword": "パスワードを隠す",
"submit": "接続", "submit": "接続",
"invalid": "パスワードが無効です。もう一度お試しください。" "required": "パスワードを入力してください。",
"invalid": "パスワードが正しくありません。もう一度お試しください。"
}, },
"account": { "account": {
"section": "アカウント", "section": "アカウント",
@@ -581,7 +582,14 @@
"searchPlaceholder": "アプリを検索", "searchPlaceholder": "アプリを検索",
"featured": "ツール", "featured": "ツール",
"loading": "アプリを読み込み中...", "loading": "アプリを読み込み中...",
"empty": "この表示に一致するツールはありません。", "empty": "検索条件に一致するツールはありません。",
"emptyApps": "利用できるアプリはありません。",
"emptyIntegrations": "利用できる連携はありません。",
"emptyReady": "使用可能なツールはまだありません。",
"clearSearch": "検索をクリア",
"browseApps": "アプリを見る",
"browseIntegrations": "連携を見る",
"emptyIntegrationsHint": "下からカスタム連携を追加できます。",
"restartRequired": "更新したアプリと機能を反映するには nanobot を再起動してください。" "restartRequired": "更新したアプリと機能を反映するには nanobot を再起動してください。"
}, },
"channels": { "channels": {
@@ -684,7 +692,9 @@
"loading": "自動タスクを読み込み中...", "loading": "自動タスクを読み込み中...",
"noMatches": "この表示に一致する自動タスクはありません。", "noMatches": "この表示に一致する自動タスクはありません。",
"empty": "自動タスクはまだありません。", "empty": "自動タスクはまだありません。",
"emptyHint": "実行元から作成すると、nanobot が正しいコンテキストを保持できます。", "emptyHint": "正しいコンテキストを保持するには、チャットで自動化を作成してください。",
"emptyAction": "チャットを開く",
"clearFilters": "フィルターをクリア",
"oneShot": "一回限り", "oneShot": "一回限り",
"systemTask": "システム管理の自動タスク", "systemTask": "システム管理の自動タスク",
"localTrigger": "ローカルトリガー", "localTrigger": "ローカルトリガー",
@@ -1367,7 +1377,7 @@
}, },
"workspaceScopeRejected": { "workspaceScopeRejected": {
"title": "ワークスペースは変更されませんでした", "title": "ワークスペースは変更されませんでした",
"body": "要求されたプロジェクトまたはアクセスモードゲートウェイ拒否されたため、Nanobot は以前のワークスペースをそのまま使用しています。" "body": "このプロジェクトまたはアクセスモードゲートウェイ拒否されました。既存のプロジェクトまたは別のアクセスモードを選択して、もう一度お試しください。"
}, },
"turnRejected": { "turnRejected": {
"title": "メッセージは送信されませんでした", "title": "メッセージは送信されませんでした",
+16 -6
View File
@@ -10,10 +10,11 @@
"gatewayHint": "gateway(`nanobot gateway`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요." "gatewayHint": "gateway(`nanobot gateway`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
}, },
"auth": { "auth": {
"title": "인증이 필요합니다", "label": "비밀번호",
"hint": "gateway 설정의 tokenIssueSecret에 구성된 비밀 값을 입력하세요.", "showPassword": "비밀번호 표시",
"placeholder": "비밀번호", "hidePassword": "비밀번호 숨기기",
"submit": "연결", "submit": "연결",
"required": "비밀번호를 입력하세요.",
"invalid": "비밀번호가 올바르지 않습니다. 다시 시도하세요." "invalid": "비밀번호가 올바르지 않습니다. 다시 시도하세요."
}, },
"account": { "account": {
@@ -581,7 +582,14 @@
"searchPlaceholder": "앱 검색", "searchPlaceholder": "앱 검색",
"featured": "도구", "featured": "도구",
"loading": "앱을 불러오는 중...", "loading": "앱을 불러오는 중...",
"empty": "이 보기에 일치하는 도구가 없습니다.", "empty": "검색과 일치하는 도구가 없습니다.",
"emptyApps": "사용 가능한 앱이 없습니다.",
"emptyIntegrations": "사용 가능한 연동이 없습니다.",
"emptyReady": "아직 준비된 도구가 없습니다.",
"clearSearch": "검색 지우기",
"browseApps": "앱 둘러보기",
"browseIntegrations": "연동 둘러보기",
"emptyIntegrationsHint": "아래에서 사용자 지정 연동을 추가하세요.",
"restartRequired": "업데이트된 앱과 기능을 적용하려면 nanobot을 다시 시작하세요." "restartRequired": "업데이트된 앱과 기능을 적용하려면 nanobot을 다시 시작하세요."
}, },
"channels": { "channels": {
@@ -684,7 +692,9 @@
"loading": "자동화를 불러오는 중...", "loading": "자동화를 불러오는 중...",
"noMatches": "이 보기와 일치하는 자동화가 없습니다.", "noMatches": "이 보기와 일치하는 자동화가 없습니다.",
"empty": "아직 자동화가 없습니다.", "empty": "아직 자동화가 없습니다.",
"emptyHint": "실행될 위치에서 만들면 nanobot이 올바른 컨텍스트를 유지합니다.", "emptyHint": "올바른 컨텍스트를 유지하려면 채팅에서 자동화를 만드세요.",
"emptyAction": "채팅 열기",
"clearFilters": "필터 지우기",
"oneShot": "일회성", "oneShot": "일회성",
"systemTask": "시스템 관리 자동화", "systemTask": "시스템 관리 자동화",
"localTrigger": "로컬 트리거", "localTrigger": "로컬 트리거",
@@ -1367,7 +1377,7 @@
}, },
"workspaceScopeRejected": { "workspaceScopeRejected": {
"title": "작업공간이 변경되지 않았습니다", "title": "작업공간이 변경되지 않았습니다",
"body": "요청한 프로젝트 또는 접근 모드가 게이트웨이에서 거부되어 Nanobot이 이전 작업공간을 계속 사용합니다." "body": "게이트웨이가 이 프로젝트 또는 접근 모드를 거부했습니다. 기존 프로젝트나 다른 접근 모드를 선택한 후 다시 시도하세요."
}, },
"turnRejected": { "turnRejected": {
"title": "메시지가 전송되지 않았습니다", "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." "gatewayHint": "Verifique se o gateway está em execução (`nanobot gateway`) e se esta página está aberta na mesma máquina."
}, },
"auth": { "auth": {
"title": "Autenticação necessária", "label": "Senha",
"hint": "Informe o segredo configurado como tokenIssueSecret na configuração do gateway.", "showPassword": "Mostrar senha",
"placeholder": "Senha", "hidePassword": "Ocultar senha",
"submit": "Conectar", "submit": "Conectar",
"invalid": "Senha inválida. Tente novamente." "required": "Digite a senha.",
"invalid": "Senha incorreta. Tente novamente."
}, },
"account": { "account": {
"section": "Conta", "section": "Conta",
@@ -595,7 +596,14 @@
"searchPlaceholder": "Buscar ferramentas", "searchPlaceholder": "Buscar ferramentas",
"featured": "Ferramentas", "featured": "Ferramentas",
"loading": "Carregando aplicativos...", "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." "restartRequired": "Reinicie o nanobot para aplicar os aplicativos e integrações atualizados."
}, },
"channels": { "channels": {
@@ -698,7 +706,9 @@
"loading": "Carregando automações...", "loading": "Carregando automações...",
"noMatches": "Nenhuma automação corresponde a esta visualização.", "noMatches": "Nenhuma automação corresponde a esta visualização.",
"empty": "Nenhuma automação ainda.", "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", "oneShot": "Uma vez",
"systemTask": "Automação gerenciada pelo sistema", "systemTask": "Automação gerenciada pelo sistema",
"localTrigger": "Gatilho local", "localTrigger": "Gatilho local",
@@ -1381,7 +1391,7 @@
}, },
"workspaceScopeRejected": { "workspaceScopeRejected": {
"title": "O espaço de trabalho não foi alterado", "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": { "turnRejected": {
"title": "A mensagem não foi enviada", "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." "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": { "auth": {
"title": "Cần xác thực", "label": "Mật khẩu",
"hint": "Nhập secret được cấu hình là tokenIssueSecret trong cấu hình gateway.", "showPassword": "Hiện mật khẩu",
"placeholder": "Mật khẩu", "hidePassword": "Ẩn mật khẩu",
"submit": "Kết nối", "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": { "account": {
"section": "Tài khoản", "section": "Tài khoản",
@@ -581,7 +582,14 @@
"searchPlaceholder": "Tìm ứng dụng", "searchPlaceholder": "Tìm ứng dụng",
"featured": "Công cụ", "featured": "Công cụ",
"loading": "Đang tải ứng dụng...", "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." "restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và tính năng đã cập nhật."
}, },
"channels": { "channels": {
@@ -684,7 +692,9 @@
"loading": "Đang tải tự động hóa...", "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.", "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.", "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", "oneShot": "Một lần",
"systemTask": "Tự động hóa do hệ thống quản lý", "systemTask": "Tự động hóa do hệ thống quản lý",
"localTrigger": "Trình kích hoạt cục bộ", "localTrigger": "Trình kích hoạt cục bộ",
@@ -1367,7 +1377,7 @@
}, },
"workspaceScopeRejected": { "workspaceScopeRejected": {
"title": "Không gian làm việc không thay đổi", "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": { "turnRejected": {
"title": "Tin nhắn chưa được gửi", "title": "Tin nhắn chưa được gửi",
+17 -7
View File
@@ -10,11 +10,12 @@
"gatewayHint": "请确认网关已启动(`nanobot gateway`),并且当前页面与网关运行在同一台机器上。" "gatewayHint": "请确认网关已启动(`nanobot gateway`),并且当前页面与网关运行在同一台机器上。"
}, },
"auth": { "auth": {
"title": "需要验证", "label": "密码",
"hint": "请输入网关配置中的 tokenIssueSecret。", "showPassword": "显示密码",
"placeholder": "密码", "hidePassword": "隐藏密码",
"submit": "连接", "submit": "连接",
"invalid": "密码无效,请重试。" "required": "请输入密码。",
"invalid": "密码错误,请重试。"
}, },
"account": { "account": {
"section": "账户", "section": "账户",
@@ -595,7 +596,14 @@
"searchPlaceholder": "搜索工具", "searchPlaceholder": "搜索工具",
"featured": "工具", "featured": "工具",
"loading": "正在加载应用...", "loading": "正在加载应用...",
"empty": "当前视图没有匹配的工具。", "empty": "没有与搜索条件匹配的工具。",
"emptyApps": "暂无可用应用。",
"emptyIntegrations": "暂无可用集成。",
"emptyReady": "还没有就绪的工具。",
"clearSearch": "清除搜索",
"browseApps": "浏览应用",
"browseIntegrations": "浏览集成",
"emptyIntegrationsHint": "可在下方添加自定义集成。",
"restartRequired": "重启 nanobot 以应用更新后的应用和集成。" "restartRequired": "重启 nanobot 以应用更新后的应用和集成。"
}, },
"channels": { "channels": {
@@ -698,7 +706,9 @@
"loading": "正在加载自动任务...", "loading": "正在加载自动任务...",
"noMatches": "当前视图没有匹配的自动任务。", "noMatches": "当前视图没有匹配的自动任务。",
"empty": "暂无自动任务。", "empty": "暂无自动任务。",
"emptyHint": "请从它应该运行的来源处创建,这样 nanobot 才能保留正确上下文。", "emptyHint": "请在对话中创建自动任务,以便保留正确上下文。",
"emptyAction": "打开对话",
"clearFilters": "清除筛选",
"oneShot": "一次性", "oneShot": "一次性",
"systemTask": "系统管理的自动任务", "systemTask": "系统管理的自动任务",
"localTrigger": "本地触发器", "localTrigger": "本地触发器",
@@ -1381,7 +1391,7 @@
}, },
"workspaceScopeRejected": { "workspaceScopeRejected": {
"title": "工作区未更改", "title": "工作区未更改",
"body": "网关拒绝了请求的项目或访问权限,Nanobot 已继续使用之前的工作区。" "body": "网关拒绝了此项目或访问权限。请选择已存在的项目或其他访问权限,然后重试。"
}, },
"turnRejected": { "turnRejected": {
"title": "消息未发送", "title": "消息未发送",
+17 -7
View File
@@ -10,11 +10,12 @@
"gatewayHint": "請確認閘道已啟動(`nanobot gateway`),並且目前頁面與閘道在同一台機器上開啟。" "gatewayHint": "請確認閘道已啟動(`nanobot gateway`),並且目前頁面與閘道在同一台機器上開啟。"
}, },
"auth": { "auth": {
"title": "需要驗證", "label": "密碼",
"hint": "請輸入閘道設定中 tokenIssueSecret 所設定的金鑰。", "showPassword": "顯示密碼",
"placeholder": "密碼", "hidePassword": "隱藏密碼",
"submit": "連線", "submit": "連線",
"invalid": "密碼無效,請再試一次。" "required": "請輸入密碼。",
"invalid": "密碼錯誤,請再試一次。"
}, },
"account": { "account": {
"section": "帳戶", "section": "帳戶",
@@ -581,7 +582,14 @@
"searchPlaceholder": "搜尋工具", "searchPlaceholder": "搜尋工具",
"featured": "工具", "featured": "工具",
"loading": "正在載入應用程式…", "loading": "正在載入應用程式…",
"empty": "沒有符合條件的工具。", "empty": "沒有符合搜尋條件的工具。",
"emptyApps": "沒有可用的應用程式。",
"emptyIntegrations": "沒有可用的整合服務。",
"emptyReady": "尚無就緒的工具。",
"clearSearch": "清除搜尋",
"browseApps": "瀏覽應用程式",
"browseIntegrations": "瀏覽整合服務",
"emptyIntegrationsHint": "可在下方新增自訂整合服務。",
"restartRequired": "重新啟動 nanobot 以套用更新後的應用程式與整合服務。" "restartRequired": "重新啟動 nanobot 以套用更新後的應用程式與整合服務。"
}, },
"channels": { "channels": {
@@ -684,7 +692,9 @@
"loading": "正在載入自動任務…", "loading": "正在載入自動任務…",
"noMatches": "目前沒有符合條件的自動任務。", "noMatches": "目前沒有符合條件的自動任務。",
"empty": "尚無自動任務。", "empty": "尚無自動任務。",
"emptyHint": "請從自動任務預定執行的對話中建立,讓 nanobot 保留正確的對話脈絡。", "emptyHint": "請在聊天中建立自動任務,以保留正確的對話脈絡。",
"emptyAction": "開啟聊天",
"clearFilters": "清除篩選",
"oneShot": "單次", "oneShot": "單次",
"systemTask": "系統管理的自動任務", "systemTask": "系統管理的自動任務",
"localTrigger": "本機觸發器", "localTrigger": "本機觸發器",
@@ -1367,7 +1377,7 @@
}, },
"workspaceScopeRejected": { "workspaceScopeRejected": {
"title": "工作區未變更", "title": "工作區未變更",
"body": "閘道拒絕要求的專案或存取模式,因此 Nanobot 繼續使用先前的工作區。" "body": "閘道拒絕了此專案或存取模式。請選擇現有專案或其他存取模式,然後再試一次。"
}, },
"turnRejected": { "turnRejected": {
"title": "訊息未傳送", "title": "訊息未傳送",
+137 -6
View File
@@ -315,11 +315,68 @@ describe("App layout", () => {
render(<App />); render(<App />);
expect(await screen.findByText("Authentication required")).toBeInTheDocument(); expect(await screen.findByRole("heading", { level: 1, name: "Password" }))
expect(screen.queryByText("Invalid password. Try again.")).not.toBeInTheDocument(); .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(); 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 () => { it("shows the auth form when bootstrap does not issue an API token", async () => {
vi.mocked(fetchBootstrap).mockRejectedValueOnce( vi.mocked(fetchBootstrap).mockRejectedValueOnce(
new BootstrapAuthRequiredError( new BootstrapAuthRequiredError(
@@ -329,8 +386,11 @@ describe("App layout", () => {
render(<App />); render(<App />);
expect(await screen.findByText("Authentication required")).toBeInTheDocument(); expect(await screen.findByRole("heading", { level: 1, name: "Password" }))
expect(screen.queryByText("Invalid password. Try again.")).not.toBeInTheDocument(); .toBeInTheDocument();
expect(
screen.queryByText("Incorrect password. Try again."),
).not.toBeInTheDocument();
expect(connectSpy).not.toHaveBeenCalled(); expect(connectSpy).not.toHaveBeenCalled();
}); });
@@ -341,11 +401,16 @@ describe("App layout", () => {
render(<App />); render(<App />);
const password = await screen.findByPlaceholderText("Password"); const password = await screen.findByLabelText("Password");
fireEvent.change(password, { target: { value: "wrong-password" } }); fireEvent.change(password, { target: { value: "wrong-password" } });
fireEvent.click(screen.getByRole("button", { name: "Connect" })); 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(fetchBootstrap).toHaveBeenLastCalledWith("", "wrong-password");
expect(connectSpy).not.toHaveBeenCalled(); expect(connectSpy).not.toHaveBeenCalled();
}); });
@@ -365,6 +430,21 @@ describe("App layout", () => {
expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true); 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 () => { it("places Automations after Skills in the main sidebar", async () => {
render(<App />); 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 () => { it("restores the Settings route after a restart fallback hash", async () => {
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now())); localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels"); localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
+73 -2
View File
@@ -347,6 +347,7 @@ function renderSettingsView(
| "runtime"; | "runtime";
initialSettings?: SettingsPayload; initialSettings?: SettingsPayload;
showSidebar?: boolean; showSidebar?: boolean;
onBackToChat?: () => void;
onSettingsChange?: (payload: SettingsPayload) => void; onSettingsChange?: (payload: SettingsPayload) => void;
onNativeEngineRestart?: () => Promise<string>; onNativeEngineRestart?: () => Promise<string>;
} = {}, } = {},
@@ -359,7 +360,7 @@ function renderSettingsView(
initialSettings={options.initialSettings} initialSettings={options.initialSettings}
showSidebar={options.showSidebar} showSidebar={options.showSidebar}
onToggleTheme={() => {}} onToggleTheme={() => {}}
onBackToChat={() => {}} onBackToChat={options.onBackToChat ?? (() => {})}
onModelNameChange={() => {}} onModelNameChange={() => {}}
onSettingsChange={options.onSettingsChange} onSettingsChange={options.onSettingsChange}
onNativeEngineRestart={options.onNativeEngineRestart} onNativeEngineRestart={options.onNativeEngineRestart}
@@ -385,6 +386,9 @@ async function chooseProviderToConfigure(label: string) {
} }
describe("SettingsView Apps catalog", () => { 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(() => { beforeEach(() => {
vi.stubGlobal( vi.stubGlobal(
"matchMedia", "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 () => { 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) => { vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input); const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload()); if (url === "/api/settings") return jsonResponse(settingsPayload());
@@ -440,11 +471,49 @@ describe("SettingsView Apps catalog", () => {
initialSection: "automations", initialSection: "automations",
initialSettings: settingsPayload(), initialSettings: settingsPayload(),
showSidebar: false, showSidebar: false,
onBackToChat,
}); });
expect(screen.getByRole("heading", { name: "Automations" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Automations" })).toBeInTheDocument();
expect(await screen.findByText("No automations yet.")).toBeInTheDocument(); expect(await screen.findByText("No automations yet.")).toBeInTheDocument();
expect(screen.queryByText("Settings")).not.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 () => { it("coalesces focus refreshes while automations are already loading", async () => {
@@ -1963,8 +2032,10 @@ describe("SettingsView Apps catalog", () => {
renderSettingsView(); 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(); 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 () => { it("shows token activity on the overview", async () => {
+54
View File
@@ -350,6 +350,30 @@ function longPress(badge: HTMLElement, pointerId = 7) {
} }
describe("ThreadComposer", () => { 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 () => { it("dismisses the touch keyboard after a successful send", async () => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: query === "(hover: none) and (pointer: coarse)", 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", () => { it("slides project controls closed without offering a compact replacement", () => {
const defaultScope = { const defaultScope = {
project_path: "/Users/test/.nanobot/workspace", project_path: "/Users/test/.nanobot/workspace",