From 2e600341f493062b55cb5ddd90f8e10c48954db4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 22 Sep 2026 21:24:43 +0100 Subject: [PATCH] feat: organize settings with tabs and concise help (#3850) --- apps/web/AGENTS.md | 7 +- apps/web/app/settings/agents/page.tsx | 100 ++--- apps/web/app/settings/executors/page.tsx | 17 +- .../workspace/workspace-edit-client.tsx | 87 +++-- .../workspace-repositories-client.tsx | 1 + .../workspace/workspace-workflows-client.tsx | 1 + .../gitlab/gitlab-settings.test.tsx | 2 + .../settings/account/api-tokens.tsx | 54 ++- .../agent-generated-task-title-settings.tsx | 57 +-- .../agents/agent-profiles-section.tsx | 15 +- .../anchored-prompt-bar-settings.test.tsx | 2 +- .../settings/anchored-prompt-bar-settings.tsx | 175 ++++++--- .../settings/app-status-bar-settings-card.tsx | 46 +-- .../settings/appearance-account-sections.tsx | 55 ++- .../archive-confirmation-settings.tsx | 57 +-- .../settings/chat-motion-settings-card.tsx | 44 +-- .../settings/creation-auto-focus-settings.tsx | 57 ++- .../components/settings/editors-settings.tsx | 71 ++-- .../components/settings/general-settings.tsx | 121 +++--- .../settings/layouts/layout-settings.test.tsx | 2 + .../settings/layouts/layout-settings.tsx | 29 +- ...sk-agent-profile-default-settings.test.tsx | 26 +- ...cp-task-agent-profile-default-settings.tsx | 219 ++++++----- .../notification-permission-section.tsx | 21 +- .../settings/notification-sound-section.tsx | 19 +- ...ifications-settings-external-providers.tsx | 24 +- .../settings/notifications-settings.test.tsx | 16 +- .../settings/notifications-settings.tsx | 105 +++--- .../settings/plugins/plugin-detail.tsx | 19 +- .../settings/plugins/plugins-settings.tsx | 20 +- .../prevent-auto-start-agent-settings.tsx | 56 +-- .../components/settings/prompts-settings.tsx | 16 +- .../rich-output-motion-settings-card.tsx | 49 +-- .../components/settings/secrets-settings.tsx | 16 +- .../settings/settings-group.test.tsx | 177 +++++++++ .../components/settings/settings-group.tsx | 351 ++++++++++++++++++ .../settings/settings-info.test.tsx | 26 ++ .../web/components/settings/settings-info.tsx | 80 ++++ .../settings/settings-menu-mode-card.tsx | 66 ++-- .../settings/settings-page-template.tsx | 18 +- .../settings/settings-save-provider.test.tsx | 36 ++ .../settings/settings-save-provider.tsx | 40 +- .../components/settings/settings-section.tsx | 42 +-- .../web/components/settings/settings-tabs.tsx | 4 +- .../settings/sidebar-hover-settings-card.tsx | 53 +-- .../sleep-inhibition-settings.test.tsx | 19 +- .../settings/sleep-inhibition-settings.tsx | 245 ++++++------ .../settings/startup-page-settings-card.tsx | 103 +++-- .../system-metrics-settings-card.test.tsx | 8 +- .../settings/system-metrics-settings-card.tsx | 73 ++-- .../settings/system/about-settings.tsx | 17 +- .../settings/system/data-logs-settings.tsx | 72 ++-- .../system/message-queue-settings.test.tsx | 13 +- .../system/message-queue-settings.tsx | 315 ++++++++++------ .../organizations/organizations-page.tsx | 44 +-- .../system/session-capacity-settings.test.tsx | 4 +- .../system/session-capacity-settings.tsx | 243 +++++++----- .../settings/system/users-table.tsx | 68 ++-- .../settings/task-behavior-settings.test.tsx | 113 ++++++ .../settings/task-behavior-settings.tsx | 164 ++++++-- .../settings/task-behavior-tabs.test.ts | 37 ++ .../components/settings/task-behavior-tabs.ts | 37 ++ .../settings/todo-list-panel-settings.tsx | 192 +++++++--- .../components/settings/units/units-page.tsx | 43 ++- .../settings/unread-divider-settings.tsx | 54 ++- .../components/settings/unsaved-indicator.tsx | 6 +- .../components/settings/utility-sections.tsx | 60 ++- apps/web/e2e/helpers/settings-composition.ts | 4 + .../e2e/tests/office/scheduler-events.spec.ts | 6 + .../settings/executor-profile-routing.spec.ts | 5 +- .../kubernetes-task-environment-helpers.ts | 5 + .../mobile-agent-profile-delete.spec.ts | 6 +- .../mobile-agent-profile-duplicate.spec.ts | 7 +- .../settings/mobile-general-settings.spec.ts | 7 +- .../mobile-settings-composition.spec.ts | 121 ++++++ .../mobile-task-behavior-concise-help.spec.ts | 62 ++++ .../settings/notifications-type-scale.spec.ts | 8 +- .../settings/settings-composition.spec.ts | 107 ++++++ .../settings/settings-manual-save.spec.ts | 2 + .../settings/settings-typography.spec.ts | 11 +- .../task-behavior-concise-help.spec.ts | 127 +++++++ .../tests/settings/todo-list-panel.spec.ts | 3 +- .../e2e/tests/settings/utility-agents.spec.ts | 36 +- .../system/message-queue-settings.spec.ts | 30 +- .../mobile-message-queue-settings.spec.ts | 6 +- .../mobile-session-capacity-settings.spec.ts | 4 + .../system/session-capacity-settings.spec.ts | 7 + .../tests/task/creation-auto-focus-helpers.ts | 3 +- .../mcp-task-agent-profile-default.spec.ts | 15 +- ...ile-mcp-task-agent-profile-default.spec.ts | 36 +- .../mobile-unread-divider-preference.spec.ts | 2 +- .../task/unread-divider-preference.spec.ts | 2 +- .../web/lib/settings-discovery/target.test.ts | 33 ++ apps/web/lib/settings-discovery/target.ts | 15 +- apps/web/src/locales/en/settings.json | 41 +- apps/web/src/locales/ja/executors.json | 22 ++ apps/web/src/locales/ja/settings.json | 41 +- apps/web/src/locales/ja/task.json | 10 + apps/web/src/locales/pseudo/settings.json | 49 ++- apps/web/src/locales/pt-pt/settings.json | 41 +- apps/web/src/locales/zh-cn/settings.json | 41 +- apps/web/src/locales/zh-hk/settings.json | 41 +- apps/web/src/locales/zh-tw/settings.json | 41 +- ...plugin-integration-settings-route.test.tsx | 24 +- .../src/plugin-integration-settings-route.tsx | 1 + docs/plans/settings-composition/plan.md | 286 ++++++++++++++ .../settings-composition/surface-inventory.md | 47 +++ .../task-01-task-behavior.md | 172 +++++++++ .../task-02-preferences.md | 151 ++++++++ .../task-03-agent-executor.md | 140 +++++++ .../task-04-workspace-integration.md | 145 ++++++++ .../task-05-system-account.md | 148 ++++++++ .../task-06-concise-help-and-tabs.md | 243 ++++++++++++ docs/plans/settings-typography/plan.md | 7 + docs/public/automation-and-mcp.md | 2 +- docs/public/configuration.md | 4 +- docs/public/coordination.md | 2 +- docs/public/developer-tools.md | 2 +- docs/public/git-operations.md | 2 +- docs/public/operations.md | 10 +- docs/public/sessions-and-review.md | 4 +- docs/public/tasks-and-workflows.md | 20 +- .../ui/requirements/settings-composition.md | 105 ++++++ .../ui/system-design/settings-composition.md | 194 ++++++++++ 124 files changed, 5496 insertions(+), 1639 deletions(-) create mode 100644 apps/web/components/settings/settings-group.test.tsx create mode 100644 apps/web/components/settings/settings-group.tsx create mode 100644 apps/web/components/settings/settings-info.test.tsx create mode 100644 apps/web/components/settings/settings-info.tsx create mode 100644 apps/web/components/settings/task-behavior-settings.test.tsx create mode 100644 apps/web/components/settings/task-behavior-tabs.test.ts create mode 100644 apps/web/components/settings/task-behavior-tabs.ts create mode 100644 apps/web/e2e/helpers/settings-composition.ts create mode 100644 apps/web/e2e/tests/settings/mobile-settings-composition.spec.ts create mode 100644 apps/web/e2e/tests/settings/mobile-task-behavior-concise-help.spec.ts create mode 100644 apps/web/e2e/tests/settings/settings-composition.spec.ts create mode 100644 apps/web/e2e/tests/settings/task-behavior-concise-help.spec.ts create mode 100644 docs/plans/settings-composition/plan.md create mode 100644 docs/plans/settings-composition/surface-inventory.md create mode 100644 docs/plans/settings-composition/task-01-task-behavior.md create mode 100644 docs/plans/settings-composition/task-02-preferences.md create mode 100644 docs/plans/settings-composition/task-03-agent-executor.md create mode 100644 docs/plans/settings-composition/task-04-workspace-integration.md create mode 100644 docs/plans/settings-composition/task-05-system-account.md create mode 100644 docs/plans/settings-composition/task-06-concise-help-and-tabs.md create mode 100644 docs/specs/ui/requirements/settings-composition.md create mode 100644 docs/specs/ui/system-design/settings-composition.md diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 1bfad55338..cf271c7f2c 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -136,11 +136,8 @@ surface. name the target only (for example, `Bitbucket Pull Request`) and preserve their registered provider icon. - **Interactivity:** all buttons and links with actions must have `cursor-pointer` class. -- **Self-documenting settings:** every setting must explain in visible, plain-language copy what - changes, when the setting applies, and when the user should choose each non-obvious option. State - important exclusions, precedence, cost, or destructive consequences next to the control when they - can affect the decision. Do not rely on tooltips, external documentation, or implementation terms - alone to teach the setting. +- **Self-documenting settings:** give each setting a short, plain-language description of its effect. Use `SettingsInfo` for optional scope and implementation details: hover/focus on desktop, a drawer on touch devices. Keep active errors, permissions, managed values, and essential input constraints visible. +- **Settings composition:** use `SettingsGroup` for bordered groups and `SettingsRow` for simple preferences; keep one domain owner/save contributor and attach discovery to actual controls. Keep sections expanded and use existing header tabs for larger pages. Preserve specialized editor/table/diagnostic/credential layouts. - **Settings save coordination:** settings surfaces with local unsaved state must register a contributor with `useSettingsSaveContributor` (or use `SettingsPageTemplate`) so the shared floating **Save changes** control, navigation guard, and discard flow own persistence. Do not add diff --git a/apps/web/app/settings/agents/page.tsx b/apps/web/app/settings/agents/page.tsx index b48a0bbd75..151c11bc02 100644 --- a/apps/web/app/settings/agents/page.tsx +++ b/apps/web/app/settings/agents/page.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { settingsActionClassName } from "@/components/settings/settings-control"; +import { SettingsGroup } from "@/components/settings/settings-group"; import Link from "@/components/routing/app-link"; import { IconAlertTriangle, @@ -93,47 +94,41 @@ function InstalledAgentsHeader({ }) { const { t } = useTranslation(); return ( -
-
-

{t("agents:installedAgents")}

-

{t("agents:installedAgentsDescription")}

-
-
- - - {onOpenTuiDialog && ( - +
+ +
+ {t("agents:rescan")} + + {onOpenTuiDialog && ( + + )}
); } @@ -210,13 +205,20 @@ function InstalledAgentsSection({ const dynamicAgent = savedAgentsByName.get(DYNAMIC_AGENT_NAME); return ( -
- setShellOpen(true)} - onOpenTuiDialog={canManage ? () => setTuiDialogOpen(true) : undefined} - onRescan={() => void handleRescan()} - /> + setShellOpen(true)} + onOpenTuiDialog={canManage ? () => setTuiDialogOpen(true) : undefined} + onRescan={() => void handleRescan()} + /> + } + contentClassName="space-y-4 divide-y-0" + > + ))}
-
+ ); } @@ -421,8 +423,6 @@ export default function AgentsSettingsPage() { - - router.push(executorProfileSettingsPath(profile.id))} > @@ -272,9 +274,8 @@ export default function ExecutorsHubPage() { {!canManageKubernetes && } {allProfiles.length > 0 && ( -
-

{t("executors:profiles")}

-
+ +
{allProfiles.map((profile) => ( ))}
-
+ )} -
-

{t("executors:createNewProfile")}

+
{EXECUTOR_TYPES.map((execType) => ( ))}
-
+ - - {t("workspaces:workspaceSettings")} - - -
- - - onNameChange(e.target.value)} - /> - - +
+ + + onNameChange(e.target.value)} /> - -
- - + + + +
+ ); } diff --git a/apps/web/app/settings/workspace/workspace-repositories-client.tsx b/apps/web/app/settings/workspace/workspace-repositories-client.tsx index 2273793077..06fdf26043 100644 --- a/apps/web/app/settings/workspace/workspace-repositories-client.tsx +++ b/apps/web/app/settings/workspace/workspace-repositories-client.tsx @@ -530,6 +530,7 @@ export function WorkspaceRepositoriesClient({ are on. A second copy of all three read as the page repeating itself. */} } title={t("workspaces:repositories")} description={ diff --git a/apps/web/app/settings/workspace/workspace-workflows-client.tsx b/apps/web/app/settings/workspace/workspace-workflows-client.tsx index 29608094c1..f0edc6b6b3 100644 --- a/apps/web/app/settings/workspace/workspace-workflows-client.tsx +++ b/apps/web/app/settings/workspace/workspace-workflows-client.tsx @@ -416,6 +416,7 @@ export function WorkspaceWorkflowsClient({ already named, marked and described. */} } title={t("workflows:workflows")} // The read-only note is the section's description here rather than a diff --git a/apps/web/components/gitlab/gitlab-settings.test.tsx b/apps/web/components/gitlab/gitlab-settings.test.tsx index 7b38c1a42c..8ebe3f0e76 100644 --- a/apps/web/components/gitlab/gitlab-settings.test.tsx +++ b/apps/web/components/gitlab/gitlab-settings.test.tsx @@ -49,6 +49,8 @@ vi.mock("@/components/toast-provider", () => ({ vi.mock("@/components/settings/settings-save-provider", () => ({ useSettingsSaveContributor: vi.fn(), + SettingsSaveDirtyScope: ({ children }: { children: (isDirty: boolean) => React.ReactNode }) => + children(false), })); vi.mock("@/components/settings/settings-prompt-editor", () => ({ diff --git a/apps/web/components/settings/account/api-tokens.tsx b/apps/web/components/settings/account/api-tokens.tsx index 841ccb0a04..624db23a8d 100644 --- a/apps/web/components/settings/account/api-tokens.tsx +++ b/apps/web/components/settings/account/api-tokens.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; -import { Card, CardContent } from "@kandev/ui/card"; import { Dialog, DialogContent, @@ -20,13 +19,9 @@ import { ApiError } from "@/lib/api/client"; import { listTokens, mintToken, revokeToken, type ApiToken } from "@/lib/api/domains/auth-api"; import { copyToClipboard } from "@/lib/utils/copy-to-clipboard"; import { formatDateTime } from "@/lib/i18n/formats"; -import { SettingsCardHeader } from "@/components/settings/settings-card-header"; -import { - SettingsErrorText, - SettingsFieldDescription, - SettingsFieldLabel, -} from "@/components/settings/settings-typography"; +import { SettingsErrorText, SettingsFieldLabel } from "@/components/settings/settings-typography"; import { settingsActionClassName } from "@/components/settings/settings-control"; +import { SettingsGroup } from "@/components/settings/settings-group"; function useTokensList() { const { t } = useTranslation(); @@ -228,26 +223,27 @@ export function ApiTokens() { }; return ( - - - {t("account:apiTokens")} - - } - actions={ - - } - /> - - {t("account:apiTokensBlurb")} + + {t("account:apiTokens")} + + } + description={t("account:apiTokensBlurb")} + action={ + + } + data-testid="api-tokens-card" + contentClassName="space-y-3 divide-y-0" + > +
{error && {error}} {!loaded && !error && (
@@ -293,8 +289,8 @@ export function ApiTokens() { {t("account:noTokensYet")}

)} - +
void reload()} /> - + ); } diff --git a/apps/web/components/settings/agent-generated-task-title-settings.tsx b/apps/web/components/settings/agent-generated-task-title-settings.tsx index 7641dec7dd..4a900418be 100644 --- a/apps/web/components/settings/agent-generated-task-title-settings.tsx +++ b/apps/web/components/settings/agent-generated-task-title-settings.tsx @@ -1,17 +1,22 @@ "use client"; +import { SettingsInfo } from "./settings-info"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card"; -import { Label } from "@kandev/ui/label"; import { Switch } from "@kandev/ui/switch"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { updateUserSettings } from "@/lib/api"; import { SettingsCard } from "./settings-card"; +import { SettingsRow, type SettingsPresentation } from "./settings-group"; import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences"; import { useSettingsSaveContributor } from "./settings-save-provider"; -export function AgentGeneratedTaskTitleSettings() { +export function AgentGeneratedTaskTitleSettings({ + presentation = "card", +}: { + presentation?: SettingsPresentation; +}) { const { t } = useTranslation(); const preference = useAppStore((state) => state.userSettings.agentGeneratedTaskTitles); const setUserSettings = useAppStore((state) => state.setUserSettings); @@ -43,6 +48,34 @@ export function AgentGeneratedTaskTitleSettings() { discard: () => setDraft(saved), }); + const row = ( + +

{t("settings:agentGeneratedTaskTitlesDescription")}

+

{t("settings:agentGeneratedTaskTitlesDisabledHint")}

+ + } + controlId="agent-generated-task-titles" + touchTarget="switch" + discoveryTargetId={GENERAL_SETTINGS_TARGETS.agentGeneratedTitles} + isDirty={isDirty} + control={ + + } + /> + ); + + if (presentation === "row") return row; + return ( {t("settings:agentGeneratedTaskTitles")} {t("settings:agentGeneratedTaskTitlesDescription")} - -
-
- -

- {t("settings:agentGeneratedTaskTitlesDisabledHint")} -

-
- -
-
+ {row}
); } diff --git a/apps/web/components/settings/agents/agent-profiles-section.tsx b/apps/web/components/settings/agents/agent-profiles-section.tsx index 340851d794..6864b6b365 100644 --- a/apps/web/components/settings/agents/agent-profiles-section.tsx +++ b/apps/web/components/settings/agents/agent-profiles-section.tsx @@ -82,9 +82,20 @@ function ProfileRowActions({ const { t } = useTranslation(); const { isMobile } = useResponsiveBreakpoint(); const pendingDelete = useRef(false); + const [open, setOpen] = useState(false); return ( - - + + { + event.preventDefault(); + event.stopPropagation(); + }} + onClick={(event) => { + event.stopPropagation(); + setOpen((current) => !current); + }} + > - - - - create_task_kandev creates a separate task.{" "} - spawn_session_kandev adds a session to the current task, so it does not - use this preference. - - - -
-

- - - create_task_kandev - {" "} - creates new tasks and subtasks. This setting applies only when the call omits{" "} - - agent_profile_id - - . - +

+ + + create_task_kandev + {" "} + creates new tasks and subtasks. This setting applies only when the call omits{" "} + agent_profile_id. + +

+

+ + + spawn_session_kandev + {" "} + and tasks you create yourself are not affected. An explicitly selected profile always + wins. + +

+

+ + create_task_kandev creates a separate task. spawn_session_kandev{" "} + adds a session to the current task, so it does not use this preference. + +

+ {OPTIONS.map((option) => ( +

+ {t(option.labelKey)}: + {t(option.descriptionKey)}

-

- - - spawn_session_kandev - {" "} - and tasks you create yourself are not affected. An explicitly selected profile always - wins. - -

-
- + ))} + ); } -export function MCPTaskAgentProfileDefaultSettings() { +function MCPTaskProfileRadioGroup({ + value, + isDirty, + onValueChange, + ariaDescribedBy, +}: { + value: MCPTaskAgentProfileDefault; + isDirty: boolean; + onValueChange: (value: MCPTaskAgentProfileDefault) => void; + ariaDescribedBy?: string; +}) { + const { t } = useTranslation(); + return ( + onValueChange(nextValue as MCPTaskAgentProfileDefault)} + data-settings-dirty={isDirty} + className="gap-3" + > + {OPTIONS.map((option) => { + const labelId = `mcp-task-profile-${option.value}-label`; + const descriptionId = `mcp-task-profile-${option.value}-description`; + return ( + + ); + })} + + ); +} + +export function MCPTaskAgentProfileDefaultSettings({ + presentation = "card", +}: { + presentation?: SettingsPresentation; +}) { const { t } = useTranslation(); const preference = useAppStore((state) => state.userSettings.mcpTaskAgentProfileDefault); const setUserSettings = useAppStore((state) => state.setUserSettings); @@ -124,6 +175,31 @@ export function MCPTaskAgentProfileDefaultSettings() { discard: () => setDraft(saved), }); + if (presentation === "row") { + return ( + + + + } + descriptionId="mcp-task-profile-description" + discoveryTargetId={GENERAL_SETTINGS_TARGETS.agentTaskProfile} + isDirty={isDirty} + control={ + + } + /> + ); + } + return ( -

{t("settings:profileForTasksCreatedByAgents")}

+

{t(PROFILE_LABEL_KEY)}

- + + +
- setDraft(value as MCPTaskAgentProfileDefault)} - data-settings-dirty={isDirty} - className="gap-3" - > - {OPTIONS.map((option) => { - const labelId = `mcp-task-profile-${option.value}-label`; - const descriptionId = `mcp-task-profile-${option.value}-description`; - return ( - - ); - })} - +
); diff --git a/apps/web/components/settings/notification-permission-section.tsx b/apps/web/components/settings/notification-permission-section.tsx index 2c76204004..a80b8c499f 100644 --- a/apps/web/components/settings/notification-permission-section.tsx +++ b/apps/web/components/settings/notification-permission-section.tsx @@ -9,7 +9,7 @@ import { type NativeNotificationPermission, } from "@/lib/desktop/native-notification-client"; import type { PermissionRefresh } from "./notifications-settings-actions"; -import { SettingsTarget } from "./settings-target"; +import { SettingsGroup } from "./settings-group"; import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences"; type NotificationPermissionState = @@ -44,14 +44,11 @@ export function DesktopNotificationsSection({ }: DesktopNotificationsSectionProps) { const { t } = useTranslation(); return ( - -
-
-
{t("settings:desktopNotifications")}
-

- {t("settings:desktopNotificationsDescription")} -

-
+
- - + } + > {notificationPermission === "denied" && (

{nativeNotifications.isAvailable() @@ -119,7 +116,7 @@ export function DesktopNotificationsSection({ {notificationPermission === "error" && (

{t("settings:notificationPermissionCheckFailed")}

)} -
+ ); } diff --git a/apps/web/components/settings/notification-sound-section.tsx b/apps/web/components/settings/notification-sound-section.tsx index eacf279dbe..ff4b3e4b1b 100644 --- a/apps/web/components/settings/notification-sound-section.tsx +++ b/apps/web/components/settings/notification-sound-section.tsx @@ -17,7 +17,7 @@ import { type SoundPresetId, } from "@/lib/notifications/sound"; import { useSettingsSaveContributor } from "./settings-save-provider"; -import { SettingsTarget } from "./settings-target"; +import { SettingsGroup } from "./settings-group"; import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences"; /** @@ -58,19 +58,14 @@ export function NotificationSoundSection({ }); return ( -
-
-
{t("settings:notificationSound")}
-

- {t("settings:notificationSoundDescription")} -

-
)} -
+ ); } diff --git a/apps/web/components/settings/notifications-settings-external-providers.tsx b/apps/web/components/settings/notifications-settings-external-providers.tsx index 757ae54cfa..685a51744d 100644 --- a/apps/web/components/settings/notifications-settings-external-providers.tsx +++ b/apps/web/components/settings/notifications-settings-external-providers.tsx @@ -6,7 +6,7 @@ import { Trans, useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; import { Textarea } from "@kandev/ui/textarea"; -import { SettingsTarget } from "@/components/settings/settings-target"; +import { SettingsGroup } from "@/components/settings/settings-group"; import { type AppriseFormMode } from "@/components/settings/notifications-settings-actions"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; import type { NotificationProvider } from "@/lib/types/http"; @@ -336,14 +336,15 @@ function AppriseProviderSectionBody({ export function ExternalProvidersSection(props: ExternalProvidersSectionProps) { const { t } = useTranslation(); return ( - -
-
-
{t("settings:externalProviders")}
-

- {t("settings:externalProvidersDescription")} -

-
+ + {t("settings:externalProvidersDescription")} + + } + discoveryTargetId={GENERAL_SETTINGS_TARGETS.notificationProviders} + action={ -
+ } + > -
+ ); } diff --git a/apps/web/components/settings/notifications-settings.test.tsx b/apps/web/components/settings/notifications-settings.test.tsx index eeeb82beae..d2f4404008 100644 --- a/apps/web/components/settings/notifications-settings.test.tsx +++ b/apps/web/components/settings/notifications-settings.test.tsx @@ -94,23 +94,21 @@ describe("NotificationsSettings", () => { ); }); - // The page body renders inside a `SettingsCard`, whose `Card` base is - // `text-xs/relaxed`; sibling settings pages keep group headings at `text-sm` - // and descriptions/tables at `text-xs`. This body used to hard-code - // `text-base`/`text-sm` throughout, so the whole page read a size larger than - // its siblings. The `text-2xl` page title above the card is the shared - // `SettingsPageTemplate` heading and is deliberately out of scope. jsdom has - // no Tailwind, so assert on the utility classes rather than computed sizes. - it("keeps the card body within the settings type scale", () => { + // The page body is frameless; each preference group owns its one bordered + // surface. jsdom has no Tailwind, so assert on the utility classes rather + // than computed sizes. + it("keeps grouped notification content within the settings type scale", () => { const { container } = render( , ); - const body = container.querySelector('[data-slot="card-content"]'); + const body = container.querySelector('[data-settings-page-content="true"]'); expect(body).not.toBeNull(); + const groupHeadings = new Set(body!.querySelectorAll("[data-settings-group] h3")); const oversized = [...body!.querySelectorAll("[class]")] + .filter((element) => !groupHeadings.has(element)) .map((element) => element.getAttribute("class") ?? "") // Lookahead, not `(?:\s|$)`: Tailwind's line-height modifier makes the // next character `/` (the Card base is literally `text-xs/relaxed`), so a diff --git a/apps/web/components/settings/notifications-settings.tsx b/apps/web/components/settings/notifications-settings.tsx index a21828a205..694220dcd1 100644 --- a/apps/web/components/settings/notifications-settings.tsx +++ b/apps/web/components/settings/notifications-settings.tsx @@ -2,7 +2,6 @@ import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Separator } from "@kandev/ui/separator"; import { NotificationSoundSection } from "@/components/settings/notification-sound-section"; import { NotificationEventsTable } from "@/components/settings/notification-events-table"; import { SettingsPageTemplate } from "@/components/settings/settings-page-template"; @@ -19,7 +18,7 @@ import { type NotificationsState, } from "@/components/settings/notifications-settings-actions"; import { ExternalProvidersSection } from "@/components/settings/notifications-settings-external-providers"; -import { SettingsTarget } from "@/components/settings/settings-target"; +import { SettingsGroup } from "@/components/settings/settings-group"; import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences"; function useTableData(state: NotificationsState) { @@ -118,59 +117,57 @@ export function NotificationsSettings() { invalidReason={saveState.invalidReason} onSave={() => saveRequest.run()} onDiscard={actions.discard} + contentFrame="none" > - - - - - - - -
-
{t("settings:notificationEvents")}
-

- {t("settings:notificationEventsDescription")} -

-
- {tableProviders.length > 0 && ( - - )} -
+
+ + + + + {tableProviders.length > 0 && ( + + )} + +
); } diff --git a/apps/web/components/settings/plugins/plugin-detail.tsx b/apps/web/components/settings/plugins/plugin-detail.tsx index 793e932e95..2dca29911a 100644 --- a/apps/web/components/settings/plugins/plugin-detail.tsx +++ b/apps/web/components/settings/plugins/plugin-detail.tsx @@ -5,7 +5,6 @@ import { IconArrowLeft } from "@tabler/icons-react"; import { Trans, useTranslation } from "react-i18next"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; -import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Separator } from "@kandev/ui/separator"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import Link from "@/components/routing/app-link"; @@ -13,7 +12,7 @@ import { useRouter } from "@/lib/routing/client-router"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { usePlugins } from "@/hooks/domains/plugins/use-plugins"; import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin"; -import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsGroup } from "@/components/settings/settings-group"; import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; import { PluginConfigForm } from "./plugin-config-form"; import { PluginManifestCard } from "./plugin-manifest-card"; @@ -173,14 +172,14 @@ type PluginSettingsCardProps = { function PluginSettingsCard({ plugin, form, busy }: PluginSettingsCardProps) { const { t } = useTranslation(); return ( - - - {t("plugins:settings")} - - - - - + + + ); } diff --git a/apps/web/components/settings/plugins/plugins-settings.tsx b/apps/web/components/settings/plugins/plugins-settings.tsx index d331e501f3..be8cb2f0be 100644 --- a/apps/web/components/settings/plugins/plugins-settings.tsx +++ b/apps/web/components/settings/plugins/plugins-settings.tsx @@ -22,6 +22,7 @@ import { PluginUpdateStatus } from "./plugin-update-status"; import { usePluginActions } from "./use-plugin-actions"; import { usePluginUpdateAction } from "./use-plugin-update-action"; import { settingsActionClassName } from "@/components/settings/settings-control"; +import { SettingsGroup } from "@/components/settings/settings-group"; /** * Operator UI to browse, install, enable, disable, uninstall, and update kandev @@ -143,12 +144,11 @@ function InstalledTab({ const { t } = useTranslation(); return ( - <> - {canManage && } - -
-
{t("plugins:installedPlugins")}
- {canManage && ( +
- )} - + ) : undefined + } + > + {canManage && } - + ); } diff --git a/apps/web/components/settings/prevent-auto-start-agent-settings.tsx b/apps/web/components/settings/prevent-auto-start-agent-settings.tsx index 3f28100ea4..148179c8cf 100644 --- a/apps/web/components/settings/prevent-auto-start-agent-settings.tsx +++ b/apps/web/components/settings/prevent-auto-start-agent-settings.tsx @@ -1,17 +1,22 @@ "use client"; +import { SettingsInfo } from "./settings-info"; import { useEffect, useRef, useState } from "react"; import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; -import { Label } from "@kandev/ui/label"; import { Switch } from "@kandev/ui/switch"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { updateUserSettings } from "@/lib/api"; import { SettingsCard } from "./settings-card"; +import { SettingsRow, type SettingsPresentation } from "./settings-group"; import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences"; import { useSettingsSaveContributor } from "./settings-save-provider"; import { useTranslation } from "react-i18next"; -export function PreventAutoStartAgentSettings() { +export function PreventAutoStartAgentSettings({ + presentation = "card", +}: { + presentation?: SettingsPresentation; +}) { const { t } = useTranslation(); const preventAutoStartAgentOnOpen = useAppStore( (state) => state.userSettings.preventAutoStartAgentOnOpen, @@ -47,6 +52,33 @@ export function PreventAutoStartAgentSettings() { discard: () => setDraft(saved), }); + const row = ( + + {t("settings:preventAutoStartAgentOnOpenHelp")} + + } + controlId="prevent-auto-start-on-open" + touchTarget="switch" + discoveryTargetId={GENERAL_SETTINGS_TARGETS.preventAutoStartOnOpen} + isDirty={isDirty} + control={ + + } + /> + ); + + if (presentation === "row") return row; + return ( {t("settings:preventAutoStartAgentOnOpen")} - -
-
- -

- {t("settings:preventAutoStartAgentOnOpenHelp")} -

-
- -
-
+ {row}
); } diff --git a/apps/web/components/settings/prompts-settings.tsx b/apps/web/components/settings/prompts-settings.tsx index e30db94ab5..bb824e60ea 100644 --- a/apps/web/components/settings/prompts-settings.tsx +++ b/apps/web/components/settings/prompts-settings.tsx @@ -9,6 +9,7 @@ import { Input } from "@kandev/ui/input"; import { PromptDeleteConfirmation } from "@/components/settings/prompt-delete-confirmation"; import { PromptRowActions } from "@/components/settings/prompt-row-actions"; import { SettingsPageTemplate } from "@/components/settings/settings-page-template"; +import { SettingsGroup } from "@/components/settings/settings-group"; import { SettingsPromptEditor } from "@/components/settings/settings-prompt-editor"; import { useToast } from "@/components/toast-provider"; import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts"; @@ -508,11 +509,10 @@ export function PromptsSettings() { place. -
-
-
- {t("settings:promptsCustomHeading")} -
+ {t("settings:promptAdd")} -
- + } + > {showCreate && (
- + ); } diff --git a/apps/web/components/settings/rich-output-motion-settings-card.tsx b/apps/web/components/settings/rich-output-motion-settings-card.tsx index c3b1ac594e..41426db98d 100644 --- a/apps/web/components/settings/rich-output-motion-settings-card.tsx +++ b/apps/web/components/settings/rich-output-motion-settings-card.tsx @@ -1,11 +1,9 @@ "use client"; import { useTranslation } from "react-i18next"; -import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; -import { Label } from "@kandev/ui/label"; import { Switch } from "@kandev/ui/switch"; import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences"; -import { SettingsCard } from "./settings-card"; +import { SettingsRow } from "./settings-group"; export function RichOutputMotionSettingsCard({ enabled, @@ -18,36 +16,25 @@ export function RichOutputMotionSettingsCard({ }) { const { t } = useTranslation(); return ( - - - {t("settings:richOutputChartMotion")} - - -
-
- -

- {t("settings:animateRichOutputChartsDescription")} -

-
- -
-
-
+ label={t("settings:animateRichOutputCharts")} + description={t("settings:animateRichOutputChartsDescription")} + controlId="animate-rich-output-charts" + touchTarget="switch" + controlWrapperTestId="rich-output-motion-toggle-row" + controlWrapperClassName="min-h-11" + control={ + + } + /> ); } diff --git a/apps/web/components/settings/secrets-settings.tsx b/apps/web/components/settings/secrets-settings.tsx index f918beda23..873083c691 100644 --- a/apps/web/components/settings/secrets-settings.tsx +++ b/apps/web/components/settings/secrets-settings.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { Button } from "@kandev/ui/button"; import { SettingsPageTemplate } from "@/components/settings/settings-page-template"; +import { SettingsGroup } from "@/components/settings/settings-group"; import { WorkspaceSectionHeader } from "@/components/settings/workspaces/workspace-section-header"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; @@ -429,9 +430,12 @@ function SecretsSettingsBody({ {/* `secrets-settings-body` is the pseudo-coverage oracle's render anchor for this screen (e2e/tests/i18n/pseudo-coverage.spec.ts). Renaming it makes that spec time out rather than silently scan an unrendered route. */} -
-
-
{secretScopeTitle(t, scope)}
+ {t("settings:addSecret")} -
- + } + > {showCreate && ( ))}
- + ); } diff --git a/apps/web/components/settings/settings-group.test.tsx b/apps/web/components/settings/settings-group.test.tsx new file mode 100644 index 0000000000..c4be06027d --- /dev/null +++ b/apps/web/components/settings/settings-group.test.tsx @@ -0,0 +1,177 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { SettingsGroup, SettingsRow } from "./settings-group"; +import { SettingsSaveProvider, useSettingsSaveContributor } from "./settings-save-provider"; + +afterEach(cleanup); + +function DirtyContributor({ id }: { id: string }) { + useSettingsSaveContributor({ + id, + revision: 1, + isDirty: true, + canSave: true, + save: async () => undefined, + discard: () => undefined, + }); + return null; +} + +describe("SettingsGroup structure", () => { + it("keeps one group card and mounts collapsed content for discovery and saving", () => { + render( + + } + /> + , + ); + + const group = screen.getByTestId("runtime-group"); + const details = group.querySelector("details"); + expect(details?.open).toBe(false); + expect(screen.getByLabelText("Maximum automatic sessions")).toBeTruthy(); + expect(group.querySelectorAll('[data-slot="card"]')).toHaveLength(1); + expect(group.querySelectorAll("h3")).toHaveLength(1); + + fireEvent.click(group.querySelector("summary")!); + expect(details?.open).toBe(true); + }); + + it("associates row labels and descriptions with the row control", () => { + render( + + } + descriptionId="auto-open-help" + /> + , + ); + + expect( + screen.getByLabelText("Open new tasks automatically").getAttribute("aria-describedby"), + ).toBe("existing-help auto-open-help"); + expect(screen.getByText("Open a new task after you create it.")).toBeTruthy(); + }); + + it("generates a description association for a production-shaped row", () => { + const { container } = render( + + } + /> + , + ); + + const control = container.querySelector("#auto-open")!; + const descriptionId = control.getAttribute("aria-describedby"); + expect(descriptionId).toMatch(/^settings-row-description-/); + expect(document.getElementById(descriptionId!)?.textContent).toBe( + "Open a new task after you create it.", + ); + }); +}); + +describe("SettingsGroup interactions", () => { + it("reveals a collapsed group on a new attention state and permits manual collapse", () => { + const { container, rerender } = render( + +
Runtime content
+
, + ); + + const details = container.querySelector("details")!; + expect(details.open).toBe(false); + + rerender( + +
Runtime content
+
, + ); + expect(details.open).toBe(true); + + fireEvent.click(details.querySelector("summary")!); + expect(details.open).toBe(false); + + rerender( + +
Runtime content
+
, + ); + expect(details.open).toBe(false); + + rerender( + +
Runtime content
+
, + ); + rerender( + +
Runtime content
+
, + ); + expect(details.open).toBe(true); + }); + + it("makes the mobile switch wrapper a real activation target", () => { + render( + + } + /> + , + ); + + fireEvent.click(screen.getByTestId("auto-open-touch-target")); + expect((screen.getByRole("checkbox") as HTMLInputElement).checked).toBe(true); + }); + + it("includes descendant save contributors in the group dirty state", () => { + const { container } = render( + + + + + , + ); + + const group = container.querySelector('[data-settings-group="true"]')!; + expect(group.getAttribute("data-settings-dirty")).toBe("true"); + expect(screen.getByRole("status").textContent).toBe("Unsaved changes"); + }); +}); + +describe("SettingsGroup heading", () => { + it("keeps title accessories outside the semantic group heading", () => { + render( + } + > +
Content
+
, + ); + + expect(screen.getByRole("heading", { name: "Repository Scope", level: 3 })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Explain repository scope" })).toBeTruthy(); + }); +}); diff --git a/apps/web/components/settings/settings-group.tsx b/apps/web/components/settings/settings-group.tsx new file mode 100644 index 0000000000..96f9cb73b9 --- /dev/null +++ b/apps/web/components/settings/settings-group.tsx @@ -0,0 +1,351 @@ +"use client"; + +import { + cloneElement, + isValidElement, + useCallback, + useEffect, + useId, + useRef, + useState, + type ComponentProps, + type ReactElement, + type ReactNode, +} from "react"; +import { SettingsCard } from "./settings-card"; +import { SettingsSaveDirtyScope } from "./settings-save-provider"; +import { SettingsFieldLabel, SETTINGS_TYPOGRAPHY } from "./settings-typography"; +import { useSettingsTargetRegistration } from "./settings-target-provider"; +import { UnsavedChangesBadge } from "./unsaved-indicator"; +import { SETTINGS_TARGET_DISCLOSURE_OPEN_EVENT } from "@/lib/settings-discovery/target"; +import { cn } from "@/lib/utils"; + +export type SettingsPresentation = "card" | "row"; + +type SettingsGroupProps = Omit, "title"> & { + title: ReactNode; + description?: ReactNode; + action?: ReactNode; + titleAccessory?: ReactNode; + children: ReactNode; + isDirty?: boolean; + discoveryTargetId?: string; + collapsible?: boolean; + defaultOpen?: boolean; + summary?: ReactNode; + revealOn?: boolean | string; + frame?: "card" | "none"; + contentClassName?: string; + titleTestId?: string; +}; + +function SettingsGroupHeading({ + title, + description, + summary, + titleTestId, + titleAccessory, + dirty, +}: Pick< + SettingsGroupProps, + "title" | "description" | "summary" | "titleTestId" | "titleAccessory" +> & { + dirty: boolean; +}) { + return ( + + + +

+ {title} +

+ {titleAccessory} +
+ {(description || summary) && ( + + {description && ( + {description} + )} + {summary && {summary}} + + )} + {dirty && } +
+ ); +} + +function SettingsGroupHeader({ + title, + description, + action, + titleTestId, + titleAccessory, + dirty, +}: Pick< + SettingsGroupProps, + "title" | "description" | "action" | "titleTestId" | "titleAccessory" +> & { dirty: boolean }) { + return ( +
+
+
+

+ {title} +

+ {titleAccessory} + {dirty && } +
+ {description &&

{description}

} +
+ {action &&
{action}
} +
+ ); +} + +function SettingsGroupContent({ + frame, + isDirty, + contentClassName, + children, +}: { + frame: "card" | "none"; + isDirty: boolean; + contentClassName?: string; + children: ReactNode; +}) { + if (frame === "none") { + return ( +
+ {children} +
+ ); + } + return ( + +
{children}
+
+ ); +} + +/** A settings group owns one heading and, by default, one bordered surface. */ +export function SettingsGroup({ + title, + description, + action, + titleAccessory, + children, + isDirty = false, + discoveryTargetId, + collapsible = false, + defaultOpen = true, + summary, + revealOn = false, + frame = "card", + className, + contentClassName, + titleTestId, + ...props +}: SettingsGroupProps) { + const registerTarget = useSettingsTargetRegistration(discoveryTargetId); + const [open, setOpen] = useState(defaultOpen); + const detailsRef = useRef(null); + const lastRevealToken = useRef(""); + const revealToken = revealOn === true ? "true" : revealOn || ""; + + useEffect(() => { + if (revealToken && revealToken !== lastRevealToken.current) setOpen(true); + lastRevealToken.current = revealToken; + }, [revealToken]); + + useEffect(() => { + const details = detailsRef.current; + if (!details) return; + const openFromDiscovery = () => setOpen(true); + details.addEventListener(SETTINGS_TARGET_DISCLOSURE_OPEN_EVENT, openFromDiscovery); + return () => + details.removeEventListener(SETTINGS_TARGET_DISCLOSURE_OPEN_EVENT, openFromDiscovery); + }, []); + + return ( + + {(nestedIsDirty) => { + const groupIsDirty = isDirty || nestedIsDirty; + const groupContent = ( + + {children} + + ); + return ( +
+ {collapsible ? ( +
setOpen(event.currentTarget.open)} + data-settings-group-disclosure="true" + > + + + +
{groupContent}
+
+ ) : ( + <> + + {groupContent} + + )} +
+ ); + }} +
+ ); +} + +type SettingsRowProps = Omit, "children"> & { + label: ReactNode; + description?: ReactNode; + control: ReactNode; + controlId?: string; + descriptionId?: string; + touchTarget?: "switch"; + controlWrapperTestId?: string; + controlWrapperClassName?: string; + info?: ReactNode; + details?: { label: ReactNode; children: ReactNode }; + discoveryTargetId?: string; + isDirty?: boolean; +}; + +/** A row keeps a setting's label, explanation, control, and discovery target together. */ +export function SettingsRow({ + label, + description, + control, + controlId, + descriptionId, + touchTarget, + controlWrapperTestId, + controlWrapperClassName, + details, + info, + discoveryTargetId, + isDirty = false, + className, + ...props +}: SettingsRowProps) { + const registerTarget = useSettingsTargetRegistration(discoveryTargetId); + const generatedDescriptionId = useId(); + const resolvedDescriptionId = description + ? (descriptionId ?? `settings-row-description-${generatedDescriptionId.replace(/:/g, "")}`) + : undefined; + const setTargetRef = useCallback( + (element: HTMLDivElement | null) => registerTarget(element), + [registerTarget], + ); + const describedControl = + resolvedDescriptionId && isValidElement(control) + ? cloneElement(control as ReactElement<{ "aria-describedby"?: string }>, { + "aria-describedby": [ + (control as ReactElement<{ "aria-describedby"?: string }>).props["aria-describedby"], + resolvedDescriptionId, + ] + .filter(Boolean) + .join(" "), + }) + : control; + + return ( +
+
+
+ {label} + {info} +
+ {description && ( +
+ {description} +
+ )} + {details && {details.children}} +
+ {touchTarget === "switch" ? ( + + ) : ( +
+ {describedControl} +
+ )} +
+ ); +} + +export function SettingsDetails({ label, children }: { label: ReactNode; children: ReactNode }) { + return ( +
+ + {label} + +
{children}
+
+ ); +} diff --git a/apps/web/components/settings/settings-info.test.tsx b/apps/web/components/settings/settings-info.test.tsx new file mode 100644 index 0000000000..d551d3b859 --- /dev/null +++ b/apps/web/components/settings/settings-info.test.tsx @@ -0,0 +1,26 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +const mode = vi.hoisted(() => ({ touch: false })); +vi.mock("@/hooks/use-compact-task-chrome", () => ({ useTouchDrawer: () => mode.touch })); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_: string, values?: { setting: string }) => (values ? `About ${values.setting}` : "Close"), + }), +})); +import { SettingsInfo } from "./settings-info"; +afterEach(cleanup); +describe("SettingsInfo", () => { + it("reveals optional details on keyboard focus", async () => { + mode.touch = false; + render(Technical details); + expect(screen.queryByRole("tooltip")).toBeNull(); + fireEvent.focus(screen.getByRole("button", { name: "About Limit" })); + expect((await screen.findByRole("tooltip")).textContent).toContain("Technical details"); + }); + it("opens a touch dialog without changing its surrounding setting", async () => { + mode.touch = true; + render(Technical details); + fireEvent.click(screen.getByRole("button", { name: "About Limit" })); + expect((await screen.findByRole("dialog")).textContent).toContain("Technical details"); + }); +}); diff --git a/apps/web/components/settings/settings-info.tsx b/apps/web/components/settings/settings-info.tsx new file mode 100644 index 0000000000..ac7be3b441 --- /dev/null +++ b/apps/web/components/settings/settings-info.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { IconInfoCircle } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@kandev/ui/drawer"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; + +export function SettingsInfo({ + label, + children, + testId, +}: { + label: string; + children: ReactNode; + testId?: string; +}) { + const { t } = useTranslation(); + const touch = useTouchDrawer(); + const [open, setOpen] = useState(false); + const button = ( + + ); + if (touch) + return ( + + {button} + + + {label} + +
+
{children}
+
+
+ + + +
+
+
+ ); + return ( + + + {button} + +
{children}
+
+
+
+ ); +} diff --git a/apps/web/components/settings/settings-menu-mode-card.tsx b/apps/web/components/settings/settings-menu-mode-card.tsx index 3e759d3b35..a219c2000c 100644 --- a/apps/web/components/settings/settings-menu-mode-card.tsx +++ b/apps/web/components/settings/settings-menu-mode-card.tsx @@ -1,12 +1,11 @@ "use client"; import { useTranslation } from "react-i18next"; -import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Label } from "@kandev/ui/label"; import { RadioGroup, RadioGroupItem } from "@kandev/ui/radio-group"; -import { SettingsCard } from "@/components/settings/settings-card"; import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences"; import { SETTINGS_MENU_MODES, type SettingsMenuMode } from "@/lib/settings/settings-menu-mode"; +import { useSettingsTargetRegistration } from "./settings-target-provider"; /** * Copy per option. Keys, not text: `t()` has to resolve at render, and a @@ -37,44 +36,37 @@ export function SettingsMenuModeCard({ onChange: (mode: SettingsMenuMode) => void; }) { const { t } = useTranslation(); + const registerTarget = useSettingsTargetRegistration(GENERAL_SETTINGS_TARGETS.settingsMenuMode); return ( - - - {t("settings:settingsMenuShape")} - - -
- {t("settings:settingsMenuShape")} - onChange(next as SettingsMenuMode)} - className="grid gap-2" + {t("settings:settingsMenuShape")} + onChange(next as SettingsMenuMode)} + className="grid gap-2" + > + {SETTINGS_MENU_MODES.map((mode) => ( + -

- {t("settings:settingsMenuShapePerDevice")} -

-
-
-
+ + + {t(MODE_COPY[mode].labelKey)} + + {t(MODE_COPY[mode].descriptionKey)} + + + + ))} + +

{t("settings:settingsMenuShapePerDevice")}

+ ); } diff --git a/apps/web/components/settings/settings-page-template.tsx b/apps/web/components/settings/settings-page-template.tsx index 80f746a47a..b1e21110a5 100644 --- a/apps/web/components/settings/settings-page-template.tsx +++ b/apps/web/components/settings/settings-page-template.tsx @@ -32,6 +32,7 @@ type SettingsPageTemplateProps = { onDiscard?: () => void; showSaveButton?: boolean; showPageChrome?: boolean; + contentFrame?: "card" | "none"; children: React.ReactNode; deleteSection?: React.ReactNode; }; @@ -50,6 +51,7 @@ export function SettingsPageTemplate({ onDiscard, showSaveButton = true, showPageChrome = true, + contentFrame = "card", children, deleteSection, }: SettingsPageTemplateProps) { @@ -72,11 +74,17 @@ export function SettingsPageTemplate({ {showPageChrome && } - {(nestedIsDirty) => ( - - {children} - - )} + {(nestedIsDirty) => + contentFrame === "none" ? ( +
+ {children} +
+ ) : ( + + {children} + + ) + }
{deleteSection} diff --git a/apps/web/components/settings/settings-save-provider.test.tsx b/apps/web/components/settings/settings-save-provider.test.tsx index 8c09cd8b1c..c859ff0085 100644 --- a/apps/web/components/settings/settings-save-provider.test.tsx +++ b/apps/web/components/settings/settings-save-provider.test.tsx @@ -8,6 +8,7 @@ import { SettingsSaveProvider, SettingsSaveCancelledError, useSettingsSaveContributor, + useSettingsSaveCoordinator, type SettingsSaveRevision, } from "./settings-save-provider"; @@ -664,3 +665,38 @@ describe("SettingsSaveProvider reset coordination", () => { expect(screen.getByRole("button", { name: SAVE_CHANGES_LABEL })).toBeTruthy(); }); }); + +const CONTRIBUTOR_STATUS_ID = "contributor-status"; + +function ContributorStatus() { + const coordinator = useSettingsSaveCoordinator(); + return ( + + {JSON.stringify(coordinator.contributorStates)} + + ); +} +it("exposes dirty, invalid and failed contributors for tab recovery", async () => { + render( + + { + throw new Error("failed"); + }} + /> + + , + ); + expect(screen.getByTestId(CONTRIBUTOR_STATUS_ID).textContent).toContain('"isDirty":true'); + fireEvent.click(screen.getByRole("button", { name: SAVE_CHANGES_LABEL })); + await waitFor(() => + expect(screen.getByTestId(CONTRIBUTOR_STATUS_ID).textContent).toContain('"saveFailed":true'), + ); + fireEvent.click(screen.getByRole("button", { name: RESET_LABEL })); + await waitFor(() => + expect(screen.getByTestId(CONTRIBUTOR_STATUS_ID).textContent).toContain('"isDirty":false'), + ); + fireEvent.click(screen.getByRole("button", { name: "Edit runtime" })); + expect(screen.getByTestId(CONTRIBUTOR_STATUS_ID).textContent).toContain('"saveFailed":false'); +}); diff --git a/apps/web/components/settings/settings-save-provider.tsx b/apps/web/components/settings/settings-save-provider.tsx index eb10ea9627..41e4509be6 100644 --- a/apps/web/components/settings/settings-save-provider.tsx +++ b/apps/web/components/settings/settings-save-provider.tsx @@ -65,7 +65,15 @@ type SaveResult = { const SettingsSaveRegistryContext = createContext(null); const SettingsDirtyScopeContext = createContext(null); +export type SettingsContributorState = { + id: string; + isDirty: boolean; + invalid: boolean; + saveFailed: boolean; +}; + export type SettingsSaveCoordinator = { + contributorStates: readonly SettingsContributorState[]; /** Save every dirty contributor. Respects each contributor's canSave; * returns canLeave=false when any contributor is invalid or fails. */ saveAll: () => Promise; @@ -90,10 +98,8 @@ export function SettingsSaveProvider({ placement?: SettingsSavePlacement; }) { const { contributors, registry, dirtyContributors, refreshRegistry } = useContributorRegistry(); - const { status, errorKind, saveAll, clearSavedStatus, markError } = useSaveCoordinator( - contributors, - refreshRegistry, - ); + const { status, errorKind, saveAll, clearSavedStatus, markError, failedContributorIds } = + useSaveCoordinator(contributors, refreshRegistry); const hasDirty = dirtyContributors.length > 0; const hasInvalid = dirtyContributors.some(({ contributor }) => contributor.canSave === false); const invalidReason = dirtyContributors.find(({ contributor }) => contributor.canSave === false) @@ -113,7 +119,14 @@ export function SettingsSaveProvider({ discardDirtyContributors, discardAndLeave, continueEditing, - } = useSaveNavigationFlow({ saveAll, contributors, markError, refreshRegistry, hasDirty }); + } = useSaveNavigationFlow({ + saveAll, + contributors, + markError, + clearSavedStatus, + refreshRegistry, + hasDirty, + }); useSettingsBeforeUnloadGuard(hasDirty); @@ -121,6 +134,12 @@ export function SettingsSaveProvider({ ({ + id: contributor.id, + isDirty: contributor.isDirty, + invalid: contributor.isDirty && contributor.canSave === false, + saveFailed: contributor.isDirty && failedContributorIds.has(contributor.id), + })), saveAll, status, errorKind, @@ -231,12 +250,14 @@ export function useSettingsSaveCoordinator(): SettingsSaveCoordinator { * navigation intent, or discards the dirty contributors and leaves. */ function useSaveNavigationFlow({ + clearSavedStatus, saveAll, contributors, markError, refreshRegistry, hasDirty, }: { + clearSavedStatus: () => void; saveAll: () => Promise; contributors: Map; markError: (kind: SettingsSaveErrorKind) => void; @@ -281,6 +302,7 @@ function useSaveNavigationFlow({ } refreshRegistry(); if (hasNewerChanges) return false; + clearSavedStatus(); settlePendingNavigation(pendingNavigationRef, setPendingNavigation); return true; } catch { @@ -290,7 +312,7 @@ function useSaveNavigationFlow({ discardingRef.current = false; setIsDiscarding(false); } - }, [contributors, markError, refreshRegistry]); + }, [contributors, markError, refreshRegistry, clearSavedStatus]); const discardAndLeave = useCallback(async () => { await discardDirtyContributors(); @@ -407,11 +429,13 @@ function useSaveCoordinator( refreshRegistry: () => void, ) { const savingRef = useRef(false); + const [failedContributorIds, setFailedContributorIds] = useState>(new Set()); const [status, setStatus] = useState("dirty"); const [errorKind, setErrorKind] = useState(null); const clearSavedStatus = useCallback(() => { setErrorKind(null); setStatus("dirty"); + setFailedContributorIds(new Set()); }, []); const markError = useCallback((kind: SettingsSaveErrorKind) => { setErrorKind(kind); @@ -427,6 +451,7 @@ function useSaveCoordinator( savingRef.current = true; setErrorKind(null); setStatus("saving"); + setFailedContributorIds(new Set()); const failedIds = new Set(); let hasNewerChanges = false; for (const { contributor } of submitted) { @@ -457,6 +482,7 @@ function useSaveCoordinator( const completionStatus = saveCompletionStatus(failedIds, hasNewerChanges); setErrorKind(completionStatus === "error" ? "save" : null); setStatus(completionStatus); + setFailedContributorIds(failedIds); refreshRegistry(); return { canLeave: failedIds.size === 0 && !hasNewerChanges && newlyDirty.length === 0, @@ -464,7 +490,7 @@ function useSaveCoordinator( }; }, [contributors, refreshRegistry]); - return { status, errorKind, saveAll, clearSavedStatus, markError }; + return { status, errorKind, saveAll, clearSavedStatus, markError, failedContributorIds }; } function saveCompletionStatus( diff --git a/apps/web/components/settings/settings-section.tsx b/apps/web/components/settings/settings-section.tsx index 93c7808840..856e2fb5fc 100644 --- a/apps/web/components/settings/settings-section.tsx +++ b/apps/web/components/settings/settings-section.tsx @@ -1,9 +1,7 @@ "use client"; import type { ReactNode } from "react"; -import { Separator } from "@kandev/ui/separator"; -import { useSettingsTargetRegistration } from "./settings-target-provider"; -import { SETTINGS_TYPOGRAPHY } from "./settings-typography"; +import { SettingsGroup } from "./settings-group"; type SettingsSectionProps = { icon?: ReactNode; @@ -14,6 +12,7 @@ type SettingsSectionProps = { action?: ReactNode; children: ReactNode; discoveryTargetId?: string; + framed?: boolean; /** * Rule the heading off from the body. For a section that *is* its page — the * workspace Repositories and Workflows tabs, which have no page heading of @@ -32,29 +31,26 @@ export function SettingsSection({ action, children, discoveryTargetId, + framed = true, divided = false, }: SettingsSectionProps) { - const registerTarget = useSettingsTargetRegistration(discoveryTargetId); return ( -
- {/* Stack the action row under the title on narrow screens instead of - squeezing both onto one line; shrink-0 keeps the actions from being - compressed by a long description when side by side. */} -
-
-
-

- {icon} - {title} -

- {titleAccessory} -
- {description &&

{description}

} -
- {action &&
{action}
} -
- {divided && } + + {icon} + {title} + + } + titleTestId={titleTestId} + titleAccessory={titleAccessory} + description={description} + action={action} + discoveryTargetId={discoveryTargetId} + frame={framed ? "card" : "none"} + contentClassName={divided ? "border-t border-border/70" : undefined} + > {children} -
+ ); } diff --git a/apps/web/components/settings/settings-tabs.tsx b/apps/web/components/settings/settings-tabs.tsx index c0d1406c65..00c3d3cedc 100644 --- a/apps/web/components/settings/settings-tabs.tsx +++ b/apps/web/components/settings/settings-tabs.tsx @@ -8,6 +8,7 @@ import { emitSettingsTargetRequest, settingsTargetFromHash } from "@/lib/setting export type SettingsTabOption = { id: string; label: ReactNode; + ariaLabel?: string; }; type SettingsTabsContextValue = { @@ -75,6 +76,7 @@ export function SettingsTabsList({ { if (delay !== null) lastValidDelay.current = String(delay); }, [delay]); - const toggleTarget = useSettingsTargetRegistration(GENERAL_SETTINGS_TARGETS.sidebarHover); - const delayTarget = useSettingsTargetRegistration(GENERAL_SETTINGS_TARGETS.sidebarHoverDelay); const enabledDirty = draft.sidebarHoverEnabled !== saved.sidebarHoverEnabled; const delayDirty = draft.sidebarHoverDelayMs !== saved.sidebarHoverDelayMs; return ( - - -
- +
+ -
-
- + } + /> + + {t("settings:sidebarHoverDelayError")} + + ) : ( + {t("settings:sidebarHoverHelp")} + ) + } + controlId="sidebar-hover-delay" + discoveryTargetId={GENERAL_SETTINGS_TARGETS.sidebarHoverDelay} + isDirty={delayDirty} + control={ - {delay === null && ( - - )} -
- - - + } + /> +
); } diff --git a/apps/web/components/settings/sleep-inhibition-settings.test.tsx b/apps/web/components/settings/sleep-inhibition-settings.test.tsx index 49a5f9b5f2..e1d7d4cfec 100644 --- a/apps/web/components/settings/sleep-inhibition-settings.test.tsx +++ b/apps/web/components/settings/sleep-inhibition-settings.test.tsx @@ -34,7 +34,7 @@ function response( return { settings: { enabled }, status }; } -function renderSettings() { +function renderSettings(options: { onAttentionChange?: (needsAttention: boolean) => void } = {}) { return render( - + , ); } @@ -78,7 +78,7 @@ describe("SleepInhibitionSettings", () => { renderSettings(); const infoButton = await screen.findByRole("button", { - name: "How host sleep prevention works", + name: "About Prevent idle system sleep", }); fireEvent.focus(infoButton); @@ -142,6 +142,19 @@ describe("SleepInhibitionSettings", () => { expect(saveContributor?.isDirty).toBe(true); }); + it("reports load attention and clears it after a successful retry", async () => { + fetchSettingsMock.mockRejectedValueOnce(new Error("offline")); + const onAttentionChange = vi.fn(); + renderSettings({ onAttentionChange }); + + await waitFor(() => expect(onAttentionChange).toHaveBeenCalledWith(true)); + + fetchSettingsMock.mockResolvedValueOnce(response()); + fireEvent.click(await screen.findByRole("button", { name: /retry/i })); + + await waitFor(() => expect(onAttentionChange).toHaveBeenCalledWith(false)); + }); + it("refreshes runtime status without replacing an unsaved draft", async () => { vi.useFakeTimers(); fetchSettingsMock.mockResolvedValueOnce( diff --git a/apps/web/components/settings/sleep-inhibition-settings.tsx b/apps/web/components/settings/sleep-inhibition-settings.tsx index ef69ccb38f..fe15962bfc 100644 --- a/apps/web/components/settings/sleep-inhibition-settings.tsx +++ b/apps/web/components/settings/sleep-inhibition-settings.tsx @@ -5,23 +5,15 @@ import { Trans, useTranslation } from "react-i18next"; import { Alert, AlertDescription } from "@kandev/ui/alert"; import { Button } from "@kandev/ui/button"; import { CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card"; -import { - Drawer, - DrawerContent, - DrawerDescription, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from "@kandev/ui/drawer"; import { Label } from "@kandev/ui/label"; import { Spinner } from "@kandev/ui/spinner"; import { Switch } from "@kandev/ui/switch"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; -import { IconAlertCircle, IconInfoCircle } from "@tabler/icons-react"; +import { SettingsInfo } from "./settings-info"; +import { IconAlertCircle } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; import { SettingsCard } from "./settings-card"; +import { SettingsRow } from "./settings-group"; import { useSettingsSaveContributor } from "./settings-save-provider"; -import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; import { useSleepInhibitionSettings } from "@/hooks/domains/settings/use-sleep-inhibition-settings"; import type { SleepInhibitionResponse } from "@/lib/types/system"; import { settingsActionClassName } from "./settings-control"; @@ -124,19 +116,29 @@ function useSleepInhibitionState(): SleepInhibitionState { }; } -function SleepInhibitionLoadError({ onRetry }: { onRetry: () => void }) { +function SleepInhibitionLoadError({ + onRetry, + withinGroup = false, +}: { + onRetry: () => void; + withinGroup?: boolean; +}) { const { t } = useTranslation(); + const content = ( +
+ + + {t("settings:sleepInhibitionLoadFailed")} + + +
+ ); + if (withinGroup) return
{content}
; return ( - - - - {t("settings:sleepInhibitionLoadFailed")} - - - + {content} ); } @@ -185,60 +187,94 @@ function SleepInhibitionInfoTooltipContent() { function SleepInhibitionInfoTooltip() { const { t } = useTranslation(); - const usesTouchDrawer = useTouchDrawer(); - const [open, setOpen] = useState(false); - const button = ( - - ); - const trigger = usesTouchDrawer ? ( - {button} - ) : ( - - - {button} - - - - - - ); - return ( - - {trigger} - - - {t("settings:sleepInhibitionInfoTitle")} - {t("settings:sleepInhibitionInfoTrigger")} - -
- -
-
-
+ + +

{t("settings:sleepInhibitionDescription")}

+

{t("settings:sleepInhibitionSwitchHint")}

+

{t("settings:sleepInhibitionCaveat")}

+
); } -function SleepInhibitionCard({ state }: { state: SleepInhibitionState }) { +function SleepInhibitionCard({ + state, + withinGroup = false, +}: { + state: SleepInhibitionState; + withinGroup?: boolean; +}) { const { t } = useTranslation(); const snapshot = state.snapshot; if (!snapshot) return null; + const control = ( + + ); + const row = ( + } + controlId="task-sleep-inhibition" + touchTarget="switch" + isDirty={state.isDirty} + control={control} + /> + ); + const body = ( + <> + {withinGroup ? ( + row + ) : ( +
+
+ +

+ {t("settings:sleepInhibitionSwitchHint")} +

+
+ {control} +
+ )} + +
+

+ {t("settings:sleepInhibitionStatusLabel")}: {t(statusMessageKey(snapshot))} +

+
+ + {!state.isAdmin && ( +

{t("settings:sleepInhibitionAdminOnly")}

+ )} + {state.saveFailed && ( + + + {t("settings:sleepInhibitionSaveFailed")} + + )} + + ); + if (withinGroup) { + return ( +
+ {body} +
+ ); + } return ( {t("settings:sleepInhibitionDescription")} - -
-
- -

- {t("settings:sleepInhibitionSwitchHint")} -

-
- -
- -
-

- {t("settings:sleepInhibitionStatusLabel")}: {t(statusMessageKey(snapshot))} -

-

- {t("settings:sleepInhibitionCaveat")} -

-
- - {!state.isAdmin && ( -

{t("settings:sleepInhibitionAdminOnly")}

- )} - {state.saveFailed && ( - - - {t("settings:sleepInhibitionSaveFailed")} - - )} -
+ {body}
); } -export function SleepInhibitionSettings() { +export function SleepInhibitionSettings({ + withinGroup = false, + onAttentionChange, +}: { + withinGroup?: boolean; + onAttentionChange?: (needsAttention: boolean) => void; +}) { const { t } = useTranslation(); const state = useSleepInhibitionState(); + useEffect(() => { + onAttentionChange?.(state.loadFailed || state.saveFailed); + }, [onAttentionChange, state.loadFailed, state.saveFailed]); if (state.loading && !state.snapshot) { + const loadingContent = ( +
+ + {t("settings:sleepInhibitionLoading")} +
+ ); + if (withinGroup) return
{loadingContent}
; return ( - - - {t("settings:sleepInhibitionLoading")} - + {loadingContent} ); } if (state.loadFailed && !state.snapshot) { - return void state.reload()} />; + return ( + void state.reload()} withinGroup={withinGroup} /> + ); } - return ; + return ; } diff --git a/apps/web/components/settings/startup-page-settings-card.tsx b/apps/web/components/settings/startup-page-settings-card.tsx index b60eb427f6..e8f5f2fbbe 100644 --- a/apps/web/components/settings/startup-page-settings-card.tsx +++ b/apps/web/components/settings/startup-page-settings-card.tsx @@ -1,12 +1,11 @@ "use client"; import { useTranslation } from "react-i18next"; -import { CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card"; import { Label } from "@kandev/ui/label"; import { RadioGroup, RadioGroupItem } from "@kandev/ui/radio-group"; import type { StartupPage } from "@/lib/types/http"; -import { SettingsCard } from "./settings-card"; import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences"; +import { useSettingsTargetRegistration } from "./settings-target-provider"; /** * Holds catalog KEYS, not copy. A module-scope table is evaluated once at import, @@ -42,59 +41,59 @@ export function StartupPageSettingsCard({ onChange: (value: StartupPage) => void; }) { const { t } = useTranslation(); + const registerTarget = useSettingsTargetRegistration(GENERAL_SETTINGS_TARGETS.startupPage); return ( - - - {t("settings:openKandevTo")} - {t("settings:thisAppliesWhenKandevStarts")} - - - onChange(next as StartupPage)} - data-settings-dirty={isDirty} - className="gap-3" - > - {OPTIONS.map((option) => { - const labelId = `startup-page-${option.value}-label`; - const descriptionId = `startup-page-${option.value}-description`; - const selected = value === option.value; - return ( - - + + {t(option.descriptionKey)} + + + + ); + })} + + ); } diff --git a/apps/web/components/settings/system-metrics-settings-card.test.tsx b/apps/web/components/settings/system-metrics-settings-card.test.tsx index 217bc0b4eb..657c75e581 100644 --- a/apps/web/components/settings/system-metrics-settings-card.test.tsx +++ b/apps/web/components/settings/system-metrics-settings-card.test.tsx @@ -39,8 +39,9 @@ describe("SystemMetricsSettingsCard", () => { ); const simplified = screen.getByRole("switch", { name: "Simplified metrics" }); + const settingsRoot = screen.getByTestId("system-metrics-settings-card"); expect(simplified.getAttribute(DIRTY_ATTRIBUTE)).toBe("true"); - expect(simplified.closest('[data-slot="card"]')?.getAttribute(DIRTY_ATTRIBUTE)).toBe("true"); + expect(settingsRoot.getAttribute(DIRTY_ATTRIBUTE)).toBe("true"); expect( screen.getByText( "Removes the Host marker and progress bars while retaining metric icons and values.", @@ -72,13 +73,14 @@ describe("SystemMetricsSettingsCard", () => { expect(updateSystemMetricsSettingsMock).not.toHaveBeenCalled(); expect(cpuMetric.getAttribute(DIRTY_ATTRIBUTE)).toBe("true"); - expect(cpuMetric.closest('[data-slot="card"]')?.getAttribute(DIRTY_ATTRIBUTE)).toBe("true"); + const settingsRoot = screen.getByTestId("system-metrics-settings-card"); + expect(settingsRoot.getAttribute(DIRTY_ATTRIBUTE)).toBe("true"); fireEvent.click(await screen.findByRole("button", { name: "Save changes" })); await waitFor(() => expect(updateSystemMetricsSettingsMock).toHaveBeenCalledTimes(1)); expect(updateSystemMetricsSettingsMock.mock.calls[0]?.[0].metrics).not.toContain("cpu_percent"); await waitFor(() => expect(cpuMetric.getAttribute(DIRTY_ATTRIBUTE)).toBe("false")); - expect(cpuMetric.closest('[data-slot="card"]')?.getAttribute(DIRTY_ATTRIBUTE)).toBe("false"); + expect(settingsRoot.getAttribute(DIRTY_ATTRIBUTE)).toBe("false"); }); }); diff --git a/apps/web/components/settings/system-metrics-settings-card.tsx b/apps/web/components/settings/system-metrics-settings-card.tsx index 2014f0b399..2019aeb2bc 100644 --- a/apps/web/components/settings/system-metrics-settings-card.tsx +++ b/apps/web/components/settings/system-metrics-settings-card.tsx @@ -2,14 +2,13 @@ import { useEffect, useState } from "react"; import { Checkbox } from "@kandev/ui/checkbox"; -import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; import { Switch } from "@kandev/ui/switch"; import { fetchSystemMetricsSettings, updateSystemMetricsSettings } from "@/lib/api"; import type { SystemMetricId, SystemMetricsGlobalSettings } from "@/lib/types/system"; import { useSettingsSaveContributor } from "./settings-save-provider"; -import { SettingsCard } from "./settings-card"; +import { useSettingsTargetRegistration } from "./settings-target-provider"; import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences"; import { useTranslation } from "react-i18next"; @@ -44,6 +43,7 @@ export function SystemMetricsSettingsCard({ onSimplifiedChange: (checked: boolean) => void; }) { const { t } = useTranslation(); + const registerTarget = useSettingsTargetRegistration(GENERAL_SETTINGS_TARGETS.resourceMetrics); const [settings, setSettings] = useState(DEFAULT_METRICS_SETTINGS); const [savedSettings, setSavedSettings] = useState(DEFAULT_METRICS_SETTINGS); @@ -91,43 +91,40 @@ export function SystemMetricsSettingsCard({ }; return ( - - - {t("settings:resourceMetrics")} - - -

- {t("settings:usefulWhenKandevIsSelfHosted")} -

- - - - setSettings({ ...settings, collect_execution: checked })} - /> -
-
+

+ {t("settings:usefulWhenKandevIsSelfHosted")} +

+ + + + setSettings({ ...settings, collect_execution: checked })} + /> + ); } diff --git a/apps/web/components/settings/system/about-settings.tsx b/apps/web/components/settings/system/about-settings.tsx index 33d9fb24d5..3ac83c332c 100644 --- a/apps/web/components/settings/system/about-settings.tsx +++ b/apps/web/components/settings/system/about-settings.tsx @@ -2,7 +2,7 @@ import { Separator } from "@kandev/ui/separator"; import { useTranslation } from "react-i18next"; -import { SettingsTarget } from "@/components/settings/settings-target"; +import { SettingsGroup } from "@/components/settings/settings-group"; import { AboutCard } from "@/components/settings/system/about-card"; import { LicensesList } from "@/components/settings/system/licenses-list"; import { SYSTEM_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/system"; @@ -15,15 +15,14 @@ export function AboutSettings({ licenses }: { licenses: LicenseEntry[] }) {
- -
-

{t("system:navLicenses")}

-

- {t("system:licensesPageDescription")} -

-
+ -
+
); } diff --git a/apps/web/components/settings/system/data-logs-settings.tsx b/apps/web/components/settings/system/data-logs-settings.tsx index ca7b0ff22d..f6bec6dc29 100644 --- a/apps/web/components/settings/system/data-logs-settings.tsx +++ b/apps/web/components/settings/system/data-logs-settings.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import { Separator } from "@kandev/ui/separator"; import { useTranslation } from "react-i18next"; import { useAppStore } from "@/components/state-provider"; -import { SettingsTarget } from "@/components/settings/settings-target"; +import { SettingsGroup } from "@/components/settings/settings-group"; import { SettingsTabs, SettingsTabsList, @@ -21,17 +21,6 @@ import { SYSTEM_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/system import { ToolPayloadRetentionCard } from "./tool-payload-retention-card"; import { BACKUP_SQL_COMMAND, SystemRouteShell } from "./system-route-shell"; -function SectionHeading({ title, description }: { title: string; description?: string }) { - return ( -
-

{title}

- {description && ( -

{description}

- )} -
- ); -} - const DATA_LOGS_TARGET_TO_TAB = { [SYSTEM_SETTINGS_TARGETS.database]: "database", [SYSTEM_SETTINGS_TARGETS.toolPayloadRetention]: "database", @@ -44,32 +33,39 @@ function DatabasePanel() { const backupDirectory = useAppStore((s) => s.system.database?.backup_directory); return (
- - + - + - + - + - - + - +
); } @@ -77,10 +73,14 @@ function DatabasePanel() { function LogsPanel() { const { t } = useTranslation(); return ( - - + - + ); } diff --git a/apps/web/components/settings/system/message-queue-settings.test.tsx b/apps/web/components/settings/system/message-queue-settings.test.tsx index 38c5c088bb..15c719f18b 100644 --- a/apps/web/components/settings/system/message-queue-settings.test.tsx +++ b/apps/web/components/settings/system/message-queue-settings.test.tsx @@ -143,7 +143,7 @@ describe("MessageQueueSettings — per-session limit", () => { const input = await screen.findByLabelText(MAXIMUM_LABEL); expect(screen.getByTestId(EFFECTIVE_VALUE_ID).textContent).toBe("Unlimited"); - expect(screen.getByText(/Set 0 for unlimited/)).toBeTruthy(); + expect(screen.getByText(/Use 0 for unlimited/)).toBeTruthy(); expect(input.className).toContain("h-11"); const root = screen.getByTestId("message-queue-settings"); expect(root.className).toContain("min-w-0"); @@ -287,13 +287,7 @@ describe("MessageQueueSettings — merge toggle", () => { expect(mergeToggle().getAttribute(ARIA_PRESSED)).toBe("true"); expect( - screen.getByText(/Lets you fold a queued message into the message directly above it/), - ).toBeTruthy(); - expect( - screen.getByText(/Only adjacent messages from the same sender can be merged/), - ).toBeTruthy(); - expect( - screen.getByText(/combined, deduplicated entity references would exceed 100/), + screen.getByText(/Combine related queued messages before the agent reads them/), ).toBeTruthy(); }); @@ -372,8 +366,7 @@ describe("MessageQueueSettings — automatic merge toggle", () => { const toggle = await screen.findByRole("button", { name: AUTO_MERGE_TOGGLE_LABEL }); expect(toggle.getAttribute(ARIA_PRESSED)).toBe("true"); - expect(screen.getByText(/stays as a separate queued message/)).toBeTruthy(); - expect(screen.getByText(/follows this value until you change Auto-merge/)).toBeTruthy(); + expect(screen.getByText(/Merge compatible consecutive messages automatically/)).toBeTruthy(); const touchTarget = screen.getByTestId("message-queue-auto-merge-touch-target"); expect(touchTarget.className).toContain("min-h-11"); expect(touchTarget.className).toContain("min-w-11"); diff --git a/apps/web/components/settings/system/message-queue-settings.tsx b/apps/web/components/settings/system/message-queue-settings.tsx index 60eeb6ed89..208d6fc53d 100644 --- a/apps/web/components/settings/system/message-queue-settings.tsx +++ b/apps/web/components/settings/system/message-queue-settings.tsx @@ -6,10 +6,11 @@ import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Input } from "@kandev/ui/input"; -import { Label } from "@kandev/ui/label"; +import { SettingsRow } from "../settings-group"; +import { SettingsInfo } from "../settings-info"; import { Spinner } from "@kandev/ui/spinner"; import { Switch } from "@kandev/ui/switch"; -import { IconAlertCircle, IconInfoCircle, IconLock } from "@tabler/icons-react"; +import { IconAlertCircle, IconLock } from "@tabler/icons-react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { useAppStore } from "@/components/state-provider"; @@ -212,7 +213,7 @@ function useQueueSettingsContributor({ } /** Derives dirty, validation, and permission state for all queue drafts. */ -function useMessageQueueSettingsDraft() { +export function useMessageQueueSettingsDraft() { const { t } = useTranslation(); const role = useAppStore((state) => state.auth.user?.role); const [saveFailed, setSaveFailed] = useState(false); @@ -259,6 +260,7 @@ function useMessageQueueSettingsDraft() { loading: load.loading, loadFailed: load.loadFailed, saveFailed, + invalidReason, isDirty: isMaxDirty || isMergeDirty || isAutoMergeDirty, isAdmin, isLocked, @@ -290,37 +292,46 @@ function QueueLimitFields({ const { t } = useTranslation(); return ( <> -
- - onDraftChange(event.target.value)} - className={settingsControlClassName("w-full max-w-xs")} - /> -

{t("system:messageQueueUnlimitedHelp")}

-
-
+ +

{t("system:messageQueueLimitDescription")}

+

{t("system:messageQueueUnlimitedHelp")}

+

{t("system:messageQueueEffectiveHelp")}

+ + } + control={ + onDraftChange(event.target.value)} + className={settingsControlClassName("w-full md:w-40")} + /> + } + /> +
- {t("system:messageQueueConfigured")} - {configured} + {String(configured) !== effectiveValue && ( + <> + {t("system:messageQueueConfigured")} + {configured} + + )} {t("system:messageQueueEffective")} {effectiveValue} {t(sourceLabelKey(source))}
-

- {t("system:messageQueueEffectiveHelp")} -

); @@ -336,115 +347,113 @@ type MergeToggleFieldsProps = { * for the same per-function line-limit reason as `QueueLimitFields`. */ function MergeToggleFields({ enabled, onChange, disabled }: MergeToggleFieldsProps) { const { t } = useTranslation(); + const label = t("system:messageQueueMergeToggleLabel"); return ( -
-
-
- -

- {t("system:messageQueueMergeDescription")} -

-
+ +

{t("system:messageQueueMergeDescription")}

+

{t("system:messageQueueMergeNotice")}

+ + } + control={ -
-
- -

{t("system:messageQueueMergeNotice")}

-
-
+ } + /> ); } -/** Renders the automatic same-source admission merge setting. */ function AutoMergeToggleFields({ enabled, onChange, disabled }: MergeToggleFieldsProps) { const { t } = useTranslation(); const label = t("system:messageQueueAutoMergeToggleLabel"); return ( -
-
-
- -

- {t("system:messageQueueAutoMergeDescription")} -

-
-
- -
-
-
- -

{t("system:messageQueueAutoMergeNotice")}

-
-
+ +

{t("system:messageQueueAutoMergeDescription")}

+

{t("system:messageQueueAutoMergeNotice")}

+ + } + control={ + + } + /> ); } -/** Settings → Task Behavior → Message Queue controls, sharing one save contributor. */ -export function MessageQueueSettings() { +function MessageQueueLoadingState({ withinGroup }: { withinGroup: boolean }) { const { t } = useTranslation(); - const state = useMessageQueueSettingsDraft(); + const loadingContent = ( +
+ + {t("system:messageQueueLoading")} +
+ ); + if (withinGroup) return
{loadingContent}
; + return ( + + {loadingContent} + + ); +} - if (state.loading && !state.snapshot) { - return ( - - - - {t("system:messageQueueLoading")} - - - ); - } - - if (state.loadFailed && !state.snapshot) { - return void state.reload()} />; - } - - if (!state.snapshot) return null; - const { settings, effective } = state.snapshot; +function MessageQueueSettingsReady({ + state, + withinGroup, +}: MessageQueueSettingsContentProps & { + state: NonNullable; +}) { + const { t } = useTranslation(); + const { settings, effective } = state.snapshot!; const isConfigurationLocked = state.lockSource === "configuration"; const effectiveValue = effective.max_per_session === 0 ? t("system:messageQueueUnlimited") : String(effective.max_per_session); - return ( - - - {t("system:messageQueueLimitTitle")} - - -

{t("system:messageQueueLimitDescription")}

+ const content = ( + <> + {withinGroup ? ( +
+

{t("system:messageQueueTitle")}

+
+ ) : ( + + {t("system:messageQueueLimitTitle")} + + )} + + {!withinGroup && ( +

+ {t("system:messageQueueLimitDescription")} +

+ )} + {state.isDirty && state.invalidReason && ( +

+ {state.invalidReason} +

+ )} {state.isLocked && ( @@ -488,27 +502,82 @@ export function MessageQueueSettings() { disabled={!state.isAdmin} />
+ + ); + + if (withinGroup) { + return ( +
+ {content} +
+ ); + } + + return ( + + {content} ); } -function MessageQueueLoadError({ onRetry }: { onRetry: () => void }) { +/** Settings → Task Behavior → Message Queue controls, sharing one save contributor. */ +type MessageQueueSettingsContentProps = { + state: ReturnType; + withinGroup?: boolean; +}; + +export function MessageQueueSettings() { + const state = useMessageQueueSettingsDraft(); + return ; +} + +export function MessageQueueSettingsContent({ + state, + withinGroup = false, +}: MessageQueueSettingsContentProps) { + if (state.loading && !state.snapshot) { + return ; + } + + if (state.loadFailed && !state.snapshot) { + return void state.reload()} withinGroup={withinGroup} />; + } + + if (!state.snapshot) return null; + return ; +} + +function MessageQueueLoadError({ + onRetry, + withinGroup = false, +}: { + onRetry: () => void; + withinGroup?: boolean; +}) { const { t } = useTranslation(); + const content = ( +
+ + + {t("system:messageQueueLoadFailed")} + + +
+ ); + if (withinGroup) return
{content}
; return ( - - - - {t("system:messageQueueLoadFailed")} - - - + {content} ); } diff --git a/apps/web/components/settings/system/organizations/organizations-page.tsx b/apps/web/components/settings/system/organizations/organizations-page.tsx index c822210e7c..774b72d85b 100644 --- a/apps/web/components/settings/system/organizations/organizations-page.tsx +++ b/apps/web/components/settings/system/organizations/organizations-page.tsx @@ -5,10 +5,11 @@ import { useTranslation } from "react-i18next"; import { IconBuilding, IconPlayerPause, IconPlayerPlay, IconTrash } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Badge } from "@kandev/ui/badge"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card"; +import { Card, CardContent } from "@kandev/ui/card"; import { useOrganizations } from "@/hooks/domains/org/use-organizations"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import type { Org } from "@/lib/types/org"; +import { SettingsGroup } from "@/components/settings/settings-group"; import { CreateOrgCard } from "./create-org-card"; import { DeleteOrgDialog } from "./delete-org-dialog"; import { OrgAdminDialog } from "./org-admin-dialog"; @@ -170,28 +171,27 @@ export function OrganizationsPage() { // The route shell already renders the page title and description, so this // page starts at its content.
- - - {t("orgs:boundaryTitle")} - {t("orgs:boundaryDescription")} - - + + - - -
- {orgs.orgs.map((org) => ( - - ))} -
+
+ {orgs.orgs.map((org) => ( + + ))} +
+
{ expect(saveContributor?.canSave).toBe(false); expect(saveContributor?.invalidReason).toBe(VALIDATION); expect(input.getAttribute("aria-invalid")).toBe("true"); - expect(input.getAttribute("aria-describedby")).toBe( - "session-capacity-maximum-help session-capacity-maximum-error", + expect(input.getAttribute("aria-describedby")?.split(/\s+/).sort()).toEqual( + ["session-capacity-maximum-error", "session-capacity-maximum-help"].sort(), ); expect(screen.getByTestId("session-capacity-maximum-error").textContent).toBe(VALIDATION); }); diff --git a/apps/web/components/settings/system/session-capacity-settings.tsx b/apps/web/components/settings/system/session-capacity-settings.tsx index a3637b6871..0a276d41e4 100644 --- a/apps/web/components/settings/system/session-capacity-settings.tsx +++ b/apps/web/components/settings/system/session-capacity-settings.tsx @@ -5,10 +5,11 @@ import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Input } from "@kandev/ui/input"; -import { Label } from "@kandev/ui/label"; +import { SettingsRow } from "../settings-group"; +import { SettingsInfo } from "../settings-info"; import { Spinner } from "@kandev/ui/spinner"; import { Switch } from "@kandev/ui/switch"; -import { IconAlertCircle, IconInfoCircle, IconLock } from "@tabler/icons-react"; +import { IconAlertCircle, IconLock } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { SettingsCard } from "@/components/settings/settings-card"; import { @@ -22,23 +23,33 @@ import { const ENVIRONMENT_VARIABLE = "KANDEV_MAX_CONCURRENT_SESSIONS"; -function SessionCapacityLoadError({ onRetry }: { onRetry: () => void }) { +function SessionCapacityLoadError({ + onRetry, + withinGroup = false, +}: { + onRetry: () => void; + withinGroup?: boolean; +}) { const { t } = useTranslation(); + const content = ( +
+ + + {t("system:sessionCapacityLoadFailed")} + + +
+ ); + if (withinGroup) return
{content}
; return ( - - - - {t("system:sessionCapacityLoadFailed")} - - - + {content} ); } @@ -55,22 +66,19 @@ function SessionCapacitySwitch({ const { t } = useTranslation(); const label = t("system:sessionCapacityEnabledLabel"); return ( -
-
- -

- {t("system:sessionCapacityEnabledDescription")} -

-
-
+ +

{t("system:sessionCapacityEnabledDescription")}

+

{t("system:sessionCapacityBehaviorHelp")}

+ + } + control={ -
-
+ } + /> ); } @@ -97,34 +105,41 @@ function MaximumField({ onChange: (value: string) => void; }) { const { t } = useTranslation(); - const describedBy = error - ? "session-capacity-maximum-help session-capacity-maximum-error" - : "session-capacity-maximum-help"; return ( -
- - onChange(event.target.value)} - className={settingsControlClassName("w-full max-w-xs")} +
+ + {t("system:sessionCapacityMaximumHelp")} + + } + control={ + onChange(event.target.value)} + className={settingsControlClassName("w-full md:w-40")} + /> + } /> -

- {t("system:sessionCapacityMaximumHelp")} -

{error && (
+
{t("system:sessionCapacityCurrent")} {current} @@ -159,7 +174,6 @@ function EffectiveCapacity({ ({t("system:sessionCapacityUnsaved")}) )}
-

{t("system:sessionCapacityScope")}

); } @@ -194,26 +208,40 @@ function sessionCapacityMaximumError({ return invalidReason; } -export function SessionCapacitySettings() { +function SessionCapacityLoadingState({ withinGroup }: { withinGroup: boolean }) { const { t } = useTranslation(); + const loadingContent = ( +
+ + {t("system:sessionCapacityLoading")} +
+ ); + if (withinGroup) return
{loadingContent}
; + return ( + + {loadingContent} + + ); +} + +type SessionCapacitySettingsContentProps = { + state: ReturnType; + withinGroup?: boolean; +}; + +export function SessionCapacitySettings() { const state = useSessionCapacitySettings(); + return ; +} - if (state.loading && !state.snapshot) { - return ( - - - - {t("system:sessionCapacityLoading")} - - - ); - } - if (state.loadFailed && !state.snapshot) { - return void state.reload()} />; - } - if (!state.snapshot) return null; - - const { effective, settings } = state.snapshot; +function SessionCapacitySettingsReady({ + state, + withinGroup, +}: SessionCapacitySettingsContentProps & { + state: NonNullable; +}) { + const { t } = useTranslation(); + const { effective, settings } = state.snapshot!; const controlsDisabled = !state.isAdmin || state.isLocked; const effectiveEnabled = state.isLocked ? effective.enabled : state.enabledDraft; const effectiveMaximum = state.isLocked ? effective.max_sessions : settings.max_sessions; @@ -225,19 +253,18 @@ export function SessionCapacitySettings() { invalidReason: state.invalidReason, }); - return ( - - - {t("system:sessionCapacityLimitTitle")} -

- {t("system:sessionCapacityLimitDescription")} -

-
- + const content = ( + <> + {withinGroup ? ( +
+

{t("system:sessionCapacityLimitTitle")}

+
+ ) : ( + + {t("system:sessionCapacityLimitTitle")} + + )} + -
- -

{t("system:sessionCapacityBehaviorHelp")}

-
{state.isLocked && } {!state.isAdmin && (

{t("system:sessionCapacityAdminOnly")}

@@ -272,6 +295,40 @@ export function SessionCapacitySettings() { )}
+ + ); + + if (withinGroup) { + return ( +
+ {content} +
+ ); + } + + return ( + + {content} ); } + +export function SessionCapacitySettingsContent({ + state, + withinGroup = false, +}: SessionCapacitySettingsContentProps) { + if (state.loading && !state.snapshot) { + return ; + } + if (state.loadFailed && !state.snapshot) { + return ( + void state.reload()} withinGroup={withinGroup} /> + ); + } + if (!state.snapshot) return null; + return ; +} diff --git a/apps/web/components/settings/system/users-table.tsx b/apps/web/components/settings/system/users-table.tsx index 3a2967f111..0200a29e0f 100644 --- a/apps/web/components/settings/system/users-table.tsx +++ b/apps/web/components/settings/system/users-table.tsx @@ -5,7 +5,6 @@ import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; -import { Card, CardContent } from "@kandev/ui/card"; import { Spinner } from "@kandev/ui/spinner"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; import { IconMailForward, IconUserPlus, IconUsers } from "@tabler/icons-react"; @@ -21,7 +20,7 @@ import { ApiError } from "@/lib/api/client"; import { listUsers, updateUser, type AuthUser } from "@/lib/api/domains/auth-api"; import { CreateUserDialog } from "./create-user-dialog"; import { InviteDialog } from "./invite-dialog"; -import { SettingsCardHeader } from "@/components/settings/settings-card-header"; +import { SettingsGroup } from "@/components/settings/settings-group"; import { SettingsErrorText } from "@/components/settings/settings-typography"; import { settingsActionClassName } from "@/components/settings/settings-control"; @@ -444,36 +443,37 @@ export function UsersTable() { }; return ( - - - {t("system:usersTitle")} - - } - actions={ -
- - -
- } - /> - + + {t("system:usersTitle")} + + } + action={ +
+ + +
+ } + data-testid="users-table-card" + contentClassName="space-y-4 divide-y-0" + > +
{error && {error}} {!loaded && isLoading && (
@@ -496,7 +496,7 @@ export function UsersTable() { {t("system:usersEmpty")}

)} - +
void reload()} /> - + ); } diff --git a/apps/web/components/settings/task-behavior-settings.test.tsx b/apps/web/components/settings/task-behavior-settings.test.tsx new file mode 100644 index 0000000000..3d5c755c72 --- /dev/null +++ b/apps/web/components/settings/task-behavior-settings.test.tsx @@ -0,0 +1,113 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const runtimeMocks = vi.hoisted(() => ({ + queue: { + snapshot: null, + loading: true, + loadFailed: false, + saveFailed: false, + invalidReason: undefined as string | undefined, + isDirty: false, + }, + session: { + snapshot: null, + loading: true, + loadFailed: false, + saveFailed: false, + invalidReason: undefined as string | undefined, + isDirty: false, + }, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("./agent-generated-task-title-settings", () => ({ + AgentGeneratedTaskTitleSettings: () => null, +})); +vi.mock("./anchored-prompt-bar-settings", () => ({ AnchoredPromptBarSettings: () => null })); +vi.mock("./archive-confirmation-settings", () => ({ ArchiveConfirmationSettings: () => null })); +vi.mock("./creation-auto-focus-settings", () => ({ CreationAutoFocusSettings: () => null })); +vi.mock("./mcp-task-agent-profile-default-settings", () => ({ + MCPTaskAgentProfileDefaultSettings: () => null, +})); +vi.mock("./prevent-auto-start-agent-settings", () => ({ + PreventAutoStartAgentSettings: () => null, +})); +vi.mock("./sleep-inhibition-settings", () => ({ + SleepInhibitionSettings: ({ + onAttentionChange, + }: { + onAttentionChange?: (needsAttention: boolean) => void; + }) => ( + + } > - - - {t("settings:utilityAddCustomAgent")} - - } - /> - -

- {t("settings:utilityCustomAgentsDescription")} -

+
{agents.length === 0 ? (

{t("settings:utilityCustomAgentsEmpty")} @@ -286,7 +276,7 @@ export function CustomAgentsSection({ ))}

)} -
- +
+
); } diff --git a/apps/web/e2e/helpers/settings-composition.ts b/apps/web/e2e/helpers/settings-composition.ts new file mode 100644 index 0000000000..4515921c12 --- /dev/null +++ b/apps/web/e2e/helpers/settings-composition.ts @@ -0,0 +1,4 @@ +import type { Page } from "@playwright/test"; +export async function openTaskBehaviorRuntime(page: Page): Promise { + await page.getByRole("tab", { name: "Runtime", exact: true }).click(); +} diff --git a/apps/web/e2e/tests/office/scheduler-events.spec.ts b/apps/web/e2e/tests/office/scheduler-events.spec.ts index d7f50f64e9..af649df8e9 100644 --- a/apps/web/e2e/tests/office/scheduler-events.spec.ts +++ b/apps/web/e2e/tests/office/scheduler-events.spec.ts @@ -65,7 +65,11 @@ test.describe("Office reactive scheduler", () => { apiClient, officeApi, officeSeed, + testPage, }) => { + // Request the office fixture's page so its per-test reset clears runs and + // sessions left by earlier office specs in the same worker. + void testPage; test.setTimeout(90_000); // Create a task without an assignee, then attach the CEO. @@ -94,7 +98,9 @@ test.describe("Office reactive scheduler", () => { apiClient, officeApi, officeSeed, + testPage, }) => { + void testPage; test.setTimeout(90_000); const task = await apiClient.createTask( diff --git a/apps/web/e2e/tests/settings/executor-profile-routing.spec.ts b/apps/web/e2e/tests/settings/executor-profile-routing.spec.ts index 59823d29bf..921677d156 100644 --- a/apps/web/e2e/tests/settings/executor-profile-routing.spec.ts +++ b/apps/web/e2e/tests/settings/executor-profile-routing.spec.ts @@ -44,7 +44,10 @@ test.describe("executor profile routing", () => { try { await testPage.goto("/settings/executors"); - await testPage.getByText(profile.name, { exact: true }).click(); + const profileCard = testPage.getByTestId(`executor-profile-card-${profile.id}`); + await expect(profileCard).toBeVisible(); + await expect(profileCard.getByText(profile.name, { exact: true })).toBeVisible(); + await profileCard.click(); await expectDockerProfileEditor(testPage, profile.id); const buildResponse = waitForHttp(testPage, "POST", /^\/api\/v1\/docker\/build$/, { diff --git a/apps/web/e2e/tests/settings/kubernetes-task-environment-helpers.ts b/apps/web/e2e/tests/settings/kubernetes-task-environment-helpers.ts index 9da1cc786e..45c77451e8 100644 --- a/apps/web/e2e/tests/settings/kubernetes-task-environment-helpers.ts +++ b/apps/web/e2e/tests/settings/kubernetes-task-environment-helpers.ts @@ -15,6 +15,11 @@ export function seedKubernetesTaskEnvironment( const sqlite = new DatabaseSync(database); let transactionOpen = false; try { + // The backend may still be committing the session or environment row when + // this fixture attaches its synthetic Kubernetes environment. Wait for + // that short write window instead of making the E2E outcome depend on + // which writer wins the SQLite lock first. + sqlite.exec("PRAGMA busy_timeout = 10000"); sqlite.exec("BEGIN IMMEDIATE"); transactionOpen = true; sqlite diff --git a/apps/web/e2e/tests/settings/mobile-agent-profile-delete.spec.ts b/apps/web/e2e/tests/settings/mobile-agent-profile-delete.spec.ts index e2ea493873..e6be02ab7a 100644 --- a/apps/web/e2e/tests/settings/mobile-agent-profile-delete.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-agent-profile-delete.spec.ts @@ -30,7 +30,11 @@ test.describe("Agent profile deletion on mobile", () => { await expect(row).toBeVisible({ timeout: 15_000 }); const trigger = row.getByTestId(`profile-actions-menu-${profile.id}`); await trigger.tap(); - await testPage.getByTestId(`delete-profile-${profile.id}`).tap(); + await expect(trigger).toHaveAttribute("aria-expanded", "true"); + const menu = testPage.locator('[data-slot="dropdown-menu-content"]:visible'); + await expect(menu).toBeVisible(); + await waitForFiniteAnimations(menu); + await menu.getByTestId(`delete-profile-${profile.id}`).tap(); const confirmation = testPage.getByRole("dialog", { name: "Delete agent profile?" }); await expect(confirmation).toBeVisible(); diff --git a/apps/web/e2e/tests/settings/mobile-agent-profile-duplicate.spec.ts b/apps/web/e2e/tests/settings/mobile-agent-profile-duplicate.spec.ts index 8763e14ec0..4c3aabf16f 100644 --- a/apps/web/e2e/tests/settings/mobile-agent-profile-duplicate.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-agent-profile-duplicate.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "../../fixtures/test-base"; +import { waitForFiniteAnimations } from "../../helpers/animations"; /** * Mobile parity for the agent profile duplicate flow: the row-level @@ -48,7 +49,11 @@ test.describe("Mobile agent profile duplicate", () => { await trigger.tap(); const duplicate = testPage.getByTestId(`duplicate-profile-${profile.id}`); await expect(duplicate).toBeVisible({ timeout: 15_000 }); - await duplicate.tap(); + await expect(trigger).toHaveAttribute("aria-expanded", "true"); + const menu = testPage.locator('[data-slot="dropdown-menu-content"]:visible'); + await expect(menu).toBeVisible(); + await waitForFiniteAnimations(menu); + await menu.getByTestId(`duplicate-profile-${profile.id}`).tap(); // The copy appears as its own row without a reload. await expect diff --git a/apps/web/e2e/tests/settings/mobile-general-settings.spec.ts b/apps/web/e2e/tests/settings/mobile-general-settings.spec.ts index c77d44b03c..7ce968d376 100644 --- a/apps/web/e2e/tests/settings/mobile-general-settings.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-general-settings.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "../../fixtures/test-base"; +import { openTaskBehaviorRuntime } from "../../helpers/settings-composition"; test.describe("Mobile general settings", () => { test("keeps the device chart-motion setting contained and persistent", async ({ testPage }) => { @@ -56,13 +57,14 @@ test.describe("Mobile general settings", () => { try { await testPage.goto("/settings/general/task-actions"); + await openTaskBehaviorRuntime(testPage); const card = testPage.getByTestId("sleep-inhibition-settings"); const toggle = card.getByRole("switch", { name: "Prevent idle system sleep" }); await expect(card).toBeVisible(); - const info = card.getByRole("button", { name: "How host sleep prevention works" }); + const info = card.getByRole("button", { name: "About Prevent idle system sleep" }); await info.click(); const infoDrawer = testPage.getByRole("dialog", { - name: "How Kandev prevents host sleep", + name: "Prevent idle system sleep", }); await expect(infoDrawer).toBeVisible(); await expect(infoDrawer).toContainText("/usr/bin/caffeinate -i -w"); @@ -134,6 +136,7 @@ test.describe("Mobile general settings", () => { }) => { await testPage.setViewportSize({ width: 390, height: 844 }); await testPage.goto("/settings/preferences/task-behavior"); + await testPage.getByRole("tab", { name: "Conversation", exact: true }).click(); const autoScrollControl = testPage.getByRole("switch", { name: "Show transcript auto-scroll control", diff --git a/apps/web/e2e/tests/settings/mobile-settings-composition.spec.ts b/apps/web/e2e/tests/settings/mobile-settings-composition.spec.ts new file mode 100644 index 0000000000..ee76b17a1d --- /dev/null +++ b/apps/web/e2e/tests/settings/mobile-settings-composition.spec.ts @@ -0,0 +1,121 @@ +import { expect, test } from "../../fixtures/test-base"; +import type { Page } from "@playwright/test"; + +async function expectContained(page: Page) { + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe( + await page.evaluate(() => document.documentElement.clientWidth), + ); +} + +test.describe("Mobile settings composition", () => { + test("task behavior keeps grouped rows inside one phone scroll region", async ({ testPage }) => { + await testPage.goto("/settings/preferences/task-behavior"); + + const page = testPage.getByTestId("task-behavior-settings"); + const runtime = testPage.getByTestId("task-behavior-runtime"); + await expect(page).toBeVisible(); + await expect(testPage.getByTestId("task-behavior-group")).toHaveCount(2); + + await testPage.getByRole("tab", { name: "Runtime", exact: true }).tap(); + await expect(runtime.locator("details")).toHaveCount(0); + await expect(testPage.getByTestId("message-queue-settings")).toBeVisible(); + + const summaryBox = await testPage + .getByRole("tab", { name: "Runtime", exact: true }) + .boundingBox(); + const queueBox = await testPage.getByTestId("message-queue-settings").boundingBox(); + const viewport = testPage.viewportSize(); + expect(summaryBox).not.toBeNull(); + expect(queueBox).not.toBeNull(); + expect(viewport).not.toBeNull(); + expect(queueBox!.x).toBeGreaterThanOrEqual(0); + expect(queueBox!.x + queueBox!.width).toBeLessThanOrEqual(viewport!.width + 1); + expect(summaryBox!.height).toBeGreaterThanOrEqual(44); + expect(await testPage.evaluate(() => document.documentElement.scrollWidth)).toBe( + await testPage.evaluate(() => document.documentElement.clientWidth), + ); + + const nestedScrollOwners = await page.evaluate( + (root) => + Array.from(root.querySelectorAll('[role="tabpanel"][data-state="active"] *')).filter( + (element) => { + const overflow = getComputedStyle(element).overflowY; + return ( + (overflow === "auto" || overflow === "scroll") && + element.scrollHeight > element.clientHeight + ); + }, + ).length, + ); + expect(nestedScrollOwners).toBe(0); + }); + + test("switch rows activate across their full coarse-pointer target", async ({ testPage }) => { + await testPage.goto("/settings/preferences/task-behavior"); + + const row = testPage + .getByTestId("task-behavior-group") + .first() + .locator('[data-settings-touch-target="true"]') + .first(); + const toggle = row.getByRole("switch"); + const rowBox = await row.boundingBox(); + expect(rowBox).not.toBeNull(); + expect(rowBox!.height).toBeGreaterThanOrEqual(44); + expect(rowBox!.width).toBeGreaterThanOrEqual(44); + + const initial = await toggle.getAttribute("data-state"); + await row.tap({ position: { x: 4, y: rowBox!.height / 2 } }); + await expect(toggle).toHaveAttribute( + "data-state", + initial === "checked" ? "unchecked" : "checked", + ); + }); + + test("preferences keep notification groups contained on a phone", async ({ testPage }) => { + await testPage.goto("/settings/preferences/notifications"); + + await expect(testPage.locator('[data-settings-group="true"]').first()).toBeVisible(); + await expectContained(testPage); + }); + + test("agent executor groups remain reachable on a phone", async ({ testPage }) => { + await testPage.goto("/settings/agents"); + await expect(testPage.getByTestId("installed-agents-actions")).toBeVisible(); + await expect(testPage.locator('[data-settings-group="true"]').first()).toBeVisible(); + await expectContained(testPage); + + await testPage.goto("/settings/executors"); + await expect(testPage.locator('[data-settings-group="true"]').last()).toBeVisible(); + await expectContained(testPage); + }); + + test("workspace integration groups remain contained beside mobile tabs", async ({ + testPage, + seedData, + }) => { + await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/repositories`); + await expect(testPage.getByTestId("workspace-settings-shell")).toBeVisible(); + const repositoriesGroup = testPage.locator('[data-settings-group="true"]').first(); + await expect(repositoriesGroup).toBeVisible(); + await expect( + repositoriesGroup.locator(':scope > [data-settings-group-card="true"]'), + ).toHaveCount(0); + await expectContained(testPage); + + await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/integrations/github`); + await expect(testPage.getByTestId("workspace-settings-shell")).toBeVisible(); + await expect(testPage.locator('[data-settings-group="true"]').first()).toBeVisible(); + await expectContained(testPage); + }); + + test("system account groups remain reachable on a phone", async ({ testPage }) => { + await testPage.goto("/settings/system/users"); + await expect(testPage.getByTestId("users-table-card")).toBeVisible(); + await expectContained(testPage); + + await testPage.goto("/settings/account/tokens"); + await expect(testPage.getByTestId("api-tokens-card")).toBeVisible(); + await expectContained(testPage); + }); +}); diff --git a/apps/web/e2e/tests/settings/mobile-task-behavior-concise-help.spec.ts b/apps/web/e2e/tests/settings/mobile-task-behavior-concise-help.spec.ts new file mode 100644 index 0000000000..8922b17be3 --- /dev/null +++ b/apps/web/e2e/tests/settings/mobile-task-behavior-concise-help.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from "../../fixtures/test-base"; + +for (const width of [390, 767, 768, 900]) { + test(`touch help preserves the draft and returns focus at ${width}px`, async ({ testPage }) => { + await testPage.setViewportSize({ width, height: 844 }); + await testPage.goto("/settings/preferences/task-behavior"); + const toggle = testPage.locator("#creation-auto-focus"); + await toggle.tap(); + const draft = await toggle.getAttribute("aria-checked"); + const info = testPage.getByRole("button", { + name: "About Profile for Tasks Created by Agents", + }); + const box = await info.boundingBox(); + expect(box!.width).toBeGreaterThanOrEqual(44); + expect(box!.height).toBeGreaterThanOrEqual(44); + await info.tap(); + const drawer = testPage.getByRole("dialog", { name: "Profile for Tasks Created by Agents" }); + await expect(drawer).toBeVisible(); + await expect(drawer).toContainText("create_task_kandev"); + const drawerBox = await drawer.boundingBox(); + expect(drawerBox!.x).toBeGreaterThanOrEqual(0); + expect(drawerBox!.x + drawerBox!.width).toBeLessThanOrEqual(width); + await drawer.getByRole("button", { name: "Close", exact: true }).tap(); + await expect(info).toBeFocused(); + await expect(toggle).toHaveAttribute("aria-checked", draft!); + await testPage.getByRole("tab", { name: "Runtime", exact: true }).tap(); + await expect(testPage.getByTestId("message-queue-max-per-session")).toBeVisible(); + await expect(testPage.getByTestId("task-behavior-settings").locator("details")).toHaveCount(0); + expect( + await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ).toBe(true); + await testPage + .getByTestId("settings-floating-save") + .getByRole("button", { name: "Reset", exact: true }) + .tap(); + }); +} + +test("long translated descriptions and help stay inside the phone viewport", async ({ + testPage, +}) => { + await testPage.goto("/settings/preferences/task-behavior"); + await testPage.evaluate(() => { + document.cookie = "kandev_locale=pseudo; path=/; max-age=31536000; SameSite=Lax"; + }); + await testPage.reload(); + await expect(testPage.locator("html")).toHaveAttribute("lang", "pseudo"); + const titleInfo = testPage.locator( + '[data-settings-target="setting-agent-generated-task-titles"] [data-settings-info]', + ); + await titleInfo.tap(); + const drawer = testPage.getByRole("dialog"); + await expect(drawer).toBeVisible(); + const box = await drawer.boundingBox(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(testPage.viewportSize()!.width); + expect( + await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ).toBe(true); + await testPage.keyboard.press("Escape"); + await expect(titleInfo).toBeFocused(); +}); diff --git a/apps/web/e2e/tests/settings/notifications-type-scale.spec.ts b/apps/web/e2e/tests/settings/notifications-type-scale.spec.ts index efac8009df..374434420f 100644 --- a/apps/web/e2e/tests/settings/notifications-type-scale.spec.ts +++ b/apps/web/e2e/tests/settings/notifications-type-scale.spec.ts @@ -23,12 +23,12 @@ test.describe("Notifications settings type scale", () => { caption: "Settings → General → Notifications at 1280px", }); - // The page body inherits the Card base (text-xs/relaxed = 12px); group - // headings sit one step up at text-sm (14px). Assert the computed sizes so - // the capture is backed by a real measurement, not just an eyeball. + // Group descriptions use the readable section role (14px), while the + // table body remains compact (12px). Assert the computed sizes so the + // capture is backed by a real measurement, not just an eyeball. expect( await description.evaluate((el) => parseFloat(getComputedStyle(el).fontSize)), - ).toBeCloseTo(12, 1); + ).toBeCloseTo(14, 1); expect(await table.evaluate((el) => parseFloat(getComputedStyle(el).fontSize))).toBeCloseTo( 12, 1, diff --git a/apps/web/e2e/tests/settings/settings-composition.spec.ts b/apps/web/e2e/tests/settings/settings-composition.spec.ts new file mode 100644 index 0000000000..f28d2923a5 --- /dev/null +++ b/apps/web/e2e/tests/settings/settings-composition.spec.ts @@ -0,0 +1,107 @@ +import { expect, test } from "../../fixtures/test-base"; + +test.describe("Settings composition", () => { + test("task behavior groups are expanded within their tabs", async ({ testPage }) => { + await testPage.goto("/settings/preferences/task-behavior"); + await expect(testPage.getByTestId("task-behavior-group")).toHaveCount(2); + await expect(testPage.getByTestId("task-behavior-creating-title")).toBeVisible(); + await expect(testPage.getByTestId("task-behavior-archiving-title")).toBeVisible(); + await testPage.getByRole("tab", { name: "Conversation", exact: true }).click(); + await expect(testPage.getByTestId("task-behavior-conversation-title")).toBeVisible(); + await testPage.getByRole("tab", { name: "Runtime", exact: true }).click(); + await expect(testPage.getByTestId("message-queue-settings")).toBeVisible(); + await expect(testPage.getByTestId("session-capacity-settings")).toBeVisible(); + await expect(testPage.getByTestId("task-behavior-settings").locator("details")).toHaveCount(0); + }); + + test("search focuses a previously visited runtime tab", async ({ testPage }) => { + await testPage.goto("/settings/preferences/task-behavior#setting-message-queue"); + const input = testPage.getByTestId("message-queue-max-per-session"); + await expect(input).toBeVisible(); + await testPage.getByRole("tab", { name: "Tasks", exact: true }).click(); + const search = testPage + .getByTestId("app-sidebar-settings-mode") + .getByTestId("settings-search") + .getByRole("searchbox"); + await search.fill("queue"); + await testPage + .getByTestId("settings-search-results") + .locator('[data-settings-search-motion-key="item:task-behavior-message-queue"]') + .click(); + await expect(input).toBeVisible(); + await expect(input).toBeFocused(); + }); + + test("preferences keep notification surfaces inside shared groups", async ({ testPage }) => { + await testPage.goto("/settings/preferences/notifications"); + + await expect(testPage.locator('[data-settings-group="true"]').first()).toBeVisible(); + expect(await testPage.evaluate(() => document.documentElement.scrollWidth)).toBe( + await testPage.evaluate(() => document.documentElement.clientWidth), + ); + }); + + test("appearance, notifications, and editors keep one frame per group", async ({ testPage }) => { + await testPage.goto("/settings/preferences/appearance"); + const themeRow = testPage.getByTestId("theme-settings-card"); + await expect(themeRow).toHaveAttribute("data-settings-row", "true"); + await expect(themeRow.locator('[data-slot="card"]')).toHaveCount(0); + await expect( + themeRow.locator('xpath=ancestor::*[@data-settings-group-card="true"]'), + ).toHaveCount(1); + + const changesRow = testPage.getByTestId("changes-panel-layout-card"); + const changesTrigger = changesRow.getByTestId("changes-panel-layout-select"); + const changesDescriptionId = await changesTrigger.getAttribute("aria-describedby"); + expect(changesDescriptionId).toBeTruthy(); + await expect(changesRow.locator(`[id="${changesDescriptionId}"]`)).toBeVisible(); + + await testPage.goto("/settings/preferences/notifications"); + const notificationsContent = testPage.locator('[data-settings-page-content="true"]'); + await expect(notificationsContent).toBeVisible(); + await expect(notificationsContent.locator(':scope > [data-slot="card"]')).toHaveCount(0); + await expect(notificationsContent.locator('[data-settings-group="true"]')).toHaveCount(4); + + await testPage.goto("/settings/preferences/terminal-editors"); + const editorContent = testPage.locator('[data-settings-page-content="true"]').last(); + await expect(editorContent).toBeVisible(); + await expect(editorContent.locator(':scope > [data-slot="card"]')).toHaveCount(0); + await expect(editorContent.locator('[data-settings-group="true"]')).toHaveCount(2); + }); + + test("agent executor surfaces keep resource and create groups visible", async ({ testPage }) => { + await testPage.goto("/settings/agents"); + await expect(testPage.getByTestId("installed-agents-actions")).toBeVisible(); + await expect(testPage.locator('[data-settings-group="true"]').first()).toBeVisible(); + + await testPage.goto("/settings/executors"); + await expect(testPage.locator('[data-settings-group="true"]').last()).toBeVisible(); + }); + + test("workspace integration surfaces keep tabs and group chrome intact", async ({ + testPage, + seedData, + }) => { + await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/repositories`); + await expect(testPage.getByTestId("workspace-settings-shell")).toBeVisible(); + const repositoriesGroup = testPage.locator('[data-settings-group="true"]').first(); + await expect(repositoriesGroup).toBeVisible(); + await expect( + repositoriesGroup.locator(':scope > [data-settings-group-card="true"]'), + ).toHaveCount(0); + + await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/integrations/github`); + await expect(testPage.getByTestId("workspace-settings-shell")).toBeVisible(); + await expect(testPage.locator('[data-settings-group="true"]').first()).toBeVisible(); + }); + + test("system account surfaces keep table and token groups discoverable", async ({ testPage }) => { + await testPage.goto("/settings/system/users"); + await expect(testPage.getByTestId("users-table-card")).toBeVisible(); + await expect(testPage.locator('[data-settings-group="true"]').first()).toBeVisible(); + + await testPage.goto("/settings/account/tokens"); + await expect(testPage.getByTestId("api-tokens-card")).toBeVisible(); + await expect(testPage.locator('[data-settings-group="true"]').first()).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/tests/settings/settings-manual-save.spec.ts b/apps/web/e2e/tests/settings/settings-manual-save.spec.ts index 57eff65125..65a3082264 100644 --- a/apps/web/e2e/tests/settings/settings-manual-save.spec.ts +++ b/apps/web/e2e/tests/settings/settings-manual-save.spec.ts @@ -210,6 +210,7 @@ test.describe("Settings manual save", () => { try { await testPage.goto("/settings/preferences/task-behavior"); + await testPage.getByRole("tab", { name: "Conversation", exact: true }).click(); const autoScrollControl = testPage.getByRole("switch", { name: "Show transcript auto-scroll control", }); @@ -231,6 +232,7 @@ test.describe("Settings manual save", () => { ); await testPage.reload(); + await testPage.getByRole("tab", { name: "Conversation", exact: true }).click(); await expect(autoScrollControl).not.toBeChecked(); } finally { await apiClient.saveUserSettings({ diff --git a/apps/web/e2e/tests/settings/settings-typography.spec.ts b/apps/web/e2e/tests/settings/settings-typography.spec.ts index 3caa2ce386..a9029f6e70 100644 --- a/apps/web/e2e/tests/settings/settings-typography.spec.ts +++ b/apps/web/e2e/tests/settings/settings-typography.spec.ts @@ -1,7 +1,10 @@ import { test, expect } from "../../fixtures/test-base"; test.describe("Settings typography contract on desktop", () => { - test("keeps page, section, and card headings distinct", async ({ testPage, prCapture }) => { + test("keeps page, section, and preference row roles distinct", async ({ + testPage, + prCapture, + }) => { await testPage.setViewportSize({ width: 1280, height: 900 }); await testPage.goto("/settings/preferences/appearance"); @@ -14,7 +17,7 @@ test.describe("Settings typography contract on desktop", () => { const card = testPage.getByTestId("theme-settings-card"); await expect(pageTitle).toBeVisible(); await expect(sectionTitle).toBeVisible(); - await expect(card.locator("h3")).toHaveCount(1); + await expect(card).toHaveAttribute("data-settings-row", "true"); const pageSize = await pageTitle.evaluate((element) => parseFloat(getComputedStyle(element).fontSize), @@ -22,12 +25,8 @@ test.describe("Settings typography contract on desktop", () => { const sectionSize = await sectionTitle.evaluate((element) => parseFloat(getComputedStyle(element).fontSize), ); - const cardSize = await card - .locator("h3") - .evaluate((element) => parseFloat(getComputedStyle(element).fontSize)); expect(pageSize).toBeCloseTo(24, 1); expect(sectionSize).toBeCloseTo(18, 1); - expect(cardSize).toBeCloseTo(16, 1); await expect(testPage.getByTestId("theme-settings-card").locator("button")).toHaveCount(1); const controlBox = await testPage .getByTestId("theme-settings-card") diff --git a/apps/web/e2e/tests/settings/task-behavior-concise-help.spec.ts b/apps/web/e2e/tests/settings/task-behavior-concise-help.spec.ts new file mode 100644 index 0000000000..cce937e2e1 --- /dev/null +++ b/apps/web/e2e/tests/settings/task-behavior-concise-help.spec.ts @@ -0,0 +1,127 @@ +import { expect, test } from "../../fixtures/test-base"; + +const PATH = "/settings/preferences/task-behavior"; +test("concise descriptions retain hover and keyboard technical help", async ({ testPage }) => { + await testPage.goto(`${PATH}?tab=unknown`); + await expect(testPage.getByRole("tab", { name: "Tasks", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(testPage.getByText("create_task_kandev", { exact: true })).not.toBeVisible(); + const info = testPage.getByRole("button", { name: "About Profile for Tasks Created by Agents" }); + await info.hover(); + await expect(testPage.getByRole("tooltip")).toContainText("Workflow-selected profiles win first"); + await testPage.mouse.move(0, 0); + await info.focus(); + await expect(testPage.getByRole("tooltip")).toContainText("agent_profile_id"); + await testPage.keyboard.press("Escape"); + await testPage.getByRole("tab", { name: "Runtime", exact: true }).click(); + await expect(testPage.getByTestId("message-queue-max-per-session")).toBeVisible(); + await expect(testPage.getByTestId("task-behavior-settings").locator("details")).toHaveCount(0); +}); + +test("drafts and Reset span tabs and a failed inactive save reveals its tab once", async ({ + testPage, +}) => { + await testPage.goto(PATH); + const creation = testPage.locator("#creation-auto-focus"); + const initial = await creation.getAttribute("aria-checked"); + await creation.click(); + await testPage.getByRole("tab", { name: "Conversation", exact: true }).click(); + await testPage.locator("#show-anchored-prompt-bar").click(); + await expect(testPage.getByRole("tab", { name: "Tasks", exact: true })).toContainText( + "Unsaved changes", + ); + await expect(testPage.getByRole("tab", { name: "Conversation", exact: true })).toContainText( + "Unsaved changes", + ); + await testPage.getByRole("tab", { name: "Runtime", exact: true }).click(); + const queue = testPage.getByTestId("message-queue-max-per-session"); + const baseline = await queue.inputValue(); + await queue.fill(String(Number(baseline) + 1)); + await testPage + .getByTestId("settings-floating-save") + .getByRole("button", { name: "Reset", exact: true }) + .click(); + await expect(queue).toHaveValue(baseline); + await testPage.getByRole("tab", { name: "Tasks", exact: true }).click(); + await expect(creation).toHaveAttribute("aria-checked", initial!); + await creation.click(); + await testPage.getByRole("tab", { name: "Conversation", exact: true }).click(); + await testPage.route("**/api/v1/user/settings", async (route) => { + if (route.request().method() === "PATCH") await route.fulfill({ status: 500, body: "{}" }); + else await route.continue(); + }); + await testPage + .getByTestId("settings-floating-save") + .getByRole("button", { name: "Save changes" }) + .click(); + await expect(testPage.getByRole("tab", { name: "Tasks", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ); + await testPage.getByRole("tab", { name: "Conversation", exact: true }).click(); + await expect(testPage.getByRole("tab", { name: "Conversation", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ); + await testPage + .getByTestId("settings-floating-save") + .getByRole("button", { name: "Reset", exact: true }) + .click(); +}); + +for (const width of [767, 768]) { + test(`tabs and info stay contained at ${width}px`, async ({ testPage }) => { + await testPage.setViewportSize({ width, height: 1000 }); + await testPage.goto(PATH); + const info = testPage.getByRole("button", { name: "About Open new tasks automatically" }); + const box = await info.boundingBox(); + expect(box!.height).toBe(width < 768 ? 44 : 24); + expect( + await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ).toBe(true); + }); +} + +test("Save changes persists drafts from all three tabs", async ({ testPage, apiClient }) => { + const baseline = (await apiClient.getUserSettings()).settings; + const queueResponse = await apiClient.rawRequest("GET", "/api/v1/system/message-queue/settings"); + const queueBaseline = (await queueResponse.json()) as { settings: { max_per_session: number } }; + try { + await testPage.goto(PATH); + await testPage.locator("#creation-auto-focus").click(); + await testPage.getByRole("tab", { name: "Conversation", exact: true }).click(); + await testPage.locator("#show-anchored-prompt-bar").click(); + await testPage.getByRole("tab", { name: "Runtime", exact: true }).click(); + const nextQueue = queueBaseline.settings.max_per_session + 1; + await testPage.getByTestId("message-queue-max-per-session").fill(String(nextQueue)); + await testPage + .getByTestId("settings-floating-save") + .getByRole("button", { name: "Save changes" }) + .click(); + await expect(testPage.getByTestId("settings-floating-save")).not.toBeVisible(); + await testPage.reload(); + await expect(testPage.getByTestId("message-queue-max-per-session")).toHaveValue( + String(nextQueue), + ); + await testPage.getByRole("tab", { name: "Conversation", exact: true }).click(); + await expect(testPage.locator("#show-anchored-prompt-bar")).toHaveAttribute( + "aria-checked", + String(!baseline.show_anchored_prompt_bar), + ); + await testPage.getByRole("tab", { name: "Tasks", exact: true }).click(); + await expect(testPage.locator("#creation-auto-focus")).toHaveAttribute( + "aria-checked", + String(!baseline.auto_focus_new_tasks), + ); + } finally { + await apiClient.saveUserSettings({ + auto_focus_new_tasks: baseline.auto_focus_new_tasks, + show_anchored_prompt_bar: baseline.show_anchored_prompt_bar, + }); + await apiClient.rawRequest("PATCH", "/api/v1/system/message-queue/settings", { + max_per_session: queueBaseline.settings.max_per_session, + }); + } +}); diff --git a/apps/web/e2e/tests/settings/todo-list-panel.spec.ts b/apps/web/e2e/tests/settings/todo-list-panel.spec.ts index 86f975ee92..e167bb6742 100644 --- a/apps/web/e2e/tests/settings/todo-list-panel.spec.ts +++ b/apps/web/e2e/tests/settings/todo-list-panel.spec.ts @@ -168,7 +168,7 @@ test.describe("Todo list panel preference", () => { // Toggle the preference from a second tab; `testPage` never navigates // away from the task — proves the change is live, not reload-dependent. const settingsPage = await testPage.context().newPage(); - await settingsPage.goto("/settings/preferences/task-behavior"); + await settingsPage.goto("/settings/preferences/task-behavior?tab=conversation"); const toggle = settingsPage.getByRole("switch", { name: "Show agent todo list panel" }); await expect(toggle).toHaveAttribute("aria-checked", "false"); await toggle.click(); @@ -335,6 +335,7 @@ test.describe("Todo list panel preference", () => { testPage.getByRole("switch", { name: "Only pin when todo list is not empty" }); await testPage.goto("/settings/general/task-actions"); + await testPage.getByRole("tab", { name: "Conversation", exact: true }).click(); const toggle = testPage.getByRole("switch", { name: "Show agent todo list panel" }); await expect(toggle).toHaveAttribute("aria-checked", "false"); await expect(subSwitch()).toHaveCount(0); diff --git a/apps/web/e2e/tests/settings/utility-agents.spec.ts b/apps/web/e2e/tests/settings/utility-agents.spec.ts index d2e0adfb9d..c36e7a4f55 100644 --- a/apps/web/e2e/tests/settings/utility-agents.spec.ts +++ b/apps/web/e2e/tests/settings/utility-agents.spec.ts @@ -465,10 +465,9 @@ test.describe("Utility Agents settings page", () => { ).toBeVisible(); }); - test("wraps each settings sub-section in a bounded card", async ({ testPage }) => { - // Regression guard: these sub-sections used to be bare divs with no - // visible border, unlike every other settings page (whose - // Card-wrapped Enable/Engine/Behavior sections). + test("wraps settings sub-sections in bounded surfaces", async ({ testPage }) => { + // Regression guard: migrated sections own one visible group surface while + // the specialized configuration chat editor retains its card surface. // // Mock the inference-agent + builtin-agent APIs so the Actions card // (which renders null when there are no builtins — see @@ -525,19 +524,30 @@ test.describe("Utility Agents settings page", () => { testPage.getByRole("heading", { name: "Utility Agents", exact: true }), ).toBeVisible({ timeout: 15_000 }); - for (const { testId, heading } of [ - { testId: "utility-default-model-card", heading: "Default utility agent model" }, - { testId: "utility-actions-card", heading: "Actions" }, - { testId: "utility-custom-agents-card", heading: "Custom utility agents" }, - { testId: "config-chat-agent-card", heading: "Configuration Chat Agent" }, + for (const { testId, heading, surface } of [ + { + testId: "utility-default-model-card", + heading: "Default utility agent model", + surface: "group", + }, + { testId: "utility-actions-card", heading: "Actions", surface: "group" }, + { testId: "utility-custom-agents-card", heading: "Custom utility agents", surface: "group" }, + { testId: "config-chat-agent-card", heading: "Configuration Chat Agent", surface: "card" }, ]) { - const card = testPage.getByTestId(testId); - await expect(card).toBeVisible(); - await expect(card).toHaveAttribute("data-slot", "card"); + const surfaceRoot = testPage.getByTestId(testId); + await expect(surfaceRoot).toBeVisible(); + if (surface === "group") { + await expect(surfaceRoot).toHaveAttribute("data-settings-group", "true"); + await expect(surfaceRoot.locator(':scope > [data-settings-group-card="true"]')).toHaveCount( + 1, + ); + } else { + await expect(surfaceRoot).toHaveAttribute("data-slot", "card"); + } // Card titles must stay real headings (level 3), not just styled div // text, so screen-reader heading navigation keeps working. await expect( - card.getByRole("heading", { name: heading, level: 3, exact: true }), + surfaceRoot.getByRole("heading", { name: heading, level: 3, exact: true }), ).toBeVisible(); } }); diff --git a/apps/web/e2e/tests/system/message-queue-settings.spec.ts b/apps/web/e2e/tests/system/message-queue-settings.spec.ts index 915fda57dc..7d61edc091 100644 --- a/apps/web/e2e/tests/system/message-queue-settings.spec.ts +++ b/apps/web/e2e/tests/system/message-queue-settings.spec.ts @@ -8,6 +8,7 @@ import { requestMessageQueueSettings, restoreMessageQueueSettings, } from "../../helpers/message-queue-settings"; +import { openTaskBehaviorRuntime } from "../../helpers/settings-composition"; test.describe.serial("Message Queue task behavior settings", () => { let baseline: MessageQueueSettingsValue | undefined; @@ -34,6 +35,7 @@ test.describe.serial("Message Queue task behavior settings", () => { await expect(testPage).toHaveURL( (url) => new URL(url).pathname === "/settings/preferences/task-behavior", ); + await openTaskBehaviorRuntime(testPage); await expect(testPage.getByText("Message Queue").first()).toBeVisible(); // The Message Queue box spans the full settings column like every other @@ -41,7 +43,7 @@ test.describe.serial("Message Queue task behavior settings", () => { const queueCard = testPage.getByTestId("message-queue-settings"); // Any other unconditional card on this page works as the reference width; // this used to be the Voice Mode card, which moved out to the Voice plugin. - const siblingCard = testPage.getByTestId("archive-confirmation-card"); + const siblingCard = testPage.getByTestId("session-capacity-settings"); await expect(queueCard).toBeVisible(); await expect(siblingCard).toBeVisible(); expect((await queueCard.boundingBox())?.width ?? 0).toBeCloseTo( @@ -65,6 +67,7 @@ test.describe.serial("Message Queue task behavior settings", () => { await expect(testPage.getByTestId("message-queue-source")).toHaveText("Saved setting"); await testPage.reload(); + await openTaskBehaviorRuntime(testPage); await expect(input).toHaveValue(String(updated)); await expect(testPage.getByTestId("message-queue-effective-value")).toHaveText(String(updated)); }); @@ -74,17 +77,20 @@ test.describe.serial("Message Queue task behavior settings", () => { }) => { if (!baseline) throw new Error("message queue settings baseline was not captured"); await testPage.goto("/settings/general/message-queue"); + await openTaskBehaviorRuntime(testPage); const toggle = testPage.getByTestId("message-queue-merge-enabled"); await expect(toggle).toBeVisible(); const wasEnabled = baseline.merge_enabled; + await testPage.getByRole("button", { name: "About Enable queued message merging" }).hover(); await expect(toggle).toHaveAttribute("aria-checked", String(wasEnabled)); - await expect( - testPage.getByText(/Only adjacent messages from the same sender can be merged/), - ).toBeVisible(); - await expect( - testPage.getByText(/combined, deduplicated entity references would exceed 100/), - ).toBeVisible(); + await expect(testPage.getByRole("tooltip")).toContainText( + "Only adjacent messages from the same sender can be merged", + ); + await expect(testPage.getByRole("tooltip")).toContainText( + "combined, deduplicated entity references would exceed 100", + ); + await testPage.mouse.move(0, 0); const saveResponse = testPage.waitForResponse( (response) => response.request().method() === "PATCH" && @@ -99,6 +105,7 @@ test.describe.serial("Message Queue task behavior settings", () => { await expect(toggle).toHaveAttribute("aria-checked", String(!wasEnabled)); await testPage.reload(); + await openTaskBehaviorRuntime(testPage); await expect(testPage.getByTestId("message-queue-merge-enabled")).toHaveAttribute( "aria-checked", String(!wasEnabled), @@ -111,6 +118,7 @@ test.describe.serial("Message Queue task behavior settings", () => { if (!baseline) throw new Error("message queue settings baseline was not captured"); expect(baseline.auto_merge_enabled).toBe(true); await testPage.goto("/settings/preferences/task-behavior"); + await openTaskBehaviorRuntime(testPage); const toggle = testPage.getByTestId("message-queue-auto-merge-enabled"); await expect(toggle).toHaveAttribute("aria-checked", "true"); @@ -132,6 +140,7 @@ test.describe.serial("Message Queue task behavior settings", () => { auto_merge_enabled: expected, }); await testPage.reload(); + await openTaskBehaviorRuntime(testPage); await expect(testPage.getByTestId("message-queue-auto-merge-enabled")).toHaveAttribute( "aria-checked", String(expected), @@ -162,6 +171,7 @@ test.describe.serial("Message Queue task behavior settings", () => { }); await testPage.goto("/settings/preferences/task-behavior"); + await openTaskBehaviorRuntime(testPage); await expect(testPage.getByTestId("message-queue-max-per-session")).toBeDisabled(); await expect(testPage.getByTestId("message-queue-effective-value")).toHaveText("41"); await expect(testPage.getByTestId("message-queue-source")).toHaveText("Environment"); @@ -195,7 +205,10 @@ test.describe.serial("Message Queue task behavior settings", () => { }); await testPage.goto("/settings/preferences/task-behavior"); - await expect(testPage.getByLabel("Maximum messages per session")).toBeDisabled(); + await openTaskBehaviorRuntime(testPage); + await expect( + testPage.getByLabel("Maximum messages per session", { exact: true }), + ).toBeDisabled(); await expect(testPage.getByTestId("message-queue-effective-value")).toHaveText("43"); await expect(testPage.getByTestId("message-queue-source")).toHaveText("Configuration"); await expect(testPage.getByText(/Managed by configuration/)).toBeVisible(); @@ -226,6 +239,7 @@ test.describe.serial("Message Queue task behavior settings", () => { }); const settingsNav = testPage.getByTestId("app-sidebar-settings-mode"); await settingsNav.getByRole("link", { name: /^Task Behavior/ }).click(); + await openTaskBehaviorRuntime(testPage); await expect(testPage.getByTestId("message-queue-max-per-session")).toBeDisabled(); await expect(testPage.getByTestId("message-queue-merge-enabled")).toBeDisabled(); diff --git a/apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts b/apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts index 70fdd8d3df..eca0e8f152 100644 --- a/apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts +++ b/apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts @@ -7,6 +7,7 @@ import { requestMessageQueueSettings, restoreMessageQueueSettings, } from "../../helpers/message-queue-settings"; +import { openTaskBehaviorRuntime } from "../../helpers/settings-composition"; let baseline: MessageQueueSettingsValue | undefined; @@ -35,6 +36,7 @@ test("mobile navigation reaches the Message Queue section with touch-safe shared await expect(testPage).toHaveURL( (url) => new URL(url).pathname === "/settings/preferences/task-behavior", ); + await openTaskBehaviorRuntime(testPage); await expect(testPage.getByText("Message Queue").first()).toBeVisible(); const input = testPage.getByTestId("message-queue-max-per-session"); @@ -83,6 +85,7 @@ test("mobile navigation reaches the Message Queue section with touch-safe shared await saveBar.getByRole("button", { name: "Save changes" }).tap(); expect((await saveResponse).request().postDataJSON()).toEqual({ auto_merge_enabled: expected }); await testPage.reload(); + await openTaskBehaviorRuntime(testPage); await expect(testPage.getByTestId("message-queue-auto-merge-enabled")).toHaveAttribute( "aria-checked", String(expected), @@ -124,8 +127,9 @@ test("mobile configuration lock keeps the source and accessible controls consist .getByTestId("settings-index") .getByRole("link", { name: /^Task Behavior/ }) .click(); + await openTaskBehaviorRuntime(testPage); - const input = testPage.getByLabel("Maximum messages per session"); + const input = testPage.getByLabel("Maximum messages per session", { exact: true }); await expect(input).toBeDisabled(); await expect(testPage.getByTestId("message-queue-source")).toHaveText("Configuration"); await expect(testPage.getByText(/Managed by configuration/)).toBeVisible(); diff --git a/apps/web/e2e/tests/system/mobile-session-capacity-settings.spec.ts b/apps/web/e2e/tests/system/mobile-session-capacity-settings.spec.ts index 10fd91b457..3f86bb643d 100644 --- a/apps/web/e2e/tests/system/mobile-session-capacity-settings.spec.ts +++ b/apps/web/e2e/tests/system/mobile-session-capacity-settings.spec.ts @@ -7,6 +7,7 @@ import { restoreSessionCapacitySettings, SESSION_CAPACITY_SETTINGS_PATH, } from "../../helpers/session-capacity-settings"; +import { openTaskBehaviorRuntime } from "../../helpers/settings-composition"; let baseline: SessionCapacitySettingsValue | undefined; @@ -33,6 +34,7 @@ test("reaches Task Behavior and keeps the form touch-safe", async ({ testPage }) .getByTestId("settings-index") .getByRole("link", { name: /^Task Behavior/ }) .click(); + await openTaskBehaviorRuntime(testPage); const card = testPage.getByTestId("session-capacity-settings"); await expect(card).toBeVisible(); @@ -71,6 +73,7 @@ test("reaches Task Behavior and keeps the form touch-safe", async ({ testPage }) await expect(saveBar).not.toBeVisible(); await testPage.reload(); + await openTaskBehaviorRuntime(testPage); await expect(card.getByTestId("session-capacity-enabled")).toHaveAttribute( "aria-checked", "true", @@ -85,6 +88,7 @@ test("reaches Task Behavior and keeps the form touch-safe", async ({ testPage }) .tap(); await expect(testPage.getByTestId("settings-floating-save")).not.toBeVisible(); await testPage.reload(); + await openTaskBehaviorRuntime(testPage); await expect(card.getByTestId("session-capacity-enabled")).toHaveAttribute( "aria-checked", "false", diff --git a/apps/web/e2e/tests/system/session-capacity-settings.spec.ts b/apps/web/e2e/tests/system/session-capacity-settings.spec.ts index 73983892f4..16ecab4e62 100644 --- a/apps/web/e2e/tests/system/session-capacity-settings.spec.ts +++ b/apps/web/e2e/tests/system/session-capacity-settings.spec.ts @@ -8,6 +8,7 @@ import { restoreSessionCapacitySettings, SESSION_CAPACITY_SETTINGS_PATH, } from "../../helpers/session-capacity-settings"; +import { openTaskBehaviorRuntime } from "../../helpers/settings-composition"; test.describe.serial("Session capacity task behavior settings", () => { let baseline: SessionCapacitySettingsValue | undefined; @@ -40,6 +41,7 @@ test.describe.serial("Session capacity task behavior settings", () => { await expect(testPage).toHaveURL( /\/settings\/preferences\/task-behavior#setting-session-capacity$/, ); + await openTaskBehaviorRuntime(testPage); await expect( testPage.locator('[data-settings-target="setting-session-capacity"]'), ).toHaveAttribute("data-settings-target-highlight", "true"); @@ -55,6 +57,7 @@ test.describe.serial("Session capacity task behavior settings", () => { await toggle.click(); const maximum = card.getByTestId("session-capacity-maximum"); await expect(maximum).toHaveValue(String(baseline.max_sessions)); + await expect(maximum).toHaveAttribute("aria-describedby", "session-capacity-maximum-help"); await maximum.fill("1"); await testPage .getByTestId("settings-floating-save") @@ -80,6 +83,7 @@ test.describe.serial("Session capacity task behavior settings", () => { await expect(card.getByTestId("session-capacity-source")).toHaveText("Saved setting"); await testPage.reload(); + await openTaskBehaviorRuntime(testPage); await expect(card.getByTestId("session-capacity-enabled")).toHaveAttribute( "aria-checked", "true", @@ -99,6 +103,7 @@ test.describe.serial("Session capacity task behavior settings", () => { expect((await secondSave).status()).toBe(200); await expect(card.getByTestId("session-capacity-effective-value")).toHaveText("2"); + await openTaskBehaviorRuntime(testPage); await card.getByTestId("session-capacity-enabled").click(); const disableSave = testPage.waitForResponse( (response) => @@ -111,6 +116,7 @@ test.describe.serial("Session capacity task behavior settings", () => { .click(); expect((await disableSave).status()).toBe(200); await testPage.reload(); + await openTaskBehaviorRuntime(testPage); await expect(card.getByTestId("session-capacity-enabled")).toHaveAttribute( "aria-checked", "false", @@ -146,6 +152,7 @@ test.describe.serial("Session capacity task behavior settings", () => { }); await testPage.goto("/settings/preferences/task-behavior"); + await openTaskBehaviorRuntime(testPage); const card = testPage.getByTestId("session-capacity-settings"); await expect(card.getByTestId("session-capacity-enabled")).toBeDisabled(); await expect(card.getByTestId("session-capacity-maximum")).toBeDisabled(); diff --git a/apps/web/e2e/tests/task/creation-auto-focus-helpers.ts b/apps/web/e2e/tests/task/creation-auto-focus-helpers.ts index 4f7ba3528d..983446e0f7 100644 --- a/apps/web/e2e/tests/task/creation-auto-focus-helpers.ts +++ b/apps/web/e2e/tests/task/creation-auto-focus-helpers.ts @@ -7,8 +7,7 @@ import { waitForHttp } from "../../helpers/causal-waits"; async function saveFocusPreference(page: Page, enabled: boolean, mobile: boolean) { await page.goto("/settings/preferences/task-behavior"); - const card = page.getByTestId("creation-auto-focus-card"); - const toggle = card.getByRole("switch", { name: "Auto-focus new tasks" }); + const toggle = page.getByTestId("creation-auto-focus-row").getByRole("switch"); await expect(toggle).toBeChecked({ checked: !enabled }); await toggle.scrollIntoViewIfNeeded(); if (mobile) { diff --git a/apps/web/e2e/tests/task/mcp-task-agent-profile-default.spec.ts b/apps/web/e2e/tests/task/mcp-task-agent-profile-default.spec.ts index 5ec2a8bb81..6ca36f1598 100644 --- a/apps/web/e2e/tests/task/mcp-task-agent-profile-default.spec.ts +++ b/apps/web/e2e/tests/task/mcp-task-agent-profile-default.spec.ts @@ -28,15 +28,12 @@ test.describe("MCP-created task agent profile default", () => { }); await testPage.goto("/settings/preferences/task-behavior"); - await expect(testPage.getByText("create_task_kandev", { exact: true })).toBeVisible(); - await expect(testPage.getByText("spawn_session_kandev", { exact: true })).toBeVisible(); - const mcpToolHelp = testPage.getByRole("button", { - name: "About affected Kandev MCP tools", - }); - await mcpToolHelp.hover(); - await expect(testPage.getByRole("tooltip")).toContainText( - "spawn_session_kandev adds a session to the current task", - ); + await expect(testPage.getByText("create_task_kandev", { exact: true })).not.toBeVisible(); + await testPage + .getByRole("button", { name: "About Profile for Tasks Created by Agents" }) + .hover(); + await expect(testPage.getByRole("tooltip")).toContainText("spawn_session_kandev"); + await testPage.mouse.move(0, 0); const workspaceDefault = testPage.getByRole("radio", { name: "Workspace default profile", }); diff --git a/apps/web/e2e/tests/task/mobile-mcp-task-agent-profile-default.spec.ts b/apps/web/e2e/tests/task/mobile-mcp-task-agent-profile-default.spec.ts index 81c4add964..d87db8eb7a 100644 --- a/apps/web/e2e/tests/task/mobile-mcp-task-agent-profile-default.spec.ts +++ b/apps/web/e2e/tests/task/mobile-mcp-task-agent-profile-default.spec.ts @@ -1,38 +1,28 @@ import { test, expect } from "../../fixtures/test-base"; test.describe("MCP-created task agent profile default on mobile", () => { - test("Task Actions choice is touch-usable, viewport-safe, and persists", async ({ + test("Creating and opening tasks choice is touch-usable, viewport-safe, and persists", async ({ testPage, apiClient, }) => { await testPage.goto("/settings"); - // Task Actions is a section inside the Task Behavior page now; the menu row - // that reaches it is the page, not the section. const taskBehaviorLink = testPage.getByRole("link", { name: /Task Behavior/ }); await expect(taskBehaviorLink).toBeVisible({ timeout: 15_000 }); await taskBehaviorLink.tap(); await expect(testPage).toHaveURL(/\/settings\/preferences\/task-behavior$/); - await expect( - testPage.getByRole("heading", { name: "Task Actions", exact: true }), - ).toBeVisible(); - await expect( - testPage.getByText(/when an agent calls a Kandev MCP tool that creates a task/i), - ).toBeVisible(); - await expect(testPage.getByText("create_task_kandev", { exact: true })).toBeVisible(); - await expect(testPage.getByText("spawn_session_kandev", { exact: true })).toBeVisible(); - await expect(testPage.getByText(/effective model, mode, and options/i)).toBeVisible(); - await expect(testPage.getByText(/workflow-selected profiles win first/i)).toBeVisible(); - await expect( - testPage.getByText(/skips the creating session and source or parent task profiles/i), - ).toBeVisible(); - - await testPage.getByRole("button", { name: "About affected Kandev MCP tools" }).tap(); - await expect(testPage.getByRole("tooltip")).toContainText( - "spawn_session_kandev adds a session to the current task", + await expect(testPage.getByTestId("task-behavior-creating-title")).toBeVisible(); + await expect(testPage.getByText("create_task_kandev", { exact: true })).not.toBeVisible(); + await testPage.getByRole("button", { name: "About Profile for Tasks Created by Agents" }).tap(); + const info = testPage.getByRole("dialog", { name: "Profile for Tasks Created by Agents" }); + await expect(info).toContainText("create_task_kandev"); + await expect(info).toContainText("spawn_session_kandev"); + await expect(info).toContainText("effective model, mode, and options"); + await expect(info).toContainText("Workflow-selected profiles win first"); + await expect(info).toContainText( + "skips the creating session and source or parent task profiles", ); - await testPage.getByRole("heading", { name: "Task Actions", exact: true }).tap(); - + await info.getByRole("button", { name: "Close", exact: true }).tap(); const currentTask = testPage.getByRole("radio", { name: "Creating session profile" }); const workspaceDefault = testPage.getByRole("radio", { name: "Workspace default profile", @@ -41,7 +31,7 @@ test.describe("MCP-created task agent profile default on mobile", () => { const choice = testPage.locator('label[for="mcp-task-profile-workspace_default"]'); const card = testPage - .locator('[data-slot="card"]') + .locator('[data-settings-group-card="true"]') .filter({ hasText: "Profile for Tasks Created by Agents" }); const [choiceBox, cardBox, viewport] = await Promise.all([ choice.boundingBox(), diff --git a/apps/web/e2e/tests/task/mobile-unread-divider-preference.spec.ts b/apps/web/e2e/tests/task/mobile-unread-divider-preference.spec.ts index 3be4459e4f..bb75fb8636 100644 --- a/apps/web/e2e/tests/task/mobile-unread-divider-preference.spec.ts +++ b/apps/web/e2e/tests/task/mobile-unread-divider-preference.spec.ts @@ -7,7 +7,7 @@ test.describe("Unread divider preference on mobile", () => { prCapture, }, testInfo) => { await apiClient.saveUserSettings({ unread_divider: true }); - await testPage.goto("/settings/preferences/task-behavior"); + await testPage.goto("/settings/preferences/task-behavior?tab=conversation"); const toggle = testPage.getByRole("switch", { name: "Show New divider in transcripts" }); await expect(toggle).toBeChecked(); diff --git a/apps/web/e2e/tests/task/unread-divider-preference.spec.ts b/apps/web/e2e/tests/task/unread-divider-preference.spec.ts index b8f07d87be..52d8cbd26e 100644 --- a/apps/web/e2e/tests/task/unread-divider-preference.spec.ts +++ b/apps/web/e2e/tests/task/unread-divider-preference.spec.ts @@ -7,7 +7,7 @@ test.describe("Unread divider preference", () => { prCapture, }, testInfo) => { await apiClient.saveUserSettings({ unread_divider: true }); - await testPage.goto("/settings/preferences/task-behavior"); + await testPage.goto("/settings/preferences/task-behavior?tab=conversation"); const toggle = testPage.getByRole("switch", { name: "Show New divider in transcripts" }); await expect(toggle).toBeChecked(); diff --git a/apps/web/lib/settings-discovery/target.test.ts b/apps/web/lib/settings-discovery/target.test.ts index 2858c6c25f..7e3c8c60e1 100644 --- a/apps/web/lib/settings-discovery/target.test.ts +++ b/apps/web/lib/settings-discovery/target.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + SETTINGS_TARGET_DISCLOSURE_OPEN_EVENT, SETTINGS_TARGET_ATTRIBUTE, SETTINGS_TARGET_HIGHLIGHT_ATTRIBUTE, createSettingsTargetRegistry, @@ -120,7 +121,30 @@ describe("revealSettingsTarget", () => { expect(target.scrollIntoView).toHaveBeenCalledWith({ behavior: "auto", block: "center" }); expect(document.activeElement).toBe(marked); }); +}); +describe("revealSettingsTarget disclosures", () => { + it("opens enclosing native details before focusing a discovered control", () => { + const details = document.createElement("details"); + const summary = document.createElement("summary"); + const target = document.createElement("div"); + const input = document.createElement("input"); + target.appendChild(input); + details.append(summary, target); + document.body.appendChild(details); + target.scrollIntoView = vi.fn(); + const opened = vi.fn(); + details.addEventListener(SETTINGS_TARGET_DISCLOSURE_OPEN_EVENT, opened); + + revealSettingsTarget(target, { reducedMotion: true }); + + expect(details.open).toBe(true); + expect(opened).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(input); + }); +}); + +describe("revealSettingsTarget settling", () => { it("re-centers the target when surrounding content grows while settling", () => { vi.useFakeTimers(); let callback: ResizeObserverCallback | undefined; @@ -199,3 +223,12 @@ describe("revealSettingsTarget", () => { expect(target.scrollIntoView).toHaveBeenCalledTimes(1); }); }); + +it("focuses the setting control rather than its optional info button", () => { + const row = document.createElement("div"); + row.innerHTML = ''; + document.body.append(row); + revealSettingsTarget(row, { settleDurationMs: 0 }); + expect(document.activeElement).toBe(row.querySelector("input")); + row.remove(); +}); diff --git a/apps/web/lib/settings-discovery/target.ts b/apps/web/lib/settings-discovery/target.ts index fe77e2109e..d742ab9bab 100644 --- a/apps/web/lib/settings-discovery/target.ts +++ b/apps/web/lib/settings-discovery/target.ts @@ -2,6 +2,7 @@ export const SETTINGS_TARGET_ATTRIBUTE = "data-settings-target"; export const SETTINGS_TARGET_FOCUS_ATTRIBUTE = "data-settings-target-focus"; export const SETTINGS_TARGET_HIGHLIGHT_ATTRIBUTE = "data-settings-target-highlight"; export const SETTINGS_TARGET_REQUEST_EVENT = "kandev:settings-target"; +export const SETTINGS_TARGET_DISCLOSURE_OPEN_EVENT = "kandev:settings-target-disclosure-open"; export type SettingsTargetRequestDetail = { targetId: string }; @@ -86,6 +87,7 @@ export function createSettingsTargetRegistry( } export function revealSettingsTarget(element: HTMLElement, options: RevealOptions = {}): void { + openEnclosingDetails(element); const reducedMotion = options.reducedMotion ?? prefersReducedMotion(); element.scrollIntoView?.({ behavior: reducedMotion ? "auto" : "smooth", @@ -97,6 +99,17 @@ export function revealSettingsTarget(element: HTMLElement, options: RevealOption keepTargetCentered(element, options.settleDurationMs ?? DEFAULT_SETTLE_DURATION_MS); } +function openEnclosingDetails(element: HTMLElement): void { + const ancestors: HTMLDetailsElement[] = []; + for (let node = element.parentElement; node; node = node.parentElement) { + if (node instanceof HTMLDetailsElement) ancestors.push(node); + } + for (const details of ancestors.reverse()) { + details.open = true; + details.dispatchEvent(new Event(SETTINGS_TARGET_DISCLOSURE_OPEN_EVENT)); + } +} + let cancelActiveSettle: (() => void) | null = null; /** @@ -145,7 +158,7 @@ function focusTargetWithin(element: HTMLElement): void { const focusTarget = element.querySelector(`[${SETTINGS_TARGET_FOCUS_ATTRIBUTE}]`) ?? element.querySelector( - `input:not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])`, + `input:not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]):not([data-settings-info]), a[href], [tabindex]:not([tabindex="-1"]):not([data-settings-info])`, ); const target = focusTarget ?? element; if (!focusTarget && !element.hasAttribute("tabindex")) element.tabIndex = -1; diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index b33e2234ec..871142ec1c 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -443,7 +443,7 @@ "manageSpritesDevRemoteSandboxIntegration": "Manage Sprites.dev remote sandbox integration for running agents in isolated cloud environments.", "manualInstallRequired": "Manual install required", "markMessagesThatArrivedWhileA": "Mark messages that arrived while a task was out of view. Takes effect immediately.", - "mcpAffectedToolsHelp": "<0>create_task_kandev creates a separate task. <3>spawn_session_kandev adds a session to the current task, so it does not use this preference.", + "mcpAffectedToolsHelp": "<0>create_task_kandev creates a separate task. <2>spawn_session_kandev adds a session to the current task, so it does not use this preference.", "mcpCreateTaskScope": "<0>create_task_kandev creates new tasks and subtasks. This setting applies only when the call omits <4>agent_profile_id. In a live task-mode call, Creating session profile copies the verified session's effective model, mode, and options.", "mcpSpawnSessionScope": "<0>spawn_session_kandev and tasks you create yourself are not affected. Workflow-selected profiles win first. Otherwise an explicit profile wins; Workspace default skips session and task profiles. External calls have no creating session and use the parent-task fallback for current_task.", "mcpTaskProfileCurrentTask": "Creating session profile", @@ -657,6 +657,16 @@ "systemLoad1Min": "System load (1 min)", "taskActions": "Task Actions", "taskBehavior": "Task Behavior", + "taskBehaviorArchiving": "Archiving", + "taskBehaviorArchivingDescription": "Choose whether Kandev asks before archiving tasks.", + "taskBehaviorConversation": "Conversation and panels", + "taskBehaviorConversationDescription": "Choose which transcript markers and task panels appear while you work.", + "taskBehaviorCreating": "Creating and opening tasks", + "taskBehaviorCreatingDescription": "Choose how new tasks open and how agent-created task defaults are applied.", + "taskBehaviorRuntime": "Runtime and limits", + "taskBehaviorRuntimeDescription": "Set instance-wide limits and control sleep prevention on the host computer.", + "taskBehaviorRuntimeScope": "Applies to everyone", + "taskBehaviorRuntimeSummary": "Automatic sessions: {{sessions}}. Queued messages: {{queue}}.", "taskOverview": "Task overview", "startModelUnavailable": "No longer available; select a different model", "startModelAria": "Profile start model settings", @@ -808,6 +818,7 @@ "discoveryAliasesSidebarHover": "sidebar hover reveal delay wait mouse trackpad", "autoFocusNewTasks": "Auto-focus new tasks", "autoFocusNewTasksHelp": "Open newly created tasks automatically. Turn this off to stay on your current view. Tasks and agents still start as requested.", + "openNewTasksAutomatically": "Open new tasks automatically", "chatAnimations": "Chat animations", "chatAnimationsDescription": "Animate incoming text, new chat items, and scrolling on this device. Respects reduced motion.", "discoveryAliasesChatMotion": "chat animations smooth scrolling text motion", @@ -838,5 +849,31 @@ "sidebarShortcutLoadError": "Shortcuts could not be loaded. Try again.", "noMatchingShortcuts": "No matching shortcuts", "searchShortcuts": "Search shortcuts", - "sidebarPreviewEmpty": "No visible navigation entries" + "sidebarPreviewEmpty": "No visible navigation entries", + "settingInfoLabel": "About {{setting}}", + "settingsNeedAttention": "Needs attention", + "taskBehaviorTabTasks": "Tasks", + "taskBehaviorTabConversation": "Conversation", + "taskBehaviorTabRuntime": "Runtime", + "taskBehaviorRuntimeScopeShort": "Applies to all workspaces.", + "autoFocusShort": "Open a task after you create it.", + "agentTitlesShort": "Let the agent name new tasks from their prompt.", + "agentProfileShort": "Choose the default profile for tasks created by agents.", + "profileSessionShort": "Reuse the creating session's profile.", + "profileWorkspaceShort": "Use the workspace's default profile.", + "preventStartShort": "Keep agents stopped when reopening tasks after a restart or from the final workflow step.", + "archiveShort": "Review cleanup options before archiving a task.", + "unreadShort": "Mark messages received while a task was out of view.", + "anchoredPromptShort": "Keep your last prompt visible while scrolling on desktop.", + "jumpPromptShort": "Show a button to return to your latest prompt.", + "jumpStartShort": "Show a button to return to the start of the conversation.", + "autoScrollShort": "Show a button to pause or resume transcript auto-scroll.", + "todoShort": "Show the agent's todo checklist in the task panel.", + "todoNonemptyShort": "Hide the pinned checklist when it has no items.", + "sessionLimitShort": "Queue automatic starts when the session limit is reached.", + "sessionMaximumShort": "Manual starts can exceed this limit.", + "queueMaximumShort": "Limit pending messages per session. Use 0 for unlimited.", + "queueMergeShort": "Combine related queued messages before the agent reads them.", + "queueAutoMergeShort": "Merge compatible consecutive messages automatically.", + "sleepShort": "Prevent the computer running Kandev from sleeping during tasks." } diff --git a/apps/web/src/locales/ja/executors.json b/apps/web/src/locales/ja/executors.json index 584194ba49..e7ebeb9b05 100644 --- a/apps/web/src/locales/ja/executors.json +++ b/apps/web/src/locales/ja/executors.json @@ -421,6 +421,28 @@ "sshTrustThisHost": "<0>このホストを信頼します。 上記のフィンガープリントが自分のサーバーと一致することを確認しました。今後、異なるフィンガープリントを報告する接続は拒否されます。", "sshTrusted": "信頼済み", "sshUnverified": "未確認", + "sshReachabilityAgeLastProbe": "前回の確認: {{age}}", + "sshReachabilityAgeLastSuccess": "前回の成功: {{age}}", + "sshReachabilityConsecutiveFailures_one": "{{count}}回連続で失敗", + "sshReachabilityConsecutiveFailures_other": "{{count}}回連続で失敗", + "sshReachabilityDescription": "このホストに対する最新のSSHプローブ結果です。バックグラウンドで自動的に確認します。", + "sshReachabilityHost": "確認対象ホスト: {{host}}", + "sshReachabilityLoadFailed": "到達性ステータスを読み込めませんでした。不明です。", + "sshReachabilityNeverProbed": "まだ確認されていません。", + "sshReachabilityNoSuccess": "成功した確認記録はありません。", + "sshReachabilityProbeNow": "今すぐ確認", + "sshReachabilityProbingOff": "定期確認は無効です。現在の状態ではなく、最後に記録された結果を表示します。", + "sshReachabilityReasonAuth": "認証に失敗しました", + "sshReachabilityReasonConfig": "接続設定が無効です", + "sshReachabilityReasonHostKey": "ホストキーが固定されたフィンガープリントと一致しません", + "sshReachabilityReasonNetwork": "ネットワークエラーが発生しました", + "sshReachabilityReasonTimeout": "接続がタイムアウトしました", + "sshReachabilityReasonUnknown": "不明なエラーが発生しました", + "sshReachabilityStale": "古い情報: 直近3回の確認間隔内にプローブされていません", + "sshReachabilityStateReachable": "到達可能", + "sshReachabilityStateUnknown": "不明", + "sshReachabilityStateUnreachable": "到達不能", + "sshReachabilityTitle": "到達性", "status": "ステータス", "stop": "停止", "success": "成功", diff --git a/apps/web/src/locales/ja/settings.json b/apps/web/src/locales/ja/settings.json index 189846c462..57a2423137 100644 --- a/apps/web/src/locales/ja/settings.json +++ b/apps/web/src/locales/ja/settings.json @@ -443,7 +443,7 @@ "manageSpritesDevRemoteSandboxIntegration": "実行中のエージェントを分離されたクラウド環境で動かすための、Sprites.dev リモートサンドボックス連携を管理します。", "manualInstallRequired": "手動インストールが必要です", "markMessagesThatArrivedWhileA": "タスクが非表示の間に届いたメッセージをマークします。すぐに反映されます。", - "mcpAffectedToolsHelp": "<0>create_task_kandev は個別のタスクを作成します。<3>spawn_session_kandev は現在のタスクにセッションを追加するため、この設定は使用されません。", + "mcpAffectedToolsHelp": "<0>create_task_kandev は個別のタスクを作成します。<2>spawn_session_kandev は現在のタスクにセッションを追加するため、この設定は使用されません。", "mcpCreateTaskScope": "<0>create_task_kandev は新しいタスクとサブタスクを作成します。この設定は、呼び出しで <4>agent_profile_id が省略された場合にのみ適用されます。ライブの task-mode 呼び出しでは、Creating session profile が検証済みセッションの有効なモデル、モード、オプションをコピーします。", "mcpSpawnSessionScope": "<0>spawn_session_kandev と、自分で作成したタスクには影響しません。ワークフローで選択されたプロファイルが最優先です。それ以外の場合は明示的なプロファイルが優先されます。Workspace default はセッションとタスクのプロファイルを省略します。外部呼び出しには作成元セッションがないため、current_task には親タスクのフォールバックを使用します。", "mcpTaskProfileCurrentTask": "セッションプロファイルを作成中", @@ -657,6 +657,16 @@ "systemLoad1Min": "システム負荷(1分)", "taskActions": "タスク操作", "taskBehavior": "タスクの動作", + "taskBehaviorArchiving": "アーカイブ", + "taskBehaviorArchivingDescription": "タスクをアーカイブする前に Kandev が確認するか選択します。", + "taskBehaviorConversation": "会話とパネル", + "taskBehaviorConversationDescription": "作業中に表示するトランスクリプトのマーカーとタスクパネルを選択します。", + "taskBehaviorCreating": "タスクの作成と開始", + "taskBehaviorCreatingDescription": "新しいタスクの開き方と、エージェントが作成するタスクの既定値を選択します。", + "taskBehaviorRuntime": "ランタイムと制限", + "taskBehaviorRuntimeDescription": "インスタンス全体の制限を設定し、ホストコンピューターでのスリープ防止を制御します。", + "taskBehaviorRuntimeScope": "全員に適用", + "taskBehaviorRuntimeSummary": "自動セッション: {{sessions}}。キュー内メッセージ: {{queue}}。", "taskOverview": "タスクの概要", "startModelUnavailable": "もう利用できません。別のモデルを選択してください", "startModelAria": "プロフィールの起動モデル設定", @@ -808,6 +818,7 @@ "discoveryAliasesSidebarHover": "サイドバー ホバー 表示 遅延 待機 マウス トラックパッド", "autoFocusNewTasks": "新しいタスクを自動でフォーカス", "autoFocusNewTasksHelp": "新しく作成されたタスクを自動で開きます。現在の画面にとどまりたい場合はオフにしてください。タスクとエージェントは引き続き要求どおりに開始されます。", + "openNewTasksAutomatically": "新しいタスクを自動的に開く", "chatAnimations": "チャットのアニメーション", "chatAnimationsDescription": "このデバイスで、受信テキスト、新しいチャット項目、スクロールをアニメーション表示します。動きを抑える設定を尊重します。", "discoveryAliasesChatMotion": "チャット アニメーション スムーズスクロール テキスト モーション", @@ -838,5 +849,31 @@ "sidebarShortcutLoadError": "ショートカットを読み込めなかった。もう一度試す。", "noMatchingShortcuts": "一致するショートカットなし", "searchShortcuts": "ショートカットを検索", - "sidebarPreviewEmpty": "表示できるナビゲーション項目なし" + "sidebarPreviewEmpty": "表示できるナビゲーション項目なし", + "settingInfoLabel": "{{setting}}について", + "settingsNeedAttention": "確認が必要", + "taskBehaviorTabTasks": "タスク", + "taskBehaviorTabConversation": "会話", + "taskBehaviorTabRuntime": "実行環境", + "taskBehaviorRuntimeScopeShort": "すべてのワークスペースに適用されます。", + "autoFocusShort": "作成したタスクを自動的に開きます。", + "agentTitlesShort": "プロンプトをもとにエージェントが新しいタスクに名前を付けます。", + "agentProfileShort": "エージェントが作成するタスクの既定プロファイルを選びます。", + "profileSessionShort": "タスクを作成したセッションのプロファイルを再利用します。", + "profileWorkspaceShort": "ワークスペースの既定プロファイルを使います。", + "preventStartShort": "再起動後やワークフローの最終ステップからタスクを開き直す際に、エージェントを停止したままにします。", + "archiveShort": "タスクをアーカイブする前にクリーンアップの選択肢を確認します。", + "unreadShort": "タスクを表示していない間に届いたメッセージに印を付けます。", + "anchoredPromptShort": "デスクトップでスクロール中も最後のプロンプトを表示します。", + "jumpPromptShort": "最新のプロンプトに戻るボタンを表示します。", + "jumpStartShort": "会話の先頭に戻るボタンを表示します。", + "autoScrollShort": "会話の自動スクロールを一時停止または再開するボタンを表示します。", + "todoShort": "タスクパネルにエージェントのチェックリストを表示します。", + "todoNonemptyShort": "固定したチェックリストが空の場合は非表示にします。", + "sessionLimitShort": "セッション上限に達すると自動開始を待ち行列に入れます。", + "sessionMaximumShort": "手動での開始はこの上限を超えることができます。", + "queueMaximumShort": "セッションごとの保留メッセージ数を制限します。0 は無制限です。", + "queueMergeShort": "エージェントが読む前に、関連する保留メッセージをまとめます。", + "queueAutoMergeShort": "互換性のある連続メッセージを自動的にまとめます。", + "sleepShort": "タスクの実行中、Kandev が動作するコンピューターのスリープを防ぎます。" } diff --git a/apps/web/src/locales/ja/task.json b/apps/web/src/locales/ja/task.json index 684cf35d0f..ca893154c1 100644 --- a/apps/web/src/locales/ja/task.json +++ b/apps/web/src/locales/ja/task.json @@ -2279,6 +2279,16 @@ "launchErrorGeneric": "タスク開始できなかった。", "launchErrorSessionPreview": "タスク launch に対応必要。", "launchNeedsAttention": "launch に対応必要", + "launchWarningTitle": "SSHホストに到達できない可能性があります", + "launchWarningHost": "プローブ対象ホスト: {{host}}", + "launchWarningLastSuccess": "前回の成功: {{age}}", + "launchWarningNeverSucceeded": "このホストで成功したプローブはまだ記録されていません。", + "launchWarningReasonConfig": "最後の試行は接続設定が無効なため失敗しました。", + "launchWarningReasonTimeout": "最後の試行はタイムアウトしました。", + "launchWarningReasonHostKey": "最後の試行はホストキーが固定されたフィンガープリントと一致しなかったため失敗しました。", + "launchWarningReasonAuth": "最後の試行は認証に失敗しました。", + "launchWarningReasonNetwork": "最後の試行はネットワークエラーで失敗しました。", + "launchWarningReasonUnknown": "最後の試行は不明な理由で失敗しました。", "launchErrorNoChanges": "task か branch 設定、どちらも変更なし。", "launchWarningTitle": "SSH ホストに到達できない可能性があります", "launchWarningHost": "プローブ対象ホスト: {{host}}", diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json index 14e89d1668..ec166a8d2c 100644 --- a/apps/web/src/locales/pseudo/settings.json +++ b/apps/web/src/locales/pseudo/settings.json @@ -443,7 +443,7 @@ "manageSpritesDevRemoteSandboxIntegration": "Ḿàńàĝē Śƥŕĩţēś.ďēv ŕēḿōţē śàńďƀōx ĩńţēĝŕàţĩōń ƒōŕ ŕũńńĩńĝ àĝēńţś ĩń ĩśōĺàţēď ćĺōũď ēńvĩŕōńḿēńţś.", "manualInstallRequired": "Ḿàńũàĺ ĩńśţàĺĺ ŕēqũĩŕēď", "markMessagesThatArrivedWhileA": "Ḿàŕķ ḿēśśàĝēś ţĥàţ àŕŕĩvēď ŵĥĩĺē à ţàśķ ŵàś ōũţ ōƒ vĩēŵ. Ţàķēś ēƒƒēćţ ĩḿḿēďĩàţēĺŷ.", - "mcpAffectedToolsHelp": "<0>ćŕēàţē_ţàśķ_ķàńďēv ćŕēàţēś à śēƥàŕàţē ţàśķ. <3>śƥàŵń_śēśśĩōń_ķàńďēv àďďś à śēśśĩōń ţō ţĥē ćũŕŕēńţ ţàśķ, śō ĩţ ďōēś ńōţ ũśē ţĥĩś ƥŕēƒēŕēńćē.", + "mcpAffectedToolsHelp": "<0>ćŕēàţē_ţàśķ_ķàńďēv ćŕēàţēś à śēƥàŕàţē ţàśķ. <2>śƥàŵń_śēśśĩōń_ķàńďēv àďďś à śēśśĩōń ţō ţĥē ćũŕŕēńţ ţàśķ, śō ĩţ ďōēś ńōţ ũśē ţĥĩś ƥŕēƒēŕēńćē.", "mcpCreateTaskScope": "<0>ćŕēàţē_ţàśķ_ķàńďēv ćŕēàţēś ńēŵ ţàśķś àńď śũƀţàśķś. Ţĥĩś śēţţĩńĝ àƥƥĺĩēś ōńĺŷ ŵĥēń ţĥē ćàĺĺ ōḿĩţś <4>àĝēńţ_ƥŕōƒĩĺē_ĩď. Ĩń à ĺĩvē ţàśķ-ḿōďē ćàĺĺ, Ćŕēàţĩńĝ śēśśĩōń ƥŕōƒĩĺē ćōƥĩēś ţĥē vēŕĩƒĩēď śēśśĩōń'ś ēƒƒēćţĩvē ḿōďēĺ, ḿōďē, àńď ōƥţĩōńś.", "mcpSpawnSessionScope": "<0>śƥàŵń_śēśśĩōń_ķàńďēv àńď ţàśķś ŷōũ ćŕēàţē ŷōũŕśēĺƒ àŕē ńōţ àƒƒēćţēď. Ŵōŕķƒĺōŵ-śēĺēćţēď ƥŕōƒĩĺēś ŵĩń ƒĩŕśţ. Ōţĥēŕŵĩśē àń ēxƥĺĩćĩţ ƥŕōƒĩĺē ŵĩńś; Ŵōŕķśƥàćē ďēƒàũĺţ śķĩƥś śēśśĩōń àńď ţàśķ ƥŕōƒĩĺēś. Ēxţēŕńàĺ ćàĺĺś ĥàvē ńō ćŕēàţĩńĝ śēśśĩōń àńď ũśē ţĥē ƥàŕēńţ-ţàśķ ƒàĺĺƀàćķ ƒōŕ ćũŕŕēńţ_ţàśķ.", "mcpTaskProfileCurrentTask": "Ćŕēàţĩńĝ śēśśĩōń ƥŕōƒĩĺē", @@ -657,6 +657,16 @@ "systemLoad1Min": "Śŷśţēḿ ĺōàď (1 ḿĩń)", "taskActions": "Ţàśķ Àćţĩōńś", "taskBehavior": "Ţàśķ Ɓēĥàvĩōŕ", + "taskBehaviorArchiving": "Àŕćĥĩvĩńĝ", + "taskBehaviorArchivingDescription": "Ćĥōōśē ŵĥēţĥēŕ Ķàńďēv àśķś ƀēƒōŕē àŕćĥĩvĩńĝ ţàśķś.", + "taskBehaviorConversation": "Ćōńvēŕśàţĩōń àńď ƥàńēĺś", + "taskBehaviorConversationDescription": "Ćĥōōśē ŵĥĩćĥ ţŕàńśćŕĩƥţ ḿàŕķēŕś àńď ţàśķ ƥàńēĺś àƥƥēàŕ ŵĥĩĺē ŷōũ ŵōŕķ.", + "taskBehaviorCreating": "Ćŕēàţĩńĝ àńď ōƥēńĩńĝ ţàśķś", + "taskBehaviorCreatingDescription": "Ćĥōōśē ĥōŵ ńēŵ ţàśķś ōƥēń àńď ĥōŵ àĝēńţ-ćŕēàţēď ţàśķ ďēƒàũĺţś àŕē àƥƥĺĩēď.", + "taskBehaviorRuntime": "Ŕũńţĩḿē àńď ĺĩḿĩţś", + "taskBehaviorRuntimeDescription": "Śēţ ĩńśţàńćē-ŵĩďē ĺĩḿĩţś àńď ćōńţŕōĺ śĺēēƥ ƥŕēvēńţĩōń ōń ţĥē ĥōśţ ćōḿƥũţēŕ.", + "taskBehaviorRuntimeScope": "Àƥƥĺĩēś ţō ēvēŕŷōńē", + "taskBehaviorRuntimeSummary": "Àũţōḿàţĩć śēśśĩōńś: {{sessions}}. Qũēũēď ḿēśśàĝēś: {{queue}}.", "taskOverview": "Ţàśķ ōvēŕvĩēŵ", "startModelUnavailable": "Ńō ĺōńĝēŕ àvàĩĺàƀĺē; śēĺēćţ à ďĩƒƒēŕēńţ ḿōďēĺ", "startModelAria": "Ƥŕōƒĩĺē śţàŕţ ḿōďēĺ śēţţĩńĝś", @@ -808,19 +818,20 @@ "discoveryAliasesSidebarHover": "śĩďēƀàŕ ĥōvēŕ ŕēvēàĺ ďēĺàŷ ŵàĩţ ḿōũśē ţŕàćķƥàď", "autoFocusNewTasks": "Àũţō-ƒōćũś ńēŵ ţàśķś", "autoFocusNewTasksHelp": "Ōƥēń ńēŵĺŷ ćŕēàţēď ţàśķś àũţōḿàţĩćàĺĺŷ. Ţũŕń ţĥĩś ōƒƒ ţō śţàŷ ōń ŷōũŕ ćũŕŕēńţ vĩēŵ. Ţàśķś àńď àĝēńţś śţĩĺĺ śţàŕţ àś ŕēqũēśţēď.", + "openNewTasksAutomatically": "Ōƥēń ńēŵ ţàśķś àũţōḿàţĩćàĺĺŷ", "chatAnimations": "Ćĥàţ àńĩḿàţĩōńś", "chatAnimationsDescription": "Àńĩḿàţē ĩńćōḿĩńĝ ţēxţ, ńēŵ ćĥàţ ĩţēḿś, àńď śćŕōĺĺĩńĝ ōń ţĥĩś ďēvĩćē. Ŕēśƥēćţś ŕēďũćēď ḿōţĩōń.", "discoveryAliasesChatMotion": "ćĥàţ àńĩḿàţĩōńś śḿōōţĥ śćŕōĺĺĩńĝ ţēxţ ḿōţĩōń", "sidebar": "Śĩďēƀàŕ", - "sidebarDescription": "Ćũśţōḿĩźē ţĥē ńàvĩĝàţĩōń ƒōŕ ţĥĩś ŵōŕķśƥàćē.", + "sidebarDescription": "Ćũśţōḿĩźē ţĥē ńàvĩĝàţĩōń ƒōŕ ţĥĩś ŵōŕķśƥàćē. Ćĥàńĝēś àƥƥĺŷ ţō ŷōũŕ àććōũńţ àńď ƒōĺĺōŵ ŷōũ àćŕōśś ćĺĩēńţś.", "sidebarLayout": "Śĩďēƀàŕ ĺàŷōũţ", "sectionName": "Śēćţĩōń ńàḿē", - "sidebarSectionNameError": "Śēćţĩōń ńàḿēś ḿũśţ ƀē 1 ţō 60 ćĥàŕàćţēŕś.", + "sidebarSectionNameError": "Śēćţĩōń ńàḿēś ḿũśţ ćōńţàĩń 1 ţō 60 ćĥàŕàćţēŕś.", "sidebarDuplicateShortcutError": "À śĥōŕţćũţ ćàń àƥƥēàŕ ōńĺŷ ōńćē ĩń à śēćţĩōń.", "sidebarLayoutLimitError": "Ţĥē śĩďēƀàŕ ĺàŷōũţ ĥàś ŕēàćĥēď ĩţś ĺĩḿĩţ.", "sidebarLayoutInvalidError": "Ŕēvĩēŵ ţĥē śĩďēƀàŕ ĺàŷōũţ ƀēƒōŕē śàvĩńĝ.", "sidebarToggleVisibility": "Ţōĝĝĺē vĩśĩƀĩĺĩţŷ", - "dragToReorder": "Đŕàĝ ţō ŕēōŕďēŕ", + "dragToReorder": "Ďŕàĝ ţō ŕēōŕďēŕ", "moveUp": "Ḿōvē ũƥ", "moveDown": "Ḿōvē ďōŵń", "moveToSection": "Ḿōvē ţō śēćţĩōń", @@ -832,11 +843,37 @@ "sidebarSaveError": "Ţĥē śĩďēƀàŕ ĺàŷōũţ ćōũĺď ńōţ ƀē śàvēď. Ţŕŷ àĝàĩń.", "sidebarUnsupportedVersion": "Ţĥĩś ĺàŷōũţ ũśēś à ńēŵēŕ vēŕśĩōń. Ēďĩţĩńĝ ĩś ƥàũśēď ũńţĩĺ ţĥē àƥƥ śũƥƥōŕţś ĩţ.", "sidebarNoWorkspace": "Śēĺēćţ à ŵōŕķśƥàćē ţō ćũśţōḿĩźē ĩţś śĩďēƀàŕ.", - "sidebarPreview": "Đŕàƒţ ƥŕēvĩēŵ", + "sidebarPreview": "Ďŕàƒţ ƥŕēvĩēŵ", "addShortcut": "Àďď śĥōŕţćũţ", "editSection": "Ēďĩţ śēćţĩōń", "sidebarShortcutLoadError": "Śĥōŕţćũţś ćōũĺď ńōţ ƀē ĺōàďēď. Ţŕŷ àĝàĩń.", "noMatchingShortcuts": "Ńō ḿàţćĥĩńĝ śĥōŕţćũţś", "searchShortcuts": "Śēàŕćĥ śĥōŕţćũţś", - "sidebarPreviewEmpty": "Ńō vĩśĩƀĺē ńàvĩĝàţĩōń ēńţŕĩēś" + "sidebarPreviewEmpty": "Ńō vĩśĩƀĺē ńàvĩĝàţĩōń ēńţŕĩēś", + "settingInfoLabel": "Àƀōũţ {{setting}}", + "settingsNeedAttention": "Ńēēďś àţţēńţĩōń", + "taskBehaviorTabTasks": "Ţàśķś", + "taskBehaviorTabConversation": "Ćōńvēŕśàţĩōń", + "taskBehaviorTabRuntime": "Ŕũńţĩḿē", + "taskBehaviorRuntimeScopeShort": "Àƥƥĺĩēś ţō àĺĺ ŵōŕķśƥàćēś.", + "autoFocusShort": "Ōƥēń à ţàśķ àƒţēŕ ŷōũ ćŕēàţē ĩţ.", + "agentTitlesShort": "Ĺēţ ţĥē àĝēńţ ńàḿē ńēŵ ţàśķś ƒŕōḿ ţĥēĩŕ ƥŕōḿƥţ.", + "agentProfileShort": "Ćĥōōśē ţĥē ďēƒàũĺţ ƥŕōƒĩĺē ƒōŕ ţàśķś ćŕēàţēď ƀŷ àĝēńţś.", + "profileSessionShort": "Ŕēũśē ţĥē ćŕēàţĩńĝ śēśśĩōń'ś ƥŕōƒĩĺē.", + "profileWorkspaceShort": "Ũśē ţĥē ŵōŕķśƥàćē'ś ďēƒàũĺţ ƥŕōƒĩĺē.", + "preventStartShort": "Ķēēƥ àĝēńţś śţōƥƥēď ŵĥēń ŕēōƥēńĩńĝ ţàśķś àƒţēŕ à ŕēśţàŕţ ōŕ ƒŕōḿ ţĥē ƒĩńàĺ ŵōŕķƒĺōŵ śţēƥ.", + "archiveShort": "Ŕēvĩēŵ ćĺēàńũƥ ōƥţĩōńś ƀēƒōŕē àŕćĥĩvĩńĝ à ţàśķ.", + "unreadShort": "Ḿàŕķ ḿēśśàĝēś ŕēćēĩvēď ŵĥĩĺē à ţàśķ ŵàś ōũţ ōƒ vĩēŵ.", + "anchoredPromptShort": "Ķēēƥ ŷōũŕ ĺàśţ ƥŕōḿƥţ vĩśĩƀĺē ŵĥĩĺē śćŕōĺĺĩńĝ ōń ďēśķţōƥ.", + "jumpPromptShort": "Śĥōŵ à ƀũţţōń ţō ŕēţũŕń ţō ŷōũŕ ĺàţēśţ ƥŕōḿƥţ.", + "jumpStartShort": "Śĥōŵ à ƀũţţōń ţō ŕēţũŕń ţō ţĥē śţàŕţ ōƒ ţĥē ćōńvēŕśàţĩōń.", + "autoScrollShort": "Śĥōŵ à ƀũţţōń ţō ƥàũśē ōŕ ŕēśũḿē ţŕàńśćŕĩƥţ àũţō-śćŕōĺĺ.", + "todoShort": "Śĥōŵ ţĥē àĝēńţ'ś ţōďō ćĥēćķĺĩśţ ĩń ţĥē ţàśķ ƥàńēĺ.", + "todoNonemptyShort": "Ĥĩďē ţĥē ƥĩńńēď ćĥēćķĺĩśţ ŵĥēń ĩţ ĥàś ńō ĩţēḿś.", + "sessionLimitShort": "Qũēũē àũţōḿàţĩć śţàŕţś ŵĥēń ţĥē śēśśĩōń ĺĩḿĩţ ĩś ŕēàćĥēď.", + "sessionMaximumShort": "Ḿàńũàĺ śţàŕţś ćàń ēxćēēď ţĥĩś ĺĩḿĩţ.", + "queueMaximumShort": "Ĺĩḿĩţ ƥēńďĩńĝ ḿēśśàĝēś ƥēŕ śēśśĩōń. Ũśē 0 ƒōŕ ũńĺĩḿĩţēď.", + "queueMergeShort": "Ćōḿƀĩńē ŕēĺàţēď qũēũēď ḿēśśàĝēś ƀēƒōŕē ţĥē àĝēńţ ŕēàďś ţĥēḿ.", + "queueAutoMergeShort": "Ḿēŕĝē ćōḿƥàţĩƀĺē ćōńśēćũţĩvē ḿēśśàĝēś àũţōḿàţĩćàĺĺŷ.", + "sleepShort": "Ƥŕēvēńţ ţĥē ćōḿƥũţēŕ ŕũńńĩńĝ Ķàńďēv ƒŕōḿ śĺēēƥĩńĝ ďũŕĩńĝ ţàśķś." } diff --git a/apps/web/src/locales/pt-pt/settings.json b/apps/web/src/locales/pt-pt/settings.json index 79eaf7de92..5eecfe5edf 100644 --- a/apps/web/src/locales/pt-pt/settings.json +++ b/apps/web/src/locales/pt-pt/settings.json @@ -433,7 +433,7 @@ "manageSpritesDevRemoteSandboxIntegration": "Faça a gestão da integração de sandboxes remotas do Sprites.dev para executar agentes em ambientes isolados na nuvem.", "manualInstallRequired": "É necessária instalação manual", "markMessagesThatArrivedWhileA": "Marca as mensagens que chegaram enquanto a tarefa esteve fora de vista. Tem efeito imediato.", - "mcpAffectedToolsHelp": "<0>create_task_kandev cria uma tarefa separada. <3>spawn_session_kandev adiciona uma sessão à tarefa atual, pelo que não usa esta preferência.", + "mcpAffectedToolsHelp": "<0>create_task_kandev cria uma tarefa separada. <2>spawn_session_kandev adiciona uma sessão à tarefa atual, pelo que não usa esta preferência.", "mcpCreateTaskScope": "<0>create_task_kandev cria novas tarefas e subtarefas. Esta definição aplica-se apenas quando a chamada omite <4>agent_profile_id. Numa chamada de tarefa ativa, o perfil da sessão criadora copia o modelo, modo e opções efetivos da sessão verificada.", "mcpSpawnSessionScope": "<0>spawn_session_kandev e as tarefas que criar não são afetados. Os perfis selecionados pelo fluxo de trabalho prevalecem primeiro. Caso contrário, um perfil explícito prevalece; o perfil predefinido da área de trabalho ignora os perfis da sessão e da tarefa. As chamadas externas não têm sessão criadora e usam a tarefa-pai como fallback de current_task.", "mcpTaskProfileCurrentTask": "Perfil da sessão criadora", @@ -639,6 +639,16 @@ "systemDefault": "Pré-definição do sistema", "systemLoad1Min": "Carga do sistema (1 min)", "taskActions": "Ações da tarefa", + "taskBehaviorArchiving": "Arquivamento", + "taskBehaviorArchivingDescription": "Escolha se o Kandev pede confirmação antes de arquivar tarefas.", + "taskBehaviorConversation": "Conversação e painéis", + "taskBehaviorConversationDescription": "Escolha os marcadores da transcrição e os painéis de tarefas apresentados enquanto trabalha.", + "taskBehaviorCreating": "Criar e abrir tarefas", + "taskBehaviorCreatingDescription": "Escolha como as novas tarefas abrem e como são aplicadas as predefinições das tarefas criadas por agentes.", + "taskBehaviorRuntime": "Runtime e limites", + "taskBehaviorRuntimeDescription": "Defina limites para esta instância e controle a prevenção da suspensão no computador anfitrião.", + "taskBehaviorRuntimeScope": "Aplica-se a todos", + "taskBehaviorRuntimeSummary": "Sessões automáticas: {{sessions}}. Mensagens em fila: {{queue}}.", "taskOverview": "Visão geral da tarefa", "terminal": "Terminal", "terminalFont": "Tipo de letra do terminal", @@ -808,6 +818,7 @@ "discoveryAliasesSidebarHover": "barra lateral passar rato mostrar atraso espera trackpad", "autoFocusNewTasks": "Focar automaticamente novas tarefas", "autoFocusNewTasksHelp": "Abre automaticamente as tarefas recém-criadas. Desative esta opção para manter a vista atual. As tarefas e os agentes continuam a iniciar conforme solicitado.", + "openNewTasksAutomatically": "Abrir novas tarefas automaticamente", "chatAnimations": "Animações do chat", "chatAnimationsDescription": "Animar o texto recebido, os novos elementos do chat e a deslocação neste dispositivo. Respeita a preferência de movimento reduzido.", "discoveryAliasesChatMotion": "chat animações deslocação suave texto movimento", @@ -838,5 +849,31 @@ "sidebarShortcutLoadError": "Não foi possível carregar os atalhos. Tente novamente.", "noMatchingShortcuts": "Nenhum atalho correspondente", "searchShortcuts": "Pesquisar atalhos", - "sidebarPreviewEmpty": "Não há entradas de navegação visíveis" + "sidebarPreviewEmpty": "Não há entradas de navegação visíveis", + "settingInfoLabel": "Sobre {{setting}}", + "settingsNeedAttention": "Requer atenção", + "taskBehaviorTabTasks": "Tarefas", + "taskBehaviorTabConversation": "Conversa", + "taskBehaviorTabRuntime": "Execução", + "taskBehaviorRuntimeScopeShort": "Aplica-se a todos os espaços de trabalho.", + "autoFocusShort": "Abre a tarefa depois de a criar.", + "agentTitlesShort": "Permite ao agente dar um nome às novas tarefas a partir do pedido.", + "agentProfileShort": "Escolhe o perfil predefinido das tarefas criadas por agentes.", + "profileSessionShort": "Reutiliza o perfil da sessão que cria a tarefa.", + "profileWorkspaceShort": "Usa o perfil predefinido do espaço de trabalho.", + "preventStartShort": "Mantém os agentes parados ao reabrir tarefas após um reinício ou a partir da última etapa do fluxo.", + "archiveShort": "Revê as opções de limpeza antes de arquivar uma tarefa.", + "unreadShort": "Assinala as mensagens recebidas enquanto a tarefa não estava visível.", + "anchoredPromptShort": "Mantém o último pedido visível ao deslocar a conversa no computador.", + "jumpPromptShort": "Mostra um botão para voltar ao último pedido.", + "jumpStartShort": "Mostra um botão para voltar ao início da conversa.", + "autoScrollShort": "Mostra um botão para pausar ou retomar o deslocamento automático da conversa.", + "todoShort": "Mostra a lista de tarefas do agente no painel da tarefa.", + "todoNonemptyShort": "Oculta a lista afixada quando não tem itens.", + "sessionLimitShort": "Coloca os arranques automáticos em fila ao atingir o limite de sessões.", + "sessionMaximumShort": "Os arranques manuais podem exceder este limite.", + "queueMaximumShort": "Limita as mensagens pendentes por sessão. Usa 0 para não limitar.", + "queueMergeShort": "Combina mensagens relacionadas na fila antes de o agente as ler.", + "queueAutoMergeShort": "Combina automaticamente mensagens consecutivas compatíveis.", + "sleepShort": "Impede que o computador onde o Kandev está a correr entre em suspensão durante as tarefas." } diff --git a/apps/web/src/locales/zh-cn/settings.json b/apps/web/src/locales/zh-cn/settings.json index 03a5388fb1..dbd41b5686 100644 --- a/apps/web/src/locales/zh-cn/settings.json +++ b/apps/web/src/locales/zh-cn/settings.json @@ -446,7 +446,7 @@ "manageSpritesDevRemoteSandboxIntegration": "管理 Sprites.dev 远程沙盒集成,以便在隔离的云环境中运行智能体。", "manualInstallRequired": "需要手动安装", "markMessagesThatArrivedWhileA": "标记任务不可见期间收到的消息。立即生效。", - "mcpAffectedToolsHelp": "<0>create_task_kandev 会创建单独任务。<3>spawn_session_kandev 会向当前任务添加会话,因此不使用此偏好设置。", + "mcpAffectedToolsHelp": "<0>create_task_kandev 会创建单独任务。<2>spawn_session_kandev 会向当前任务添加会话,因此不使用此偏好设置。", "mcpCreateTaskScope": "<0>create_task_kandev 会创建新任务和子任务。该设置仅在调用时未提供 <4>agent_profile_id 时生效。在实时任务调用中,创建会话配置会复制已验证会话的有效模型、模式和选项。", "mcpSpawnSessionScope": "<0>spawn_session_kandev 和你自行创建的任务不受影响。工作流选择的配置始终优先。否则,明确选择的配置优先;工作区默认配置会跳过会话和任务配置。外部调用没有创建会话,current_task 使用父任务回退配置。", "mcpTaskProfileCurrentTask": "创建会话配置", @@ -658,6 +658,16 @@ "systemLoad1Min": "系统负载(1 分钟)", "taskActions": "任务操作", "taskBehavior": "任务行为", + "taskBehaviorArchiving": "归档", + "taskBehaviorArchivingDescription": "选择归档任务前是否需要确认。", + "taskBehaviorConversation": "对话和面板", + "taskBehaviorConversationDescription": "选择工作时显示的对话记录标记和任务面板。", + "taskBehaviorCreating": "创建和打开任务", + "taskBehaviorCreatingDescription": "选择新任务的打开方式,以及代理创建任务时使用的默认设置。", + "taskBehaviorRuntime": "运行时和限制", + "taskBehaviorRuntimeDescription": "设置此实例的限制,并控制主机电脑的防休眠功能。", + "taskBehaviorRuntimeScope": "适用于所有人", + "taskBehaviorRuntimeSummary": "自动会话:{{sessions}}。排队消息:{{queue}}。", "taskOverview": "任务概览", "startModelUnavailable": "已不可用,请另选模型", "startModelAria": "配置起始模型设置", @@ -808,6 +818,7 @@ "discoveryAliasesSidebarHover": "侧边栏 悬停 显示 延迟 等待 鼠标 触控板", "autoFocusNewTasks": "自动聚焦新任务", "autoFocusNewTasksHelp": "自动打开新创建的任务。关闭此选项可保留当前视图。任务和代理仍会按要求启动。", + "openNewTasksAutomatically": "自动打开新任务", "chatAnimations": "聊天动画", "chatAnimationsDescription": "在此设备上为新文本、新聊天项目和滚动添加动画。遵循减少动态效果的偏好设置。", "discoveryAliasesChatMotion": "聊天 动画 平滑滚动 文本 动态效果", @@ -838,5 +849,31 @@ "sidebarShortcutLoadError": "无法加载快捷方式。请重试。", "noMatchingShortcuts": "没有匹配的快捷方式", "searchShortcuts": "搜索快捷方式", - "sidebarPreviewEmpty": "没有可见的导航项" + "sidebarPreviewEmpty": "没有可见的导航项", + "settingInfoLabel": "关于{{setting}}", + "settingsNeedAttention": "需要处理", + "taskBehaviorTabTasks": "任务", + "taskBehaviorTabConversation": "对话", + "taskBehaviorTabRuntime": "运行时", + "taskBehaviorRuntimeScopeShort": "适用于所有工作区。", + "autoFocusShort": "创建任务后自动打开。", + "agentTitlesShort": "让代理根据提示为新任务命名。", + "agentProfileShort": "选择代理创建任务时使用的默认配置。", + "profileSessionShort": "复用创建任务的会话配置。", + "profileWorkspaceShort": "使用工作区的默认配置。", + "preventStartShort": "重启后或从工作流最后一步重新打开任务时,保持代理停止。", + "archiveShort": "归档任务前查看清理选项。", + "unreadShort": "标记任务不在视图中时收到的消息。", + "anchoredPromptShort": "在桌面端滚动时保持最后一条提示可见。", + "jumpPromptShort": "显示返回最新提示的按钮。", + "jumpStartShort": "显示返回对话开头的按钮。", + "autoScrollShort": "显示暂停或恢复对话自动滚动的按钮。", + "todoShort": "在任务面板中显示代理的待办清单。", + "todoNonemptyShort": "固定清单没有项目时隐藏。", + "sessionLimitShort": "达到会话限制时,将自动启动加入队列。", + "sessionMaximumShort": "手动启动可以超过此限制。", + "queueMaximumShort": "限制每个会话的待处理消息数。0 表示无限制。", + "queueMergeShort": "在代理读取前合并队列中的相关消息。", + "queueAutoMergeShort": "自动合并兼容的连续消息。", + "sleepShort": "任务运行期间防止运行 Kandev 的计算机休眠。" } diff --git a/apps/web/src/locales/zh-hk/settings.json b/apps/web/src/locales/zh-hk/settings.json index 9889399687..1a29ef123a 100644 --- a/apps/web/src/locales/zh-hk/settings.json +++ b/apps/web/src/locales/zh-hk/settings.json @@ -446,7 +446,7 @@ "manageSpritesDevRemoteSandboxIntegration": "管理 Sprites.dev 遠端沙盒集成,以便在隔離的雲環境中執行代理程式。", "manualInstallRequired": "需要手動安裝", "markMessagesThatArrivedWhileA": "標記任務不可見期間收到的訊息。立即生效。", - "mcpAffectedToolsHelp": "<0>create_task_kandev 會建立單獨任務。<3>spawn_session_kandev 會向目前任務新增工作階段,因此不使用此偏好設定。", + "mcpAffectedToolsHelp": "<0>create_task_kandev 會建立單獨任務。<2>spawn_session_kandev 會向目前任務新增工作階段,因此不使用此偏好設定。", "mcpCreateTaskScope": "<0>create_task_kandev 會建立新任務和子任務。該設定僅在呼叫時未提供 <4>agent_profile_id 時生效。在實時任務呼叫中,建立工作階段設定會複製已驗證工作階段的有效模型、模式和選項。", "mcpSpawnSessionScope": "<0>spawn_session_kandev 和你自行建立的任務不受影響。工作流程選擇的設定始終優先。否則,明確選擇的設定優先;工作區預設設定會跳過工作階段和任務設定。外部呼叫沒有建立工作階段,current_task 使用父任務後備設定。", "mcpTaskProfileCurrentTask": "建立工作階段設定", @@ -658,6 +658,16 @@ "systemLoad1Min": "系統負載(1 分鐘)", "taskActions": "任務操作", "taskBehavior": "任務行為", + "taskBehaviorArchiving": "歸檔", + "taskBehaviorArchivingDescription": "選擇歸檔任務前是否需要確認。", + "taskBehaviorConversation": "對話和面板", + "taskBehaviorConversationDescription": "選擇工作時顯示的對話記錄標記和任務面板。", + "taskBehaviorCreating": "建立和開啟任務", + "taskBehaviorCreatingDescription": "選擇新任務的開啟方式,以及代理建立任務時使用的預設設定。", + "taskBehaviorRuntime": "執行時和限制", + "taskBehaviorRuntimeDescription": "設定此實例的限制,並控制主機電腦的防休眠功能。", + "taskBehaviorRuntimeScope": "適用於所有人", + "taskBehaviorRuntimeSummary": "自動工作階段:{{sessions}}。排隊訊息:{{queue}}。", "taskOverview": "任務概覽", "startModelUnavailable": "已不可用,請另選模型", "startModelAria": "設定起始模型設定", @@ -808,6 +818,7 @@ "discoveryAliasesSidebarHover": "側邊欄 懸停 顯示 延遲 等待 鼠標 觸控板", "autoFocusNewTasks": "自動聚焦新任務", "autoFocusNewTasksHelp": "自動開啟新建立的任務。關閉此選項可保留目前視圖。任務和代理仍會按要求啓動。", + "openNewTasksAutomatically": "自動開啟新任務", "chatAnimations": "聊天動畫", "chatAnimationsDescription": "在此設備上為新文字、新聊天項目和滾動新增動畫。遵循減少動態效果的偏好設定。", "discoveryAliasesChatMotion": "聊天 動畫 平滑滾動 文字 動態效果", @@ -838,5 +849,31 @@ "sidebarShortcutLoadError": "無法載入捷徑。請重試。", "noMatchingShortcuts": "沒有相符的捷徑", "searchShortcuts": "搜尋捷徑", - "sidebarPreviewEmpty": "沒有可見的導覽項目" + "sidebarPreviewEmpty": "沒有可見的導覽項目", + "settingInfoLabel": "關於{{setting}}", + "settingsNeedAttention": "需要處理", + "taskBehaviorTabTasks": "任務", + "taskBehaviorTabConversation": "對話", + "taskBehaviorTabRuntime": "執行時", + "taskBehaviorRuntimeScopeShort": "適用於所有工作區。", + "autoFocusShort": "建立任務後自動開啟。", + "agentTitlesShort": "讓代理根據提示為新任務命名。", + "agentProfileShort": "選擇代理建立任務時使用的預設設定。", + "profileSessionShort": "複用建立任務的工作階段設定。", + "profileWorkspaceShort": "使用工作區的預設設定。", + "preventStartShort": "重啓後或從工作流程最後一步重新開啟任務時,保持代理停止。", + "archiveShort": "歸檔任務前查看清理選項。", + "unreadShort": "標記任務不在視圖中時收到的訊息。", + "anchoredPromptShort": "在桌面端滾動時保持最後一條提示可見。", + "jumpPromptShort": "顯示返回最新提示的按鈕。", + "jumpStartShort": "顯示返回對話開頭的按鈕。", + "autoScrollShort": "顯示暫停或恢復對話自動滾動的按鈕。", + "todoShort": "在任務面板中顯示代理的待辦清單。", + "todoNonemptyShort": "固定清單沒有項目時隱藏。", + "sessionLimitShort": "達到工作階段限制時,將自動啓動加入佇列。", + "sessionMaximumShort": "手動啓動可以超過此限制。", + "queueMaximumShort": "限制每個工作階段的待處理訊息數。0 表示無限制。", + "queueMergeShort": "在代理讀取前合併佇列中的相關訊息。", + "queueAutoMergeShort": "自動合併兼容的連續訊息。", + "sleepShort": "任務執行期間防止執行 Kandev 的計算機休眠。" } diff --git a/apps/web/src/locales/zh-tw/settings.json b/apps/web/src/locales/zh-tw/settings.json index a80170140f..c9f2f92271 100644 --- a/apps/web/src/locales/zh-tw/settings.json +++ b/apps/web/src/locales/zh-tw/settings.json @@ -446,7 +446,7 @@ "manageSpritesDevRemoteSandboxIntegration": "管理 Sprites.dev 遠端沙盒整合,以便在隔離的雲環境中執行代理程式。", "manualInstallRequired": "需要手動安裝", "markMessagesThatArrivedWhileA": "標記任務不可見期間收到的訊息。立即生效。", - "mcpAffectedToolsHelp": "<0>create_task_kandev 會建立單獨任務。<3>spawn_session_kandev 會向目前任務新增工作階段,因此不使用此偏好設定。", + "mcpAffectedToolsHelp": "<0>create_task_kandev 會建立單獨任務。<2>spawn_session_kandev 會向目前任務新增工作階段,因此不使用此偏好設定。", "mcpCreateTaskScope": "<0>create_task_kandev 會建立新任務和子任務。該設定僅在呼叫時未提供 <4>agent_profile_id 時生效。在即時任務呼叫中,建立工作階段設定會複製已驗證工作階段的有效模型、模式和選項。", "mcpSpawnSessionScope": "<0>spawn_session_kandev 和你自行建立的任務不受影響。工作流程選擇的設定始終優先。否則,明確選擇的設定優先;工作區預設設定會跳過工作階段和任務設定。外部呼叫沒有建立工作階段,current_task 使用父任務備援設定。", "mcpTaskProfileCurrentTask": "建立工作階段設定", @@ -658,6 +658,16 @@ "systemLoad1Min": "系統負載(1 分鐘)", "taskActions": "任務操作", "taskBehavior": "任務行為", + "taskBehaviorArchiving": "歸檔", + "taskBehaviorArchivingDescription": "選擇歸檔任務前是否需要確認。", + "taskBehaviorConversation": "對話與面板", + "taskBehaviorConversationDescription": "選擇工作時顯示的對話記錄標記和任務面板。", + "taskBehaviorCreating": "建立與開啟任務", + "taskBehaviorCreatingDescription": "選擇新任務的開啟方式,以及代理建立任務時使用的預設設定。", + "taskBehaviorRuntime": "執行時與限制", + "taskBehaviorRuntimeDescription": "設定此執行個體的限制,並控制主機電腦的防休眠功能。", + "taskBehaviorRuntimeScope": "適用於所有人", + "taskBehaviorRuntimeSummary": "自動工作階段:{{sessions}}。排隊訊息:{{queue}}。", "taskOverview": "任務概覽", "startModelUnavailable": "已不可用,請另選模型", "startModelAria": "設定起始模型設定", @@ -808,6 +818,7 @@ "discoveryAliasesSidebarHover": "側邊欄 懸停 顯示 延遲 等待 滑鼠 觸控板", "autoFocusNewTasks": "自動聚焦新任務", "autoFocusNewTasksHelp": "自動開啟新建立的任務。關閉此選項可保留目前檢視。任務和代理仍會按要求啟動。", + "openNewTasksAutomatically": "自動開啟新任務", "chatAnimations": "聊天動畫", "chatAnimationsDescription": "在此裝置上為新文字、新聊天項目和滾動新增動畫。遵循減少動態效果的偏好設定。", "discoveryAliasesChatMotion": "聊天 動畫 平滑滾動 文字 動態效果", @@ -838,5 +849,31 @@ "sidebarShortcutLoadError": "無法載入捷徑。請重試。", "noMatchingShortcuts": "沒有相符的捷徑", "searchShortcuts": "搜尋捷徑", - "sidebarPreviewEmpty": "沒有可見的導覽項目" + "sidebarPreviewEmpty": "沒有可見的導覽項目", + "settingInfoLabel": "關於{{setting}}", + "settingsNeedAttention": "需要處理", + "taskBehaviorTabTasks": "任務", + "taskBehaviorTabConversation": "對話", + "taskBehaviorTabRuntime": "執行時", + "taskBehaviorRuntimeScopeShort": "適用於所有工作區。", + "autoFocusShort": "建立任務後自動開啟。", + "agentTitlesShort": "讓代理根據提示為新任務命名。", + "agentProfileShort": "選擇代理建立任務時使用的預設設定。", + "profileSessionShort": "複用建立任務的工作階段設定。", + "profileWorkspaceShort": "使用工作區的預設設定。", + "preventStartShort": "重啟後或從工作流程最後一步重新開啟任務時,保持代理停止。", + "archiveShort": "歸檔任務前檢視清理選項。", + "unreadShort": "標記任務不在檢視中時收到的訊息。", + "anchoredPromptShort": "在桌面端滾動時保持最後一條提示可見。", + "jumpPromptShort": "顯示返回最新提示的按鈕。", + "jumpStartShort": "顯示返回對話開頭的按鈕。", + "autoScrollShort": "顯示暫停或恢復對話自動滾動的按鈕。", + "todoShort": "在任務面板中顯示代理的待辦清單。", + "todoNonemptyShort": "固定清單沒有專案時隱藏。", + "sessionLimitShort": "達到工作階段限制時,將自動啟動加入佇列。", + "sessionMaximumShort": "手動啟動可以超過此限制。", + "queueMaximumShort": "限制每個工作階段的待處理訊息數。0 表示無限制。", + "queueMergeShort": "在代理讀取前合併佇列中的相關訊息。", + "queueAutoMergeShort": "自動合併相容的連續訊息。", + "sleepShort": "任務執行期間防止執行 Kandev 的計算機休眠。" } diff --git a/apps/web/src/plugin-integration-settings-route.test.tsx b/apps/web/src/plugin-integration-settings-route.test.tsx index 2593063e3d..260e8dc6fa 100644 --- a/apps/web/src/plugin-integration-settings-route.test.tsx +++ b/apps/web/src/plugin-integration-settings-route.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render } from "@testing-library/react"; +import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { pluginRegistry } from "@/lib/plugins/registry"; @@ -6,6 +6,7 @@ import { renderPluginIntegrationSettings } from "./plugin-integration-settings-r const PLUGIN_ID = "plugin-route-test"; const INTEGRATION_ID = "route-provider"; +const WORKSPACE_ID = "workspace-from-route"; afterEach(() => { cleanup(); @@ -28,13 +29,30 @@ describe("plugin integration settings route", () => { action: Action, }); - const page = renderPluginIntegrationSettings(INTEGRATION_ID, "workspace-from-route"); + const page = renderPluginIntegrationSettings(INTEGRATION_ID, WORKSPACE_ID); render(page); expect(Action.mock.calls[0]?.[0]).toEqual({ - workspaceId: "workspace-from-route", + workspaceId: WORKSPACE_ID, surface: "detail", }); expect(actionWorkspaceIds).toEqual(["workspace-from-route"]); }); + + it("keeps plugin-owned settings content frameless", () => { + pluginRegistry.forPlugin(PLUGIN_ID).registerIntegrationSettings({ + id: INTEGRATION_ID, + label: "Route provider", + description: "Route provider settings.", + Component: () =>
Plugin settings
, + }); + + const page = renderPluginIntegrationSettings(INTEGRATION_ID, WORKSPACE_ID); + render(page); + + expect(screen.getByTestId("plugin-settings-surface")).toBeTruthy(); + expect( + screen.queryByTestId("plugin-settings-surface")?.closest("[data-settings-group-card]"), + ).toBeNull(); + }); }); diff --git a/apps/web/src/plugin-integration-settings-route.tsx b/apps/web/src/plugin-integration-settings-route.tsx index 275b1976a6..5d9376107f 100644 --- a/apps/web/src/plugin-integration-settings-route.tsx +++ b/apps/web/src/plugin-integration-settings-route.tsx @@ -23,6 +23,7 @@ export function renderPluginIntegrationSettings(integrationId: string, workspace description={registration.description} icon={} action={Action ? : undefined} + framed={false} > diff --git a/docs/plans/settings-composition/plan.md b/docs/plans/settings-composition/plan.md new file mode 100644 index 0000000000..ed73048bbb --- /dev/null +++ b/docs/plans/settings-composition/plan.md @@ -0,0 +1,286 @@ +--- +created: 2026-09-21 +status: implemented +requirements: + - REQ-UI-SETTINGS-COMPOSITION-001 + - REQ-UI-SETTINGS-COMPOSITION-002 + - REQ-UI-SETTINGS-COMPOSITION-003 + - REQ-UI-SETTINGS-COMPOSITION-004 + - REQ-UI-SETTINGS-COMPOSITION-005 +system_design: + - ../../specs/ui/system-design/settings-composition.md +legacy_specs: [] +--- + +# Implementation Plan: Settings Composition + +## Overview + +Revision on 2026-09-22: the user rejected collapsed Runtime and excessive inline text. +Task 06 adds Tasks, Conversation, and Runtime header tabs with expanded sections and concise help. +The completed Tasks 01 through 05 and their results remain historical delivery evidence. +Task 06 supersedes their collapsed-section and long-description presentation. + + +Keep Task behavior on one page and use consistent composition across first-party settings. +Deliver shared groups with Task behavior first, then migrate bounded route families. +Use the [surface inventory](surface-inventory.md) to prevent partial coverage. +Each work order includes its own rendered and behavioral checks. + +## Scope + +### In scope + +- Three Task behavior header tabs, expanded sections, and short descriptions with optional info. +- Shared card padding, row spacing, descriptions, fields, actions, and disclosure treatment. +- All first-party settings route families and the forms they expose. +- Localization, discovery compatibility, dirty-state preservation, phone composition, and relevant documentation. + +### Out of scope + +- New settings pages or sidebar entries. Tabs are limited to Task behavior. +- Backend changes, new permissions, altered defaults, or auto-start switch inversion. +- Global UI Card styling and third-party plugin content. +- Specialized editor, table, terminal, and pipeline redesigns. + +## Inputs and decisions + +The user retained one page and requested less visible text, no collapsed settings sections, and evaluation of the Data & Logs tab pattern. +The UI system owns this independent reusable presentation contract. +Existing typography, sizing, header tabs, search, and save coordination remain authoritative dependencies. +The [typography plan](../settings-typography/plan.md) has unfinished historical migration records. +This package owns grouping and spacing without marking those records complete or replacing their evidence. +No material product question blocked implementation. The five work orders were executed sequentially in the primary session; +no delegation was used. + +- [Requirements](../../specs/ui/requirements/settings-composition.md) +- [System design](../../specs/ui/system-design/settings-composition.md) +- [Save coordinator ADR](../../decisions/0046-settings-route-save-coordinator.md) + +## Technical approach + +Task 06 reuses the existing header tabs and adds shared info presentation. +It removes Runtime collapse and redundant descriptions without changing domain settings behavior. +Its copy table, target mapping, mobile preview, and exact checks are in the work order. + +The following describes the original delivery: + + +Task 01 introduces small settings-local group/row compositions using existing shared roles. +It integrates them into Task behavior, preserves domain draft owners, and opens native disclosures before target focus. +Tasks 02 through 05 migrate callers by route family and record retained exceptions in the inventory. +Each task updates its own translations and runs its own targeted tests. +No global CSS sweep, schema-driven renderer, or duplicated settings state is needed. + +Use `SettingsSaveDirtyScope` to aggregate group state, preserving field markers and existing save contributors. +Reuse `SettingsPageHeader`, `SettingsCardHeader`, `SettingsField`, and settings sizing helpers. +Keep header tabs and immediate commands unchanged. +Update `apps/web/AGENTS.md` with the shipped composition rule in Task 01. +Update public descriptions of Task behavior during Task 01, not during this design turn. + +## ASCII UI preview + +### UI-05: Concise Task behavior, desktop + +Entry: `/settings/preferences/task-behavior`, default Tasks tab. + +```text +Task behavior [Tasks] [Conversation] [Runtime] +------------------------------------------------------------- +Creating and opening tasks ++-----------------------------------------------------------+ +| Open new tasks automatically (i) [switch] | +| Open a task after you create it. | +|-----------------------------------------------------------| +| Agent-generated titles (i) [switch] | +| Let the agent name new tasks from their prompt. | +|-----------------------------------------------------------| +| Profile for agent-created tasks (i) | +| Choose the default profile for tasks created by agents. | +| (o) Creating session: reuse the creating session's profile.| +| ( ) Workspace default: use the workspace's default profile.| +|-----------------------------------------------------------| +| Prevent auto-start on open (i) [switch] | +| Keep agents stopped when reopening tasks after a restart | +| or from the final workflow step. | ++-----------------------------------------------------------+ +Archiving +[Archive confirmation row with short help and (i)] +``` + +Tasks contains creation/opening and archiving. Conversation contains transcript and panel preferences. +Info appears beside the setting label. There is no repeated section-description paragraph. +Hover/focus on `(i)` reveals technical details without changing the setting. + +### UI-06: Runtime, desktop + +Entry: the visible Runtime tab or an existing runtime search fragment. + +```text +Task behavior [Tasks] [Conversation] [Runtime*] +------------------------------------------------------------- +Applies to all workspaces. +Session capacity +[Enable automatic-session limit (i) switch] +[Maximum automatic sessions (i) 5 ] + Manual starts can exceed this limit. +Message queue +[Maximum queued messages (i) 10 ] + 0 means unlimited. +[Allow queued message merging (i) switch] +[Merge consecutive messages automatically (i) switch] +Power +[Keep host awake (i) switch] + Prevent the computer running Kandev from sleeping during tasks. + +[Inline managed-value notice or error only when applicable] + Unsaved changes [Reset] [Save changes] +``` + +Every group is expanded. No chevrons, expansion summaries, or Details accordions precede the settings. +The floating Save surface appears only while dirty and applies across tabs. +A new save/validation error in an inactive tab reveals that tab once. Load failures mark the tab without interrupting active edits. + +### UI-07: Phone tabs and info + +```text +< Settings +Task behavior +[Tasks] [Conversation] [Runtime] +-------------------------------- +Agent-generated titles (i) [on] +Let the agent name new tasks +from their prompt. + +Tap (i): ++------------------------------+ +| Agent-generated titles [X] | +| Prompt requirements, naming | +| fallback, and manual editing | +| details. | ++------------------------------+ +``` + +Phone tabs occupy their own header row. Settings keep one vertical scroll owner. +Info opens an inset drawer with safe-area clearance and 44px targets. Long details scroll inside the drawer. +Dismissal returns focus to the info button. It never changes the draft. +The desktop tooltip and phone drawer use identical content. +Required structure: three visible tabs, expanded sections, concise row descriptions, and optional accessible information. +Copy and ASCII spacing are illustrative. The work-order copy table is the English starting point. +Maps to AC-UI-SETTINGS-COMPOSITION-002.1 through .6, -003.1 through .6, -004.1 through .5, and -005.1 through .7. + +## Tests and E2E + +Task 06 owns the exact commands and scenarios for the refinement. +Its component tests cover info accessibility, cross-tab draft lifetime, error routing, and discovery ordering. +New desktop/mobile concise-help suites prove visible runtime controls, short descriptions, info interactions, and tab navigation. +Existing composition, save, queue, and capacity suites retain their behavioral assertions with updated navigation. +The work order also requires every modified existing E2E consumer to run and record results. + +## Work orders + +- [x] [Task 01: Shared composition and Task behavior](task-01-task-behavior.md) +- [x] [Task 02: Remaining preferences](task-02-preferences.md) +- [x] [Task 03: Agents and execution settings](task-03-agent-executor.md) +- [x] [Task 04: Workspace and integration settings](task-04-workspace-integration.md) +- [x] [Task 05: System, account, and plugin settings](task-05-system-account.md) + +- [x] [Task 06: Concise help and header tabs](task-06-concise-help-and-tabs.md) + +Tasks 01 through 05 are complete. Task 06 follows them sequentially. No delegation is authorized. + +## Verification results + +Revision design checks on 2026-09-22: specification catalog validation, full specification lint, relative links, acceptance references, and whitespace checks passed. +No application builds or tests ran during this design turn. + + +Task 06 refinement is implemented. Its work order records current validation; results below describe the original delivery. + +Implementation checks on 2026-09-21: + +- `(cd apps/web && pnpm exec vitest run components/settings lib/settings-discovery/target.test.ts src/settings-routes.test.ts)`: passed, 207 files and 1,400 tests. +- `(cd apps/web && pnpm e2e:run e2e/tests/settings/settings-composition.spec.ts)`: passed, 7 desktop tests. +- `(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/settings/mobile-settings-composition.spec.ts)`: passed, 6 mobile tests. +- `(cd apps/web && pnpm e2e:run e2e/tests/system/message-queue-settings.spec.ts e2e/tests/system/session-capacity-settings.spec.ts e2e/tests/settings/settings-manual-save.spec.ts)`: passed, 14 desktop tests. +- `(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/system/mobile-message-queue-settings.spec.ts e2e/tests/settings/mobile-general-settings.spec.ts e2e/tests/system/mobile-session-capacity-settings.spec.ts)`: passed, 9 mobile tests. +- `(cd apps/web && pnpm e2e:run e2e/tests/settings/notifications-type-scale.spec.ts e2e/tests/integrations/github-workspace-settings.spec.ts -- --grep 'renders the card body|repository scope is saved per workspace')`: passed, 2 focused desktop tests. +- `(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/settings/mobile-notifications-type-scale.spec.ts e2e/tests/settings/mobile-settings-typography.spec.ts e2e/tests/settings/mobile-agent-profile-layout.spec.ts e2e/tests/settings/mobile-executor-profile-spacing.spec.ts e2e/tests/integrations/mobile-github-workspace-settings.spec.ts e2e/tests/settings/mobile-workspace-settings-tabs.spec.ts)`: passed, 10 mobile tests. +- `pnpm --filter @kandev/web build:vite` from `apps`: passed. Vite emitted the repository's existing chunk-size, deprecated-option, and ineffective-dynamic-import warnings. +- Typecheck, localization checks, the new-copy ratchet, scoped ESLint, scoped Prettier, and `git diff --check`: passed. +- `python3 scripts/list-docs.py validate`: passed (294 decisions, 1,067 specifications). +- `python3 scripts/lint-spec-files.py --all`: passed. +Design validation on 2026-09-21: + +- `python3 scripts/list-docs.py validate`: passed (294 decisions, 1067 specifications). +- `python3 scripts/lint-spec-files.test.py`: passed (36 tests). +- `python3 scripts/lint-spec-files.py --all`: passed. +- `python3 scripts/list-docs.py specs --system ui --format paths`: both new artifacts discovered. +- Requirement/acceptance references, relative document links, and existing test paths: checked. +- `git diff --check -- docs/specs docs/plans`: passed. +- `git status --short -- docs/specs docs/plans`: new package files and the related typography-plan link identified. + +## Review remediation results + +The implementation review identified five composition gaps after the original +work-order checks. They are resolved by the current implementation and covered +by focused evidence: + +- Runtime load failures, invalid drafts, and asynchronous save failures reveal + the mounted Runtime and limits disclosure once for queue, session, and sleep + owners. A later manual collapse remains effective until a new attention + transition occurs. +- Queue and session draft contributors now pass their aggregate dirty state to + the runtime group. The shared green, localized status badge is visible for + queue-only, session-only, and descendant sleep changes, and Reset and Save + clear it through the existing coordinator. +- Appearance simple preference cards are rows, while Notifications and + Terminal and Editors use frameless page content with group-owned borders. + Structural desktop checks cover all three pages and retain specialized + editor/resource interiors. +- `SettingsRow` generates a stable description ID when callers omit one and + preserves caller-provided `aria-describedby` values. Production-shaped, + custom MCP radio, and wrapped Appearance Select rows cover this association. +- Switch rows use a real label hit area with mobile/coarse-pointer dimensions, + and `SettingsDetails` summaries have the same touch sizing. The mobile + composition test taps near a switch target edge. + +Fixup remediation on 2026-09-21 addressed the automated review findings: + +- Runtime attention now uses owner-specific transition keys, so a queue, + session, or sleep failure that arrives while another failure remains active + reopens the mounted disclosure after a manual collapse. Discovery also sends + an explicit synchronous disclosure-open event to React-controlled groups. +- Plugin integration routes opt out of the native group frame, preserving the + frameless boundary for plugin-owned settings content. +- Sleep attention now has a load-failure and successful-retry callback test. + +The focused fixup suite passed 4 files and 28 tests: +`(cd apps/web && pnpm exec vitest run components/settings/task-behavior-settings.test.tsx components/settings/sleep-inhibition-settings.test.tsx src/plugin-integration-settings-route.test.tsx lib/settings-discovery/target.test.ts)`. + +Remediation checks run after these changes: + +- Full settings component matrix: 207 files and 1,400 tests passed. +- `settings-composition.spec.ts`: 7 desktop tests passed, including the + Appearance, Notifications, and Terminal and Editors one-frame checks. +- `mobile-settings-composition.spec.ts`: 6 mobile tests passed, including the + coarse-pointer switch-edge activation check. +- Desktop typography/type-scale checks: 3 tests passed. Mobile + typography/type-scale checks: 3 tests passed. +- Desktop runtime/manual-save checks: 14 tests passed. Mobile runtime/settings + checks: 9 tests passed. +- Existing auto-focus desktop and mobile flows were updated to target the + migrated row surface and passed with retries disabled (1 test each). + +Use the exact commands in each work order. Install dependencies once with `(cd apps && pnpm install --frozen-lockfile)` before the first package command. +The managed E2E runner rebuilds the frontend and backend and enforces its resource limits. +Do not overlap these E2E commands or use worker overrides. + +## Risks + +- Switching tabs can hide invalid controls or break search focus unless panel activation precedes focus. +- Replacing nested cards can lose dirty borders, target registrations, or domain hook identity. +- Effective values must remain distinct from drafts without duplicate runtime requests. +- Shared spacing can affect specialized editors. Retain and document their inner geometry. +- Brief descriptions can omit decision-critical exclusions. Keep those visible and move only secondary detail. +- Existing E2E selectors reference card structure. Preserve semantic assertions and migrate affected selectors together. diff --git a/docs/plans/settings-composition/surface-inventory.md b/docs/plans/settings-composition/surface-inventory.md new file mode 100644 index 0000000000..43c782dec3 --- /dev/null +++ b/docs/plans/settings-composition/surface-inventory.md @@ -0,0 +1,47 @@ +# Settings Surface Inventory + +This inventory records the original migration coverage. It is a delivery record, not another product contract. +The 2026-09-22 [Task 06 refinement](task-06-concise-help-and-tabs.md) is pending. +It supersedes the completed Task behavior row's collapsed-runtime presentation with tabs and concise optional help. +Original completion claims below do not verify this refinement. +The route source is `apps/web/src/settings-routes.tsx`, including its dynamic routes and integration route adapter. +For each row, record changed components, retained specialized layouts, and exact rendered evidence during implementation. +A shared primitive change alone does not prove that every caller conforms. + +| Family | Entry files or directories | Composition | Work order | Result | +| --- | --- | --- | --- | --- | +| Task behavior | `components/settings/task-behavior-settings.tsx`, task controls, `system/message-queue-settings.tsx`, `system/session-capacity-settings.tsx`, `sleep-inhibition-settings.tsx` | Preference groups and runtime form disclosure | 01 | Complete. Four ordered groups keep runtime mounted behind a closed disclosure. Load, validation, and asynchronous save attention from queue, session, and sleep owners reveal it once while preserving later manual collapse. Queue/session contributors feed the group dirty marker, and focused component tests cover dirty, reset, save, and disclosure transitions. | +| Appearance and keyboard shortcuts | `components/settings/general-settings.tsx`, `appearance-account-sections.tsx`, `startup-page-settings-card.tsx` | Preference groups | 02 | Complete. Simple preference cards are rows inside one group frame, while menu/startup/metrics controls keep their specialized form bodies. Route targets, dirty state, and existing controls remain intact; the Appearance structural check verifies no row-owned card is nested in the group frame. | +| Notifications | `components/settings/notifications-settings.tsx`, external-provider and event sections | Preference groups and provider forms | 02 | Complete. The page template is frameless and sound, permission, provider, and event surfaces own their shared group frames. The component type-scale test and desktop/mobile structural checks verify grouped content without an extra page card. | +| Layouts and terminal/editors | `components/settings/layouts/`, `terminal-editors-settings.tsx`, `terminal-settings.tsx`, `editors-settings.tsx` | Preference/form groups, retained previews | 02 | Complete. The terminal/editors page is frameless with two group frames; terminal, editor, preview, and language-server bodies retain specialized geometry. The desktop structural check verifies the page-level frame is not duplicated. | +| Agents and profiles | `app/settings/agents/`, `components/settings/agent-profile-page.tsx`, agent setup and profile children | Resource and form groups | 03 | Complete. Installed-agent resource chrome and profile children preserve actions and dialogs; desktop and mobile agent layout checks passed. | +| Executors and profiles | `app/settings/executor/`, `app/settings/executors/`, `components/settings/profile-edit/`, SSH and Kubernetes settings | Resource/form groups, retained diagnostics | 03 | Complete. Executor/profile groups retain SSH and Kubernetes diagnostics; desktop and mobile executor spacing checks passed. | +| Utility agents, external MCP, prompts | Corresponding routes under `app/settings/`, `components/settings/prompts-settings.tsx` | Resource/form groups, retained editors | 03 | Complete. Utility, MCP, prompt, and editor surfaces use shared surrounding groups while retaining their specialized bodies; component and family matrices passed. | +| Workspaces and repositories | `app/settings/workspace/`, `components/settings/workspaces/`, repository cards and forms | Resource and form groups | 04 | Complete. Workspace settings and route sections use shared group chrome; the repository resource section is frameless so each repository card remains the single bordered surface; tabs, forms, and repository children retain their behavior; desktop/mobile family and workspace-tab checks passed. | +| Workflows and automations | Workspace workflow routes, workflow forms, automation routes | Group chrome around retained editors | 04 | Complete. Shared route sections are frameless around retained workflow editor cards, while repository-set rows and automation forms keep their owning surfaces; workspace/integration family coverage and the full settings matrix passed. | +| Integration connections and watches | `src/integration-settings-route.tsx`, `components/github/`, `gitlab/`, `jira/`, `linear/`, `azure-devops/`, `sentry/`, native integration host forms | Credential/form and resource groups | 04 | Complete. Integration route sections use shared group semantics; GitHub/Jira desktop checks and GitHub mobile checks passed, including repository-scope heading and save behavior. | +| Global and workspace secrets | `components/settings/secrets-settings.tsx` and secret dialogs | Resource/form groups | 04 | Complete. Secret list/form chrome is grouped while dialogs and credential behavior remain domain-owned; component and family matrices passed. | +| System, storage, status, updates, feature toggles | `components/settings/system/` and system route shells | Resource/form groups, retained diagnostics | 05 | Complete. Data/logs, About, and system route surfaces use shared groups around retained diagnostics and tables; system family desktop/mobile checks passed. | +| Account, users, organizations, units | `components/settings/account/`, `system/users-table.tsx`, `system/organizations/`, `units/` | Resource/form groups | 05 | Complete. Token, user, organization, and unit resource chrome is grouped while permissions and dialogs remain intact; system/account family desktop/mobile checks passed. | +| Plugin listing/details and host-provided settings | `app/settings/plugins/`, `components/settings/plugins/`, native host settings chrome | Resource/form groups with plugin-owned frame opt-out | 05 | Complete. Installed plugin and detail settings use shared groups; browse/canvas/plugin-owned bodies remain specialized, and integration routes explicitly preserve frameless plugin-owned content; full component and family matrices passed. | + +Paths in the table are relative to `apps/web/`. +Existing redirect routes and the Settings index retain navigation behavior. +Existing dialogs inherit field and action presentation without replacing their submission flow. +Plugin-owned content is outside this repository's migration scope. + +## Retained specialized layouts + +- Workflow pipeline, sidebar/layout preview, drag ordering, and automation editors retain their interaction geometry. +- Monaco, terminal, prompt, code, query, and log content retain technical fonts and editor sizing. +- Tables retain columns and overflow within their owning surface. Their headings, helpers, and actions use the shared settings contract. +- Health, connection, update, and permission states retain their semantic alert colors. +- Destructive operations remain visibly separate and retain confirmation behavior. +- Settings header tabs retain their current placement and URL behavior. + +## Evidence protocol + +For each row, inspect its imported first-party forms and identify every composition exception. +Mark a row complete only after its work-order commands pass and its desktop/phone evidence is recorded. +Use seeded mock resources for empty and populated states. Do not mutate a developer instance. +Record exact commands and outcomes, not screenshots alone or class-name inspection alone. diff --git a/docs/plans/settings-composition/task-01-task-behavior.md b/docs/plans/settings-composition/task-01-task-behavior.md new file mode 100644 index 0000000000..469ee02567 --- /dev/null +++ b/docs/plans/settings-composition/task-01-task-behavior.md @@ -0,0 +1,172 @@ +--- +id: "01-task-behavior" +title: "Shared composition and Task behavior" +status: complete +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-UI-SETTINGS-COMPOSITION-001 + - REQ-UI-SETTINGS-COMPOSITION-002 + - REQ-UI-SETTINGS-COMPOSITION-003 + - REQ-UI-SETTINGS-COMPOSITION-004 +acceptance_criteria: + - AC-UI-SETTINGS-COMPOSITION-001.1 + - AC-UI-SETTINGS-COMPOSITION-001.2 + - AC-UI-SETTINGS-COMPOSITION-001.3 + - AC-UI-SETTINGS-COMPOSITION-001.4 + - AC-UI-SETTINGS-COMPOSITION-001.5 + - AC-UI-SETTINGS-COMPOSITION-002.1 + - AC-UI-SETTINGS-COMPOSITION-002.2 + - AC-UI-SETTINGS-COMPOSITION-002.3 + - AC-UI-SETTINGS-COMPOSITION-002.4 + - AC-UI-SETTINGS-COMPOSITION-002.5 + - AC-UI-SETTINGS-COMPOSITION-002.6 + - AC-UI-SETTINGS-COMPOSITION-003.1 + - AC-UI-SETTINGS-COMPOSITION-003.2 + - AC-UI-SETTINGS-COMPOSITION-003.3 + - AC-UI-SETTINGS-COMPOSITION-003.4 + - AC-UI-SETTINGS-COMPOSITION-003.5 + - AC-UI-SETTINGS-COMPOSITION-003.6 + - AC-UI-SETTINGS-COMPOSITION-004.1 + - AC-UI-SETTINGS-COMPOSITION-004.2 + - AC-UI-SETTINGS-COMPOSITION-004.3 + - AC-UI-SETTINGS-COMPOSITION-004.4 + - AC-UI-SETTINGS-COMPOSITION-004.5 +system_design: + - ../../specs/ui/system-design/settings-composition.md +--- + +# Task 01: Shared composition and Task behavior + +## Summary + +Deliver the shared settings compositions through the complete Task behavior page. Preserve existing values, permissions, saves, and discovery targets. + +## In scope + +- Add shared groups and rows with aggregate dirty state and the existing typography and control sizes. +- Recompose every Task behavior control into UI-01 and UI-02. Keep runtime mounted while closed and derive its effective summary from existing state owners. +- Reveal native disclosures before discovery focus. Add error revelation and preserve repeated target requests. +- Update labels and all locale catalogs. Update relevant sections in `docs/public/configuration.md`, `operations.md`, `tasks-and-workflows.md`, `automation-and-mcp.md`, `coordination.md`, and `sessions-and-review.md` only where wording changes. +- Add the shipped composition convention to `apps/web/AGENTS.md`. + +## Out of scope + +- Other settings route migrations. +- Switch-polarity, backend, or plugin API changes. + +## Acceptance + +- The four groups and runtime states match UI-01 through UI-03, including scope and loading/error summaries. +- Collapsed drafts save/reset correctly, failures reveal controls, and direct/repeated discovery targets focus visible content. +- Desktop and phone tests prove geometry, localization, permissions, and saved behavior. + +## ASCII UI preview + +### UI-01 / UI-02 / UI-03: Task behavior + +```text +Task behavior +Creating and opening tasks [grouped rows] +Conversation and panels [grouped rows] +Archiving [grouped rows] +> Runtime and limits [effective summary] [scope] + Expand -> limits, merging, host sleep, visible errors +[Reset] [Save changes] only while dirty +``` + +On phones, selectors stack below wrapping descriptions and actions retain 44px targets. + +See the [full labelled previews](plan.md#ascii-ui-preview). Applicable criteria are listed in frontmatter. +Group structure and state visibility are required. ASCII spacing is illustrative. + +## Verification + +Start from the repository root. Install dependencies once before the first package command. +Add or update tests before changing covered behavior. Compare rendered results with the assigned preview. +New E2E files and family tags are defined in the plan and must exist before running these commands. + +```bash +(cd apps/web && pnpm exec vitest run components/settings lib/settings-discovery/target.test.ts src/settings-routes.test.ts) +(cd apps/web && pnpm e2e:run e2e/tests/settings/settings-composition.spec.ts -- --grep 'task behavior') +(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/settings/mobile-settings-composition.spec.ts -- --grep 'task behavior') +(cd apps/web && pnpm e2e:run e2e/tests/system/message-queue-settings.spec.ts e2e/tests/system/session-capacity-settings.spec.ts e2e/tests/settings/settings-manual-save.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/system/mobile-message-queue-settings.spec.ts e2e/tests/settings/mobile-general-settings.spec.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm run i18n:ratchet) +(cd apps/web && pnpm exec eslint components/settings app/settings lib/settings-discovery --max-warnings 0) +git diff --check +``` + +Also run every existing E2E file whose selectors or assertions this work order changes. +Record its exact command and outcome under Results. Do not weaken assertions or leave changed tests unrun. + +## Files likely touched + +- `apps/web/components/settings/settings-{group,row}.tsx (new)` +- `apps/web/components/settings/settings-{card,card-header,section,page-template,typography,save-provider}.tsx` +- `apps/web/components/settings/{task-behavior-settings,general-settings}.tsx and all Task behavior controls` +- `apps/web/components/settings/system/{message-queue-settings,session-capacity-settings}.tsx` +- `apps/web/components/settings/system/use-session-capacity-settings.ts` +- `apps/web/lib/settings-discovery/{target.ts,target.test.ts,catalog/preferences.ts}` +- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw}/` +- `apps/web/AGENTS.md and affected public documentation` +- `apps/web/e2e/tests/settings/settings-composition.spec.ts` (new shared family matrix) +- `apps/web/e2e/tests/settings/mobile-settings-composition.spec.ts` (new shared family matrix) +- Existing component and E2E tests beside the migrated surfaces + +## Dependencies + +None. + +## Risks + +- Shared wrappers can lose dirty markers, accessible descriptions, discovery targets, or state identity. +- Long translations and coarse-pointer controls can expose clipping despite correct desktop appearance. +- Existing tests can depend on card nesting. Preserve behavioral checks when updating selectors. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/ui/requirements/settings-composition.md). +- [System design](../../specs/ui/system-design/settings-composition.md). +- [Surface inventory](surface-inventory.md) and the assigned route imports. +- `apps/web/AGENTS.md`, `/tdd`, `/mobile-parity`, and `/e2e`. +- Existing typography, header tabs, control sizing, and route-save contracts. + +## Results + +Implemented and verified. Added the shared `SettingsGroup` and `SettingsRow` compositions, moved Task behavior into the four ordered groups, kept Runtime and limits mounted behind a closed native disclosure with effective summaries, preserved dirty/save ownership, and made discovery reveal closed targets before focus. Updated locales, public Task behavior descriptions, the engineering guide, and affected E2E selectors. + +- `(cd apps/web && pnpm exec vitest run components/settings lib/settings-discovery/target.test.ts src/settings-routes.test.ts)`: passed, 207 files and 1,400 tests. +- `(cd apps/web && pnpm e2e:run e2e/tests/settings/settings-composition.spec.ts)`: passed, 7 desktop tests, including the Task behavior family. +- `(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/settings/mobile-settings-composition.spec.ts)`: passed, 6 mobile tests, including the Task behavior family. +- `(cd apps/web && pnpm e2e:run e2e/tests/system/message-queue-settings.spec.ts e2e/tests/system/session-capacity-settings.spec.ts e2e/tests/settings/settings-manual-save.spec.ts)`: passed, 14 desktop tests. +- `(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/system/mobile-message-queue-settings.spec.ts e2e/tests/settings/mobile-general-settings.spec.ts e2e/tests/system/mobile-session-capacity-settings.spec.ts)`: passed, 9 mobile tests. +- Typecheck, localization checks, ratchet, scoped ESLint, and `git diff --check`: passed. + +Review remediation on 2026-09-21 added transition-aware runtime revelation for +queue, session, and sleep load/validation/save attention, passed queue/session +dirty state into the Runtime group, and exposed the existing localized green +unsaved status. The focused Task behavior tests cover initial failure, invalid +draft, save failure after collapse, queue dirty state, and sleep attention while +the existing system save/reset suites cover successful persistence. + +Fixup remediation added owner-specific attention transition keys so a second +runtime owner reopens the group while the first owner remains failed. It also +made discovery disclosure opening synchronize through an explicit event and +added the Sleep attention callback regression. The focused fixup suite passed +4 files and 28 tests. + +The follow-up E2E selector migration uses the shipped row surface for the +auto-focus preference instead of the former standalone card. The exact desktop +and mobile flows pass with retries disabled. + +- `(cd apps/web && pnpm e2e:raw --project chromium --retries=0 e2e/tests/task/creation-auto-focus.spec.ts)`: passed, 1 test. +- `(cd apps/web && pnpm e2e:raw --project mobile-chrome --retries=0 e2e/tests/task/mobile-creation-auto-focus.spec.ts)`: passed, 1 test. +- `(cd apps/web && pnpm e2e:raw --project mobile-chrome --retries=0 e2e/tests/task/mobile-mcp-task-agent-profile-default.spec.ts)`: passed, 1 test after aligning the flow with the group-owned surface. diff --git a/docs/plans/settings-composition/task-02-preferences.md b/docs/plans/settings-composition/task-02-preferences.md new file mode 100644 index 0000000000..ea8681f6e2 --- /dev/null +++ b/docs/plans/settings-composition/task-02-preferences.md @@ -0,0 +1,151 @@ +--- +id: "02-preferences" +title: "Remaining preferences" +status: complete +wave: 2 +depends_on: + - 01-task-behavior +plan: "plan.md" +requirements: + - REQ-UI-SETTINGS-COMPOSITION-001 + - REQ-UI-SETTINGS-COMPOSITION-003 + - REQ-UI-SETTINGS-COMPOSITION-004 +acceptance_criteria: + - AC-UI-SETTINGS-COMPOSITION-001.1 + - AC-UI-SETTINGS-COMPOSITION-001.2 + - AC-UI-SETTINGS-COMPOSITION-001.3 + - AC-UI-SETTINGS-COMPOSITION-001.4 + - AC-UI-SETTINGS-COMPOSITION-001.5 + - AC-UI-SETTINGS-COMPOSITION-003.1 + - AC-UI-SETTINGS-COMPOSITION-003.2 + - AC-UI-SETTINGS-COMPOSITION-003.3 + - AC-UI-SETTINGS-COMPOSITION-003.4 + - AC-UI-SETTINGS-COMPOSITION-003.5 + - AC-UI-SETTINGS-COMPOSITION-004.1 + - AC-UI-SETTINGS-COMPOSITION-004.2 + - AC-UI-SETTINGS-COMPOSITION-004.3 + - AC-UI-SETTINGS-COMPOSITION-004.4 + - AC-UI-SETTINGS-COMPOSITION-004.5 +system_design: + - ../../specs/ui/system-design/settings-composition.md +--- + +# Task 02: Remaining preferences + +## Summary + +Apply the shared composition to Appearance, keyboard shortcuts, notifications, layouts, and terminal/editor settings. Retain their existing tabs, previews, and save behavior. + +## In scope + +- Migrate every preference-family row in the surface inventory. +- Group simple preferences and normalize compound forms without changing their options. +- Remove duplicate headings and descriptions. Preserve technical previews, credential fields, and immediate permission commands. +- Extend desktop and mobile matrix tests with the preferences tag. + +## Out of scope + +- Task behavior logic and non-preference routes. +- Rewriting layout editors or terminal content. + +## Acceptance + +- All preference inventory rows use the shared patterns or record a specialized-layout exception. +- One saved setting per affected page persists after reload, with Reset and existing tabs intact. +- Long descriptions and actions remain contained on desktop, phone, and boundary widths. + +## ASCII UI preview + +### UI-04 / UI-03: Shared group composition + +```text +Page title [existing actions/tabs] +One description. +Group heading [group action] ++-------------------------------------------------------+ +| Label / short description [control] | +| Related field or existing resource content | +| Visible error or scope notice when applicable | ++-------------------------------------------------------+ +[Reset] [Save changes] only while dirty +``` + +On phones, group actions and fields stack. Specialized bodies retain their existing mobile interaction. + +See the [full labelled previews](plan.md#ascii-ui-preview). Applicable criteria are listed in frontmatter. +Group structure and state visibility are required. ASCII spacing is illustrative. + +## Verification + +Start from the repository root. Install dependencies once before the first package command. +Add or update tests before changing covered behavior. Compare rendered results with the assigned preview. +New E2E files and family tags are defined in the plan and must exist before running these commands. + +```bash +(cd apps/web && pnpm exec vitest run components/settings) +(cd apps/web && pnpm e2e:run e2e/tests/settings/settings-composition.spec.ts -- --grep preferences) +(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/settings/mobile-settings-composition.spec.ts -- --grep preferences) +(cd apps/web && pnpm e2e:run e2e/tests/settings/notifications-type-scale.spec.ts e2e/tests/settings/settings-typography.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/settings/mobile-settings-typography.spec.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm run i18n:ratchet) +(cd apps/web && pnpm exec eslint components/settings app/settings lib/settings-discovery --max-warnings 0) +git diff --check +``` + +Also run every existing E2E file whose selectors or assertions this work order changes. +Record its exact command and outcome under Results. Do not weaken assertions or leave changed tests unrun. + +## Files likely touched + +- `apps/web/components/settings/general-settings.tsx` +- `apps/web/components/settings/notifications-settings*.tsx` +- `apps/web/components/settings/layouts/` +- `apps/web/components/settings/{terminal-editors-settings,terminal-settings,editors-settings,appearance-account-sections,startup-page-settings-card}.tsx` +- `apps/web/src/locales/` +- `apps/web/e2e/tests/settings/settings-composition.spec.ts` (new shared family matrix) +- `apps/web/e2e/tests/settings/mobile-settings-composition.spec.ts` (new shared family matrix) +- Existing component and E2E tests beside the migrated surfaces + +## Dependencies + +Complete 01-task-behavior first. + +## Risks + +- Shared wrappers can lose dirty markers, accessible descriptions, discovery targets, or state identity. +- Long translations and coarse-pointer controls can expose clipping despite correct desktop appearance. +- Existing tests can depend on card nesting. Preserve behavioral checks when updating selectors. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/ui/requirements/settings-composition.md). +- [System design](../../specs/ui/system-design/settings-composition.md). +- [Surface inventory](surface-inventory.md) and the assigned route imports. +- `apps/web/AGENTS.md`, `/tdd`, `/mobile-parity`, and `/e2e`. +- Existing typography, header tabs, control sizing, and route-save contracts. + +## Results + +Implemented and verified. Preference callers now use the shared group contract through direct migrations and the `SettingsSection` adapter. Notifications, layouts, and file-editor settings have group chrome; terminal and editor bodies retain their specialized previews and technical geometry. Existing tabs, save contributors, discovery targets, and immediate permission/provider actions remain in their owning components. + +- `(cd apps/web && pnpm exec vitest run components/settings lib/settings-discovery/target.test.ts src/settings-routes.test.ts)`: passed, 207 files and 1,400 tests. +- `(cd apps/web && pnpm e2e:run e2e/tests/settings/settings-composition.spec.ts)`: passed, 7 desktop tests, including preferences coverage. +- `(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/settings/mobile-settings-composition.spec.ts)`: passed, 6 mobile tests, including preferences coverage. +- `(cd apps/web && pnpm e2e:run e2e/tests/settings/notifications-type-scale.spec.ts e2e/tests/integrations/github-workspace-settings.spec.ts -- --grep 'renders the card body|repository scope is saved per workspace')`: passed, 2 focused desktop tests. +- `(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/settings/mobile-notifications-type-scale.spec.ts e2e/tests/settings/mobile-settings-typography.spec.ts)`: passed as part of the 10-test mobile specialized suite. +- Typecheck, localization checks, ratchet, scoped ESLint, scoped Prettier, and `git diff --check`: passed. + +Review remediation on 2026-09-21 removed the extra page card from Notifications +and Terminal and Editors, flattened simple Appearance preference cards into +rows, and updated structural/type-scale assertions to inspect the actual +frameless page composition. Specialized editor, table, and provider bodies +remain inside their owning group frames. The production Appearance Select also +forwards its generated row description to the trigger. The shared section +adapter now has an explicit frame opt-out for content whose owner remains +outside the native settings composition. diff --git a/docs/plans/settings-composition/task-03-agent-executor.md b/docs/plans/settings-composition/task-03-agent-executor.md new file mode 100644 index 0000000000..b99ec150ef --- /dev/null +++ b/docs/plans/settings-composition/task-03-agent-executor.md @@ -0,0 +1,140 @@ +--- +id: "03-agent-executor" +title: "Agents and execution settings" +status: complete +wave: 3 +depends_on: + - 02-preferences +plan: "plan.md" +requirements: + - REQ-UI-SETTINGS-COMPOSITION-001 + - REQ-UI-SETTINGS-COMPOSITION-003 + - REQ-UI-SETTINGS-COMPOSITION-004 +acceptance_criteria: + - AC-UI-SETTINGS-COMPOSITION-001.1 + - AC-UI-SETTINGS-COMPOSITION-001.2 + - AC-UI-SETTINGS-COMPOSITION-001.3 + - AC-UI-SETTINGS-COMPOSITION-001.4 + - AC-UI-SETTINGS-COMPOSITION-001.5 + - AC-UI-SETTINGS-COMPOSITION-003.1 + - AC-UI-SETTINGS-COMPOSITION-003.2 + - AC-UI-SETTINGS-COMPOSITION-003.3 + - AC-UI-SETTINGS-COMPOSITION-003.4 + - AC-UI-SETTINGS-COMPOSITION-003.5 + - AC-UI-SETTINGS-COMPOSITION-004.1 + - AC-UI-SETTINGS-COMPOSITION-004.2 + - AC-UI-SETTINGS-COMPOSITION-004.3 + - AC-UI-SETTINGS-COMPOSITION-004.4 + - AC-UI-SETTINGS-COMPOSITION-004.5 +system_design: + - ../../specs/ui/system-design/settings-composition.md +--- + +# Task 03: Agents and execution settings + +## Summary + +Normalize agent, executor, utility-agent, MCP, and prompt settings chrome. Preserve their forms, editor content, technical details, and immediate commands. + +## In scope + +- Migrate all assigned inventory rows, including profile detail and create pages. +- Reuse shared card headers, fields, helpers, descriptions, and actions in nested settings forms and dialogs. +- Retain SSH/Kubernetes diagnostic bodies and technical editors. Record exact exceptions. +- Extend the rendered matrix with the agent executor tag. + +## Out of scope + +- Executor lifecycle changes, real-container scenarios, model resolution changes, and provider API contracts. + +## Acceptance + +- Assigned pages and their dialogs use shared presentation without new navigation. +- Profile edits preserve Save/Reset and reload behavior, while immediate test/install/connect actions retain their contracts. +- Desktop and phone evidence covers long names, disabled states, and field/action geometry. + +## ASCII UI preview + +### UI-04 / UI-03: Shared group composition + +```text +Page title [existing actions/tabs] +One description. +Group heading [group action] ++-------------------------------------------------------+ +| Label / short description [control] | +| Related field or existing resource content | +| Visible error or scope notice when applicable | ++-------------------------------------------------------+ +[Reset] [Save changes] only while dirty +``` + +On phones, group actions and fields stack. Specialized bodies retain their existing mobile interaction. + +See the [full labelled previews](plan.md#ascii-ui-preview). Applicable criteria are listed in frontmatter. +Group structure and state visibility are required. ASCII spacing is illustrative. + +## Verification + +Start from the repository root. Install dependencies once before the first package command. +Add or update tests before changing covered behavior. Compare rendered results with the assigned preview. +New E2E files and family tags are defined in the plan and must exist before running these commands. + +```bash +(cd apps/web && pnpm exec vitest run components/settings) +(cd apps/web && pnpm e2e:run e2e/tests/settings/settings-composition.spec.ts -- --grep 'agent executor') +(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/settings/mobile-settings-composition.spec.ts -- --grep 'agent executor') +(cd apps/web && pnpm e2e:run e2e/tests/settings/agent-profile-layout.spec.ts e2e/tests/settings/executor-profile-spacing.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/settings/mobile-agent-profile-layout.spec.ts e2e/tests/settings/mobile-executor-profile-spacing.spec.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm run i18n:ratchet) +(cd apps/web && pnpm exec eslint components/settings app/settings lib/settings-discovery --max-warnings 0) +git diff --check +``` + +Also run every existing E2E file whose selectors or assertions this work order changes. +Record its exact command and outcome under Results. Do not weaken assertions or leave changed tests unrun. + +## Files likely touched + +- `apps/web/app/settings/{agents,executor,executors,utility-agents,external-mcp}/` +- `apps/web/components/settings/{profile-edit,agent-profile-page.tsx,prompts-settings.tsx}` +- `apps/web/components/settings/ SSH, Kubernetes, agent, MCP, and executor forms` +- `apps/web/src/locales/` +- `apps/web/e2e/tests/settings/settings-composition.spec.ts` (new shared family matrix) +- `apps/web/e2e/tests/settings/mobile-settings-composition.spec.ts` (new shared family matrix) +- Existing component and E2E tests beside the migrated surfaces + +## Dependencies + +Complete 02-preferences first. + +## Risks + +- Shared wrappers can lose dirty markers, accessible descriptions, discovery targets, or state identity. +- Long translations and coarse-pointer controls can expose clipping despite correct desktop appearance. +- Existing tests can depend on card nesting. Preserve behavioral checks when updating selectors. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/ui/requirements/settings-composition.md). +- [System design](../../specs/ui/system-design/settings-composition.md). +- [Surface inventory](surface-inventory.md) and the assigned route imports. +- `apps/web/AGENTS.md`, `/tdd`, `/mobile-parity`, and `/e2e`. +- Existing typography, header tabs, control sizing, and route-save contracts. + +## Results + +Implemented and verified. Agents, executors, utility-agent, MCP, prompt, and create/profile surfaces now use shared resource or form group chrome where they own the surrounding presentation. Specialized SSH/Kubernetes diagnostics, prompt/editor bodies, and immediate install/test/connect actions remain intact. + +- `(cd apps/web && pnpm exec vitest run components/settings lib/settings-discovery/target.test.ts src/settings-routes.test.ts)`: passed, 207 files and 1,389 tests. +- `(cd apps/web && pnpm e2e:run e2e/tests/settings/settings-composition.spec.ts)`: passed, 6 desktop tests, including agent/executor coverage. +- `(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/settings/mobile-settings-composition.spec.ts)`: passed, 5 mobile tests, including agent/executor coverage. +- `(cd apps/web && pnpm e2e:run e2e/tests/settings/agent-profile-layout.spec.ts e2e/tests/settings/executor-profile-spacing.spec.ts)`: passed in the 22-test desktop specialized run. +- `(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/settings/mobile-agent-profile-layout.spec.ts e2e/tests/settings/mobile-executor-profile-spacing.spec.ts)`: passed in the 10-test mobile specialized run. +- Typecheck, localization checks, ratchet, scoped ESLint, scoped Prettier, and `git diff --check`: passed. diff --git a/docs/plans/settings-composition/task-04-workspace-integration.md b/docs/plans/settings-composition/task-04-workspace-integration.md new file mode 100644 index 0000000000..f18c901a09 --- /dev/null +++ b/docs/plans/settings-composition/task-04-workspace-integration.md @@ -0,0 +1,145 @@ +--- +id: "04-workspace-integration" +title: "Workspace and integration settings" +status: complete +wave: 4 +depends_on: + - 03-agent-executor +plan: "plan.md" +requirements: + - REQ-UI-SETTINGS-COMPOSITION-001 + - REQ-UI-SETTINGS-COMPOSITION-003 + - REQ-UI-SETTINGS-COMPOSITION-004 +acceptance_criteria: + - AC-UI-SETTINGS-COMPOSITION-001.1 + - AC-UI-SETTINGS-COMPOSITION-001.2 + - AC-UI-SETTINGS-COMPOSITION-001.3 + - AC-UI-SETTINGS-COMPOSITION-001.4 + - AC-UI-SETTINGS-COMPOSITION-001.5 + - AC-UI-SETTINGS-COMPOSITION-003.1 + - AC-UI-SETTINGS-COMPOSITION-003.2 + - AC-UI-SETTINGS-COMPOSITION-003.3 + - AC-UI-SETTINGS-COMPOSITION-003.4 + - AC-UI-SETTINGS-COMPOSITION-003.5 + - AC-UI-SETTINGS-COMPOSITION-004.1 + - AC-UI-SETTINGS-COMPOSITION-004.2 + - AC-UI-SETTINGS-COMPOSITION-004.3 + - AC-UI-SETTINGS-COMPOSITION-004.4 + - AC-UI-SETTINGS-COMPOSITION-004.5 +system_design: + - ../../specs/ui/system-design/settings-composition.md +--- + +# Task 04: Workspace and integration settings + +## Summary + +Normalize workspace, repository, workflow, automation, secret, and integration settings presentation. Preserve scoped credentials and specialized editor interactions. + +## In scope + +- Migrate assigned inventory rows and first-party connection/watch forms. +- Retain workflow pipeline and automation editor geometry while normalizing their surrounding fields and actions. +- Apply consistent credential descriptions, visible permissions, and status treatment without hiding essential scope or override rules. +- Extend the rendered matrix with the workspace integration tag. + +## Out of scope + +- Integration protocol changes, credential scope changes, and third-party plugin content. + +## Acceptance + +- Workspace and provider settings share form/resource anatomy while retaining their active-workspace and tab behavior. +- Existing save, credential, confirmation, and immediate-command tests retain their behavioral assertions. +- Mock-backed desktop and mobile coverage proves save/reload, empty/populated states, and contained credentials/actions. + +## ASCII UI preview + +### UI-04 / UI-03: Shared group composition + +```text +Page title [existing actions/tabs] +One description. +Group heading [group action] ++-------------------------------------------------------+ +| Label / short description [control] | +| Related field or existing resource content | +| Visible error or scope notice when applicable | ++-------------------------------------------------------+ +[Reset] [Save changes] only while dirty +``` + +On phones, group actions and fields stack. Specialized bodies retain their existing mobile interaction. + +See the [full labelled previews](plan.md#ascii-ui-preview). Applicable criteria are listed in frontmatter. +Group structure and state visibility are required. ASCII spacing is illustrative. + +## Verification + +Start from the repository root. Install dependencies once before the first package command. +Add or update tests before changing covered behavior. Compare rendered results with the assigned preview. +New E2E files and family tags are defined in the plan and must exist before running these commands. + +```bash +(cd apps/web && pnpm exec vitest run components/settings components/github components/gitlab components/jira components/linear components/azure-devops components/sentry) +(cd apps/web && pnpm exec eslint components/github components/gitlab components/jira components/linear components/azure-devops components/sentry components/integrations --max-warnings 0) +(cd apps/web && pnpm e2e:run e2e/tests/settings/settings-composition.spec.ts -- --grep 'workspace integration') +(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/settings/mobile-settings-composition.spec.ts -- --grep 'workspace integration') +(cd apps/web && pnpm e2e:run e2e/tests/integrations/github-workspace-settings.spec.ts e2e/tests/integrations/jira-settings.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/integrations/mobile-github-workspace-settings.spec.ts e2e/tests/settings/mobile-workspace-settings-tabs.spec.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm run i18n:ratchet) +(cd apps/web && pnpm exec eslint components/settings app/settings lib/settings-discovery --max-warnings 0) +git diff --check +``` + +Also run every existing E2E file whose selectors or assertions this work order changes. +Record its exact command and outcome under Results. Do not weaken assertions or leave changed tests unrun. + +## Files likely touched + +- `apps/web/app/settings/workspace/` +- `apps/web/components/settings/workspaces/` +- `apps/web/components/settings/ repository, workflow, secret, and automation forms` +- `apps/web/components/{github,gitlab,jira,linear,azure-devops,sentry}/ settings forms` +- `apps/web/components/integrations/ host-owned settings forms` +- `apps/web/src/locales/` +- `apps/web/e2e/tests/settings/settings-composition.spec.ts` (new shared family matrix) +- `apps/web/e2e/tests/settings/mobile-settings-composition.spec.ts` (new shared family matrix) +- Existing component and E2E tests beside the migrated surfaces + +## Dependencies + +Complete 03-agent-executor first. + +## Risks + +- Shared wrappers can lose dirty markers, accessible descriptions, discovery targets, or state identity. +- Long translations and coarse-pointer controls can expose clipping despite correct desktop appearance. +- Existing tests can depend on card nesting. Preserve behavioral checks when updating selectors. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/ui/requirements/settings-composition.md). +- [System design](../../specs/ui/system-design/settings-composition.md). +- [Surface inventory](surface-inventory.md) and the assigned route imports. +- `apps/web/AGENTS.md`, `/tdd`, `/mobile-parity`, and `/e2e`. +- Existing typography, header tabs, control sizing, and route-save contracts. + +## Results + +Implemented and verified. Workspace settings, secrets, and the integration route adapter now expose shared group chrome while preserving workspace tabs, credential scope, watch tables, form saves, and specialized workflow or automation editors. Repository and workflow sections opt out of the outer group frame so their resource/editor cards remain the single bordered surface. Existing integration children retain their domain forms and immediate commands. + +- `(cd apps/web && pnpm exec vitest run components/settings lib/settings-discovery/target.test.ts src/settings-routes.test.ts)`: passed, 207 files and 1,389 tests. +- `(cd apps/web && pnpm e2e:run e2e/tests/settings/settings-composition.spec.ts)`: passed, 6 desktop tests, including workspace/integration coverage. +- `(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/settings/mobile-settings-composition.spec.ts)`: passed, 5 mobile tests, including workspace/integration coverage. +- `(cd apps/web && pnpm e2e:run e2e/tests/integrations/github-workspace-settings.spec.ts e2e/tests/integrations/jira-settings.spec.ts)`: the specialized desktop run passed 20 of 22 tests; the affected repository-scope test passed in the focused rerun after the heading semantics fix. The other specialized integration tests passed in the original run. +- `(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/integrations/mobile-github-workspace-settings.spec.ts e2e/tests/settings/mobile-workspace-settings-tabs.spec.ts)`: passed as part of the 10-test mobile specialized run. +- `(cd apps/web && pnpm e2e:run --host --no-build -- --retries=0 e2e/tests/settings/repository-delete.spec.ts e2e/tests/settings/repository-add-local.spec.ts)`: passed, 3 desktop repository tests after removing the duplicate outer card. +- `(cd apps/web && pnpm e2e:run --host --no-build -- --project=mobile-chrome --retries=0 e2e/tests/settings/mobile-repository-add-local.spec.ts)`: passed, 1 mobile repository test. +- Typecheck, localization checks, ratchet, scoped ESLint, scoped Prettier, and `git diff --check`: passed. diff --git a/docs/plans/settings-composition/task-05-system-account.md b/docs/plans/settings-composition/task-05-system-account.md new file mode 100644 index 0000000000..1f59a62074 --- /dev/null +++ b/docs/plans/settings-composition/task-05-system-account.md @@ -0,0 +1,148 @@ +--- +id: "05-system-account" +title: "System, account, and plugin settings" +status: complete +wave: 5 +depends_on: + - 04-workspace-integration +plan: "plan.md" +requirements: + - REQ-UI-SETTINGS-COMPOSITION-001 + - REQ-UI-SETTINGS-COMPOSITION-003 + - REQ-UI-SETTINGS-COMPOSITION-004 +acceptance_criteria: + - AC-UI-SETTINGS-COMPOSITION-001.1 + - AC-UI-SETTINGS-COMPOSITION-001.2 + - AC-UI-SETTINGS-COMPOSITION-001.3 + - AC-UI-SETTINGS-COMPOSITION-001.4 + - AC-UI-SETTINGS-COMPOSITION-001.5 + - AC-UI-SETTINGS-COMPOSITION-003.1 + - AC-UI-SETTINGS-COMPOSITION-003.2 + - AC-UI-SETTINGS-COMPOSITION-003.3 + - AC-UI-SETTINGS-COMPOSITION-003.4 + - AC-UI-SETTINGS-COMPOSITION-003.5 + - AC-UI-SETTINGS-COMPOSITION-004.1 + - AC-UI-SETTINGS-COMPOSITION-004.2 + - AC-UI-SETTINGS-COMPOSITION-004.3 + - AC-UI-SETTINGS-COMPOSITION-004.4 + - AC-UI-SETTINGS-COMPOSITION-004.5 +system_design: + - ../../specs/ui/system-design/settings-composition.md +--- + +# Task 05: System, account, and plugin settings + +## Summary + +Complete the remaining first-party settings migration and reconcile the surface inventory. Keep system operations, account permissions, and plugin-owned content boundaries intact. + +## In scope + +- Migrate System, Account, users, organizations, units, plugin listing/detail, and host-owned plugin settings chrome. +- Preserve feature-toggle metadata, locks, restart actions, and existing runtime flag behavior. This is presentation only. +- Retain diagnostic and table layouts with shared surrounding chrome. +- Extend the rendered matrix with the system account tag. Record final family coverage and reconcile related typography-plan evidence. + +## Out of scope + +- Runtime flag registration or rollout, plugin SDK changes, and new system operations. + +## Acceptance + +- Every inventory row has migration or retained-layout evidence, including dynamic route forms. +- System and account actions retain their permissions, confirmation, and persistence behavior. +- The complete desktop/mobile matrix passes and the durable package records exact results without claiming historical unfinished tasks are done. + +## ASCII UI preview + +### UI-04 / UI-03: Shared group composition + +```text +Page title [existing actions/tabs] +One description. +Group heading [group action] ++-------------------------------------------------------+ +| Label / short description [control] | +| Related field or existing resource content | +| Visible error or scope notice when applicable | ++-------------------------------------------------------+ +[Reset] [Save changes] only while dirty +``` + +On phones, group actions and fields stack. Specialized bodies retain their existing mobile interaction. + +See the [full labelled previews](plan.md#ascii-ui-preview). Applicable criteria are listed in frontmatter. +Group structure and state visibility are required. ASCII spacing is illustrative. + +## Verification + +Start from the repository root. Install dependencies once before the first package command. +Add or update tests before changing covered behavior. Compare rendered results with the assigned preview. +New E2E files and family tags are defined in the plan and must exist before running these commands. + +```bash +(cd apps/web && pnpm exec vitest run components/settings src/settings-routes.test.ts) +(cd apps/web && pnpm e2e:run e2e/tests/settings/settings-composition.spec.ts e2e/tests/settings/settings-typography.spec.ts e2e/tests/settings/settings-manual-save.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/settings/mobile-settings-composition.spec.ts e2e/tests/settings/mobile-settings-typography.spec.ts) +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm run i18n:ratchet) +(cd apps/web && pnpm exec eslint components/settings app/settings lib/settings-discovery --max-warnings 0) +git diff --check +``` + +Also run every existing E2E file whose selectors or assertions this work order changes. +Record its exact command and outcome under Results. Do not weaken assertions or leave changed tests unrun. + +## Files likely touched + +- `apps/web/components/settings/system/` +- `apps/web/components/settings/account/` +- `apps/web/components/settings/units/` +- `apps/web/components/settings/plugins/` +- `apps/web/app/settings/plugins/` +- `apps/web/src/settings-routes.tsx (presentation wrappers only)` +- `apps/web/src/locales/` +- `docs/plans/settings-composition/ and related typography records` +- `apps/web/e2e/tests/settings/settings-composition.spec.ts` (new shared family matrix) +- `apps/web/e2e/tests/settings/mobile-settings-composition.spec.ts` (new shared family matrix) +- Existing component and E2E tests beside the migrated surfaces + +## Dependencies + +Complete 04-workspace-integration first. + +## Risks + +- Shared wrappers can lose dirty markers, accessible descriptions, discovery targets, or state identity. +- Long translations and coarse-pointer controls can expose clipping despite correct desktop appearance. +- Existing tests can depend on card nesting. Preserve behavioral checks when updating selectors. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/ui/requirements/settings-composition.md). +- [System design](../../specs/ui/system-design/settings-composition.md). +- [Surface inventory](surface-inventory.md) and the assigned route imports. +- `apps/web/AGENTS.md`, `/tdd`, `/mobile-parity`, and `/e2e`. +- Existing typography, header tabs, control sizing, and route-save contracts. + +## Results + +Implemented and verified. System data, logs, About, users, organizations, units, account tokens, plugin listings, and plugin detail surfaces now use shared group chrome around their existing diagnostics, tables, forms, and plugin-owned bodies. Permissions, locks, restart actions, confirmation flows, and discovery targets remain owned by the existing domain components. + +- `(cd apps/web && pnpm exec vitest run components/settings lib/settings-discovery/target.test.ts src/settings-routes.test.ts)`: passed, 207 files and 1,389 tests. +- `(cd apps/web && pnpm e2e:run e2e/tests/settings/settings-composition.spec.ts)`: passed, 6 desktop tests, including system/account coverage. +- `(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/settings/mobile-settings-composition.spec.ts)`: passed, 5 mobile tests, including system/account coverage. +- `python3 scripts/list-docs.py validate`: passed (294 decisions, 1,067 specifications). +- `python3 scripts/lint-spec-files.py --all`: passed. +- Typecheck, localization checks, ratchet, scoped ESLint, scoped Prettier, and `git diff --check`: passed. + +Fixup remediation preserves plugin-owned integration settings as frameless +content through the explicit `SettingsSection` frame opt-out. The route test +verifies that the plugin surface is rendered without a native group card. diff --git a/docs/plans/settings-composition/task-06-concise-help-and-tabs.md b/docs/plans/settings-composition/task-06-concise-help-and-tabs.md new file mode 100644 index 0000000000..586afa291d --- /dev/null +++ b/docs/plans/settings-composition/task-06-concise-help-and-tabs.md @@ -0,0 +1,243 @@ +--- +id: "06-concise-help-and-tabs" +title: "Concise Task behavior help and header tabs" +status: complete +wave: 6 +depends_on: + - 05-system-account +plan: "plan.md" +requirements: + - REQ-UI-SETTINGS-COMPOSITION-002 + - REQ-UI-SETTINGS-COMPOSITION-003 + - REQ-UI-SETTINGS-COMPOSITION-004 + - REQ-UI-SETTINGS-COMPOSITION-005 +acceptance_criteria: + - AC-UI-SETTINGS-COMPOSITION-002.1 + - AC-UI-SETTINGS-COMPOSITION-002.2 + - AC-UI-SETTINGS-COMPOSITION-002.3 + - AC-UI-SETTINGS-COMPOSITION-002.4 + - AC-UI-SETTINGS-COMPOSITION-002.5 + - AC-UI-SETTINGS-COMPOSITION-002.6 + - AC-UI-SETTINGS-COMPOSITION-003.1 + - AC-UI-SETTINGS-COMPOSITION-003.2 + - AC-UI-SETTINGS-COMPOSITION-003.3 + - AC-UI-SETTINGS-COMPOSITION-003.4 + - AC-UI-SETTINGS-COMPOSITION-003.5 + - AC-UI-SETTINGS-COMPOSITION-003.6 + - AC-UI-SETTINGS-COMPOSITION-004.1 + - AC-UI-SETTINGS-COMPOSITION-004.2 + - AC-UI-SETTINGS-COMPOSITION-004.3 + - AC-UI-SETTINGS-COMPOSITION-004.4 + - AC-UI-SETTINGS-COMPOSITION-004.5 + - AC-UI-SETTINGS-COMPOSITION-005.1 + - AC-UI-SETTINGS-COMPOSITION-005.2 + - AC-UI-SETTINGS-COMPOSITION-005.3 + - AC-UI-SETTINGS-COMPOSITION-005.4 + - AC-UI-SETTINGS-COMPOSITION-005.5 + - AC-UI-SETTINGS-COMPOSITION-005.6 + - AC-UI-SETTINGS-COMPOSITION-005.7 +system_design: + - ../../specs/ui/system-design/settings-composition.md +--- + +# Task 06: Concise Task behavior help and header tabs + +## Summary + +Replace the long Task behavior page with Tasks, Conversation, and Runtime header tabs. +Keep all sections expanded and use one short description plus an info button for each setting. +This work order supersedes the collapsed-runtime direction from the original delivery. + +## In scope + +- Reuse the Data & Logs tab composition and preserve settings links and cross-tab drafts. +- Add shared desktop-hover/keyboard and touch-drawer information presentation. +- Apply the copy inventory to every Task behavior setting, including compound runtime fields and profile choices. +- Remove repeated introductions, the collapsed runtime summary, and redundant inline technical paragraphs. +- Retain essential inline restrictions, current errors, and managed-value notices. +- Update all six real locales, pseudo generation, affected tests, and documentation. + +## Out of scope + +- New pages, sidebar entries, or backend settings behavior. +- Changing switch polarity, profile fallback logic, limits, or permissions. +- A copy rewrite of unrelated settings pages. The shared info component remains reusable there. +- Third-party plugin content or a new global tooltip system. + +## Acceptance + +- Tabs preserve values, Save/Reset, error recovery, and existing search fragments. Runtime needs no expansion action. +- Every Task behavior setting has one concise visible description and keyboard/touch-accessible optional information. +- Desktop and phone checks prove the new composition and existing persistence behavior without weakening prior regression assertions. + +## Visible-copy inventory + +These are starting English strings, not new backend semantics. Keep existing stable keys when their meaning remains valid. +Descriptions wrap naturally. Do not shrink fonts, truncate text, or enforce a one-line height. +Retain unique details from current strings in info content. Remove repeated statements rather than duplicating them in multiple popups. + +| Setting | Short description | Info content | +| --- | --- | --- | +| Open new tasks automatically | Open a task after you create it. | Staying on the current view does not prevent task or agent start. | +| Agent-generated titles | Let the agent name new tasks from their prompt. | Required prompt, provisional name, rename fallback, manual edits, and save timing. | +| Profile for agent-created tasks | Choose the default profile for tasks created by agents. | Explicit/workflow precedence, effective creating-session values, parent fallback, scope, and cost implications. | +| Prevent auto-start on open | Keep agents stopped when reopening tasks after a restart or from the final workflow step. | Start agent remains available. Preserve the current switch meaning. | +| Archive confirmation | Review cleanup options before archiving a task. | Existing cleanup, subtask, and confirmation behavior. | +| Unread divider | Mark messages received while a task was out of view. | Read tracking continues when the visual divider is off. | +| Anchored prompt | Keep your last prompt visible while scrolling on desktop. | Trigger conditions and desktop-only scope. | +| Jump to last prompt | Show a button to return to your latest prompt. | Existing visibility threshold and navigation behavior. | +| Jump to start | Show a button to return to the start of the conversation. | Existing scroll threshold and destination. | +| Auto-scroll control | Show a button to pause or resume transcript auto-scroll. | Per-session behavior and what this preference changes. | +| Todo checklist | Show the agent's todo checklist in the task panel. | Desktop panel placement and manual panel additions. | +| Only show nonempty checklist | Hide the pinned checklist when it has no items. | Dependency on the checklist toggle and retained saved value. | +| Enable automatic-session limit | Queue automatic starts when the session limit is reached. | Instance scope and interaction with workflow WIP limits. | +| Maximum automatic sessions | Manual starts can exceed this limit. | Positive-integer constraint, effective source, and automatic-start behavior. | +| Maximum queued messages | Limit pending messages per session. Use 0 for unlimited. | Admission timing, existing queue preservation, retries, and override precedence. | +| Allow queued message merging | Combine related queued messages before the agent reads them. | Sender, adjacency, workflow/system exclusions, and entity-reference limit. | +| Automatic message merging | Merge compatible consecutive messages automatically. | Session override, compatibility checks, and new-admission-only behavior. | +| Keep host awake | Prevent the computer running Kandev from sleeping during tasks. | OS mechanism, release timing, remote host scope, and platform limitations. | + +Profile option descriptions become “Reuse the creating session's profile” and “Use the workspace's default profile”. +Keep the full precedence rules in the setting's info content, including cases where workflow profiles win. +Avoid changing technical wording by inference. Compare retained information with the existing profile and runtime source contracts. + +## ASCII UI preview + +### UI-05 / UI-06 / UI-07: Concise settings + +```text +Task behavior [Tasks] [Conversation] [Runtime] +Group heading ++--------------------------------------------------+ +| Setting label (i) [control]| +| One short description. | ++--------------------------------------------------+ +Runtime: all limit, queue, and power sections expanded. +Phone: tabs below title; tap (i) opens a details drawer. +``` + +See the [full previews](plan.md#ascii-ui-preview). +Tab labels, expanded groups, optional information, and preserved Save are required. +The applicable criteria are listed in frontmatter. + +## Technical notes + +Use `SettingsPageHeader`, `SettingsTabs`, `SettingsTabsList`, `SettingsTabsPanel`, and `useSettingsTab`. +Keep visited panels mounted. Use the existing target-to-tab mapping pattern from Data & Logs. +Keep both queue/session state owners single-mounted and preserve their contributor IDs. +Aggregate panel dirty/error status without creating duplicate save contributors. + +Add `SettingsInfo` beside the label in `SettingsRow`, outside the associated label element. +Reuse `Tooltip`, `Drawer`, and `useTouchDrawer`. Use the sleep info control as a reference. +Keep control helpers associated through `aria-describedby`. Info has its own localized accessible name. +Use short noninteractive technical content on desktop and the same content in a scrollable phone drawer. + +Remove runtime disclosure props and obsolete runtime-only attention/summary helpers after checking all callers. +Keep general disclosure discovery support used by other settings surfaces. +Update scoped self-documenting-settings guidance to distinguish short inline effects from optional implementation details. +Current permission, validation, and managed-value notices remain inline. + +## Verification + +Start from the repository root. Dependencies already exist in this worktree. +Write focused tests before behavior changes. The two concise-help E2E files below are new outputs of this work order. + +```bash +(cd apps/web && pnpm exec vitest run components/settings/settings-info.test.tsx components/settings/settings-group.test.tsx components/settings/settings-tabs.test.tsx components/settings/task-behavior-settings.test.tsx components/settings/task-behavior-tabs.test.ts components/settings/settings-save-provider.test.tsx components/settings/mcp-task-agent-profile-default-settings.test.tsx components/settings/sleep-inhibition-settings.test.tsx hooks/domains/settings/use-settings-tab.test.ts lib/settings-discovery/target.test.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm run i18n:ratchet) +(cd apps/web && pnpm exec eslint components/settings hooks/domains/settings/use-settings-tab.ts lib/settings-discovery --max-warnings 0) +(cd apps/web && pnpm e2e:run e2e/tests/settings/task-behavior-concise-help.spec.ts e2e/tests/settings/settings-composition.spec.ts e2e/tests/settings/settings-manual-save.spec.ts e2e/tests/system/message-queue-settings.spec.ts e2e/tests/system/session-capacity-settings.spec.ts e2e/tests/settings/todo-list-panel.spec.ts e2e/tests/task/mcp-task-agent-profile-default.spec.ts e2e/tests/task/unread-divider-preference.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/settings/mobile-task-behavior-concise-help.spec.ts e2e/tests/settings/mobile-settings-composition.spec.ts e2e/tests/system/mobile-message-queue-settings.spec.ts e2e/tests/system/mobile-session-capacity-settings.spec.ts e2e/tests/settings/mobile-general-settings.spec.ts e2e/tests/task/mobile-mcp-task-agent-profile-default.spec.ts e2e/tests/task/mobile-unread-divider-preference.spec.ts) +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +Also run every existing test whose selector or navigation path changes and record its exact command. +Use `rg` to inventory tests opening Task behavior before editing them, including task, workflow, and kanban suites. +Replace the old `openTaskBehaviorRuntime` helper's expansion action with selection of the visible Runtime tab. +Conversation-specific tests must select Conversation. Preserve their value and persistence assertions. +The managed E2E runner builds fresh artifacts. Do not overlap suites or bypass its resource limits. + +Required scenarios: + +- Tasks default, explicit Runtime query, unknown-query fallback, and every existing target-to-tab mapping. +- Repeated discovery into a previously visited hidden panel, with actual focus after activation. +- Edit across tabs, Save/Reset all contributors, preserve drafts on tab change, and reveal a failed inactive-tab save once. +- Inline load/permission/managed-value errors, without popup-only recovery instructions. +- Runtime controls visible immediately after tab selection. No collapsed settings sections. +- Hover and keyboard focus reveal desktop help. Touch opens the drawer; dismissal returns focus and preserves the draft. +- Real 44px hit targets, 767px/768px geometry, coarse-pointer tablet, long translations, and no horizontal document overflow. +- Concise visible text. MCP identifiers and fallback algorithms appear only in optional info. +- Current manual-save, capacity, queue, title, profile, and conversation assertions remain intact. + +## Files likely touched + +- `apps/web/components/settings/task-behavior-settings.tsx` and its tests +- `apps/web/components/settings/settings-group.tsx` and its tests +- `apps/web/components/settings/settings-info.tsx` and `.test.tsx` (new) +- Existing creation, title, profile, opening, archive, unread, transcript, todo, and sleep settings components +- `apps/web/components/settings/system/message-queue-settings.tsx` and `session-capacity-settings.tsx` +- `apps/web/lib/settings-discovery/catalog/preferences.ts` +- `apps/web/e2e/helpers/settings-composition.ts` and all affected route consumers +- New concise-help E2E files and existing composition/runtime tests +- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw,ja,pseudo}/` +- `apps/web/AGENTS.md`, affected Task behavior sections under `docs/public/`, and this package + +## Dependencies + +Tasks 01 through 05 are complete. Reuse their implementation and review fixes. + +## Risks + +- Hidden visited panels can consume discovery focus too early. +- Tab changes can lose drafts if domain owners unmount. +- Shortening copy can erase scope or precedence facts. Preserve them in info and retain active restrictions inline. +- Hover-only help can exclude phones or keyboard users. +- Tests can continue asserting old disclosure behavior unless all route consumers are audited. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/ui/requirements/settings-composition.md) +- [System design](../../specs/ui/system-design/settings-composition.md) +- [Header tabs contract](../../specs/ui/system-design/settings-header-tabs.md) +- Existing Data & Logs composition and sleep-prevention tooltip/drawer +- `/tdd`, `/mobile-parity`, `/e2e`, and `apps/web/AGENTS.md` + +## Results + +Implemented on 2026-09-22. Tasks, Conversation, and Runtime reuse the header tabs and retain visited drafts. +Every Task behavior control has concise copy and optional desktop/touch information. +Runtime sections stay expanded. The existing save coordinator exposes contributor status for tab badges and one-time error recovery. +Discovery skips optional info buttons and focuses the setting after tab activation. + +Validation: +- Focused Vitest suite: 10 files, 63 tests passed, including save-coordinator state, Reset recovery, and all nine discovery mappings. +- Desktop regression suite: 37 passed, including composition, manual save, cross-tab Save/Reset/failure, queue/capacity, todo, MCP profile, and unread preference. +- Mobile regression suite: 22 passed, including 390/767/768/900px touch geometry, long pseudo translations, drawer focus return, runtime permissions, profile, and unread preference. +- Typecheck, scoped ESLint, six-locale/pseudo checks, documentation catalog/spec lint, and harness checks passed. +- Final targeted browser reruns: 14 desktop and 7 mobile tests passed against fresh production artifacts, including the final profile markup and capacity description association. + +RED tests reproduced missing info interaction, tab ownership, contributor status, stale failure after Reset, and discovery focusing the info button. +Browser regressions also caught outdated selectors and tooltip-copy assertions, which now use the intended tab/help flow without removing persistence or permission checks. +The Traditional Chinese generator ran with `--namespace settings`; its unrestricted run found a pre-existing residual Simplified Chinese string in `workflows.openAgentSettings` and refused to write that unrelated namespace. + + +Final browser reruns (from `apps/web`): + +```bash +pnpm e2e:run e2e/tests/settings/task-behavior-concise-help.spec.ts e2e/tests/system/message-queue-settings.spec.ts e2e/tests/system/session-capacity-settings.spec.ts e2e/tests/task/mcp-task-agent-profile-default.spec.ts +pnpm e2e:run --no-build --project mobile-chrome e2e/tests/settings/mobile-task-behavior-concise-help.spec.ts e2e/tests/system/mobile-session-capacity-settings.spec.ts e2e/tests/task/mobile-mcp-task-agent-profile-default.spec.ts +``` + +The second command reused the immediately preceding fresh build. No source changed between these runs. +The final focused Vitest rerun passed all 63 tests after the last production edit. +Public docs are reference/how-to navigation updates; the obsolete all-in-one Task Behavior screenshot was removed from the operations guide. +Harness validation passed (19 harness tests, 36 specification-linter tests, full harness lint, and the targeted `harness-lint` hook). +The frontend guide shrank from 300 to 297 lines. diff --git a/docs/plans/settings-typography/plan.md b/docs/plans/settings-typography/plan.md index dea110edef..9a59b17e9a 100644 --- a/docs/plans/settings-typography/plan.md +++ b/docs/plans/settings-typography/plan.md @@ -174,3 +174,10 @@ remaining audit callsites are still in progress and are explicitly tracked in the audit README rather than treated as complete. The full web test suite was not completed because the existing plugin-host happy-dom stylesheet tests emitted external-stylesheet warnings and did not finish in the available run. + +## Related composition work + +The [settings composition package](../settings-composition/plan.md) owns the later grouping, spacing, and Task behavior reorganization. +It reuses this package's shipped typography components and preserves their roles. +Its work orders record new caller migrations and rendered evidence separately. +This link does not change the existing task statuses or verification results. diff --git a/docs/public/automation-and-mcp.md b/docs/public/automation-and-mcp.md index 2b8f014bac..411d5a5473 100644 --- a/docs/public/automation-and-mcp.md +++ b/docs/public/automation-and-mcp.md @@ -653,7 +653,7 @@ A task session currently registers these tool groups: | Relationships and workspace sources | List related tasks, add a mixed repository/folder source batch to an idle task, use the legacy one-branch tool, and change a repository's diff base. | | Workflow signal | Signal step completion when an auto-advance step explicitly requires that signal. | -When **Settings → General → Task Actions → Agent-generated task titles** is enabled (the default; an +When **Settings → Preferences → Task Behavior → Tasks → Agent-generated task titles** is enabled (the default; an explicitly saved **off** value remains off), a task-mode session for a newly created task or subtask can expose `set_task_title_kandev`. The first eligible session to launch atomically claims the handoff and is prompted to call it before any other work, even though the task already has a provisional title. Use a diff --git a/docs/public/configuration.md b/docs/public/configuration.md index 58623c7d2e..04b355418c 100644 --- a/docs/public/configuration.md +++ b/docs/public/configuration.md @@ -581,7 +581,7 @@ The source checkout's `make dev` activates the embedded development profile, whi ## Credentials and product settings -The **Unread Messages** preference in **Settings > Preferences > Task Behavior** controls the Slack-style **New** divider in session transcripts. It defaults off for each user, persists with user settings, and takes effect immediately. Enabling it also allows that user's active transcript view to advance the session read cursor. +The **Unread Messages** preference in **Settings > Preferences > Task Behavior > Conversation** controls the Slack-style **New** divider in session transcripts. It defaults off for each user, persists with user settings, and takes effect immediately. Enabling it also allows that user's active transcript view to advance the session read cursor. Most integrations, executor profiles, agent profiles, MCP servers, repository settings, and UI preferences are persistent database records edited under **Settings**. They are not fields in `config.yaml`. Secret values use an encrypted secret store backed by `/data/master.key`; filesystem permissions, database backups, and key backup are part of the security boundary. @@ -642,7 +642,7 @@ overrides. | `office.gateFailureThreshold` | `KANDEV_OFFICE_GATE_FAILURE_THRESHOLD` | `3` | Consecutive launch-safety gate failures before the durable alert state is raised. Invalid or non-positive values use the default. | | `observability.otlpEndpoint` | `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Enables OTLP/HTTP tracing for backend and agentctl spans; unset uses a no-op tracer. | -Changing concurrency values trades process pressure against throughput and requires a restart. Under **Settings > Task Behavior > Message Queue**, an admin can save an install-wide capacity and independently control manual and automatic merging. The merge switches apply live and persist across restarts. `messageQueue.maxPerSession` resolves as environment, YAML, saved setting, then default; a non-negative YAML value or any valid environment value locks only capacity. A negative environment value means unlimited. See [Operations](operations.md#message-queue-settings) for capacity and automatic-fold behavior. +Changing concurrency values trades process pressure against throughput and requires a restart. Under **Settings > Preferences > Task Behavior > Runtime**, an admin can save an install-wide capacity and independently control manual and automatic merging. The merge switches apply live and persist across restarts. `messageQueue.maxPerSession` resolves as environment, YAML, saved setting, then default; a non-negative YAML value or any valid environment value locks only capacity. A negative environment value means unlimited. See [Operations](operations.md#message-queue-settings) for capacity and automatic-fold behavior. The current OTLP exporter strips an `http://` or `https://` prefix from the configured endpoint and always uses `WithInsecure()`. Treat this as implementation-bound cleartext transport: send it only to a trusted private collector over a protected network, not directly across an untrusted network. The service name is `kandev-agentctl`, and spans can include task/session/execution IDs plus raw agent-event JSON truncated to 8192 characters. That payload can contain prompts, files, and tool data. Use collector-side access controls and retention accordingly. diff --git a/docs/public/coordination.md b/docs/public/coordination.md index 9a68c1b865..1513a20151 100644 --- a/docs/public/coordination.md +++ b/docs/public/coordination.md @@ -141,7 +141,7 @@ For predictable top-level creation, pass `repository_url`, `repository_id`, or ` | Created but not started | Starts the session with the message. | | Failed or cancelled | Returns an error. | -The default delivery mode is queued. Each session accepts 10 queued messages by default. An admin can change the install-wide limit under **Settings > Task Behavior > Message Queue**; `0` means unlimited. The saved value applies immediately to new admissions without removing messages already waiting. `KANDEV_QUEUE_MAX_PER_SESSION` has higher precedence, locks only the capacity field, and requires a restart when changed; zero or a negative value means unlimited. With **Auto-run** ON, one queued message runs per agent turn. At or above the cap, an eligible direct automatic fold into the pending tail may still succeed without creating another row. Other admissions return `queue_full`; staged attachments are rejected before any fold or attachment claim. +The default delivery mode is queued. Each session accepts 10 queued messages by default. An admin can change the install-wide limit under **Settings > Preferences > Task Behavior > Runtime**; `0` means unlimited. The saved value applies immediately to new admissions without removing messages already waiting. `KANDEV_QUEUE_MAX_PER_SESSION` has higher precedence, locks only the capacity field, and requires a restart when changed; zero or a negative value means unlimited. With **Auto-run** ON, one queued message runs per agent turn. At or above the cap, an eligible direct automatic fold into the pending tail may still succeed without creating another row. Other admissions return `queue_full`; staged attachments are rejected before any fold or attachment claim. **Automatically merge consecutive messages** is enabled by default on the same settings card. Each session follows later changes to this install-wide value until its **Auto-merge** queue pill is changed. That first session change creates an explicit override that remains independent for the lifetime of that session, including across an empty queue and backend restart. For each new admission, Kandev resolves the effective session or global setting once. A compatible message may fold into the immediately preceding pending entry when both have the same strict source and compatible task, model, mode, metadata, attachments, and references. Otherwise it remains a separate FIFO entry. The earlier entry survives, so a successful admission may return an older queue-entry ID. The setting affects only later admissions and never sweeps rows already waiting. It is independent from **Enable queued message merging**, which controls the manual **Merge with above** action. diff --git a/docs/public/developer-tools.md b/docs/public/developer-tools.md index ba76d7c0bd..ca071f5da2 100644 --- a/docs/public/developer-tools.md +++ b/docs/public/developer-tools.md @@ -64,7 +64,7 @@ Quick Chat supports multiple tabs, tab renaming, and **+** to open another ordin Your chats and their names are shared by every browser and device signed in to the same Kandev instance. Starting, renaming, or closing a chat on one device updates the others, and a device that was offline catches up when it reconnects. -When **Settings > Preferences > Task Behavior > Agent-generated task titles** is enabled, an ordinary +When **Settings > Preferences > Task Behavior > Tasks > Agent-generated task titles** is enabled, an ordinary Quick Chat starts with its normal provisional label and its owner agent can replace that label with a short title based on your first request. Structured and CLI-passthrough chats receive the title instruction through their existing first-turn path. The new title appears on every connected device diff --git a/docs/public/git-operations.md b/docs/public/git-operations.md index 6d089b2d8a..9ba613d9f7 100644 --- a/docs/public/git-operations.md +++ b/docs/public/git-operations.md @@ -213,7 +213,7 @@ Policies are not available in **Quick Chat**, **Remote**, **Add Sources**, or ** When a task opens an existing branch or GitHub PR, Kandev fetches that branch; for a numbered GitHub PR it can fetch `refs/pull/NUMBER/head`, including fork PRs. At materialization, Kandev uses the PR's current GitHub base when available. Polling also keeps the task's stored comparison base aligned after GitHub retargets a stacked PR. If the intended branch is already checked out in another worktree, the new worktree uses a suffixed local branch and tracks the original `origin` branch when available. The required-refresh rule still applies before that new worktree is created. Tasks created without an initial title can expose the one-shot `set_task_title_kandev` handoff when -**Settings → General → Task Actions → Agent-generated task titles** is enabled. After the owning +**Settings → Preferences → Task Behavior → Tasks → Agent-generated task titles** is enabled. After the owning session accepts its final title, Kandev regenerates Kandev-managed branch names from that title and updates the stored branch snapshots. It never renames a repository row with an explicit checkout branch (for example, a GitHub PR branch) or a Local/Local PC checkout. A branch manually selected diff --git a/docs/public/operations.md b/docs/public/operations.md index 0d4650d16c..bb375d0924 100644 --- a/docs/public/operations.md +++ b/docs/public/operations.md @@ -29,7 +29,7 @@ See [Desktop app](desktop-app.md), [CLI](cli.md), [Run as a service](run-as-a-se ## Prevent host sleep during active tasks -Administrators can open **Settings > Preferences > Task Behavior** and enable +Administrators can open **Settings > Preferences > Task Behavior > Runtime** and enable **Prevent host sleep while tasks run**. The install-wide setting is off by default and is saved with the Kandev database. It applies only to the machine running the backend; it does not change executor or node power policy. @@ -56,7 +56,9 @@ availability policy instead. Enabling the preference is safe when moving the database between hosts: Kandev reacquires a native request only after startup if the new backend can provide it and a working task still exists. -![Settings > Preferences > Task Behavior showing task title, archive, unread message, and host sleep controls.](../screenshots/settings-task-behavior.png) +Task Behavior uses **Tasks**, **Conversation**, and **Runtime** tabs on one page. +Each setting has a short description. Use its info button for technical details. +On touch devices, the info button opens a drawer. **Save changes** saves drafts across all three tabs. ## Health and readiness @@ -151,7 +153,7 @@ Add `--system` to both commands for a system service. ## Message queue settings -Open **Settings > Task Behavior > Message Queue** to manage install-wide queue behavior. The default capacity is `10`; `0` means unlimited. Admin saves apply immediately to later admissions. Lowering the limit does not prune rows already waiting. At or above the new limit, only an eligible direct automatic fold into the existing tail can still succeed; other work is rejected until messages run or are removed. Staged attachments are rejected before a fold or claim. Delivery retries for work accepted before the change are not discarded by the lower cap. +Open **Settings > Preferences > Task Behavior > Runtime** to manage install-wide queue behavior. The default capacity is `10`; `0` means unlimited. Admin saves apply immediately to later admissions. Lowering the limit does not prune rows already waiting. At or above the new limit, only an eligible direct automatic fold into the existing tail can still succeed; other work is rejected until messages run or are removed. Staged attachments are rejected before a fold or claim. Delivery retries for work accepted before the change are not discarded by the lower cap. `KANDEV_QUEUE_MAX_PER_SESSION` has higher precedence than the saved capacity. A valid environment value makes only that field read-only; zero or a negative value means unlimited. Invalid text is logged and ignored in favor of the saved setting or default. Environment changes require a backend restart, while UI changes do not. @@ -766,7 +768,7 @@ Kandev warns when its live WebSocket connection has not recovered for three seco - **Office session identity**: experimental, high risk, and on in every profile by default. The live `(task_id, agent_profile_id)` pair is guarded in-transaction on the Office session creation path, not by a table-level index; pre-existing duplicate rows are retained and resolved by selection. Two Kandev processes must not write the same SQLite file. It gives each Office participant a separate task conversation and requires a restart. Disabling the toggle restores the pre-graduation runner-seat binding and task-active-session decision re-evaluation. - **App status bar**: stable, low risk, and off in the production profile by default. Enabling it adds the desktop/tablet bar and phone Status entry after restart; disabling it again does not stop connections, metrics collection requested by other clients, or plugins. Urgent WebSocket connectivity warnings still remain visible while the feature is off. - **Claude background prompt handoff**: experimental, high risk, and off in every profile by default. Enabling it lets Claude Code accept another prompt after its foreground yields while recognized async subagent, `run_in_background` shell, or Monitor work remains active. ACP lifecycle gaps can misclassify activity or overlap prompts; use it only for controlled testing. -- **Unread divider**: a per-user setting at **Settings > General > Task Actions**. It defaults off, takes effect immediately, and controls both the Slack-style **New** divider and read-cursor updates while that user's transcript view is visible. +- **Unread divider**: a per-user setting at **Settings > Preferences > Task Behavior > Conversation**. It defaults off, takes effect immediately, and controls both the Slack-style **New** divider and read-cursor updates while that user's transcript view is visible. - **Debug mode**: high risk; enables diagnostic endpoints and agent-message logging that can contain sensitive content. Each feature toggle requires restart. A value supplied explicitly by its environment variable locks the UI control; the debug toggle is also locked by explicit legacy/debug-message environment variables. Otherwise the UI stores an override in the database. The page can request restart only when the native local supervisor is available. A normal Unix `kandev` terminal launch is supervised; Desktop, a service, a container, a directly started backend, a deploy preview, or Windows requires a manual application restart. diff --git a/docs/public/sessions-and-review.md b/docs/public/sessions-and-review.md index 7cd9f58b7a..f419bf15d6 100644 --- a/docs/public/sessions-and-review.md +++ b/docs/public/sessions-and-review.md @@ -189,7 +189,7 @@ Delivery follows the target state: Without an explicit session ID the message goes to the primary session, and falls back to the newest session that can still take a message when the primary is cancelled or failed. A session named explicitly is never redirected. When every session is terminal the call fails and names `spawn_session_kandev`. -The default pending-message limit is 10 per session. An admin can change it live under **Settings > Task Behavior > Message Queue**; `0` removes the cap. A valid `KANDEV_QUEUE_MAX_PER_SESSION` value takes precedence and makes only the capacity field read-only; changing the environment still requires a restart. Malformed environment values are logged and ignored, so the saved setting or default applies instead. Lowering the saved limit does not delete entries already waiting. An eligible direct automatic fold may still succeed at or above capacity because it does not add a row; other admissions are rejected, and staged attachments are rejected before any fold or claim. +The default pending-message limit is 10 per session. An admin can change it live under **Settings > Preferences > Task Behavior > Runtime**; `0` removes the cap. A valid `KANDEV_QUEUE_MAX_PER_SESSION` value takes precedence and makes only the capacity field read-only; changing the environment still requires a restart. Malformed environment values are logged and ignored, so the saved setting or default applies instead. Lowering the saved limit does not delete entries already waiting. An eligible direct automatic fold may still succeed at or above capacity because it does not add a row; other admissions are rejected, and staged attachments are rejected before any fold or claim. The same card enables **Automatically merge consecutive messages** by default. Untouched sessions inherit this value and later changes to it. Changing a session's **Auto-merge** pill creates an explicit override for that session's lifetime. Compatible consecutive messages from the same strict source fold into the earlier pending entry; incompatible messages remain separate when capacity permits. The earlier entry's ID survives, and only admissions after the effective setting is read are eligible. This behavior is independent from the manual **Enable queued message merging** switch. Interrupt delivery is restricted to a direct parent task messaging its child. Other senders always queue, and only user-origin rows may be edited or manually merged. @@ -452,7 +452,7 @@ Before moving a task to done: - **New Agent has no profiles:** create a profile compatible with the task executor. A profile for another executor is intentionally hidden. - **Summary or generated text fails:** configure the corresponding utility agent with an enabled ACP profile in **Settings > Utility Agents**. Repair any stale or disabled profile binding before retrying. - **Resume fails:** start fresh when the executor no longer has resumable session state, then supply a summary or copy the relevant context. -- **A peer message never arrives:** check the target session state and ID. Running sessions queue messages; failed or cancelled sessions reject them. Expand the queue chip and check Auto-run: turn it ON for normal FIFO processing, use a row's Send Now for targeted priority, or remove stale work. For a full queue, remove or clear pending rows before retrying; an admin can also review the install-wide limit under **Settings > Task Behavior > Message Queue**. +- **A peer message never arrives:** check the target session state and ID. Running sessions queue messages; failed or cancelled sessions reject them. Expand the queue chip and check Auto-run: turn it ON for normal FIFO processing, use a row's Send Now for targeted priority, or remove stale work. For a full queue, remove or clear pending rows before retrying; an admin can also review the install-wide limit under **Settings > Preferences > Task Behavior > Runtime**. - **Changes is empty:** select the correct repository and comparison, then confirm the agent wrote inside the materialized task path. - **Review marks became stale:** the underlying diff changed. Re-review the new hash before marking the file complete. - **Walkthrough does not appear:** confirm an active task-MCP session exists and that the saved `changes-walkthrough` prompt was not removed or made invalid. diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index 26694bb3e2..f9fe429d17 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -23,7 +23,7 @@ The task carries the outcome through the workflow. The repository and session pr ## Keep your view when creating tasks -In **Settings > Task Behavior**, turn off **Auto-focus new tasks** +In **Settings > Preferences > Task Behavior**, turn off **Open new tasks automatically** and select **Save changes** to create tasks without leaving your current view. The setting is on by default and is saved with your user preferences across reloads. It works on desktop and mobile. @@ -133,7 +133,7 @@ Use **New Task** in the sidebar. In an open task, the **Task** split button also 1. When the title field is shown, enter a concise title of up to 60 characters. Titles prefilled from a remote pull request, issue, or merge request are shortened with an ellipsis when needed; the - detailed context belongs in the description. If **Settings → General → Task Actions → Agent-generated + detailed context belongs in the description. If **Settings → Preferences → Task Behavior → Tasks → Agent-generated task titles** is enabled, the New Task dialog hides this field, requires a nonempty prompt, and uses the prompt's first six words as a provisional title while the first eligible agent session chooses the final title. The empty-description Plan Mode exception applies only when this setting is disabled. @@ -227,7 +227,7 @@ If another person or tool initializes the remote before the first publication, K ### Let the agent name new tasks -Open **Settings → General → Task Actions → Agent-generated task titles** and choose **Save changes**. +Open **Settings → Preferences → Task Behavior → Tasks → Agent-generated task titles** and choose **Save changes**. The setting is enabled by default; an explicitly saved **off** value remains off. When enabled, new task and subtask dialogs use the prompt as the source of the title: the prompt must contain text, and Kandev immediately displays its first six normalized words as a provisional title. The first eligible task-mode @@ -243,7 +243,7 @@ tool. Config and Office sessions never receive the title tool. ### Choose the profile for tasks created by agents -Open **Settings → General → Task Actions → Profile for Tasks Created by Agents** to choose which agent profile Kandev assigns when an agent calls `create_task_kandev` without choosing `agent_profile_id`. The preference covers new tasks and subtasks, and it also controls the effective model, mode, and dynamic options used by the first session: +Open **Settings → Preferences → Task Behavior → Tasks → Profile for Tasks Created by Agents** to choose which agent profile Kandev assigns when an agent calls `create_task_kandev` without choosing `agent_profile_id`. The preference covers new tasks and subtasks, and it also controls the effective model, mode, and dynamic options used by the first session: - **Creating session profile** is useful when follow-up work needs the same live setup. For a session-bound task-mode call, Kandev uses the verified creating session's profile and its effective model, mode, and dynamic options, including changes made during that session. A workflow launch profile wins first. When no workflow profile wins, the creating session profile is used. This option can reuse a more expensive setup. - **Workspace default profile** is useful when you want agent-created tasks to follow a consistent workspace cost policy. It skips the creating session and source, parent, or current task profiles. Kandev uses the workflow launch profile first, then the **Default Agent Profile** from the workspace that will own the new task. It does not copy the creating session's model, mode, or dynamic options. If neither source supplies a profile, task creation fails. @@ -282,8 +282,8 @@ arrow points the direction the transcript will actually scroll: upward once you've scrolled further down past your prompt, or downward if you've scrolled back up above it while browsing earlier history. **Scroll to start of transcript** appears when the first prompt is no longer fully visible. You can -show or hide each action independently in **Settings → General → Task -Actions → Transcript Navigation**. +show or hide each action independently in **Settings → Preferences → Task +Behavior → Conversation and panels**. For a compact reminder while you read later replies, enable **Show anchored prompt bar** in the same settings section. On desktop, it pins a shortened @@ -444,7 +444,7 @@ foreground-generating Claude turns retain the coarse queueing behavior. ### Prevent auto-start on open -Under **Settings → General → Task actions**, the **Prevent auto-start on open** +Under **Settings → Preferences → Task Behavior**, the **Prevent auto-start on open** preference is off by default. When enabled, opening a task never launches or resumes its agent on its own; it shows the **Start agent** button instead. The preference applies in two situations: @@ -722,7 +722,7 @@ global session capacity blocks automatic launch. This is a separate queue: WIP queueing waits before destination entry, while global capacity queueing keeps the selected session and retries it automatically. The task detail and task navigator identify this cause as **Global session limit**, state that it -applies to all workspaces, and link to Settings > Task Behavior > Session +applies to all workspaces, and link to Settings > Preferences > Task Behavior > Runtime capacity. They show the destination, the latest capacity observation, queue time, and retry state. They do not show a queue position or estimated start time. Capacity counts older than 40 seconds, or counts unavailable because the @@ -732,7 +732,7 @@ explicit **Start** or **Resume** action, or send a message, to override the automatic ceiling for that conversation. The instance session capacity is disabled by default. An administrator can -enable it in Settings > Task Behavior > Session capacity, enter a positive +enable it in Settings > Preferences > Task Behavior > Runtime, enter a positive maximum, and save the change. The saved value applies to later automatic starts without a restart and persists across restarts. Disabling it retains the maximum for later use. Manual starts can exceed the limit. Workflow WIP @@ -959,7 +959,7 @@ Archive records the task as archived and removes it from active views immediatel | Sprites | Attempts to destroy the sandbox; if cleanup succeeds, uncommitted sandbox work is lost. | | SSH | Attempts to stop the remote session runtime, but the remote task directory remains. Audit and remove retained task directories manually after confirming that no session needs them. | -The archive confirmation is enabled by default at **Settings → General → Task Actions → Archive Confirmation** under **Confirm before archiving tasks**. If a parent has children, **Also archive _N_ subtasks** is unchecked by default; without it, the children remain active. Task MCP archive/delete operations affect only the selected task and do not offer the cascade checkbox. MCP delete also does not reparent direct children the way the UI's non-cascade delete does; use the UI rather than task MCP to delete a parent that still has children. +The archive confirmation is enabled by default at **Settings → Preferences → Task Behavior → Tasks → Archiving** under **Confirm before archiving tasks**. If a parent has children, **Also archive _N_ subtasks** is unchecked by default; without it, the children remain active. Task MCP archive/delete operations affect only the selected task and do not offer the cascade checkbox. MCP delete also does not reparent direct children the way the UI's non-cascade delete does; use the UI rather than task MCP to delete a parent that still has children. To restore a task, open **List**, enable **Show archived**, and choose **Unarchive**. You can also choose **Unarchive** in the open task view on desktop or a phone. If unarchive fails, the task stays archived and recovery stays disabled. If the parent was archived with its children, the cascade-owned children are restored with it. diff --git a/docs/specs/ui/requirements/settings-composition.md b/docs/specs/ui/requirements/settings-composition.md new file mode 100644 index 0000000000..d29503f44a --- /dev/null +++ b/docs/specs/ui/requirements/settings-composition.md @@ -0,0 +1,105 @@ +--- +status: active +system: ui +created: 2026-09-21 +owners: + - Kandev frontend +--- + +# Settings Composition Requirements + +## Overview + +Users need consistent settings groups, descriptions, and actions without more settings pages. +UI owns this reusable presentation contract. Domain systems retain behavior, permissions, and persistence. +The accepted direction keeps Task behavior on one page and extends consistent composition across existing first-party settings. +The 2026-09-22 refinement removes collapsed sections and reduces visible text. +Tasks, Conversation, and Runtime header tabs are the proposed same-page organization. + +This contract extends [settings typography](settings-typography.md). +It preserves [manual saving](settings-manual-save.md), [discovery](settings-discovery.md), and [header tabs](settings-header-tabs.md). + +## Terminology + +- **Group:** Related settings with one heading and one outer container. +- **Row:** One setting with its label, explanation, control, and optional status. +- **Info:** Optional technical explanation available from a labelled info button. + +## Requirements + +### REQ-UI-SETTINGS-COMPOSITION-001: Consistent settings groups + +**Intent:** Equivalent content has the same hierarchy across settings pages. + +#### Acceptance criteria + +- **AC-UI-SETTINGS-COMPOSITION-001.1:** First-party settings pages shall use consistent page headings, group headings, card padding, row spacing, descriptions, and action placement. +- **AC-UI-SETTINGS-COMPOSITION-001.2:** Related simple preferences shall share one group container. A row shall not repeat its label as a separate card title. +- **AC-UI-SETTINGS-COMPOSITION-001.3:** Forms shall group related fields. Resource lists shall use consistent headings and actions while retaining their list, table, or editor interaction. +- **AC-UI-SETTINGS-COMPOSITION-001.4:** The change shall preserve existing page destinations, sidebar entries, and settings-search targets. Task behavior may add the header tabs defined below. +- **AC-UI-SETTINGS-COMPOSITION-001.5:** Equivalent content shall use the existing typography and control-size roles. Technical editors, diagnostics, and tables shall retain documented specialized layouts. + +### REQ-UI-SETTINGS-COMPOSITION-002: Task behavior organization + +**Intent:** Users can find task preferences by the activity they affect. + +#### Acceptance criteria + +- **AC-UI-SETTINGS-COMPOSITION-002.1:** Task behavior shall provide Tasks, Conversation, and Runtime tabs. Tasks shall contain Creating and opening tasks, followed by Archiving. +- **AC-UI-SETTINGS-COMPOSITION-002.2:** Creating and opening tasks shall contain automatic opening, generated titles, the profile for agent-created tasks, and automatic agent start on opening. +- **AC-UI-SETTINGS-COMPOSITION-002.3:** The Conversation tab shall contain the unread divider, transcript navigation, and todo checklist. Archiving shall contain archive confirmation. +- **AC-UI-SETTINGS-COMPOSITION-002.4:** Runtime shall contain session capacity, message queue limits and merging, and sleep prevention. All settings sections shall remain expanded within their tab. +- **AC-UI-SETTINGS-COMPOSITION-002.5:** Runtime shall show effective limits beside their controls when these differ from drafts or saved values. Loading and unavailable values shall remain explicit. +- **AC-UI-SETTINGS-COMPOSITION-002.6:** Runtime settings shall identify their instance scope. Sleep prevention shall identify the host computer, including when the user connects remotely. + +### REQ-UI-SETTINGS-COMPOSITION-003: Concise descriptions and optional information + +**Intent:** Short explanations preserve information needed to choose safely. + +#### Acceptance criteria + +- **AC-UI-SETTINGS-COMPOSITION-003.1:** Each setting shall show one short, plain-language sentence explaining its effect. Active errors, permission restrictions, and override reasons shall remain visible beside its control. +- **AC-UI-SETTINGS-COMPOSITION-003.2:** Technical explanations shall use a labelled info button beside the setting label. Desktop hover or keyboard focus shall reveal details. Touch shall open a drawer. +- **AC-UI-SETTINGS-COMPOSITION-003.3:** Tab changes and info interactions shall preserve drafts, validation, save contributors, and unsaved-change protection. Neither action shall persist settings. +- **AC-UI-SETTINGS-COMPOSITION-003.4:** A search result or direct fragment shall select its owning tab before focus and highlight. Repeated requests shall work after switching tabs. +- **AC-UI-SETTINGS-COMPOSITION-003.5:** Dirty tabs shall expose an accessible unsaved indicator. Invalid or failed saves in inactive tabs shall provide a visible route to affected controls. +- **AC-UI-SETTINGS-COMPOSITION-003.6:** Effective values shall remain distinct from unsaved changes. Save and Reset shall retain their existing route-level behavior across tabs. + +### REQ-UI-SETTINGS-COMPOSITION-004: Accessible responsive composition + +**Intent:** Users can operate the same settings on desktop and phone. + +#### Acceptance criteria + +- **AC-UI-SETTINGS-COMPOSITION-004.1:** Phones shall use the existing settings navigation and one vertical page scroll region. Header tabs shall occupy their own row, as on Data & Logs. Settings groups shall not collapse. +- **AC-UI-SETTINGS-COMPOSITION-004.2:** Phone fields and group actions shall stack when needed. Labels, descriptions, controls, and save actions shall remain inside the viewport. +- **AC-UI-SETTINGS-COMPOSITION-004.3:** Info buttons shall support keyboard and touch activation. Inactive tabs shall not receive focus. Labels and short descriptions shall remain associated with controls. +- **AC-UI-SETTINGS-COMPOSITION-004.4:** Long translations shall wrap without document horizontal scrolling. Shared phone and coarse-pointer actions shall retain targets of at least 44px. +- **AC-UI-SETTINGS-COMPOSITION-004.5:** Layout changes shall preserve settings values, permissions, runtime defaults, and confirmation behavior across reloads and viewport changes. + +### REQ-UI-SETTINGS-COMPOSITION-005: Task behavior navigation and readable copy + +**Intent:** Users can scan settings without reading implementation details. + +#### Acceptance criteria + +- **AC-UI-SETTINGS-COMPOSITION-005.1:** Ordinary entry shall select Tasks. A valid tab query shall select that tab. Unknown values shall select Tasks. +- **AC-UI-SETTINGS-COMPOSITION-005.2:** The header shall always expose the Runtime tab. Selecting it shall show runtime controls without an expansion action. +- **AC-UI-SETTINGS-COMPOSITION-005.3:** English row descriptions shall use at most 20 words. Technical identifiers and fallback algorithms shall appear in optional info content. +- **AC-UI-SETTINGS-COMPOSITION-005.4:** Profile choices shall have short labels and at most one short sentence each. Precedence and session-copy rules shall remain available through info. +- **AC-UI-SETTINGS-COMPOSITION-005.5:** Section introductions that repeat row descriptions shall be omitted. Runtime shall identify instance scope once and identify the host in sleep help. +- **AC-UI-SETTINGS-COMPOSITION-005.6:** Info dismissal shall preserve the draft and return touch focus to the opener. Details shall not change the setting or navigate away. +- **AC-UI-SETTINGS-COMPOSITION-005.7:** Existing fragments shall select the correct tab even when the query names another tab. Browser navigation shall retain the existing guard. + +## Out of scope + +- New settings pages, sidebar categories, or a global settings search redesign. Tabs are limited to Task behavior in this refinement. +- Backend schemas, policy, permissions, defaults, or save transaction changes. +- A global UI theme or changes to unrelated application cards. +- Restyling third-party plugin-owned content or changing the plugin SDK. +- Redesigning specialized workflow canvases, editors, terminals, or diagnostic tables. +- Inverting the automatic-start switch. This package keeps its existing semantics and translated label. + +## Implementation plan + +See [Settings composition](../../../plans/settings-composition/plan.md). The latest refinement is [Task 06](../../../plans/settings-composition/task-06-concise-help-and-tabs.md). diff --git a/docs/specs/ui/system-design/settings-composition.md b/docs/specs/ui/system-design/settings-composition.md new file mode 100644 index 0000000000..70d4e26dc9 --- /dev/null +++ b/docs/specs/ui/system-design/settings-composition.md @@ -0,0 +1,194 @@ +--- +status: current +system: ui +requirements: + - REQ-UI-SETTINGS-COMPOSITION-001 + - REQ-UI-SETTINGS-COMPOSITION-002 + - REQ-UI-SETTINGS-COMPOSITION-003 + - REQ-UI-SETTINGS-COMPOSITION-004 + - REQ-UI-SETTINGS-COMPOSITION-005 +--- + +# Settings Composition System Design + +## Purpose and boundaries + +This design owns reusable settings composition, including the Task behavior arrangement. +Existing domain components retain API calls, drafts, permissions, validation, and immediate commands. +No settings route, backend contract, or plugin API changes. + +The current implementation has shared groups and rows, a collapsed Runtime group, and lengthy inline descriptions. +The 2026-09-22 revision replaces that presentation with visible header tabs and optional technical help. +The original implementation remains the baseline for field state, permissions, and persistence. + +## Requirement mapping + +| Requirement | Design section | +| --- | --- | +| REQ-UI-SETTINGS-COMPOSITION-001 | Shared presentation and rollout | +| REQ-UI-SETTINGS-COMPOSITION-002 | Task behavior composition | +| REQ-UI-SETTINGS-COMPOSITION-003 | Concise help and state lifetime | +| REQ-UI-SETTINGS-COMPOSITION-004 | Mobile and accessibility | +| REQ-UI-SETTINGS-COMPOSITION-005 | Header tabs and discovery | + +## Shared presentation and rollout + +Reuse `SettingsPageHeader`, `SettingsSection`, `SettingsCardHeader`, `SettingsField`, and the settings sizing helpers. +Reuse `SettingsGroup` and `SettingsRow` from `components/settings/settings-group.tsx`. +Do not create a schema-driven settings renderer or a second settings store. + +A group has one semantic heading, optional description/action, and one outer `SettingsCard` by default. +The `SettingsSection` adapter has an explicit frameless mode for plugin-owned +content, which remains outside the native settings frame. +Simple rows have no separate card frame or repeated title. +Use `SettingsSaveDirtyScope` for aggregate group markers while preserving each field marker. +Keep one discovery registration per existing target ID, attached to its actual row or subgroup. + +Use three compositions: + +| Composition | Shape | Use | +| --- | --- | --- | +| Preference group | Heading, bordered group, divided rows | Toggles and short selectors | +| Form group | Heading, bordered group, vertically related fields | Profiles, credentials, workspace forms | +| Resource group | Heading and actions, existing list or table | Repositories, agents, users, plugins | + +Use a settings-local spacing contract: 24px between groups, 16px group padding, and 12px row vertical padding. +Use 8px label/control spacing in form fields and 4px label/helper spacing in compact rows. +These values use existing spacing tokens and root-font scaling. +Keep the current typography roles rather than introducing another font scale. +Ordinary desktop controls remain 28px. Touch controls retain at least 44px active targets. +Content rows grow with text and do not have a fixed height. +Use theme border and surface tokens without extra shadows or custom colors. + +Use a single page heading followed by ordered group headings. +Allow `SettingsCardHeader` to use a heading level appropriate to its parent rather than hardcoding nested h3 headings. +Retain existing header tabs and action placement from the header-tabs contract. +Do not change global `@kandev/ui` Card styles or use broad descendant CSS to restyle unrelated components. + +The [surface inventory](../../../plans/settings-composition/surface-inventory.md) assigns every first-party route family to a work order. +Within each family, inspect route imports and child forms, including dialogs reached from those pages. +Only shared typography, spacing, descriptions, and action composition change in dialogs. +Their lifecycle and primary submission controls remain intact. + +## Task behavior composition + +Keep `/settings/preferences/task-behavior`, its breadcrumb, and all legacy redirects. +Add the existing `SettingsPageHeader` with `SettingsTabsList` in its tabs slot, matching Data & Logs. +Use three tabs with stable IDs: `tasks`, `conversation`, and `runtime`. +The default is `tasks`. Do not add pages or sidebar entries. + +| Tab | Existing controls in display order | +| --- | --- | +| Tasks | `CreationAutoFocusSettings`, `AgentGeneratedTaskTitleSettings`, `MCPTaskAgentProfileDefaultSettings`, `PreventAutoStartAgentSettings`, then `ArchiveConfirmationSettings` | +| Conversation | `UnreadDividerSettings`, `AnchoredPromptBarSettings`, `TodoListPanelSettings` | +| Runtime | `SessionCapacitySettingsContent`, `MessageQueueSettingsContent`, `SleepInhibitionSettings` | + +Every section in the active tab is expanded. Remove the Runtime disclosure, its chevron, and its collapsed summary. +Remove the redundant introduction below each Task behavior group heading. +Keep short subgroup labels where they distinguish limits, merging, and power controls. +Keep profile options as two compact radio choices with one-sentence descriptions. +The profile precedence algorithm moves to the profile setting's info content. + +The [work order](../../../plans/settings-composition/task-06-concise-help-and-tabs.md) contains the complete visible-copy inventory. +Each setting has a short description and an info control with its applicable detail. +Do not invent technical explanations merely to fill a popup. +Existing long descriptions are source material, not text to copy wholesale into every popup. +Preserve unique technical facts once, under the setting they explain. + +## Concise help and state lifetime + +Add a settings-local `SettingsInfo` component, using the sleep-prevention info control as the shipped exemplar. +Use existing `Tooltip` for noninteractive desktop details and `Drawer` for touch, selected through `useTouchDrawer`. +Provide a named button beside the label, such as “About agent-generated titles”. +Do not nest the info button inside a toggle label or radio label. +Hover and keyboard focus reveal desktop details. Escape dismisses them. +Touch opens a titled drawer with one scroll region, safe-area clearance, and focus return. +Keep the same translated content in both presentations. Opening info never mounts another setting owner. + +Add an info slot to `SettingsRow`. Keep short help connected to the control through the existing generated description ID. +Use plain text or structured short paragraphs in info, including the existing technical identifiers where needed. +Do not place links, inputs, or other interactive controls inside Tooltip content. +Runtime field components use the same info component beside their labels. +Replace the existing profile-specific inline MCP explanation and sleep-specific trigger with this shared pattern. + +Keep active load errors, invalid input, permission restrictions, and managed-value notices inline. +Represent instance scope once at the Runtime tab introduction: “Applies to all workspaces.” +Keep “Manual starts can exceed this limit” beside the automatic-session field. +Keep “0 means unlimited” beside the queue field. +Show effective values and source badges only where they explain a difference or an override. +Move merge compatibility algorithms, title fallback rules, OS commands, and session-copy precedence into info. + +Retain the existing stateful domain components and one contributor per resource. +The page can retain queue/session hooks above tab presentation, avoiding duplicate fetches. +Pass their aggregate dirty state to the Runtime group as today. +Remove Runtime-specific reveal keys, attention callbacks, and summary formatting only when they lose all callers. +Do not remove general target-disclosure support still used elsewhere. + +## Header tabs and discovery + +Reuse `SettingsTabs`, `SettingsTabsPanel`, and `useSettingsTab` with their existing URL and mounted-panel contracts. +Panels remain mounted after first visit. Never conditionally unmount a visited settings owner on tab selection. +The route-level save provider owns all dirty contributors across panels, including Reset and partial failures. +Do not add tab-specific Save buttons or bypass cross-route guards. + +Map existing `GENERAL_SETTINGS_TARGETS` to tabs: + +- `agentTaskProfile`, `agentGeneratedTitles`, `archiveConfirmation`, `creationAutoFocus`, `preventAutoStartOnOpen`: `tasks`. +- `unreadMessages`, `transcriptNavigation`: `conversation`. +- `messageQueue`, `sessionCapacity`: `runtime`. + +Preserve target IDs and aliases. Reuse the existing tab hook for initial fragments, query selection, history, and explicit discovery events. +A recognized fragment takes precedence over an unrelated tab query. +For a previously visited hidden panel, complete selection before calling target focus/highlight. +Use one post-activation reveal request through the existing registry if the existing tab composition does not already guarantee this ordering. +Do not create another registry or retry timer. + +Use `UnsavedChangesBadge` in tab labels for dirty panels, with unchanged base tab names for accessible matching. +Report dirty/error state from existing domain owners without another save contributor or persisted UI state. +A new invalid draft or save failure in an inactive tab selects that tab once so its field is visible. +Resolve simultaneous failures by tab order: Tasks, Conversation, Runtime. Do not switch repeatedly on rerender. +A load error in an inactive tab shows an accessible error marker and opens when its tab is selected, without stealing active editing focus. + +## Mobile and accessibility + +The shipped Settings index, page shell, and Data & Logs header tabs define navigation and geometry. +Desktop tabs align beside the page title. Phone tabs form a row below the title. +Short English labels fit the existing tab strip. Translated overflow stays within that strip, not the document. +Runtime remains a labelled navigation choice, not an unlabeled expansion affordance. + +All sections stay visible in the active tab. Info is a temporary explanation, so phone details use a drawer. +Keep settings in one page scroll region. The drawer owns scrolling only while it is open. +Keep existing safe-area-aware Save controls and content clearance. +Touch tab and info targets measure at least 44px. Desktop controls retain their shared 28px size. +Selectors stack below descriptions on phones. Switch labels wrap without clipping the control or its active target. +Inactive panels cannot receive focus. Info triggers have visible focus and localized accessible names. + +## Localization and compatibility + +Render translations through `t()` or `Trans`, including tab labels, badges, info labels, and short descriptions. +Update English, Portuguese, Simplified Chinese, and Japanese. Generate Traditional Chinese through `pnpm run i18n:zh-hant`. +Use plural keys with counts. Descriptions may span multiple lines and never use ellipsis. +Preserve existing translation keys when their meaning remains unchanged. +Preserve technical values, existing test IDs where practical, and search aliases for old labels. + +The [typography package](../../../plans/settings-typography/plan.md) remains a related implementation record. +Its existing unfinished statuses are not evidence of completion or a reason to rebuild shipped primitives. +Record this package as the owner of grouping and spacing. Reconcile overlapping verification evidence without replacing historical results. +The requirement and design pair preserves the local composition rationale, so this change does not require a separate ADR. + +## Verification and documentation + +Use component tests for cross-tab drafts, error routing, accessible help, and target revelation. +Use desktop and mobile E2E for tabs, expanded runtime, info interaction, saving, and repeated search targets. +Extend the existing settings typography route matrix for each migrated family. +Keep public documentation unchanged during design. During implementation, update affected Task behavior descriptions and section names. +Add the shared composition rule to `apps/web/AGENTS.md` when its implementation ships. + +## Related contracts + +- [Settings typography](../requirements/settings-typography.md) +- [Settings discovery](../requirements/settings-discovery.md) +- [Settings header tabs](settings-header-tabs.md) +- [Control sizing](control-sizing.md) +- [Route save coordinator](../../../decisions/0046-settings-route-save-coordinator.md) +- [Implementation plan](../../../plans/settings-composition/plan.md)