feat: organize settings with tabs and concise help (#3850)

This commit is contained in:
Carlos Florêncio
2026-09-22 21:24:43 +01:00
committed by GitHub
parent 8a6a3c1e61
commit 2e600341f4
124 changed files with 5496 additions and 1639 deletions
+2 -5
View File
@@ -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
+50 -50
View File
@@ -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 (
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<h3 className="text-lg font-semibold">{t("agents:installedAgents")}</h3>
<p className="text-sm text-muted-foreground">{t("agents:installedAgentsDescription")}</p>
</div>
<div className="flex w-full flex-wrap gap-2 md:w-auto" data-testid="installed-agents-actions">
<Button
variant="outline"
onClick={onOpenShell}
className={installedAgentsActionClassName}
data-testid="open-host-shell"
>
<IconTerminal2 className="h-4 w-4 mr-2" />
{t("agents:terminal")}
</Button>
<Button
variant="outline"
onClick={onRescan}
disabled={rescanning}
className={installedAgentsActionClassName}
data-testid="rescan-agents-button"
>
{rescanning ? (
<IconLoader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<IconRefresh className="h-4 w-4 mr-2" />
)}
{t("agents:rescan")}
</Button>
{onOpenTuiDialog && (
<Button
variant="outline"
onClick={onOpenTuiDialog}
className={installedAgentsActionClassName}
data-testid="new-agent-button"
>
<IconPlus className="h-4 w-4 mr-2" />
{t("agents:addTuiAgent")}
</Button>
<div className="flex w-full flex-wrap gap-2 md:w-auto" data-testid="installed-agents-actions">
<Button
variant="outline"
onClick={onOpenShell}
className={installedAgentsActionClassName}
data-testid="open-host-shell"
>
<IconTerminal2 className="h-4 w-4 mr-2" />
{t("agents:terminal")}
</Button>
<Button
variant="outline"
onClick={onRescan}
disabled={rescanning}
className={installedAgentsActionClassName}
data-testid="rescan-agents-button"
>
{rescanning ? (
<IconLoader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<IconRefresh className="h-4 w-4 mr-2" />
)}
</div>
{t("agents:rescan")}
</Button>
{onOpenTuiDialog && (
<Button
variant="outline"
onClick={onOpenTuiDialog}
className={installedAgentsActionClassName}
data-testid="new-agent-button"
>
<IconPlus className="h-4 w-4 mr-2" />
{t("agents:addTuiAgent")}
</Button>
)}
</div>
);
}
@@ -210,13 +205,20 @@ function InstalledAgentsSection({
const dynamicAgent = savedAgentsByName.get(DYNAMIC_AGENT_NAME);
return (
<div className="space-y-4">
<InstalledAgentsHeader
rescanning={rescanning}
onOpenShell={() => setShellOpen(true)}
onOpenTuiDialog={canManage ? () => setTuiDialogOpen(true) : undefined}
onRescan={() => void handleRescan()}
/>
<SettingsGroup
title={t("agents:installedAgents")}
description={t("agents:installedAgentsDescription")}
action={
<InstalledAgentsHeader
rescanning={rescanning}
onOpenShell={() => setShellOpen(true)}
onOpenTuiDialog={canManage ? () => setTuiDialogOpen(true) : undefined}
onRescan={() => void handleRescan()}
/>
}
contentClassName="space-y-4 divide-y-0"
>
<HideDisabledAgentProfilesSetting />
<HostShellDialog
open={shellOpen}
onOpenChange={setShellOpen}
@@ -274,7 +276,7 @@ function InstalledAgentsSection({
</InstalledAgentCard>
))}
</div>
</div>
</SettingsGroup>
);
}
@@ -421,8 +423,6 @@ export default function AgentsSettingsPage() {
<Separator />
<HideDisabledAgentProfilesSetting />
<InstalledAgentsSection
canManage={canManage}
installedAgents={installedAgents}
+10 -7
View File
@@ -22,6 +22,7 @@ import { EXECUTOR_ICON_MAP, getExecutorLabel } from "@/lib/executor-icons";
import type { Executor, ExecutorProfile } from "@/lib/types/http";
import { KubernetesReadOnlyNotice } from "@/components/settings/kubernetes-read-only-notice";
import { settingsActionClassName } from "@/components/settings/settings-control";
import { SettingsGroup } from "@/components/settings/settings-group";
import { executorProfileSettingsPath } from "@/lib/settings/executor-settings-routes";
type ProfileWithExecutor = ExecutorProfile & {
@@ -115,6 +116,7 @@ function ProfileCard({
return (
<Card
className="group cursor-pointer transition-colors hover:bg-muted/50"
data-testid={`executor-profile-card-${profile.id}`}
onClick={() => router.push(executorProfileSettingsPath(profile.id))}
>
<CardContent className="flex items-center gap-3 p-4">
@@ -272,9 +274,8 @@ export default function ExecutorsHubPage() {
<Separator />
{!canManageKubernetes && <KubernetesReadOnlyNotice />}
{allProfiles.length > 0 && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t("executors:profiles")}</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<SettingsGroup title={t("executors:profiles")} contentClassName="space-y-4 divide-y-0">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{allProfiles.map((profile) => (
<ProfileCard
key={profile.id}
@@ -284,10 +285,12 @@ export default function ExecutorsHubPage() {
/>
))}
</div>
</div>
</SettingsGroup>
)}
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t("executors:createNewProfile")}</h3>
<SettingsGroup
title={t("executors:createNewProfile")}
contentClassName="space-y-4 divide-y-0"
>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{EXECUTOR_TYPES.map((execType) => (
<CreateTypeCard
@@ -298,7 +301,7 @@ export default function ExecutorsHubPage() {
/>
))}
</div>
</div>
</SettingsGroup>
<DeleteProfileDialog
profileName={profileToDelete?.name}
open={Boolean(deleteProfileId)}
@@ -31,7 +31,7 @@ import { useRequest } from "@/lib/http/use-request";
import { useToast } from "@/components/toast-provider";
import { useAppStore } from "@/components/state-provider";
import { useFeature } from "@/hooks/domains/features/use-feature";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsGroup } from "@/components/settings/settings-group";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { SettingsTarget } from "@/components/settings/settings-target";
import { workspaceDiscoveryTarget } from "@/lib/settings-discovery/dynamic-targets";
@@ -168,50 +168,49 @@ function WorkspaceSettingsCard({
name: p.label,
}));
return (
<SettingsCard isDirty={nameIsDirty || executorIsDirty || agentProfileIsDirty}>
<CardHeader>
<CardTitle>{t("workspaces:workspaceSettings")}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<SettingsTarget
targetId={workspaceDiscoveryTarget(workspaceId, "name")}
className="space-y-2"
>
<Label htmlFor="workspace-name">{t("workspaces:name")}</Label>
<Input
id="workspace-name"
value={workspaceNameDraft}
disabled={!canManage}
data-settings-dirty={nameIsDirty}
onChange={(e) => onNameChange(e.target.value)}
/>
</SettingsTarget>
<SelectField
label={t("workspaces:defaultExecutor")}
placeholder={t("workspaces:selectDefaultExecutor")}
value={defaultExecutorId}
isDirty={executorIsDirty}
onChange={onExecutorChange}
options={executorsEmpty ? [] : executorOptions}
emptyLabel={t("workspaces:noExecutorsAvailable")}
emptyValue=""
discoveryTargetId={workspaceDiscoveryTarget(workspaceId, "default-executor")}
<SettingsGroup
title={t("workspaces:workspaceSettings")}
isDirty={nameIsDirty || executorIsDirty || agentProfileIsDirty}
contentClassName="space-y-4 divide-y-0"
>
<div className="space-y-4">
<SettingsTarget
targetId={workspaceDiscoveryTarget(workspaceId, "name")}
className="space-y-2"
>
<Label htmlFor="workspace-name">{t("workspaces:name")}</Label>
<Input
id="workspace-name"
value={workspaceNameDraft}
disabled={!canManage}
data-settings-dirty={nameIsDirty}
onChange={(e) => onNameChange(e.target.value)}
/>
<SelectField
label={t("workspaces:defaultAgentProfile")}
placeholder={t("workspaces:selectDefaultAgentProfile")}
value={defaultAgentProfileId}
isDirty={agentProfileIsDirty}
onChange={onAgentProfileChange}
options={profileOptions}
emptyLabel={t("workspaces:noAgentProfilesAvailable")}
emptyValue="empty-agent-profiles"
discoveryTargetId={workspaceDiscoveryTarget(workspaceId, "default-agent-profile")}
/>
</div>
</CardContent>
</SettingsCard>
</SettingsTarget>
<SelectField
label={t("workspaces:defaultExecutor")}
placeholder={t("workspaces:selectDefaultExecutor")}
value={defaultExecutorId}
isDirty={executorIsDirty}
onChange={onExecutorChange}
options={executorsEmpty ? [] : executorOptions}
emptyLabel={t("workspaces:noExecutorsAvailable")}
emptyValue=""
discoveryTargetId={workspaceDiscoveryTarget(workspaceId, "default-executor")}
/>
<SelectField
label={t("workspaces:defaultAgentProfile")}
placeholder={t("workspaces:selectDefaultAgentProfile")}
value={defaultAgentProfileId}
isDirty={agentProfileIsDirty}
onChange={onAgentProfileChange}
options={profileOptions}
emptyLabel={t("workspaces:noAgentProfilesAvailable")}
emptyValue="empty-agent-profiles"
discoveryTargetId={workspaceDiscoveryTarget(workspaceId, "default-agent-profile")}
/>
</div>
</SettingsGroup>
);
}
@@ -530,6 +530,7 @@ export function WorkspaceRepositoriesClient({
are on. A second copy of all three read as the page repeating itself. */}
<SettingsSection
divided
framed={false}
icon={<IconGitBranch className="h-5 w-5" />}
title={t("workspaces:repositories")}
description={
@@ -416,6 +416,7 @@ export function WorkspaceWorkflowsClient({
already named, marked and described. */}
<SettingsSection
divided
framed={false}
icon={<IconArrowsShuffle className="h-5 w-5" />}
title={t("workflows:workflows")}
// The read-only note is the section's description here rather than a
@@ -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", () => ({
@@ -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 (
<Card data-testid="api-tokens-card">
<SettingsCardHeader
title={
<span className="flex items-center gap-2">
<IconKey className="h-4 w-4" /> {t("account:apiTokens")}
</span>
}
actions={
<Button
size="sm"
className={settingsActionClassName("cursor-pointer")}
onClick={() => setMintOpen(true)}
data-testid="api-tokens-create"
>
{t("account:newToken")}
</Button>
}
/>
<CardContent className="space-y-3">
<SettingsFieldDescription>{t("account:apiTokensBlurb")}</SettingsFieldDescription>
<SettingsGroup
title={
<span className="flex items-center gap-2">
<IconKey className="h-4 w-4" /> {t("account:apiTokens")}
</span>
}
description={t("account:apiTokensBlurb")}
action={
<Button
size="sm"
className={settingsActionClassName("cursor-pointer")}
onClick={() => setMintOpen(true)}
data-testid="api-tokens-create"
>
{t("account:newToken")}
</Button>
}
data-testid="api-tokens-card"
contentClassName="space-y-3 divide-y-0"
>
<div className="space-y-3">
{error && <SettingsErrorText data-testid="api-tokens-error">{error}</SettingsErrorText>}
{!loaded && !error && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
@@ -293,8 +289,8 @@ export function ApiTokens() {
{t("account:noTokensYet")}
</p>
)}
</CardContent>
</div>
<MintTokenDialog open={mintOpen} onOpenChange={setMintOpen} onCreated={() => void reload()} />
</Card>
</SettingsGroup>
);
}
@@ -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 = (
<SettingsRow
label={t("settings:useAgentForNewTaskTitles")}
description={t("settings:agentTitlesShort")}
info={
<SettingsInfo label={t("settings:useAgentForNewTaskTitles")}>
<p>{t("settings:agentGeneratedTaskTitlesDescription")}</p>
<p>{t("settings:agentGeneratedTaskTitlesDisabledHint")}</p>
</SettingsInfo>
}
controlId="agent-generated-task-titles"
touchTarget="switch"
discoveryTargetId={GENERAL_SETTINGS_TARGETS.agentGeneratedTitles}
isDirty={isDirty}
control={
<Switch
id="agent-generated-task-titles"
checked={draft}
data-settings-dirty={isDirty}
onCheckedChange={setDraft}
className="shrink-0 cursor-pointer"
/>
}
/>
);
if (presentation === "row") return row;
return (
<SettingsCard
isDirty={isDirty}
@@ -53,25 +86,7 @@ export function AgentGeneratedTaskTitleSettings() {
<CardTitle className="text-base">{t("settings:agentGeneratedTaskTitles")}</CardTitle>
<CardDescription>{t("settings:agentGeneratedTaskTitlesDescription")}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex min-h-11 items-center justify-between gap-4">
<div className="min-w-0 space-y-0.5">
<Label htmlFor="agent-generated-task-titles">
{t("settings:useAgentForNewTaskTitles")}
</Label>
<p className="text-xs text-muted-foreground">
{t("settings:agentGeneratedTaskTitlesDisabledHint")}
</p>
</div>
<Switch
id="agent-generated-task-titles"
checked={draft}
data-settings-dirty={isDirty}
onCheckedChange={setDraft}
className="shrink-0 cursor-pointer"
/>
</div>
</CardContent>
<CardContent>{row}</CardContent>
</SettingsCard>
);
}
@@ -82,9 +82,20 @@ function ProfileRowActions({
const { t } = useTranslation();
const { isMobile } = useResponsiveBreakpoint();
const pendingDelete = useRef(false);
const [open, setOpen] = useState(false);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
asChild
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
setOpen((current) => !current);
}}
>
<Button
ref={deleteAnchorRef}
variant="ghost"
@@ -57,7 +57,7 @@ describe("AnchoredPromptBarSettings", () => {
expect(
screen.getByRole("switch", { name: AUTO_SCROLL_CONTROL_LABEL }).getAttribute(DATA_STATE),
).toBe("unchecked");
screen.getByText(/desktop only/i);
screen.getByText(/Keep your last prompt visible while scrolling on desktop/i);
screen.getByText(/show scroll to last prompt/i);
});
@@ -1,4 +1,5 @@
"use client";
import { SettingsInfo } from "./settings-info";
import { useEffect, useRef, useState } from "react";
import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
@@ -7,6 +8,7 @@ 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";
@@ -54,37 +56,130 @@ type TranscriptNavigationSwitchProps = {
id: string;
label: string;
description: string;
info: string;
checked: boolean;
isDirty: boolean;
onCheckedChange: (checked: boolean) => void;
presentation?: SettingsPresentation;
discoveryTargetId?: string;
};
function TranscriptNavigationSwitch({
id,
label,
description,
info,
checked,
isDirty,
onCheckedChange,
presentation = "card",
discoveryTargetId,
}: TranscriptNavigationSwitchProps) {
const control = (
<Switch
id={id}
checked={checked}
data-settings-dirty={isDirty}
onCheckedChange={onCheckedChange}
className="shrink-0 cursor-pointer"
/>
);
if (presentation === "row") {
return (
<SettingsRow
label={label}
description={description}
info={<SettingsInfo label={label}>{info}</SettingsInfo>}
controlId={id}
touchTarget="switch"
discoveryTargetId={discoveryTargetId}
isDirty={isDirty}
control={control}
/>
);
}
return (
<div className="flex min-h-11 items-center justify-between gap-4">
<div className="min-w-0 space-y-0.5">
<Label htmlFor={id}>{label}</Label>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<Switch
id={id}
checked={checked}
data-settings-dirty={isDirty}
onCheckedChange={onCheckedChange}
className="shrink-0 cursor-pointer"
/>
{control}
</div>
);
}
export function AnchoredPromptBarSettings() {
function TranscriptNavigationSwitches({
draft,
isDirty,
presentation,
onChange,
}: {
draft: TranscriptNavigationSettings;
isDirty: boolean;
presentation: SettingsPresentation;
onChange: (update: Partial<TranscriptNavigationSettings>) => void;
}) {
const { t } = useTranslation();
const discoveryTargetId =
presentation === "row" ? GENERAL_SETTINGS_TARGETS.transcriptNavigation : undefined;
return (
<>
<TranscriptNavigationSwitch
id="show-anchored-prompt-bar"
label={t("settings:showAnchoredPromptBar")}
description={t("settings:anchoredPromptShort")}
info={t("settings:desktopOnlyWhileYouScrollPast")}
checked={draft.showAnchoredPromptBar}
isDirty={isDirty}
presentation={presentation}
discoveryTargetId={discoveryTargetId}
onCheckedChange={(showAnchoredPromptBar) => onChange({ showAnchoredPromptBar })}
/>
<TranscriptNavigationSwitch
id="show-scroll-to-last-prompt"
label={t("settings:showScrollToLastPrompt")}
description={t("settings:jumpPromptShort")}
info={t("settings:showTheJumpControlAfterYour")}
checked={draft.showScrollToLastPrompt}
isDirty={isDirty}
presentation={presentation}
onCheckedChange={(showScrollToLastPrompt) => onChange({ showScrollToLastPrompt })}
/>
<TranscriptNavigationSwitch
id="show-scroll-to-start"
label={t("settings:showScrollToStart")}
description={t("settings:jumpStartShort")}
info={t("settings:showTheControlThatJumpsTo")}
checked={draft.showScrollToStart}
isDirty={isDirty}
presentation={presentation}
onCheckedChange={(showScrollToStart) => onChange({ showScrollToStart })}
/>
<TranscriptNavigationSwitch
id="show-transcript-auto-scroll-control"
label={t("settings:showTranscriptAutoScrollControl")}
description={t("settings:autoScrollShort")}
info={t("settings:showThePerSessionButtonThat")}
checked={draft.showTranscriptAutoScrollControl}
isDirty={isDirty}
presentation={presentation}
onCheckedChange={(showTranscriptAutoScrollControl) =>
onChange({ showTranscriptAutoScrollControl })
}
/>
</>
);
}
export function AnchoredPromptBarSettings({
presentation = "card",
}: {
presentation?: SettingsPresentation;
}) {
const { t } = useTranslation();
const userSettings = useAppStore((state) => state.userSettings);
const setUserSettings = useAppStore((state) => state.setUserSettings);
@@ -127,6 +222,27 @@ export function AnchoredPromptBarSettings() {
discard: () => setDraft(saved),
});
const switches = (
<TranscriptNavigationSwitches
draft={draft}
isDirty={isDirty}
presentation={presentation}
onChange={(update) => setDraft((previous) => ({ ...previous, ...update }))}
/>
);
if (presentation === "row") {
return (
<div
data-testid="anchored-prompt-bar-card"
data-settings-dirty={isDirty}
className="divide-y divide-border/70"
>
{switches}
</div>
);
}
return (
<SettingsCard
isDirty={isDirty}
@@ -136,48 +252,7 @@ export function AnchoredPromptBarSettings() {
<CardHeader>
<CardTitle className="text-base">{t("settings:transcriptNavigation")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<TranscriptNavigationSwitch
id="show-anchored-prompt-bar"
label={t("settings:showAnchoredPromptBar")}
description={t("settings:desktopOnlyWhileYouScrollPast")}
checked={draft.showAnchoredPromptBar}
isDirty={isDirty}
onCheckedChange={(showAnchoredPromptBar) =>
setDraft((previous) => ({ ...previous, showAnchoredPromptBar }))
}
/>
<TranscriptNavigationSwitch
id="show-scroll-to-last-prompt"
label={t("settings:showScrollToLastPrompt")}
description={t("settings:showTheJumpControlAfterYour")}
checked={draft.showScrollToLastPrompt}
isDirty={isDirty}
onCheckedChange={(showScrollToLastPrompt) =>
setDraft((previous) => ({ ...previous, showScrollToLastPrompt }))
}
/>
<TranscriptNavigationSwitch
id="show-scroll-to-start"
label={t("settings:showScrollToStart")}
description={t("settings:showTheControlThatJumpsTo")}
checked={draft.showScrollToStart}
isDirty={isDirty}
onCheckedChange={(showScrollToStart) =>
setDraft((previous) => ({ ...previous, showScrollToStart }))
}
/>
<TranscriptNavigationSwitch
id="show-transcript-auto-scroll-control"
label={t("settings:showTranscriptAutoScrollControl")}
description={t("settings:showThePerSessionButtonThat")}
checked={draft.showTranscriptAutoScrollControl}
isDirty={isDirty}
onCheckedChange={(showTranscriptAutoScrollControl) =>
setDraft((previous) => ({ ...previous, showTranscriptAutoScrollControl }))
}
/>
</CardContent>
<CardContent className="space-y-4">{switches}</CardContent>
</SettingsCard>
);
}
@@ -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 AppStatusBarSettingsCard({
enabled,
@@ -18,34 +16,24 @@ export function AppStatusBarSettingsCard({
}) {
const { t } = useTranslation();
return (
<SettingsCard
<SettingsRow
isDirty={isDirty}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.appStatusBar}
data-testid="app-status-bar-settings-card"
>
<CardHeader>
<CardTitle className="text-base">{t("settings:statusBar")}</CardTitle>
</CardHeader>
<CardContent>
<div
className="flex min-h-11 items-center justify-between gap-4"
data-testid="app-status-bar-toggle-row"
>
<div className="space-y-1">
<Label htmlFor="show-app-status-bar">{t("settings:showStatusBar")}</Label>
<p className="max-w-3xl text-xs text-muted-foreground">
{t("settings:showStatusBarDescription")}
</p>
</div>
<Switch
id="show-app-status-bar"
checked={enabled}
onCheckedChange={onChange}
data-settings-dirty={isDirty}
className="data-checked:bg-transparent data-unchecked:bg-transparent dark:data-unchecked:bg-transparent data-[size=default]:h-11 data-[size=default]:w-11 cursor-pointer p-2 before:absolute before:left-2 before:top-1/2 before:h-[16.6px] before:w-7 before:-translate-y-1/2 before:rounded-full before:bg-input before:content-[''] data-checked:before:bg-primary dark:data-unchecked:before:bg-input/80 [&_[data-slot=switch-thumb]]:z-10"
/>
</div>
</CardContent>
</SettingsCard>
label={t("settings:showStatusBar")}
description={t("settings:showStatusBarDescription")}
controlId="show-app-status-bar"
touchTarget="switch"
controlWrapperTestId="app-status-bar-toggle-row"
control={
<Switch
id="show-app-status-bar"
checked={enabled}
onCheckedChange={onChange}
data-settings-dirty={isDirty}
className="data-checked:bg-transparent data-unchecked:bg-transparent dark:data-unchecked:bg-transparent data-[size=default]:h-11 data-[size=default]:w-11 cursor-pointer p-2 before:absolute before:left-2 before:top-1/2 before:h-[16.6px] before:w-7 before:-translate-y-1/2 before:rounded-full before:bg-input before:content-[''] data-checked:before:bg-primary dark:data-unchecked:before:bg-input/80 [&_[data-slot=switch-thumb]]:z-10"
/>
}
/>
);
}
@@ -2,15 +2,13 @@
import { useTranslation } from "react-i18next";
import { IconActivity, IconGitBranch, IconLayoutSidebar } from "@tabler/icons-react";
import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Label } from "@kandev/ui/label";
import { SettingsRow } from "./settings-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Separator } from "@kandev/ui/separator";
import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
import type { AppearanceState } from "./appearance-settings-state";
import { SidebarHoverSettingsCard } from "./sidebar-hover-settings-card";
import { AppStatusBarSettingsCard } from "./app-status-bar-settings-card";
import { SettingsCard } from "./settings-card";
import { SettingsSection } from "./settings-section";
import { SystemMetricsSettingsCard } from "./system-metrics-settings-card";
@@ -25,37 +23,32 @@ function ChangesPanelLayoutCard({
}) {
const { t } = useTranslation();
return (
<SettingsCard
<SettingsRow
isDirty={isDirty}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.changesPanelLayout}
data-testid="changes-panel-layout-card"
>
<CardHeader>
<CardTitle className="text-base">{t("settings:changesPanelLayout")}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="changes-panel-layout">{t("settings:fileListView")}</Label>
<Select value={value} onValueChange={(next) => onChange(next as "flat" | "tree")}>
<SelectTrigger
id="changes-panel-layout"
data-testid="changes-panel-layout-select"
data-settings-dirty={isDirty}
className="cursor-pointer"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="flat">{t("settings:flatList")}</SelectItem>
<SelectItem value="tree">{t("settings:tree")}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("settings:displayChangedFilesAsAFlat")}
</p>
</div>
</CardContent>
</SettingsCard>
label={t("settings:fileListView")}
description={t("settings:displayChangedFilesAsAFlat")}
descriptionId="changes-panel-layout-description"
controlId="changes-panel-layout"
control={
<Select value={value} onValueChange={(next) => onChange(next as "flat" | "tree")}>
<SelectTrigger
id="changes-panel-layout"
data-testid="changes-panel-layout-select"
data-settings-dirty={isDirty}
aria-describedby="changes-panel-layout-description"
className="cursor-pointer"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="flat">{t("settings:flatList")}</SelectItem>
<SelectItem value="tree">{t("settings:tree")}</SelectItem>
</SelectContent>
</Select>
}
/>
);
}
@@ -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 ArchiveConfirmationSettings() {
export function ArchiveConfirmationSettings({
presentation = "card",
}: {
presentation?: SettingsPresentation;
}) {
const { t } = useTranslation();
const confirmTaskArchive = useAppStore((state) => state.userSettings.confirmTaskArchive);
const setUserSettings = useAppStore((state) => state.setUserSettings);
@@ -42,6 +47,34 @@ export function ArchiveConfirmationSettings() {
discard: () => setDraft(saved),
});
const row = (
<SettingsRow
data-testid={presentation === "row" ? "archive-confirmation-card" : undefined}
label={t("settings:confirmBeforeArchivingTasks")}
description={t("settings:archiveShort")}
info={
<SettingsInfo label={t("settings:confirmBeforeArchivingTasks")}>
{t("settings:showCleanupDetailsAndSubtaskOptions")}
</SettingsInfo>
}
controlId="confirm-task-archive"
touchTarget="switch"
discoveryTargetId={GENERAL_SETTINGS_TARGETS.archiveConfirmation}
isDirty={isDirty}
control={
<Switch
id="confirm-task-archive"
checked={draft}
data-settings-dirty={isDirty}
onCheckedChange={setDraft}
className="shrink-0 cursor-pointer"
/>
}
/>
);
if (presentation === "row") return row;
return (
<SettingsCard
isDirty={isDirty}
@@ -51,25 +84,7 @@ export function ArchiveConfirmationSettings() {
<CardHeader>
<CardTitle className="text-base">{t("settings:archiveConfirmation")}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex min-h-11 items-center justify-between gap-4">
<div className="min-w-0 space-y-0.5">
<Label htmlFor="confirm-task-archive">
{t("settings:confirmBeforeArchivingTasks")}
</Label>
<p className="text-xs text-muted-foreground">
{t("settings:showCleanupDetailsAndSubtaskOptions")}
</p>
</div>
<Switch
id="confirm-task-archive"
checked={draft}
data-settings-dirty={isDirty}
onCheckedChange={setDraft}
className="shrink-0 cursor-pointer"
/>
</div>
</CardContent>
<CardContent>{row}</CardContent>
</SettingsCard>
);
}
@@ -1,11 +1,9 @@
"use client";
import { useTranslation } from "react-i18next";
import { CardContent } 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 ChatMotionSettingsCard({
enabled,
@@ -18,32 +16,24 @@ export function ChatMotionSettingsCard({
}) {
const { t } = useTranslation();
return (
<SettingsCard
<SettingsRow
isDirty={isDirty}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.chatMotion}
data-testid="chat-motion-settings-card"
>
<CardContent>
<div
className="flex items-center justify-between gap-4"
data-testid="chat-motion-toggle-row"
>
<div className="min-w-0 space-y-1">
<Label htmlFor="animate-chat">{t("settings:chatAnimations")}</Label>
<p id="chat-motion-description" className="max-w-3xl text-xs text-muted-foreground">
{t("settings:chatAnimationsDescription")}
</p>
</div>
<Switch
id="animate-chat"
checked={enabled}
onCheckedChange={onChange}
aria-describedby="chat-motion-description"
data-settings-dirty={isDirty}
className="data-[state=checked]:bg-transparent data-[state=unchecked]:bg-transparent dark:data-[state=unchecked]:bg-transparent data-[size=default]:h-7 data-[size=default]:w-9 [@media(pointer:coarse)]:data-[size=default]:h-11 [@media(pointer:coarse)]:data-[size=default]:w-11 cursor-pointer shrink-0 p-1 [@media(pointer:coarse)]:p-2 before:absolute before:left-1 [@media(pointer:coarse)]:before:left-2 before:top-1/2 before:h-[16.6px] before:w-7 before:-translate-y-1/2 before:rounded-full before:bg-input before:content-[''] data-[state=checked]:before:bg-primary dark:data-[state=unchecked]:before:bg-input/80 [&_[data-slot=switch-thumb]]:z-10"
/>
</div>
</CardContent>
</SettingsCard>
label={t("settings:chatAnimations")}
description={t("settings:chatAnimationsDescription")}
controlId="animate-chat"
touchTarget="switch"
controlWrapperTestId="chat-motion-toggle-row"
control={
<Switch
id="animate-chat"
checked={enabled}
onCheckedChange={onChange}
data-settings-dirty={isDirty}
className="data-[state=checked]:bg-transparent data-[state=unchecked]:bg-transparent dark:data-[state=unchecked]:bg-transparent data-[size=default]:h-7 data-[size=default]:w-9 [@media(pointer:coarse)]:data-[size=default]:h-11 [@media(pointer:coarse)]:data-[size=default]:w-11 cursor-pointer shrink-0 p-1 [@media(pointer:coarse)]:p-2 before:absolute before:left-1 [@media(pointer:coarse)]:before:left-2 before:top-1/2 before:h-[16.6px] before:w-7 before:-translate-y-1/2 before:rounded-full before:bg-input before:content-[''] data-[state=checked]:before:bg-primary dark:data-[state=unchecked]:before:bg-input/80 [&_[data-slot=switch-thumb]]:z-10"
/>
}
/>
);
}
@@ -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 CreationAutoFocusSettings() {
export function CreationAutoFocusSettings({
presentation = "card",
}: {
presentation?: SettingsPresentation;
}) {
const { t } = useTranslation();
const autoFocusNewTasks = useAppStore((state) => state.userSettings.autoFocusNewTasks);
const setUserSettings = useAppStore((state) => state.setUserSettings);
@@ -45,6 +50,38 @@ export function CreationAutoFocusSettings() {
discard: () => setDraft(saved),
});
const row = (
<SettingsRow
label={
presentation === "row"
? t("settings:openNewTasksAutomatically")
: t("settings:autoFocusNewTasks")
}
description={t("settings:autoFocusShort")}
info={
<SettingsInfo label={t("settings:openNewTasksAutomatically")}>
{t("settings:autoFocusNewTasksHelp")}
</SettingsInfo>
}
controlId="creation-auto-focus"
data-testid="creation-auto-focus-row"
touchTarget="switch"
discoveryTargetId={GENERAL_SETTINGS_TARGETS.creationAutoFocus}
isDirty={isDirty}
control={
<Switch
id="creation-auto-focus"
checked={draft}
data-settings-dirty={isDirty}
onCheckedChange={setDraft}
className="shrink-0 cursor-pointer [@media(pointer:coarse)]:after:-inset-y-3.5"
/>
}
/>
);
if (presentation === "row") return row;
return (
<SettingsCard
isDirty={isDirty}
@@ -54,21 +91,7 @@ export function CreationAutoFocusSettings() {
<CardHeader>
<CardTitle className="text-base">{t("settings:autoFocusNewTasks")}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex min-h-11 items-center justify-between gap-4">
<div className="min-w-0 space-y-0.5">
<Label htmlFor="creation-auto-focus">{t("settings:autoFocusNewTasks")}</Label>
<p className="text-xs text-muted-foreground">{t("settings:autoFocusNewTasksHelp")}</p>
</div>
<Switch
id="creation-auto-focus"
checked={draft}
data-settings-dirty={isDirty}
onCheckedChange={setDraft}
className="shrink-0 cursor-pointer [@media(pointer:coarse)]:after:-inset-y-3.5"
/>
</div>
</CardContent>
<CardContent>{row}</CardContent>
</SettingsCard>
);
}
@@ -7,7 +7,7 @@ import { Button } from "@kandev/ui/button";
import { Separator } from "@kandev/ui/separator";
import { Textarea } from "@kandev/ui/textarea";
import { SettingsPageTemplate } from "@/components/settings/settings-page-template";
import { SettingsTarget } from "@/components/settings/settings-target";
import { SettingsGroup } from "@/components/settings/settings-group";
import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
import { Combobox, type ComboboxOption } from "@/components/combobox";
import { EditableCard } from "@/components/settings/editable-card";
@@ -49,6 +49,7 @@ import { SETTINGS_TYPOGRAPHY } from "@/components/settings/settings-typography";
* an LSP method breaks the thing it names.
*/
const LSP_CONFIG_METHOD = "workspace/configuration";
const EDITORS_TITLE_KEY = "settings:editors";
type LspServerConfigSectionProps = {
lspConfigStrings: Record<string, string>;
@@ -308,6 +309,7 @@ function CustomEditorsList({
type EditorsSectionProps = {
embedded: boolean;
showHeading?: boolean;
defaultOptions: ComboboxOption[];
defaultEditorId: string;
baselineDefaultId: string;
@@ -326,6 +328,7 @@ type EditorsSectionProps = {
function EditorsSection({
embedded,
showHeading = true,
defaultOptions,
defaultEditorId,
baselineDefaultId,
@@ -344,13 +347,14 @@ function EditorsSection({
const { t } = useTranslation();
return (
<div className="space-y-6">
{embedded ? (
<h3 className={SETTINGS_TYPOGRAPHY.sectionTitle}>{t("settings:editors")}</h3>
) : (
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t("settings:editors")}
</div>
)}
{showHeading &&
(embedded ? (
<h3 className={SETTINGS_TYPOGRAPHY.sectionTitle}>{t(EDITORS_TITLE_KEY)}</h3>
) : (
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t(EDITORS_TITLE_KEY)}
</div>
))}
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">{t("settings:default")}</div>
<div
@@ -447,7 +451,7 @@ export function EditorsSettings({ embedded = false }: { embedded?: boolean }) {
return (
<SettingsPageTemplate
title={t("settings:editors")}
title={t(EDITORS_TITLE_KEY)}
description={t("settings:configureTheIncludedCodeEditorAnd")}
// Explicit, because the template otherwise derives the save-contributor id
// from `title` — which is now translated, and an identity must not be.
@@ -461,12 +465,14 @@ export function EditorsSettings({ embedded = false }: { embedded?: boolean }) {
}
onSave={() => saveDefaultRequest.run()}
showPageChrome={!embedded}
contentFrame="none"
>
<div className="space-y-6">
<SettingsTarget targetId={GENERAL_SETTINGS_TARGETS.fileEditor} className="space-y-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t("settings:fileEditor")}
</div>
<SettingsGroup
title={t("settings:fileEditor")}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.fileEditor}
contentClassName="space-y-4 divide-y-0"
>
<LspLanguageCards
lspAutoStartLanguages={state.lspAutoStartLanguages}
lspAutoInstallLanguages={state.lspAutoInstallLanguages}
@@ -488,25 +494,28 @@ export function EditorsSettings({ embedded = false }: { embedded?: boolean }) {
setExpandedConfigLang={state.setExpandedConfigLang}
updateLspConfigString={updateLspConfigString}
/>
</SettingsTarget>
</SettingsGroup>
<Separator />
<EditorsSection
embedded={embedded}
defaultOptions={defaultOptions}
defaultEditorId={state.defaultEditorId}
baselineDefaultId={state.baselineDefaultId}
availableEditors={availableEditors}
builtInEditors={builtInEditors}
onDefaultEditorChange={state.setDefaultEditorId}
customEditors={customEditors}
editingId={state.editingId}
setEditingId={state.setEditingId}
isAdding={state.isAdding}
setIsAdding={state.setIsAdding}
createRequest={createRequest}
updateRequest={updateRequest}
deleteRequest={deleteRequest}
/>
<SettingsGroup title={t(EDITORS_TITLE_KEY)} contentClassName="space-y-4 divide-y-0">
<EditorsSection
embedded={embedded}
showHeading={false}
defaultOptions={defaultOptions}
defaultEditorId={state.defaultEditorId}
baselineDefaultId={state.baselineDefaultId}
availableEditors={availableEditors}
builtInEditors={builtInEditors}
onDefaultEditorChange={state.setDefaultEditorId}
customEditors={customEditors}
editingId={state.editingId}
setEditingId={state.setEditingId}
isAdding={state.isAdding}
setIsAdding={state.setIsAdding}
createRequest={createRequest}
updateRequest={updateRequest}
deleteRequest={deleteRequest}
/>
</SettingsGroup>
</div>
</SettingsPageTemplate>
);
@@ -12,14 +12,11 @@ import {
IconListCheck,
IconHome,
} from "@tabler/icons-react";
import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Label } from "@kandev/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Separator } from "@kandev/ui/separator";
import { SettingsSection } from "@/components/settings/settings-section";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/settings-card-header";
import { settingsControlClassName } from "@/components/settings/settings-control";
import { SettingsRow } from "@/components/settings/settings-group";
import { SettingsPageHeader } from "@/components/settings/settings-typography";
import { KeyboardShortcutsCard } from "@/components/settings/keyboard-shortcuts-card";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
@@ -69,31 +66,29 @@ function ThemeSettingsCard({
}) {
const { t } = useTranslation();
return (
<SettingsCard
<SettingsRow
isDirty={isDirty}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.colorTheme}
data-testid="theme-settings-card"
>
<SettingsCardHeader title={t("settings:colorTheme")} />
<CardContent>
<div className="space-y-2">
<Select value={theme} onValueChange={(value) => onChange(value as Theme)}>
<SelectTrigger
id="theme"
className={settingsControlClassName()}
data-settings-dirty={isDirty}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">{t("common:system")}</SelectItem>
<SelectItem value="light">{t("settings:light")}</SelectItem>
<SelectItem value="dark">{t("settings:dark")}</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</SettingsCard>
label={t("settings:colorTheme")}
controlId="theme"
control={
<Select value={theme} onValueChange={(value) => onChange(value as Theme)}>
<SelectTrigger
id="theme"
className={settingsControlClassName()}
data-settings-dirty={isDirty}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">{t("common:system")}</SelectItem>
<SelectItem value="light">{t("settings:light")}</SelectItem>
<SelectItem value="dark">{t("settings:dark")}</SelectItem>
</SelectContent>
</Select>
}
/>
);
}
@@ -108,34 +103,34 @@ function ChatSubmitKeyCard({
}) {
const { t } = useTranslation();
return (
<SettingsCard
<SettingsRow
isDirty={isDirty}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.submitShortcut}
data-testid="chat-submit-key-card"
>
<CardHeader>
<CardTitle className="text-base">{t("settings:submitShortcut")}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="chat-submit-key">{t("settings:messageSubmitKey")}</Label>
<Select value={value} onValueChange={(next) => onChange(next as "enter" | "cmd_enter")}>
<SelectTrigger id="chat-submit-key" data-settings-dirty={isDirty}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="cmd_enter">{t("settings:cmdCtrlEnterToSend")}</SelectItem>
<SelectItem value="enter">{t("settings:enterToSend")}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{value === "cmd_enter"
? t("settings:pressCmdCtrlEnterToSend")
: t("settings:pressEnterToSendMessagesPress")}
</p>
</div>
</CardContent>
</SettingsCard>
label={t("settings:messageSubmitKey")}
description={
value === "cmd_enter"
? t("settings:pressCmdCtrlEnterToSend")
: t("settings:pressEnterToSendMessagesPress")
}
controlId="chat-submit-key"
descriptionId="chat-submit-key-description"
control={
<Select value={value} onValueChange={(next) => onChange(next as "enter" | "cmd_enter")}>
<SelectTrigger
id="chat-submit-key"
aria-describedby="chat-submit-key-description"
data-settings-dirty={isDirty}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="cmd_enter">{t("settings:cmdCtrlEnterToSend")}</SelectItem>
<SelectItem value="enter">{t("settings:enterToSend")}</SelectItem>
</SelectContent>
</Select>
}
/>
);
}
@@ -219,19 +214,17 @@ function AppearanceThemeSection({
title={t("settings:appearance")}
description={t("settings:customizeHowTheApplicationLooks")}
>
<div className="space-y-4">
<ThemeSettingsCard theme={theme} isDirty={isThemeDirty} onChange={onThemeChange} />
<RichOutputMotionSettingsCard
enabled={richOutputAnimationsEnabled}
isDirty={isRichOutputMotionDirty}
onChange={onRichOutputMotionChange}
/>
<ChatMotionSettingsCard
enabled={chatAnimationsEnabled}
isDirty={isChatMotionDirty}
onChange={onChatMotionChange}
/>
</div>
<ThemeSettingsCard theme={theme} isDirty={isThemeDirty} onChange={onThemeChange} />
<RichOutputMotionSettingsCard
enabled={richOutputAnimationsEnabled}
isDirty={isRichOutputMotionDirty}
onChange={onRichOutputMotionChange}
/>
<ChatMotionSettingsCard
enabled={chatAnimationsEnabled}
isDirty={isChatMotionDirty}
onChange={onChatMotionChange}
/>
</SettingsSection>
);
}
@@ -20,6 +20,8 @@ vi.mock("@/hooks/use-responsive-breakpoint", () => ({
vi.mock("@/components/settings/settings-save-provider", () => ({
useSettingsSaveContributor: vi.fn(),
SettingsSaveDirtyScope: ({ children }: { children: (isDirty: boolean) => React.ReactNode }) =>
children(false),
}));
vi.mock("@/components/settings/settings-target", () => ({
@@ -25,6 +25,7 @@ import { useLayoutSettings } from "./use-layout-settings";
import { SidebarLayoutEditor } from "../sidebar-layout-editor";
import { useTranslation } from "react-i18next";
import { SettingsTarget } from "@/components/settings/settings-target";
import { SettingsGroup } from "@/components/settings/settings-group";
import {
settingsActionClassName,
settingsControlClassName,
@@ -268,18 +269,26 @@ export function LayoutSettings() {
<div className="min-w-0 space-y-6" data-testid="layout-settings">
<LayoutSettingsHeader tabs={<SettingsTabsList ariaLabel={t("settings:layouts")} />} />
<SettingsTabsPanel value="profiles">
<LayoutProfilesPanel
controller={controller}
isFinePointer={isFinePointer}
deleteOpen={deleteOpen}
deleteAnchorRef={deleteAnchorRef}
requestDelete={requestDelete}
closeDelete={closeDelete}
confirmDelete={confirmDelete}
/>
<SettingsGroup title={t("settings:layoutProfiles")} contentClassName="p-0 divide-y-0">
<LayoutProfilesPanel
controller={controller}
isFinePointer={isFinePointer}
deleteOpen={deleteOpen}
deleteAnchorRef={deleteAnchorRef}
requestDelete={requestDelete}
closeDelete={closeDelete}
confirmDelete={confirmDelete}
/>
</SettingsGroup>
</SettingsTabsPanel>
<SettingsTabsPanel value="sidebar" testId="settings-layouts-sidebar">
<SidebarLayoutEditor embedded />
<SettingsGroup
title={t("settings:sidebar")}
description={t("settings:sidebarDescription")}
contentClassName="p-0 divide-y-0"
>
<SidebarLayoutEditor embedded />
</SettingsGroup>
</SettingsTabsPanel>
</div>
</SettingsTabs>
@@ -48,36 +48,28 @@ describe("MCPTaskAgentProfileDefaultSettings", () => {
renderSettings();
screen.getByRole("heading", { name: "Profile for Tasks Created by Agents" });
screen.getByText(/when an agent calls a Kandev MCP tool that creates a task/i);
screen.getByText(/Kandev must assign an agent profile/i);
screen.getByText("create_task_kandev");
screen.getByText(/creates new tasks and subtasks/i);
screen.getByText(/applies only when the call omits/i);
screen.getByText("agent_profile_id");
screen.getByText("spawn_session_kandev");
screen.getByText(/tasks you create yourself are not affected/i);
screen.getByRole("button", { name: "About affected Kandev MCP tools" });
expect(screen.queryByText("create_task_kandev")).toBeNull();
screen.getByRole("button", { name: "About Profile for Tasks Created by Agents" });
expect(screen.getByRole("radio", { name: CURRENT_TASK_LABEL }).getAttribute(ARIA_CHECKED)).toBe(
"true",
);
expect(
screen.getByRole("radio", { name: WORKSPACE_DEFAULT_LABEL }).getAttribute(ARIA_CHECKED),
).toBe("false");
screen.getByText(/effective model, mode, and options/i);
screen.getByText(/workflow-selected profiles win first/i);
screen.getByText(/higher-cost setup/i);
screen.getByText(/skips the creating session and source or parent task profiles/i);
screen.getByText(/consistent cost policy/i);
});
it("explains why session creation does not use this preference", async () => {
renderSettings();
fireEvent.focus(screen.getByRole("button", { name: "About affected Kandev MCP tools" }));
fireEvent.focus(
screen.getByRole("button", { name: "About Profile for Tasks Created by Agents" }),
);
const tooltip = await screen.findByRole("tooltip");
expect(tooltip.textContent).toMatch(/create_task_kandev creates a separate task/i);
expect(tooltip.textContent).toMatch(/spawn_session_kandev adds a session to the current task/i);
expect(tooltip.textContent).toMatch(/create_task_kandev creates new tasks and subtasks/i);
expect(tooltip.textContent).toMatch(
/spawn_session_kandev and tasks you create yourself are not affected/i,
);
});
it("keeps the choice local until Save changes is pressed", async () => {
@@ -1,15 +1,15 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { IconInfoCircle } from "@tabler/icons-react";
import { CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card";
import { SettingsInfo } from "./settings-info";
import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Label } from "@kandev/ui/label";
import { RadioGroup, RadioGroupItem } from "@kandev/ui/radio-group";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { updateUserSettings } from "@/lib/api";
import type { MCPTaskAgentProfileDefault } from "@/lib/types/http";
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 { Trans, useTranslation } from "react-i18next";
@@ -19,6 +19,8 @@ import { Trans, useTranslation } from "react-i18next";
* and description hold catalog KEYS, resolved at render: a module-scope `t()`
* would freeze this copy at the boot locale (see docs/i18n.md).
*/
const PROFILE_LABEL_KEY = "settings:profileForTasksCreatedByAgents";
const OPTIONS: Array<{
value: MCPTaskAgentProfileDefault;
labelKey: string;
@@ -39,57 +41,106 @@ const OPTIONS: Array<{
function MCPTaskProfileScopeDescription() {
const { t } = useTranslation();
return (
<CardDescription className="space-y-3">
<div className="space-y-2">
<p>{t("settings:useThisSettingWhenAnAgent")}</p>
<div className="space-y-1.5">
<div className="flex items-center gap-1 text-foreground">
<span className="font-medium">{t("settings:affectedKandevMcpTool")}</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={t("settings:aboutAffectedKandevMcpTools")}
className="relative inline-flex size-6 shrink-0 cursor-pointer items-center justify-center text-muted-foreground outline-none after:absolute after:-inset-2.5 hover:text-foreground focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<IconInfoCircle className="size-4" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-xs">
<Trans i18nKey="settings:mcpAffectedToolsHelp">
<code>create_task_kandev</code> creates a separate task.{" "}
<code>spawn_session_kandev</code> adds a session to the current task, so it does not
use this preference.
</Trans>
</TooltipContent>
</Tooltip>
</div>
<p>
<Trans i18nKey="settings:mcpCreateTaskScope">
<code className="rounded-sm bg-muted px-1 py-0.5 text-foreground">
create_task_kandev
</code>{" "}
creates new tasks and subtasks. This setting applies only when the call omits{" "}
<code className="rounded-sm bg-muted px-1 py-0.5 text-foreground">
agent_profile_id
</code>
.
</Trans>
<p>
<Trans i18nKey="settings:mcpCreateTaskScope">
<code className="rounded-sm bg-muted px-1 py-0.5 text-foreground">
create_task_kandev
</code>{" "}
creates new tasks and subtasks. This setting applies only when the call omits{" "}
<code className="rounded-sm bg-muted px-1 py-0.5 text-foreground">agent_profile_id</code>.
</Trans>
</p>
<p>
<Trans i18nKey="settings:mcpSpawnSessionScope">
<code className="rounded-sm bg-muted px-1 py-0.5 text-foreground">
spawn_session_kandev
</code>{" "}
and tasks you create yourself are not affected. An explicitly selected profile always
wins.
</Trans>
</p>
<p>
<Trans i18nKey="settings:mcpAffectedToolsHelp">
<code>create_task_kandev</code> creates a separate task. <code>spawn_session_kandev</code>{" "}
adds a session to the current task, so it does not use this preference.
</Trans>
</p>
{OPTIONS.map((option) => (
<p key={option.value}>
<strong>{t(option.labelKey)}: </strong>
{t(option.descriptionKey)}
</p>
<p>
<Trans i18nKey="settings:mcpSpawnSessionScope">
<code className="rounded-sm bg-muted px-1 py-0.5 text-foreground">
spawn_session_kandev
</code>{" "}
and tasks you create yourself are not affected. An explicitly selected profile always
wins.
</Trans>
</p>
</div>
</CardDescription>
))}
</div>
);
}
export function MCPTaskAgentProfileDefaultSettings() {
function MCPTaskProfileRadioGroup({
value,
isDirty,
onValueChange,
ariaDescribedBy,
}: {
value: MCPTaskAgentProfileDefault;
isDirty: boolean;
onValueChange: (value: MCPTaskAgentProfileDefault) => void;
ariaDescribedBy?: string;
}) {
const { t } = useTranslation();
return (
<RadioGroup
aria-label={t(PROFILE_LABEL_KEY)}
aria-describedby={ariaDescribedBy}
value={value}
onValueChange={(nextValue) => 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 (
<Label
key={option.value}
htmlFor={`mcp-task-profile-${option.value}`}
className="flex min-h-11 w-full min-w-0 cursor-pointer items-start gap-3 rounded-md border p-3 hover:bg-muted/30"
>
<RadioGroupItem
id={`mcp-task-profile-${option.value}`}
value={option.value}
aria-labelledby={labelId}
aria-describedby={descriptionId}
className="mt-0.5"
/>
<span className="min-w-0 space-y-1">
<span id={labelId} className="block text-sm font-medium">
{t(option.labelKey)}
</span>
<span
id={descriptionId}
className="block whitespace-normal break-words text-xs text-muted-foreground"
>
{t(
option.value === "current_task"
? "settings:profileSessionShort"
: "settings:profileWorkspaceShort",
)}
</span>
</span>
</Label>
);
})}
</RadioGroup>
);
}
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 (
<SettingsRow
label={t(PROFILE_LABEL_KEY)}
description={t("settings:agentProfileShort")}
info={
<SettingsInfo label={t(PROFILE_LABEL_KEY)}>
<MCPTaskProfileScopeDescription />
</SettingsInfo>
}
descriptionId="mcp-task-profile-description"
discoveryTargetId={GENERAL_SETTINGS_TARGETS.agentTaskProfile}
isDirty={isDirty}
control={
<MCPTaskProfileRadioGroup
value={draft}
isDirty={isDirty}
onValueChange={setDraft}
ariaDescribedBy="mcp-task-profile-description"
/>
}
/>
);
}
return (
<SettingsCard
isDirty={isDirty}
@@ -132,49 +208,14 @@ export function MCPTaskAgentProfileDefaultSettings() {
>
<CardHeader>
<CardTitle className="text-base">
<h3>{t("settings:profileForTasksCreatedByAgents")}</h3>
<h3>{t(PROFILE_LABEL_KEY)}</h3>
</CardTitle>
<MCPTaskProfileScopeDescription />
<SettingsInfo label={t(PROFILE_LABEL_KEY)}>
<MCPTaskProfileScopeDescription />
</SettingsInfo>
</CardHeader>
<CardContent>
<RadioGroup
aria-label={t("settings:profileForTasksCreatedByAgents")}
value={draft}
onValueChange={(value) => 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 (
<Label
key={option.value}
htmlFor={`mcp-task-profile-${option.value}`}
className="flex min-h-11 w-full min-w-0 cursor-pointer items-start gap-3 rounded-md border p-3 hover:bg-muted/30"
>
<RadioGroupItem
id={`mcp-task-profile-${option.value}`}
value={option.value}
aria-labelledby={labelId}
aria-describedby={descriptionId}
className="mt-0.5"
/>
<span className="min-w-0 space-y-1">
<span id={labelId} className="block text-sm font-medium">
{t(option.labelKey)}
</span>
<span
id={descriptionId}
className="block whitespace-normal break-words text-xs text-muted-foreground"
>
{t(option.descriptionKey)}
</span>
</span>
</Label>
);
})}
</RadioGroup>
<MCPTaskProfileRadioGroup value={draft} isDirty={isDirty} onValueChange={setDraft} />
</CardContent>
</SettingsCard>
);
@@ -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 (
<SettingsTarget targetId={GENERAL_SETTINGS_TARGETS.desktopNotifications} className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">{t("settings:desktopNotifications")}</div>
<p className="text-xs text-muted-foreground">
{t("settings:desktopNotificationsDescription")}
</p>
</div>
<SettingsGroup
title={t("settings:desktopNotifications")}
description={t("settings:desktopNotificationsDescription")}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.desktopNotifications}
action={
<div className="flex items-center gap-2">
<Button
title={t("settings:enableDesktopNotifications")}
@@ -104,8 +101,8 @@ export function DesktopNotificationsSection({
</HoverCardContent>
</HoverCard>
</div>
</div>
}
>
{notificationPermission === "denied" && (
<p className="text-xs text-amber-600">
{nativeNotifications.isAvailable()
@@ -119,7 +116,7 @@ export function DesktopNotificationsSection({
{notificationPermission === "error" && (
<p className="text-xs text-amber-600">{t("settings:notificationPermissionCheckFailed")}</p>
)}
</SettingsTarget>
</SettingsGroup>
);
}
@@ -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 (
<SettingsTarget
targetId={GENERAL_SETTINGS_TARGETS.notificationSound}
className="space-y-4 rounded-md border p-4"
data-settings-dirty={isDirty}
<SettingsGroup
title={t("settings:notificationSound")}
description={t("settings:notificationSoundDescription")}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.notificationSound}
isDirty={isDirty}
data-testid="notification-sound-group"
>
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">{t("settings:notificationSound")}</div>
<p className="text-xs text-muted-foreground">
{t("settings:notificationSoundDescription")}
</p>
</div>
<Switch
checked={prefs.enabled}
data-settings-dirty={prefs.enabled !== saved.enabled}
@@ -122,6 +117,6 @@ export function NotificationSoundSection({
</TooltipProvider>
</div>
)}
</SettingsTarget>
</SettingsGroup>
);
}
@@ -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 (
<SettingsTarget targetId={GENERAL_SETTINGS_TARGETS.notificationProviders} className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="text-sm font-medium">{t("settings:externalProviders")}</div>
<p className="text-xs text-muted-foreground" data-testid="external-providers-description">
{t("settings:externalProvidersDescription")}
</p>
</div>
<SettingsGroup
title={t("settings:externalProviders")}
description={
<span data-testid="external-providers-description">
{t("settings:externalProvidersDescription")}
</span>
}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.notificationProviders}
action={
<AppriseRescanControls
notificationProvidersLoaded={props.notificationProvidersLoaded}
notificationProvidersLoading={props.notificationProvidersLoading}
@@ -352,9 +353,10 @@ export function ExternalProvidersSection(props: ExternalProvidersSectionProps) {
appriseRescanResult={props.appriseRescanResult}
onRescanApprise={props.onRescanApprise}
/>
</div>
}
>
<AppriseProviderSectionBody {...props} />
</SettingsTarget>
</SettingsGroup>
);
}
@@ -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(
<SettingsSaveProvider>
<NotificationsSettings />
</SettingsSaveProvider>,
);
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
@@ -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"
>
<DesktopNotificationsSection
notificationPermission={notificationPermission}
onRequestPermission={actions.handleRequestPermission}
onRefreshPermission={actions.handleRefreshPermission}
onTestNotification={actions.handleTestNotification}
/>
<Separator className="my-4" />
<NotificationSoundSection onDirtyChange={setSoundIsDirty} />
<Separator className="my-4" />
<ExternalProvidersSection
appriseAvailable={appriseAvailable}
appriseProviders={appriseProviders}
baselineProviders={baselineProviders}
appriseFormMode={appriseFormMode}
activeAppriseId={activeAppriseId}
appriseName={appriseName}
appriseUrls={appriseUrls}
showAppriseForm={showAppriseForm}
notificationProvidersLoaded={notificationProvidersLoaded}
notificationProvidersLoading={notificationProvidersLoading}
appriseRescanPending={appriseRescanPending}
appriseRescanError={appriseRescanError}
appriseRescanResult={appriseRescanResult}
setAppriseName={setAppriseName}
setAppriseUrls={setAppriseUrls}
onAppriseNameEdit={actions.handleAppriseNameEdit}
onAppriseEdit={actions.handleAppriseEdit}
onOpenForm={actions.openAppriseForm}
onCloseForm={actions.closeAppriseForm}
onCancelForm={actions.cancelAppriseForm}
onDeleteProvider={actions.handleDeleteProvider}
onTestProvider={actions.handleTestProvider}
onRescanApprise={rescanApprise}
/>
<Separator className="my-4" />
<SettingsTarget targetId={GENERAL_SETTINGS_TARGETS.notificationEvents} className="space-y-4">
<div>
<div className="text-sm font-medium">{t("settings:notificationEvents")}</div>
<p className="text-xs text-muted-foreground">
{t("settings:notificationEventsDescription")}
</p>
</div>
{tableProviders.length > 0 && (
<NotificationEventsTable
tableProviders={tableProviders}
baselineProviders={baselineProviders}
tableEvents={tableEvents}
onToggleEvent={actions.handleToggleEvent}
onTestProvider={actions.handleTestProvider}
/>
)}
</SettingsTarget>
<div className="space-y-6">
<DesktopNotificationsSection
notificationPermission={notificationPermission}
onRequestPermission={actions.handleRequestPermission}
onRefreshPermission={actions.handleRefreshPermission}
onTestNotification={actions.handleTestNotification}
/>
<NotificationSoundSection onDirtyChange={setSoundIsDirty} />
<ExternalProvidersSection
appriseAvailable={appriseAvailable}
appriseProviders={appriseProviders}
baselineProviders={baselineProviders}
appriseFormMode={appriseFormMode}
activeAppriseId={activeAppriseId}
appriseName={appriseName}
appriseUrls={appriseUrls}
showAppriseForm={showAppriseForm}
notificationProvidersLoaded={notificationProvidersLoaded}
notificationProvidersLoading={notificationProvidersLoading}
appriseRescanPending={appriseRescanPending}
appriseRescanError={appriseRescanError}
appriseRescanResult={appriseRescanResult}
setAppriseName={setAppriseName}
setAppriseUrls={setAppriseUrls}
onAppriseNameEdit={actions.handleAppriseNameEdit}
onAppriseEdit={actions.handleAppriseEdit}
onOpenForm={actions.openAppriseForm}
onCloseForm={actions.closeAppriseForm}
onCancelForm={actions.cancelAppriseForm}
onDeleteProvider={actions.handleDeleteProvider}
onTestProvider={actions.handleTestProvider}
onRescanApprise={rescanApprise}
/>
<SettingsGroup
title={t("settings:notificationEvents")}
description={t("settings:notificationEventsDescription")}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.notificationEvents}
>
{tableProviders.length > 0 && (
<NotificationEventsTable
tableProviders={tableProviders}
baselineProviders={baselineProviders}
tableEvents={tableEvents}
onToggleEvent={actions.handleToggleEvent}
onTestProvider={actions.handleTestProvider}
/>
)}
</SettingsGroup>
</div>
</SettingsPageTemplate>
);
}
@@ -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 (
<SettingsCard isDirty={form.isDirty} data-testid="plugin-settings-card">
<CardHeader>
<CardTitle className="text-base">{t("plugins:settings")}</CardTitle>
</CardHeader>
<CardContent>
<PluginSettingsBody plugin={plugin} form={form} busy={busy} />
</CardContent>
</SettingsCard>
<SettingsGroup
title={t("plugins:settings")}
isDirty={form.isDirty}
data-testid="plugin-settings-card"
contentClassName="divide-y-0"
>
<PluginSettingsBody plugin={plugin} form={form} busy={busy} />
</SettingsGroup>
);
}
@@ -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 && <GlobalAutoUpdateToggle settings={autoUpdate} />}
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="text-sm font-medium text-foreground">{t("plugins:installedPlugins")}</div>
{canManage && (
<SettingsGroup
title={t("plugins:installedPlugins")}
contentClassName="space-y-4 divide-y-0"
action={
canManage ? (
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
<Button
data-testid="plugins-sync-button"
@@ -178,8 +178,10 @@ function InstalledTab({
{t("plugins:installPlugin")}
</Button>
</div>
)}
</div>
) : undefined
}
>
{canManage && <GlobalAutoUpdateToggle settings={autoUpdate} />}
<PluginUpdateStatus
checking={updates.checking}
@@ -209,7 +211,7 @@ function InstalledTab({
updateAction={updateAction}
isFinePointer={isFinePointer}
/>
</>
</SettingsGroup>
);
}
@@ -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 = (
<SettingsRow
label={t("settings:preventAutoStartAgentOnOpen")}
description={t("settings:preventStartShort")}
info={
<SettingsInfo label={t("settings:preventAutoStartAgentOnOpen")}>
{t("settings:preventAutoStartAgentOnOpenHelp")}
</SettingsInfo>
}
controlId="prevent-auto-start-on-open"
touchTarget="switch"
discoveryTargetId={GENERAL_SETTINGS_TARGETS.preventAutoStartOnOpen}
isDirty={isDirty}
control={
<Switch
id="prevent-auto-start-on-open"
checked={draft}
data-settings-dirty={isDirty}
onCheckedChange={setDraft}
className="shrink-0 cursor-pointer"
/>
}
/>
);
if (presentation === "row") return row;
return (
<SettingsCard
isDirty={isDirty}
@@ -56,25 +88,7 @@ export function PreventAutoStartAgentSettings() {
<CardHeader>
<CardTitle className="text-base">{t("settings:preventAutoStartAgentOnOpen")}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex min-h-11 items-center justify-between gap-4">
<div className="min-w-0 space-y-0.5">
<Label htmlFor="prevent-auto-start-on-open">
{t("settings:preventAutoStartAgentOnOpen")}
</Label>
<p className="text-xs text-muted-foreground">
{t("settings:preventAutoStartAgentOnOpenHelp")}
</p>
</div>
<Switch
id="prevent-auto-start-on-open"
checked={draft}
data-settings-dirty={isDirty}
onCheckedChange={setDraft}
className="shrink-0 cursor-pointer"
/>
</div>
</CardContent>
<CardContent>{row}</CardContent>
</SettingsCard>
);
}
@@ -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.
</Trans>
</div>
<div className="space-y-6 mt-4">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="text-sm font-medium text-foreground">
{t("settings:promptsCustomHeading")}
</div>
<SettingsGroup
title={t("settings:promptsCustomHeading")}
contentClassName="space-y-6 divide-y-0"
action={
<Button
onClick={startCreate}
disabled={isBusy || isEditing || showCreate}
@@ -521,8 +521,8 @@ export function PromptsSettings() {
>
{t("settings:promptAdd")}
</Button>
</div>
}
>
{showCreate && (
<PromptCreateForm
formState={formState}
@@ -551,7 +551,7 @@ export function PromptsSettings() {
deleteTargetId={deleteTarget?.id ?? null}
/>
</div>
</div>
</SettingsGroup>
</SettingsPageTemplate>
);
}
@@ -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 (
<SettingsCard
<SettingsRow
isDirty={isDirty}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.richOutputMotion}
data-testid="rich-output-motion-settings-card"
>
<CardHeader>
<CardTitle className="text-base">{t("settings:richOutputChartMotion")}</CardTitle>
</CardHeader>
<CardContent>
<div
className="flex min-h-11 items-center justify-between gap-4"
data-testid="rich-output-motion-toggle-row"
>
<div className="min-w-0 space-y-1">
<Label htmlFor="animate-rich-output-charts">
{t("settings:animateRichOutputCharts")}
</Label>
<p className="max-w-3xl text-xs text-muted-foreground">
{t("settings:animateRichOutputChartsDescription")}
</p>
</div>
<Switch
id="animate-rich-output-charts"
checked={enabled}
onCheckedChange={onChange}
data-settings-dirty={isDirty}
className="data-[state=checked]:bg-transparent data-[state=unchecked]:bg-transparent dark:data-[state=unchecked]:bg-transparent data-[size=default]:h-11 data-[size=default]:w-11 cursor-pointer shrink-0 p-2 before:absolute before:left-2 before:top-1/2 before:h-[16.6px] before:w-7 before:-translate-y-1/2 before:rounded-full before:bg-input before:content-[''] data-[state=checked]:before:bg-primary dark:data-[state=unchecked]:before:bg-input/80 [&_[data-slot=switch-thumb]]:z-10"
/>
</div>
</CardContent>
</SettingsCard>
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={
<Switch
id="animate-rich-output-charts"
checked={enabled}
onCheckedChange={onChange}
data-settings-dirty={isDirty}
className="data-[state=checked]:bg-transparent data-[state=unchecked]:bg-transparent dark:data-[state=unchecked]:bg-transparent data-[size=default]:h-11 data-[size=default]:w-11 cursor-pointer shrink-0 p-2 before:absolute before:left-2 before:top-1/2 before:h-[16.6px] before:w-7 before:-translate-y-1/2 before:rounded-full before:bg-input before:content-[''] data-[state=checked]:before:bg-primary dark:data-[state=unchecked]:before:bg-input/80 [&_[data-slot=switch-thumb]]:z-10"
/>
}
/>
);
}
@@ -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. */}
<div className="space-y-6" data-testid="secrets-settings-body">
<div className="flex items-center justify-between">
<div className="text-sm font-medium text-foreground">{secretScopeTitle(t, scope)}</div>
<SettingsGroup
title={secretScopeTitle(t, scope)}
description={secretScopeDescription(t, scope)}
data-testid="secrets-settings-body"
contentClassName="space-y-6 divide-y-0"
action={
<Button
onClick={actions.startCreate}
disabled={isBusy || Boolean(editingId) || showCreate}
@@ -439,8 +443,8 @@ function SecretsSettingsBody({
>
{t("settings:addSecret")}
</Button>
</div>
}
>
{showCreate && (
<SecretForm
title={t("settings:newSecret")}
@@ -505,7 +509,7 @@ function SecretsSettingsBody({
/>
))}
</div>
</div>
</SettingsGroup>
</>
);
}
@@ -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(
<SettingsGroup
title="Runtime and limits"
summary="Automatic sessions: 5"
collapsible
defaultOpen={false}
data-testid="runtime-group"
>
<SettingsRow
label="Maximum automatic sessions"
description="Manual starts can exceed this limit."
controlId="maximum-sessions"
control={<input id="maximum-sessions" />}
/>
</SettingsGroup>,
);
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(
<SettingsGroup title="Preferences">
<SettingsRow
label="Open new tasks automatically"
description="Open a new task after you create it."
controlId="auto-open"
control={<input id="auto-open" aria-describedby="existing-help" />}
descriptionId="auto-open-help"
/>
</SettingsGroup>,
);
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(
<SettingsGroup title="Preferences">
<SettingsRow
label="Open new tasks automatically"
description="Open a new task after you create it."
controlId="auto-open"
control={<input id="auto-open" />}
/>
</SettingsGroup>,
);
const control = container.querySelector<HTMLInputElement>("#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(
<SettingsGroup title="Runtime" collapsible defaultOpen={false} revealOn={false}>
<div>Runtime content</div>
</SettingsGroup>,
);
const details = container.querySelector<HTMLDetailsElement>("details")!;
expect(details.open).toBe(false);
rerender(
<SettingsGroup title="Runtime" collapsible defaultOpen={false} revealOn>
<div>Runtime content</div>
</SettingsGroup>,
);
expect(details.open).toBe(true);
fireEvent.click(details.querySelector("summary")!);
expect(details.open).toBe(false);
rerender(
<SettingsGroup title="Runtime" collapsible defaultOpen={false} revealOn>
<div>Runtime content</div>
</SettingsGroup>,
);
expect(details.open).toBe(false);
rerender(
<SettingsGroup title="Runtime" collapsible defaultOpen={false} revealOn={false}>
<div>Runtime content</div>
</SettingsGroup>,
);
rerender(
<SettingsGroup title="Runtime" collapsible defaultOpen={false} revealOn>
<div>Runtime content</div>
</SettingsGroup>,
);
expect(details.open).toBe(true);
});
it("makes the mobile switch wrapper a real activation target", () => {
render(
<SettingsGroup title="Preferences">
<SettingsRow
label="Open new tasks automatically"
controlId="auto-open"
touchTarget="switch"
controlWrapperTestId="auto-open-touch-target"
control={<input id="auto-open" type="checkbox" />}
/>
</SettingsGroup>,
);
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(
<SettingsSaveProvider>
<SettingsGroup title="Runtime" isDirty={false}>
<DirtyContributor id="sleep-inhibition" />
</SettingsGroup>
</SettingsSaveProvider>,
);
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(
<SettingsGroup
title="Repository Scope"
titleAccessory={<button type="button" aria-label="Explain repository scope" />}
>
<div>Content</div>
</SettingsGroup>,
);
expect(screen.getByRole("heading", { name: "Repository Scope", level: 3 })).toBeTruthy();
expect(screen.getByRole("button", { name: "Explain repository scope" })).toBeTruthy();
});
});
@@ -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<ComponentProps<"section">, "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 (
<span className="min-w-0">
<span className="flex items-center gap-2">
<svg
aria-hidden="true"
className="size-4 shrink-0 transition-transform group-open:rotate-90"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m9 5 7 7-7 7" />
</svg>
<h3 className={SETTINGS_TYPOGRAPHY.sectionTitle} data-testid={titleTestId}>
{title}
</h3>
{titleAccessory}
</span>
{(description || summary) && (
<span className="mt-1 flex flex-wrap gap-x-3 gap-y-1 pl-6">
{description && (
<span className={SETTINGS_TYPOGRAPHY.sectionDescription}>{description}</span>
)}
{summary && <span className={SETTINGS_TYPOGRAPHY.meta}>{summary}</span>}
</span>
)}
{dirty && <UnsavedChangesBadge />}
</span>
);
}
function SettingsGroupHeader({
title,
description,
action,
titleTestId,
titleAccessory,
dirty,
}: Pick<
SettingsGroupProps,
"title" | "description" | "action" | "titleTestId" | "titleAccessory"
> & { dirty: boolean }) {
return (
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className={SETTINGS_TYPOGRAPHY.sectionTitle} data-testid={titleTestId}>
{title}
</h3>
{titleAccessory}
{dirty && <UnsavedChangesBadge />}
</div>
{description && <p className={SETTINGS_TYPOGRAPHY.sectionDescription}>{description}</p>}
</div>
{action && <div className="w-full shrink-0 md:w-auto">{action}</div>}
</div>
);
}
function SettingsGroupContent({
frame,
isDirty,
contentClassName,
children,
}: {
frame: "card" | "none";
isDirty: boolean;
contentClassName?: string;
children: ReactNode;
}) {
if (frame === "none") {
return (
<div className={cn("min-w-0", contentClassName)} data-settings-group-content="true">
{children}
</div>
);
}
return (
<SettingsCard isDirty={isDirty} className="min-w-0 w-full" data-settings-group-card="true">
<div className={cn("divide-y divide-border/70 px-4 py-2", contentClassName)}>{children}</div>
</SettingsCard>
);
}
/** 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<HTMLDetailsElement | null>(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 (
<SettingsSaveDirtyScope>
{(nestedIsDirty) => {
const groupIsDirty = isDirty || nestedIsDirty;
const groupContent = (
<SettingsGroupContent
frame={frame}
isDirty={groupIsDirty}
contentClassName={contentClassName}
>
{children}
</SettingsGroupContent>
);
return (
<section
{...props}
ref={registerTarget}
className={cn("min-w-0 space-y-3", className)}
data-settings-group="true"
data-settings-dirty={groupIsDirty}
>
{collapsible ? (
<details
ref={detailsRef}
open={open}
onToggle={(event) => setOpen(event.currentTarget.open)}
data-settings-group-disclosure="true"
>
<summary className="group flex min-h-11 cursor-pointer list-none items-center justify-between gap-3 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring [&::-webkit-details-marker]:hidden">
<SettingsGroupHeading
title={title}
description={description}
summary={summary}
titleTestId={titleTestId}
titleAccessory={titleAccessory}
dirty={groupIsDirty}
/>
</summary>
<div className="mt-3">{groupContent}</div>
</details>
) : (
<>
<SettingsGroupHeader
title={title}
description={description}
action={action}
titleTestId={titleTestId}
titleAccessory={titleAccessory}
dirty={groupIsDirty}
/>
{groupContent}
</>
)}
</section>
);
}}
</SettingsSaveDirtyScope>
);
}
type SettingsRowProps = Omit<ComponentProps<"div">, "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 (
<div
{...props}
ref={setTargetRef}
className={cn(
"flex min-w-0 flex-col gap-3 py-3 first:pt-2 last:pb-2 md:flex-row md:items-center md:justify-between",
className,
)}
data-settings-row="true"
data-settings-dirty={isDirty}
>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-1">
<SettingsFieldLabel htmlFor={controlId}>{label}</SettingsFieldLabel>
{info}
</div>
{description && (
<div id={resolvedDescriptionId} className={SETTINGS_TYPOGRAPHY.fieldDescription}>
{description}
</div>
)}
{details && <SettingsDetails label={details.label}>{details.children}</SettingsDetails>}
</div>
{touchTarget === "switch" ? (
<label
htmlFor={controlId}
data-testid={controlWrapperTestId}
data-settings-touch-target="true"
className={cn(
"flex min-w-0 shrink-0 items-center md:max-w-[min(50%,28rem)] md:justify-end max-md:min-h-11 max-md:min-w-11 max-md:w-full max-md:justify-end",
"[@media(pointer:coarse)]:min-h-11 [@media(pointer:coarse)]:min-w-11",
controlWrapperClassName,
)}
>
{describedControl}
</label>
) : (
<div
data-testid={controlWrapperTestId}
className={cn(
"flex min-w-0 shrink-0 items-center md:max-w-[min(50%,28rem)] md:justify-end max-md:min-h-11 max-md:w-full",
"[@media(pointer:coarse)]:min-h-11 [@media(pointer:coarse)]:min-w-11",
controlWrapperClassName,
)}
>
{describedControl}
</div>
)}
</div>
);
}
export function SettingsDetails({ label, children }: { label: ReactNode; children: ReactNode }) {
return (
<details className="pt-1 text-xs text-muted-foreground">
<summary className="inline-flex w-fit cursor-pointer items-center px-2 -mx-2 font-medium text-foreground max-md:min-h-11 max-md:min-w-11 [@media(pointer:coarse)]:min-h-11 [@media(pointer:coarse)]:min-w-11">
{label}
</summary>
<div className="mt-2 space-y-2">{children}</div>
</details>
);
}
@@ -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(<SettingsInfo label="Limit">Technical details</SettingsInfo>);
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(<SettingsInfo label="Limit">Technical details</SettingsInfo>);
fireEvent.click(screen.getByRole("button", { name: "About Limit" }));
expect((await screen.findByRole("dialog")).textContent).toContain("Technical details");
});
});
@@ -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 = (
<Button
type="button"
variant="ghost"
size="icon"
data-testid={testId}
data-settings-info="true"
aria-label={t("settings:settingInfoLabel", { setting: label })}
aria-haspopup={touch ? "dialog" : undefined}
aria-expanded={touch ? open : undefined}
className="size-6 shrink-0 cursor-pointer text-muted-foreground max-md:size-11 [@media(pointer:coarse)]:size-11"
>
<IconInfoCircle className="size-4" aria-hidden="true" />
</Button>
);
if (touch)
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{button}</DrawerTrigger>
<DrawerContent aria-describedby={undefined} className="max-h-[85dvh]">
<DrawerHeader>
<DrawerTitle>{label}</DrawerTitle>
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-4 text-sm leading-relaxed">
<div className="space-y-3">{children}</div>
</div>
<div className="p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
<DrawerClose asChild>
<Button variant="outline" className="h-11 w-full cursor-pointer">
{t("common:close")}
</Button>
</DrawerClose>
</div>
</DrawerContent>
</Drawer>
);
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
className="max-h-[70vh] max-w-[min(28rem,calc(100vw-2rem))] overflow-y-auto text-xs leading-relaxed"
>
<div className="space-y-2">{children}</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -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 (
<SettingsCard
isDirty={isDirty}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.settingsMenuMode}
<fieldset
ref={registerTarget}
className="space-y-2 py-3"
data-testid="settings-menu-mode-card"
data-settings-dirty={isDirty}
>
<CardHeader>
<CardTitle className="text-base">{t("settings:settingsMenuShape")}</CardTitle>
</CardHeader>
<CardContent>
<fieldset className="space-y-2" data-settings-dirty={isDirty}>
<legend className="sr-only">{t("settings:settingsMenuShape")}</legend>
<RadioGroup
value={value}
onValueChange={(next) => onChange(next as SettingsMenuMode)}
className="grid gap-2"
<legend className="text-sm font-medium">{t("settings:settingsMenuShape")}</legend>
<RadioGroup
value={value}
onValueChange={(next) => onChange(next as SettingsMenuMode)}
className="grid gap-2"
>
{SETTINGS_MENU_MODES.map((mode) => (
<Label
key={mode}
className="flex cursor-pointer items-start gap-3 rounded-md border p-3"
data-testid={`settings-menu-mode-${mode}`}
>
{SETTINGS_MENU_MODES.map((mode) => (
<Label
key={mode}
className="flex cursor-pointer items-start gap-3 rounded-md border p-3"
data-testid={`settings-menu-mode-${mode}`}
>
<RadioGroupItem value={mode} className="mt-0.5" />
<span>
<span className="block text-sm font-medium">{t(MODE_COPY[mode].labelKey)}</span>
<span className="block text-xs font-normal leading-5 text-muted-foreground">
{t(MODE_COPY[mode].descriptionKey)}
</span>
</span>
</Label>
))}
</RadioGroup>
<p className="text-xs text-muted-foreground">
{t("settings:settingsMenuShapePerDevice")}
</p>
</fieldset>
</CardContent>
</SettingsCard>
<RadioGroupItem value={mode} className="mt-0.5" />
<span>
<span className="block text-sm font-medium">{t(MODE_COPY[mode].labelKey)}</span>
<span className="block text-xs font-normal leading-5 text-muted-foreground">
{t(MODE_COPY[mode].descriptionKey)}
</span>
</span>
</Label>
))}
</RadioGroup>
<p className="text-xs text-muted-foreground">{t("settings:settingsMenuShapePerDevice")}</p>
</fieldset>
);
}
@@ -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 && <Separator />}
<SettingsSaveDirtyScope>
{(nestedIsDirty) => (
<SettingsCard isDirty={cardIsDirty || nestedIsDirty}>
<CardContent className="">{children}</CardContent>
</SettingsCard>
)}
{(nestedIsDirty) =>
contentFrame === "none" ? (
<div className="min-w-0" data-settings-page-content="true">
{children}
</div>
) : (
<SettingsCard isDirty={cardIsDirty || nestedIsDirty}>
<CardContent>{children}</CardContent>
</SettingsCard>
)
}
</SettingsSaveDirtyScope>
{deleteSection}
@@ -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 (
<output data-testid={CONTRIBUTOR_STATUS_ID}>
{JSON.stringify(coordinator.contributorStates)}
</output>
);
}
it("exposes dirty, invalid and failed contributors for tab recovery", async () => {
render(
<SettingsSaveProvider>
<DraftContributor
id="runtime"
onSave={() => {
throw new Error("failed");
}}
/>
<ContributorStatus />
</SettingsSaveProvider>,
);
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');
});
@@ -65,7 +65,15 @@ type SaveResult = {
const SettingsSaveRegistryContext = createContext<Registry | null>(null);
const SettingsDirtyScopeContext = createContext<DirtyScopeRegistry | null>(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<SaveResult>;
@@ -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({
<SettingsSaveProviderBody
registry={registry}
coordinator={{
contributorStates: [...contributors.values()].map(({ contributor }) => ({
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<SaveResult>;
contributors: Map<string, RegisteredContributor>;
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<ReadonlySet<string>>(new Set());
const [status, setStatus] = useState<SettingsSaveStatus>("dirty");
const [errorKind, setErrorKind] = useState<SettingsSaveErrorKind | null>(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<string>();
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(
@@ -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 (
<section ref={registerTarget} className="space-y-4">
{/* 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. */}
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className={SETTINGS_TYPOGRAPHY.sectionTitle} data-testid={titleTestId}>
{icon}
{title}
</h3>
{titleAccessory}
</div>
{description && <p className={SETTINGS_TYPOGRAPHY.sectionDescription}>{description}</p>}
</div>
{action && <div className="w-full shrink-0 md:w-auto">{action}</div>}
</div>
{divided && <Separator />}
<SettingsGroup
title={
<span className="flex items-center gap-2">
{icon}
{title}
</span>
}
titleTestId={titleTestId}
titleAccessory={titleAccessory}
description={description}
action={action}
discoveryTargetId={discoveryTargetId}
frame={framed ? "card" : "none"}
contentClassName={divided ? "border-t border-border/70" : undefined}
>
{children}
</section>
</SettingsGroup>
);
}
@@ -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({
<TabsTrigger
key={tab.id}
value={tab.id}
aria-label={tab.ariaLabel}
className={cn(
"h-7 min-w-24 flex-none cursor-pointer px-3 max-md:h-11 [@media(pointer:coarse)]:h-11",
"text-muted-foreground hover:bg-muted/60 hover:text-foreground",
@@ -106,7 +108,7 @@ export function SettingsTabsPanel({
// i18n-exempt: programmer error for an invalid component composition.
if (!context) throw new Error("SettingsTabsPanel must be used inside SettingsTabs");
const active = context.value === value;
const visited = context.visitedTabs.has(value);
const visited = active || context.visitedTabs.has(value);
return (
<TabsContent
value={value}
@@ -2,15 +2,12 @@
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { CardContent } from "@kandev/ui/card";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Switch } from "@kandev/ui/switch";
import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
import { parseSidebarHoverDelay, type AppearanceState } from "./appearance-settings-state";
import { SettingsCard } from "./settings-card";
import { SettingsRow } from "./settings-group";
import { settingsControlClassName } from "./settings-control";
import { useSettingsTargetRegistration } from "./settings-target-provider";
export function SidebarHoverSettingsCard({
draft,
@@ -27,15 +24,17 @@ export function SidebarHoverSettingsCard({
useEffect(() => {
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 (
<SettingsCard isDirty={enabledDirty || delayDirty} data-testid="sidebar-hover-settings-card">
<CardContent className="space-y-4">
<div ref={toggleTarget} className="flex items-center justify-between gap-4">
<Label htmlFor="sidebar-hover-enabled">{t("settings:sidebarHoverEnabled")}</Label>
<div data-testid="sidebar-hover-settings-card" data-settings-dirty={enabledDirty || delayDirty}>
<SettingsRow
label={t("settings:sidebarHoverEnabled")}
controlId="sidebar-hover-enabled"
discoveryTargetId={GENERAL_SETTINGS_TARGETS.sidebarHover}
isDirty={enabledDirty}
touchTarget="switch"
control={
<Switch
id="sidebar-hover-enabled"
checked={draft.sidebarHoverEnabled}
@@ -50,9 +49,23 @@ export function SidebarHoverSettingsCard({
}
className="data-checked:bg-transparent data-unchecked:bg-transparent dark:data-unchecked:bg-transparent data-[size=default]:h-7 max-md:data-[size=default]:h-11 [@media(pointer:coarse)]:data-[size=default]:h-11 data-[size=default]:w-11 p-2 before:absolute before:left-2 before:top-1/2 before:h-[16.6px] before:w-7 before:-translate-y-1/2 before:rounded-full before:bg-input before:content-[''] data-checked:before:bg-primary dark:data-unchecked:before:bg-input/80 [&_[data-slot=switch-thumb]]:z-10"
/>
</div>
<div ref={delayTarget} className="space-y-2">
<Label htmlFor="sidebar-hover-delay">{t("settings:sidebarHoverDelay")}</Label>
}
/>
<SettingsRow
label={t("settings:sidebarHoverDelay")}
description={
delay === null ? (
<span id="sidebar-hover-delay-error" className="text-destructive" role="alert">
{t("settings:sidebarHoverDelayError")}
</span>
) : (
<span id="sidebar-hover-help">{t("settings:sidebarHoverHelp")}</span>
)
}
controlId="sidebar-hover-delay"
discoveryTargetId={GENERAL_SETTINGS_TARGETS.sidebarHoverDelay}
isDirty={delayDirty}
control={
<Input
id="sidebar-hover-delay"
type="number"
@@ -72,16 +85,8 @@ export function SidebarHoverSettingsCard({
updateDraft({ sidebarHoverDelayMs: value });
}}
/>
{delay === null && (
<p id="sidebar-hover-delay-error" className="text-sm text-destructive" role="alert">
{t("settings:sidebarHoverDelayError")}
</p>
)}
</div>
<p id="sidebar-hover-help" className="text-xs text-muted-foreground">
{t("settings:sidebarHoverHelp")}
</p>
</CardContent>
</SettingsCard>
}
/>
</div>
);
}
@@ -34,7 +34,7 @@ function response(
return { settings: { enabled }, status };
}
function renderSettings() {
function renderSettings(options: { onAttentionChange?: (needsAttention: boolean) => void } = {}) {
return render(
<StateProvider
initialState={{
@@ -54,7 +54,7 @@ function renderSettings() {
},
}}
>
<SleepInhibitionSettings />
<SleepInhibitionSettings {...options} />
</StateProvider>,
);
}
@@ -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(
@@ -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 = (
<div className="py-6">
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>{t("settings:sleepInhibitionLoadFailed")}</AlertDescription>
</Alert>
<Button variant="outline" className={settingsActionClassName("mt-3")} onClick={onRetry}>
{t("settings:sleepInhibitionRetry")}
</Button>
</div>
);
if (withinGroup) return <div data-testid="sleep-inhibition-settings">{content}</div>;
return (
<SettingsCard data-testid="sleep-inhibition-settings">
<CardContent className="py-6">
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>{t("settings:sleepInhibitionLoadFailed")}</AlertDescription>
</Alert>
<Button variant="outline" className={settingsActionClassName("mt-3")} onClick={onRetry}>
{t("settings:sleepInhibitionRetry")}
</Button>
</CardContent>
<CardContent>{content}</CardContent>
</SettingsCard>
);
}
@@ -185,60 +187,94 @@ function SleepInhibitionInfoTooltipContent() {
function SleepInhibitionInfoTooltip() {
const { t } = useTranslation();
const usesTouchDrawer = useTouchDrawer();
const [open, setOpen] = useState(false);
const button = (
<Button
variant="ghost"
size="icon"
type="button"
aria-label={t("settings:sleepInhibitionInfoLabel")}
aria-haspopup={usesTouchDrawer ? "dialog" : undefined}
aria-expanded={usesTouchDrawer ? open : undefined}
data-testid="sleep-inhibition-info"
className="absolute right-0 top-1/2 -translate-y-1/2 shrink-0 cursor-pointer text-muted-foreground hover:text-foreground sm:static sm:translate-y-0"
>
<IconInfoCircle className="size-4" aria-hidden="true" />
</Button>
);
const trigger = usesTouchDrawer ? (
<DrawerTrigger asChild>{button}</DrawerTrigger>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
sideOffset={8}
className="max-w-[min(22rem,calc(100vw-2rem))]"
>
<SleepInhibitionInfoTooltipContent />
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
return (
<Drawer open={open} onOpenChange={setOpen}>
{trigger}
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{t("settings:sleepInhibitionInfoTitle")}</DrawerTitle>
<DrawerDescription>{t("settings:sleepInhibitionInfoTrigger")}</DrawerDescription>
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-[max(1.5rem,env(safe-area-inset-bottom))]">
<SleepInhibitionInfoDetails />
</div>
</DrawerContent>
</Drawer>
<SettingsInfo label={t("settings:sleepInhibitionSwitchLabel")} testId="sleep-inhibition-info">
<SleepInhibitionInfoTooltipContent />
<p>{t("settings:sleepInhibitionDescription")}</p>
<p>{t("settings:sleepInhibitionSwitchHint")}</p>
<p>{t("settings:sleepInhibitionCaveat")}</p>
</SettingsInfo>
);
}
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 = (
<Switch
id="task-sleep-inhibition"
checked={state.draft ?? snapshot.settings.enabled}
disabled={!state.canEdit}
data-testid="sleep-inhibition-switch"
data-settings-dirty={state.isDirty}
onCheckedChange={state.setDraft}
className="shrink-0 cursor-pointer"
/>
);
const row = (
<SettingsRow
data-testid="sleep-inhibition-control-row"
label={t("settings:sleepInhibitionSwitchLabel")}
description={t("settings:sleepShort")}
info={<SleepInhibitionInfoTooltip />}
controlId="task-sleep-inhibition"
touchTarget="switch"
isDirty={state.isDirty}
control={control}
/>
);
const body = (
<>
{withinGroup ? (
row
) : (
<div
className="flex min-h-11 items-center justify-between gap-4"
data-testid="sleep-inhibition-control-row"
>
<div className="min-w-0 space-y-0.5">
<Label htmlFor="task-sleep-inhibition">
{t("settings:sleepInhibitionSwitchLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("settings:sleepInhibitionSwitchHint")}
</p>
</div>
{control}
</div>
)}
<div className="rounded-md border border-border/70 bg-muted/20 p-3 text-sm">
<p data-testid="sleep-inhibition-status">
{t("settings:sleepInhibitionStatusLabel")}: {t(statusMessageKey(snapshot))}
</p>
</div>
{!state.isAdmin && (
<p className="text-sm text-muted-foreground">{t("settings:sleepInhibitionAdminOnly")}</p>
)}
{state.saveFailed && (
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>{t("settings:sleepInhibitionSaveFailed")}</AlertDescription>
</Alert>
)}
</>
);
if (withinGroup) {
return (
<div className="min-w-0 space-y-4 py-3" data-testid="sleep-inhibition-settings">
{body}
</div>
);
}
return (
<SettingsCard
isDirty={state.isDirty}
@@ -252,69 +288,42 @@ function SleepInhibitionCard({ state }: { state: SleepInhibitionState }) {
</CardTitle>
<CardDescription>{t("settings:sleepInhibitionDescription")}</CardDescription>
</CardHeader>
<CardContent className="min-w-0 space-y-4">
<div
className="flex min-h-11 items-center justify-between gap-4"
data-testid="sleep-inhibition-control-row"
>
<div className="min-w-0 space-y-0.5">
<Label htmlFor="task-sleep-inhibition">
{t("settings:sleepInhibitionSwitchLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("settings:sleepInhibitionSwitchHint")}
</p>
</div>
<Switch
id="task-sleep-inhibition"
checked={state.draft ?? snapshot.settings.enabled}
disabled={!state.canEdit}
data-testid="sleep-inhibition-switch"
data-settings-dirty={state.isDirty}
onCheckedChange={state.setDraft}
className="shrink-0 cursor-pointer"
/>
</div>
<div className="rounded-md border border-border/70 bg-muted/20 p-3 text-sm">
<p data-testid="sleep-inhibition-status">
{t("settings:sleepInhibitionStatusLabel")}: {t(statusMessageKey(snapshot))}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{t("settings:sleepInhibitionCaveat")}
</p>
</div>
{!state.isAdmin && (
<p className="text-sm text-muted-foreground">{t("settings:sleepInhibitionAdminOnly")}</p>
)}
{state.saveFailed && (
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>{t("settings:sleepInhibitionSaveFailed")}</AlertDescription>
</Alert>
)}
</CardContent>
<CardContent className="min-w-0 space-y-4">{body}</CardContent>
</SettingsCard>
);
}
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 = (
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
<Spinner className="size-4" />
{t("settings:sleepInhibitionLoading")}
</div>
);
if (withinGroup) return <div data-testid="sleep-inhibition-settings">{loadingContent}</div>;
return (
<SettingsCard data-testid="sleep-inhibition-settings">
<CardContent className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
<Spinner className="size-4" />
{t("settings:sleepInhibitionLoading")}
</CardContent>
<CardContent>{loadingContent}</CardContent>
</SettingsCard>
);
}
if (state.loadFailed && !state.snapshot) {
return <SleepInhibitionLoadError onRetry={() => void state.reload()} />;
return (
<SleepInhibitionLoadError onRetry={() => void state.reload()} withinGroup={withinGroup} />
);
}
return <SleepInhibitionCard state={state} />;
return <SleepInhibitionCard state={state} withinGroup={withinGroup} />;
}
@@ -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 (
<SettingsCard
isDirty={isDirty}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.startupPage}
<div
ref={registerTarget}
className="space-y-4 py-3"
data-testid="startup-page-settings-card"
data-settings-dirty={isDirty}
>
<CardHeader>
<CardTitle className="text-base">{t("settings:openKandevTo")}</CardTitle>
<CardDescription>{t("settings:thisAppliesWhenKandevStarts")}</CardDescription>
</CardHeader>
<CardContent>
<RadioGroup
aria-label={t("settings:startupPage")}
value={value}
onValueChange={(next) => 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 (
<Label
key={option.value}
htmlFor={`startup-page-${option.value}`}
className={`flex min-h-11 w-full min-w-0 cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors ${
selected ? "border-primary bg-primary/5" : "border-border hover:bg-muted/30"
}`}
>
<RadioGroupItem
id={`startup-page-${option.value}`}
value={option.value}
aria-labelledby={labelId}
aria-describedby={descriptionId}
className="mt-0.5 border border-muted-foreground/80 data-[state=checked]:border-primary"
/>
<span className="min-w-0 space-y-1">
<span id={labelId} className="block text-sm font-medium">
{t(option.labelKey)}
</span>
<span
id={descriptionId}
className="block whitespace-normal break-words text-xs text-muted-foreground"
>
{t(option.descriptionKey)}
</span>
<div>
<h4 className="text-sm font-semibold">{t("settings:openKandevTo")}</h4>
<p className="text-xs text-muted-foreground">{t("settings:thisAppliesWhenKandevStarts")}</p>
</div>
<RadioGroup
aria-label={t("settings:startupPage")}
value={value}
onValueChange={(next) => 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 (
<Label
key={option.value}
htmlFor={`startup-page-${option.value}`}
className={`flex min-h-11 w-full min-w-0 cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors ${
selected ? "border-primary bg-primary/5" : "border-border hover:bg-muted/30"
}`}
>
<RadioGroupItem
id={`startup-page-${option.value}`}
value={option.value}
aria-labelledby={labelId}
aria-describedby={descriptionId}
className="mt-0.5 border border-muted-foreground/80 data-[state=checked]:border-primary"
/>
<span className="min-w-0 space-y-1">
<span id={labelId} className="block text-sm font-medium">
{t(option.labelKey)}
</span>
</Label>
);
})}
</RadioGroup>
</CardContent>
</SettingsCard>
<span
id={descriptionId}
className="block whitespace-normal break-words text-xs text-muted-foreground"
>
{t(option.descriptionKey)}
</span>
</span>
</Label>
);
})}
</RadioGroup>
</div>
);
}
@@ -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");
});
});
@@ -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<SystemMetricsGlobalSettings>(DEFAULT_METRICS_SETTINGS);
const [savedSettings, setSavedSettings] =
useState<SystemMetricsGlobalSettings>(DEFAULT_METRICS_SETTINGS);
@@ -91,43 +91,40 @@ export function SystemMetricsSettingsCard({
};
return (
<SettingsCard
isDirty={isDirty || Boolean(isShowInTopbarDirty) || Boolean(isSimplifiedDirty)}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.resourceMetrics}
<div
ref={registerTarget}
className="space-y-5 py-3"
data-testid="system-metrics-settings-card"
data-settings-dirty={isDirty || Boolean(isShowInTopbarDirty) || Boolean(isSimplifiedDirty)}
>
<CardHeader>
<CardTitle className="text-base">{t("settings:resourceMetrics")}</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
<p className="max-w-3xl text-sm text-muted-foreground">
{t("settings:usefulWhenKandevIsSelfHosted")}
</p>
<MetricsDisplayToggle
checked={showInTopbar}
isDirty={Boolean(isShowInTopbarDirty)}
onCheckedChange={onShowInTopbarChange}
/>
<SimplifiedMetricsToggle
checked={simplified}
isDirty={Boolean(isSimplifiedDirty)}
onCheckedChange={onSimplifiedChange}
/>
<MetricsSamplerControls
settings={settings}
savedSettings={savedSettings}
isSaving={!loaded}
onToggleMetric={toggleMetric}
onChangeSettings={setSettings}
onDraftSettings={setSettings}
/>
<ExecutionMetricsToggle
checked={settings.collect_execution}
isDirty={settings.collect_execution !== savedSettings.collect_execution}
disabled={!loaded}
onCheckedChange={(checked) => setSettings({ ...settings, collect_execution: checked })}
/>
</CardContent>
</SettingsCard>
<p className="max-w-3xl text-sm text-muted-foreground">
{t("settings:usefulWhenKandevIsSelfHosted")}
</p>
<MetricsDisplayToggle
checked={showInTopbar}
isDirty={Boolean(isShowInTopbarDirty)}
onCheckedChange={onShowInTopbarChange}
/>
<SimplifiedMetricsToggle
checked={simplified}
isDirty={Boolean(isSimplifiedDirty)}
onCheckedChange={onSimplifiedChange}
/>
<MetricsSamplerControls
settings={settings}
savedSettings={savedSettings}
isSaving={!loaded}
onToggleMetric={toggleMetric}
onChangeSettings={setSettings}
onDraftSettings={setSettings}
/>
<ExecutionMetricsToggle
checked={settings.collect_execution}
isDirty={settings.collect_execution !== savedSettings.collect_execution}
disabled={!loaded}
onCheckedChange={(checked) => setSettings({ ...settings, collect_execution: checked })}
/>
</div>
);
}
@@ -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[] }) {
<div className="space-y-8">
<AboutCard />
<Separator />
<SettingsTarget targetId={SYSTEM_SETTINGS_TARGETS.licenses} className="space-y-4">
<div>
<h3 className="text-lg font-semibold">{t("system:navLicenses")}</h3>
<p className="text-sm text-muted-foreground mt-1">
{t("system:licensesPageDescription")}
</p>
</div>
<SettingsGroup
title={t("system:navLicenses")}
description={t("system:licensesPageDescription")}
discoveryTargetId={SYSTEM_SETTINGS_TARGETS.licenses}
contentClassName="divide-y-0"
>
<LicensesList entries={licenses} />
</SettingsTarget>
</SettingsGroup>
</div>
);
}
@@ -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 (
<div>
<h3 className="text-lg font-semibold">{title}</h3>
{description && (
<p className="mt-1 break-words text-sm text-muted-foreground">{description}</p>
)}
</div>
);
}
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 (
<div className="space-y-8">
<SettingsTarget targetId={SYSTEM_SETTINGS_TARGETS.database} className="space-y-4">
<SectionHeading
title={t("system:navDatabase")}
description={t("system:databasePageDescription")}
/>
<SettingsGroup
title={t("system:navDatabase")}
description={t("system:databasePageDescription")}
discoveryTargetId={SYSTEM_SETTINGS_TARGETS.database}
contentClassName="divide-y-0"
>
<DatabaseStatsCard />
</SettingsTarget>
</SettingsGroup>
<Separator />
<SettingsTarget targetId={SYSTEM_SETTINGS_TARGETS.toolPayloadRetention}>
<SettingsGroup
title={t("system:toolPayload.title")}
description={t("system:toolPayload.description")}
discoveryTargetId={SYSTEM_SETTINGS_TARGETS.toolPayloadRetention}
contentClassName="divide-y-0"
>
<ToolPayloadRetentionCard />
</SettingsTarget>
</SettingsGroup>
<Separator />
<SettingsTarget targetId={SYSTEM_SETTINGS_TARGETS.backups} className="space-y-4">
<SectionHeading
title={t("system:navBackups")}
description={
backupDirectory
? t("system:backupsPageDescription", {
command: BACKUP_SQL_COMMAND,
path: backupDirectory,
})
: undefined
}
/>
<SettingsGroup
title={t("system:navBackups")}
description={
backupDirectory
? t("system:backupsPageDescription", {
command: BACKUP_SQL_COMMAND,
path: backupDirectory,
})
: undefined
}
discoveryTargetId={SYSTEM_SETTINGS_TARGETS.backups}
contentClassName="divide-y-0"
>
<BackupsTable />
</SettingsTarget>
</SettingsGroup>
</div>
);
}
@@ -77,10 +73,14 @@ function DatabasePanel() {
function LogsPanel() {
const { t } = useTranslation();
return (
<SettingsTarget targetId={SYSTEM_SETTINGS_TARGETS.logs} className="space-y-4">
<SectionHeading title={t("system:navLogs")} description={t("settings:logsPageDescription")} />
<SettingsGroup
title={t("system:navLogs")}
description={t("settings:logsPageDescription")}
discoveryTargetId={SYSTEM_SETTINGS_TARGETS.logs}
contentClassName="divide-y-0"
>
<LogViewer />
</SettingsTarget>
</SettingsGroup>
);
}
@@ -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");
@@ -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 (
<>
<div className="min-w-0 space-y-2">
<Label htmlFor="message-queue-max-per-session">
{t("system:messageQueueMaximumLabel")}
</Label>
<Input
id="message-queue-max-per-session"
data-testid="message-queue-max-per-session"
type="number"
inputMode="numeric"
min={0}
step={1}
value={draft}
disabled={disabled}
onChange={(event) => onDraftChange(event.target.value)}
className={settingsControlClassName("w-full max-w-xs")}
/>
<p className="text-xs text-muted-foreground">{t("system:messageQueueUnlimitedHelp")}</p>
</div>
<div className="rounded-md border border-border/70 bg-muted/20 p-3 text-sm">
<SettingsRow
label={t("system:messageQueueMaximumLabel")}
description={t("settings:queueMaximumShort")}
controlId="message-queue-max-per-session"
info={
<SettingsInfo label={t("system:messageQueueMaximumLabel")}>
<p>{t("system:messageQueueLimitDescription")}</p>
<p>{t("system:messageQueueUnlimitedHelp")}</p>
<p>{t("system:messageQueueEffectiveHelp")}</p>
</SettingsInfo>
}
control={
<Input
id="message-queue-max-per-session"
data-testid="message-queue-max-per-session"
type="number"
inputMode="numeric"
min={0}
step={1}
value={draft}
disabled={disabled}
onChange={(event) => onDraftChange(event.target.value)}
className={settingsControlClassName("w-full md:w-40")}
/>
}
/>
<div className="text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">{t("system:messageQueueConfigured")}</span>
<span>{configured}</span>
{String(configured) !== effectiveValue && (
<>
<span>{t("system:messageQueueConfigured")}</span>
<span>{configured}</span>
</>
)}
<span className="text-muted-foreground">{t("system:messageQueueEffective")}</span>
<strong data-testid="message-queue-effective-value">{effectiveValue}</strong>
<Badge variant="secondary" data-testid="message-queue-source">
{t(sourceLabelKey(source))}
</Badge>
</div>
<p className="mt-2 text-xs text-muted-foreground">
{t("system:messageQueueEffectiveHelp")}
</p>
</div>
</>
);
@@ -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 (
<div className="min-w-0 space-y-3 border-t border-border/70 pt-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<Label htmlFor="message-queue-merge-enabled">
{t("system:messageQueueMergeToggleLabel")}
</Label>
<p className="text-sm text-muted-foreground">
{t("system:messageQueueMergeDescription")}
</p>
</div>
<SettingsRow
label={label}
description={t("settings:queueMergeShort")}
controlId="message-queue-merge-enabled"
touchTarget="switch"
controlWrapperTestId="message-queue-merge-touch-target"
info={
<SettingsInfo label={label}>
<p>{t("system:messageQueueMergeDescription")}</p>
<p>{t("system:messageQueueMergeNotice")}</p>
</SettingsInfo>
}
control={
<Switch
id="message-queue-merge-enabled"
data-testid="message-queue-merge-enabled"
checked={enabled}
disabled={disabled}
onCheckedChange={onChange}
aria-label={t("system:messageQueueMergeToggleLabel")}
aria-label={label}
className="cursor-pointer disabled:cursor-not-allowed"
/>
</div>
<div className="flex gap-2 rounded-md border border-border/70 bg-muted/20 p-3 text-sm text-muted-foreground">
<IconInfoCircle className="size-4 shrink-0" />
<p>{t("system:messageQueueMergeNotice")}</p>
</div>
</div>
}
/>
);
}
/** Renders the automatic same-source admission merge setting. */
function AutoMergeToggleFields({ enabled, onChange, disabled }: MergeToggleFieldsProps) {
const { t } = useTranslation();
const label = t("system:messageQueueAutoMergeToggleLabel");
return (
<div className="min-w-0 space-y-3 border-t border-border/70 pt-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<Label
htmlFor="message-queue-auto-merge-enabled"
className="inline-flex min-h-11 cursor-pointer items-center py-2"
>
{label}
</Label>
<p className="text-sm text-muted-foreground">
{t("system:messageQueueAutoMergeDescription")}
</p>
</div>
<div
data-testid="message-queue-auto-merge-touch-target"
className="flex min-h-11 min-w-11 shrink-0 items-center justify-center"
>
<Switch
id="message-queue-auto-merge-enabled"
data-testid="message-queue-auto-merge-enabled"
checked={enabled}
disabled={disabled}
onCheckedChange={onChange}
aria-label={label}
className="cursor-pointer [@media(pointer:coarse)]:after:-inset-y-3.5 disabled:cursor-not-allowed"
/>
</div>
</div>
<div className="flex gap-2 rounded-md border border-border/70 bg-muted/20 p-3 text-sm text-muted-foreground">
<IconInfoCircle className="size-4 shrink-0" />
<p>{t("system:messageQueueAutoMergeNotice")}</p>
</div>
</div>
<SettingsRow
label={label}
description={t("settings:queueAutoMergeShort")}
controlId="message-queue-auto-merge-enabled"
touchTarget="switch"
controlWrapperTestId="message-queue-auto-merge-touch-target"
info={
<SettingsInfo label={label}>
<p>{t("system:messageQueueAutoMergeDescription")}</p>
<p>{t("system:messageQueueAutoMergeNotice")}</p>
</SettingsInfo>
}
control={
<Switch
id="message-queue-auto-merge-enabled"
data-testid="message-queue-auto-merge-enabled"
checked={enabled}
disabled={disabled}
onCheckedChange={onChange}
aria-label={label}
className="cursor-pointer disabled:cursor-not-allowed"
/>
}
/>
);
}
/** 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 = (
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
<Spinner className="size-4" />
{t("system:messageQueueLoading")}
</div>
);
if (withinGroup) return <div data-testid="message-queue-settings">{loadingContent}</div>;
return (
<SettingsCard>
<CardContent>{loadingContent}</CardContent>
</SettingsCard>
);
}
if (state.loading && !state.snapshot) {
return (
<SettingsCard>
<CardContent className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
<Spinner className="size-4" />
{t("system:messageQueueLoading")}
</CardContent>
</SettingsCard>
);
}
if (state.loadFailed && !state.snapshot) {
return <MessageQueueLoadError onRetry={() => void state.reload()} />;
}
if (!state.snapshot) return null;
const { settings, effective } = state.snapshot;
function MessageQueueSettingsReady({
state,
withinGroup,
}: MessageQueueSettingsContentProps & {
state: NonNullable<MessageQueueSettingsContentProps["state"]>;
}) {
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 (
<SettingsCard
isDirty={state.isDirty}
className="min-w-0 w-full"
data-testid="message-queue-settings"
>
<CardHeader>
<CardTitle className="text-base">{t("system:messageQueueLimitTitle")}</CardTitle>
</CardHeader>
<CardContent className="min-w-0 space-y-5">
<p className="text-sm text-muted-foreground">{t("system:messageQueueLimitDescription")}</p>
const content = (
<>
{withinGroup ? (
<div className="space-y-1 pb-3">
<h4 className="text-sm font-semibold">{t("system:messageQueueTitle")}</h4>
</div>
) : (
<CardHeader>
<CardTitle className="text-base">{t("system:messageQueueLimitTitle")}</CardTitle>
</CardHeader>
)}
<CardContent className={withinGroup ? "min-w-0 space-y-5 px-0" : "min-w-0 space-y-5"}>
{!withinGroup && (
<p className="text-sm text-muted-foreground">
{t("system:messageQueueLimitDescription")}
</p>
)}
<QueueLimitFields
draft={state.draft}
onDraftChange={state.setDraft}
@@ -454,6 +463,11 @@ export function MessageQueueSettings() {
source={effective.source}
/>
{state.isDirty && state.invalidReason && (
<p role="alert" className="text-sm text-destructive">
{state.invalidReason}
</p>
)}
{state.isLocked && (
<Alert>
<IconLock className="size-4" />
@@ -488,27 +502,82 @@ export function MessageQueueSettings() {
disabled={!state.isAdmin}
/>
</CardContent>
</>
);
if (withinGroup) {
return (
<div className="min-w-0 py-3" data-testid="message-queue-settings">
{content}
</div>
);
}
return (
<SettingsCard
isDirty={state.isDirty}
className="min-w-0 w-full"
data-testid="message-queue-settings"
>
{content}
</SettingsCard>
);
}
function MessageQueueLoadError({ onRetry }: { onRetry: () => void }) {
/** Settings → Task Behavior → Message Queue controls, sharing one save contributor. */
type MessageQueueSettingsContentProps = {
state: ReturnType<typeof useMessageQueueSettingsDraft>;
withinGroup?: boolean;
};
export function MessageQueueSettings() {
const state = useMessageQueueSettingsDraft();
return <MessageQueueSettingsContent state={state} />;
}
export function MessageQueueSettingsContent({
state,
withinGroup = false,
}: MessageQueueSettingsContentProps) {
if (state.loading && !state.snapshot) {
return <MessageQueueLoadingState withinGroup={withinGroup} />;
}
if (state.loadFailed && !state.snapshot) {
return <MessageQueueLoadError onRetry={() => void state.reload()} withinGroup={withinGroup} />;
}
if (!state.snapshot) return null;
return <MessageQueueSettingsReady state={state} withinGroup={withinGroup} />;
}
function MessageQueueLoadError({
onRetry,
withinGroup = false,
}: {
onRetry: () => void;
withinGroup?: boolean;
}) {
const { t } = useTranslation();
const content = (
<div className="space-y-3 py-6">
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>{t("system:messageQueueLoadFailed")}</AlertDescription>
</Alert>
<Button
variant="outline"
className={settingsActionClassName("cursor-pointer")}
onClick={onRetry}
>
{t("system:messageQueueRetry")}
</Button>
</div>
);
if (withinGroup) return <div data-testid="message-queue-settings">{content}</div>;
return (
<SettingsCard>
<CardContent className="space-y-3 py-6">
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>{t("system:messageQueueLoadFailed")}</AlertDescription>
</Alert>
<Button
variant="outline"
className={settingsActionClassName("cursor-pointer")}
onClick={onRetry}
>
{t("system:messageQueueRetry")}
</Button>
</CardContent>
<CardContent>{content}</CardContent>
</SettingsCard>
);
}
@@ -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.
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t("orgs:boundaryTitle")}</CardTitle>
<CardDescription>{t("orgs:boundaryDescription")}</CardDescription>
</CardHeader>
</Card>
<SettingsGroup
title={t("orgs:boundaryTitle")}
description={t("orgs:boundaryDescription")}
contentClassName="space-y-4 divide-y-0"
>
<CreateOrgCard busy={orgs.busy} onCreate={orgs.create} />
<CreateOrgCard busy={orgs.busy} onCreate={orgs.create} />
<div className="grid gap-3">
{orgs.orgs.map((org) => (
<OrgRow
key={org.id}
org={org}
busy={orgs.busy}
onSuspend={orgs.suspend}
onResume={orgs.resume}
onDelete={setDeleting}
onAddAdmin={setAdminFor}
/>
))}
</div>
<div className="grid gap-3">
{orgs.orgs.map((org) => (
<OrgRow
key={org.id}
org={org}
busy={orgs.busy}
onSuspend={orgs.suspend}
onResume={orgs.resume}
onDelete={setDeleting}
onAddAdmin={setAdminFor}
/>
))}
</div>
</SettingsGroup>
<DeleteOrgDialog
org={deleting}
@@ -138,8 +138,8 @@ describe("SessionCapacitySettings drafts", () => {
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);
});
@@ -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 = (
<div className="space-y-3 py-6">
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>{t("system:sessionCapacityLoadFailed")}</AlertDescription>
</Alert>
<Button
variant="outline"
className={settingsActionClassName("cursor-pointer")}
onClick={onRetry}
>
{t("system:sessionCapacityRetry")}
</Button>
</div>
);
if (withinGroup) return <div data-testid="session-capacity-settings">{content}</div>;
return (
<SettingsCard data-testid="session-capacity-settings">
<CardContent className="space-y-3 py-6">
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>{t("system:sessionCapacityLoadFailed")}</AlertDescription>
</Alert>
<Button
variant="outline"
className={settingsActionClassName("cursor-pointer")}
onClick={onRetry}
>
{t("system:sessionCapacityRetry")}
</Button>
</CardContent>
<CardContent>{content}</CardContent>
</SettingsCard>
);
}
@@ -55,22 +66,19 @@ function SessionCapacitySwitch({
const { t } = useTranslation();
const label = t("system:sessionCapacityEnabledLabel");
return (
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<Label
htmlFor="session-capacity-enabled"
className="inline-flex min-h-11 items-center py-2"
>
{label}
</Label>
<p className="text-sm text-muted-foreground">
{t("system:sessionCapacityEnabledDescription")}
</p>
</div>
<div
data-testid="session-capacity-enabled-touch-target"
className="flex min-h-11 min-w-11 shrink-0 items-center justify-center"
>
<SettingsRow
label={label}
description={t("settings:sessionLimitShort")}
controlId="session-capacity-enabled"
touchTarget="switch"
controlWrapperTestId="session-capacity-enabled-touch-target"
info={
<SettingsInfo label={label}>
<p>{t("system:sessionCapacityEnabledDescription")}</p>
<p>{t("system:sessionCapacityBehaviorHelp")}</p>
</SettingsInfo>
}
control={
<Switch
id="session-capacity-enabled"
data-testid="session-capacity-enabled"
@@ -78,10 +86,10 @@ function SessionCapacitySwitch({
disabled={disabled}
onCheckedChange={onChange}
aria-label={label}
className="cursor-pointer [@media(pointer:coarse)]:after:-inset-y-3.5 disabled:cursor-not-allowed"
className="cursor-pointer disabled:cursor-not-allowed"
/>
</div>
</div>
}
/>
);
}
@@ -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 (
<div className="min-w-0 space-y-2 border-t border-border/70 pt-5">
<Label htmlFor="session-capacity-maximum">{t("system:sessionCapacityMaximumLabel")}</Label>
<Input
id="session-capacity-maximum"
data-testid="session-capacity-maximum"
type="number"
inputMode="numeric"
min={1}
max={2147483647}
step={1}
value={value}
disabled={disabled}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
onChange={(event) => onChange(event.target.value)}
className={settingsControlClassName("w-full max-w-xs")}
<div className="space-y-2">
<SettingsRow
label={t("system:sessionCapacityMaximumLabel")}
description={t("settings:sessionMaximumShort")}
descriptionId="session-capacity-maximum-help"
controlId="session-capacity-maximum"
info={
<SettingsInfo label={t("system:sessionCapacityMaximumLabel")}>
{t("system:sessionCapacityMaximumHelp")}
</SettingsInfo>
}
control={
<Input
id="session-capacity-maximum"
data-testid="session-capacity-maximum"
type="number"
inputMode="numeric"
min={1}
max={2147483647}
step={1}
value={value}
disabled={disabled}
aria-invalid={error ? true : undefined}
aria-describedby={error ? "session-capacity-maximum-error" : undefined}
onChange={(event) => onChange(event.target.value)}
className={settingsControlClassName("w-full md:w-40")}
/>
}
/>
<p id="session-capacity-maximum-help" className="text-xs text-muted-foreground">
{t("system:sessionCapacityMaximumHelp")}
</p>
{error && (
<p
id="session-capacity-maximum-error"
data-testid="session-capacity-maximum-error"
role="alert"
className="text-sm text-destructive"
>
{error}
@@ -148,7 +163,7 @@ function EffectiveCapacity({
const { t } = useTranslation();
const current = enabled ? String(maximum) : t("system:sessionCapacityNoLimit");
return (
<div className="rounded-md border border-border/70 bg-muted/20 p-3 text-sm">
<div className="text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">{t("system:sessionCapacityCurrent")}</span>
<strong data-testid="session-capacity-effective-value">{current}</strong>
@@ -159,7 +174,6 @@ function EffectiveCapacity({
<span className="text-muted-foreground">({t("system:sessionCapacityUnsaved")})</span>
)}
</div>
<p className="mt-2 text-xs text-muted-foreground">{t("system:sessionCapacityScope")}</p>
</div>
);
}
@@ -194,26 +208,40 @@ function sessionCapacityMaximumError({
return invalidReason;
}
export function SessionCapacitySettings() {
function SessionCapacityLoadingState({ withinGroup }: { withinGroup: boolean }) {
const { t } = useTranslation();
const loadingContent = (
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
<Spinner className="size-4" />
{t("system:sessionCapacityLoading")}
</div>
);
if (withinGroup) return <div data-testid="session-capacity-settings">{loadingContent}</div>;
return (
<SettingsCard data-testid="session-capacity-settings">
<CardContent>{loadingContent}</CardContent>
</SettingsCard>
);
}
type SessionCapacitySettingsContentProps = {
state: ReturnType<typeof useSessionCapacitySettings>;
withinGroup?: boolean;
};
export function SessionCapacitySettings() {
const state = useSessionCapacitySettings();
return <SessionCapacitySettingsContent state={state} />;
}
if (state.loading && !state.snapshot) {
return (
<SettingsCard data-testid="session-capacity-settings">
<CardContent className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
<Spinner className="size-4" />
{t("system:sessionCapacityLoading")}
</CardContent>
</SettingsCard>
);
}
if (state.loadFailed && !state.snapshot) {
return <SessionCapacityLoadError onRetry={() => void state.reload()} />;
}
if (!state.snapshot) return null;
const { effective, settings } = state.snapshot;
function SessionCapacitySettingsReady({
state,
withinGroup,
}: SessionCapacitySettingsContentProps & {
state: NonNullable<SessionCapacitySettingsContentProps["state"]>;
}) {
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 (
<SettingsCard
isDirty={state.isDirty}
className="min-w-0 w-full"
data-testid="session-capacity-settings"
>
<CardHeader>
<CardTitle className="text-base">{t("system:sessionCapacityLimitTitle")}</CardTitle>
<p className="text-sm text-muted-foreground">
{t("system:sessionCapacityLimitDescription")}
</p>
</CardHeader>
<CardContent className="min-w-0 space-y-5">
const content = (
<>
{withinGroup ? (
<div className="space-y-1 pb-3">
<h4 className="text-sm font-semibold">{t("system:sessionCapacityLimitTitle")}</h4>
</div>
) : (
<CardHeader>
<CardTitle className="text-base">{t("system:sessionCapacityLimitTitle")}</CardTitle>
</CardHeader>
)}
<CardContent className={withinGroup ? "min-w-0 space-y-5 px-0" : "min-w-0 space-y-5"}>
<SessionCapacitySwitch
checked={effectiveEnabled}
disabled={controlsDisabled}
@@ -257,10 +284,6 @@ export function SessionCapacitySettings() {
source={effective.source}
isDirty={state.isDirty}
/>
<div className="flex gap-2 rounded-md border border-border/70 bg-muted/20 p-3 text-sm text-muted-foreground">
<IconInfoCircle className="size-4 shrink-0" />
<p>{t("system:sessionCapacityBehaviorHelp")}</p>
</div>
{state.isLocked && <SessionCapacityManagedNotice />}
{!state.isAdmin && (
<p className="text-sm text-muted-foreground">{t("system:sessionCapacityAdminOnly")}</p>
@@ -272,6 +295,40 @@ export function SessionCapacitySettings() {
</Alert>
)}
</CardContent>
</>
);
if (withinGroup) {
return (
<div className="min-w-0 py-3" data-testid="session-capacity-settings">
{content}
</div>
);
}
return (
<SettingsCard
isDirty={state.isDirty}
className="min-w-0 w-full"
data-testid="session-capacity-settings"
>
{content}
</SettingsCard>
);
}
export function SessionCapacitySettingsContent({
state,
withinGroup = false,
}: SessionCapacitySettingsContentProps) {
if (state.loading && !state.snapshot) {
return <SessionCapacityLoadingState withinGroup={withinGroup} />;
}
if (state.loadFailed && !state.snapshot) {
return (
<SessionCapacityLoadError onRetry={() => void state.reload()} withinGroup={withinGroup} />
);
}
if (!state.snapshot) return null;
return <SessionCapacitySettingsReady state={state} withinGroup={withinGroup} />;
}
@@ -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 (
<Card data-testid="users-table-card">
<SettingsCardHeader
title={
<span className="flex items-center gap-2">
<IconUsers className="h-4 w-4" /> {t("system:usersTitle")}
</span>
}
actions={
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
<Button
size="sm"
variant="outline"
className={settingsActionClassName("cursor-pointer")}
onClick={() => setInviteOpen(true)}
data-testid="users-table-invite"
>
<IconMailForward className="h-3.5 w-3.5" /> {t("system:usersInviteLink")}
</Button>
<Button
size="sm"
className={settingsActionClassName("cursor-pointer")}
onClick={() => setCreateOpen(true)}
data-testid="users-table-create"
>
<IconUserPlus className="h-3.5 w-3.5" /> {t("system:usersAddUser")}
</Button>
</div>
}
/>
<CardContent className="space-y-4">
<SettingsGroup
title={
<span className="flex items-center gap-2">
<IconUsers className="h-4 w-4" /> {t("system:usersTitle")}
</span>
}
action={
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
<Button
size="sm"
variant="outline"
className={settingsActionClassName("cursor-pointer")}
onClick={() => setInviteOpen(true)}
data-testid="users-table-invite"
>
<IconMailForward className="h-3.5 w-3.5" /> {t("system:usersInviteLink")}
</Button>
<Button
size="sm"
className={settingsActionClassName("cursor-pointer")}
onClick={() => setCreateOpen(true)}
data-testid="users-table-create"
>
<IconUserPlus className="h-3.5 w-3.5" /> {t("system:usersAddUser")}
</Button>
</div>
}
data-testid="users-table-card"
contentClassName="space-y-4 divide-y-0"
>
<div className="space-y-4">
{error && <SettingsErrorText data-testid="users-table-error">{error}</SettingsErrorText>}
{!loaded && isLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
@@ -496,7 +496,7 @@ export function UsersTable() {
{t("system:usersEmpty")}
</p>
)}
</CardContent>
</div>
<CreateUserDialog
open={createOpen}
onOpenChange={setCreateOpen}
@@ -507,6 +507,6 @@ export function UsersTable() {
onOpenChange={setInviteOpen}
onCreated={() => void reload()}
/>
</Card>
</SettingsGroup>
);
}
@@ -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;
}) => (
<button type="button" data-testid="sleep-attention" onClick={() => onAttentionChange?.(true)} />
),
}));
vi.mock("./todo-list-panel-settings", () => ({ TodoListPanelSettings: () => null }));
vi.mock("./unread-divider-settings", () => ({ UnreadDividerSettings: () => null }));
vi.mock("./system/message-queue-settings", () => ({
MessageQueueSettingsContent: () => <div data-testid="message-queue" />,
useMessageQueueSettingsDraft: () => runtimeMocks.queue,
}));
vi.mock("./system/session-capacity-settings", () => ({
SessionCapacitySettingsContent: () => <div data-testid="session-capacity" />,
}));
vi.mock("./system/use-session-capacity-settings", () => ({
useSessionCapacitySettings: () => runtimeMocks.session,
}));
import { TaskBehaviorSettings } from "./task-behavior-settings";
beforeEach(() => {
runtimeMocks.queue.loadFailed = false;
runtimeMocks.queue.saveFailed = false;
runtimeMocks.queue.invalidReason = undefined;
runtimeMocks.queue.isDirty = false;
runtimeMocks.session.loadFailed = false;
runtimeMocks.session.saveFailed = false;
runtimeMocks.session.invalidReason = undefined;
runtimeMocks.session.isDirty = false;
});
afterEach(cleanup);
vi.mock("@/hooks/domains/settings/use-settings-tab", async () => {
const { useState } = await import("react");
return {
useSettingsTab: () => {
const [value, selectTab] = useState("tasks");
return { value, selectTab };
},
};
});
vi.mock("./settings-save-provider", async (importOriginal) => ({
...(await importOriginal<typeof import("./settings-save-provider")>()),
useSettingsSaveCoordinator: () => ({ contributorStates: [] }),
}));
describe("TaskBehaviorSettings tabs", () => {
it("starts in Tasks and reveals expanded runtime through a visible tab", () => {
render(<TaskBehaviorSettings />);
expect(
screen
.getByRole("tab", { name: "settings:taskBehaviorTabTasks" })
.getAttribute("aria-selected"),
).toBe("true");
fireEvent.keyDown(screen.getByRole("tab", { name: "settings:taskBehaviorTabRuntime" }), {
key: "Enter",
});
expect(screen.getByTestId("task-behavior-runtime").querySelector("details")).toBeNull();
expect(screen.getByTestId("task-behavior-runtime").getAttribute("aria-hidden")).not.toBe(
"true",
);
});
it("does not steal the active tab when runtime loading fails", () => {
runtimeMocks.queue.loadFailed = true;
render(<TaskBehaviorSettings />);
expect(
screen
.getByRole("tab", { name: "settings:taskBehaviorTabTasks" })
.getAttribute("aria-selected"),
).toBe("true");
});
});
@@ -1,41 +1,147 @@
"use client";
import { IconGauge, IconMessage } from "@tabler/icons-react";
import { Separator } from "@kandev/ui/separator";
import { useTranslation } from "react-i18next";
import { TaskActionsSettings } from "@/components/settings/general-settings";
import { SettingsSection } from "@/components/settings/settings-section";
import { useEffect, useRef, useState } from "react";
import { AgentGeneratedTaskTitleSettings } from "@/components/settings/agent-generated-task-title-settings";
import { AnchoredPromptBarSettings } from "@/components/settings/anchored-prompt-bar-settings";
import { ArchiveConfirmationSettings } from "@/components/settings/archive-confirmation-settings";
import { CreationAutoFocusSettings } from "@/components/settings/creation-auto-focus-settings";
import { MCPTaskAgentProfileDefaultSettings } from "@/components/settings/mcp-task-agent-profile-default-settings";
import { PreventAutoStartAgentSettings } from "@/components/settings/prevent-auto-start-agent-settings";
import { SettingsGroup } from "@/components/settings/settings-group";
import { SettingsTarget } from "@/components/settings/settings-target";
import { MessageQueueSettings } from "@/components/settings/system/message-queue-settings";
import { SessionCapacitySettings } from "@/components/settings/system/session-capacity-settings";
import { SleepInhibitionSettings } from "@/components/settings/sleep-inhibition-settings";
import { TodoListPanelSettings } from "@/components/settings/todo-list-panel-settings";
import { UnreadDividerSettings } from "@/components/settings/unread-divider-settings";
import {
MessageQueueSettingsContent,
useMessageQueueSettingsDraft,
} from "@/components/settings/system/message-queue-settings";
import { SessionCapacitySettingsContent } from "@/components/settings/system/session-capacity-settings";
import { useSessionCapacitySettings } from "@/components/settings/system/use-session-capacity-settings";
import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
import { SettingsPageHeader } from "./settings-typography";
import { SettingsTabs, SettingsTabsList, SettingsTabsPanel } from "./settings-tabs";
import { useSettingsTab } from "@/hooks/domains/settings/use-settings-tab";
import { useSettingsSaveCoordinator } from "./settings-save-provider";
import { UnsavedChangesBadge } from "./unsaved-indicator";
import {
TASK_BEHAVIOR_TABS,
TASK_BEHAVIOR_TARGET_TABS,
taskBehaviorTab,
firstNewAttentionTab,
} from "./task-behavior-tabs";
function useTabAttention(value: string, selectTab: (tab: string) => void) {
const { contributorStates } = useSettingsSaveCoordinator();
const previousAttention = useRef<string[]>([]);
const attention = contributorStates.filter((state) => state.invalid || state.saveFailed);
const attentionKey = attention
.map((state) => `${state.id}:${state.invalid}:${state.saveFailed}`)
.join("|");
useEffect(() => {
const tokens = attentionKey ? attentionKey.split("|") : [];
const newTabs = tokens
.filter((token) => !previousAttention.current.includes(token))
.map((token) => taskBehaviorTab(token.split(":")[0]))
.filter((tab): tab is NonNullable<typeof tab> => !!tab);
previousAttention.current = tokens;
const next = firstNewAttentionTab(newTabs, []);
if (next && next !== value) selectTab(next);
}, [attentionKey, selectTab, value]);
return { contributorStates, attention };
}
/** Task behavior: the former Task Actions and Message Queue pages as one page. */
export function TaskBehaviorSettings() {
const { t } = useTranslation();
const queueState = useMessageQueueSettingsDraft();
const sessionState = useSessionCapacitySettings();
const [sleepAttention, setSleepAttention] = useState(false);
const { value, selectTab } = useSettingsTab({
tabs: TASK_BEHAVIOR_TABS,
defaultTab: "tasks",
targetToTab: TASK_BEHAVIOR_TARGET_TABS,
});
const { contributorStates, attention } = useTabAttention(value, selectTab);
const runtimeLoadError = queueState.loadFailed || sessionState.loadFailed || sleepAttention;
const tabLabels = {
tasks: t("settings:taskBehaviorTabTasks"),
conversation: t("settings:taskBehaviorTabConversation"),
runtime: t("settings:taskBehaviorTabRuntime"),
};
const tabs = TASK_BEHAVIOR_TABS.map((id) => ({
id,
ariaLabel: tabLabels[id],
label: (
<span className="flex items-center gap-2">
{tabLabels[id]}
{contributorStates.some((state) => state.isDirty && taskBehaviorTab(state.id) === id) && (
<UnsavedChangesBadge />
)}
{(attention.some((state) => taskBehaviorTab(state.id) === id) ||
(id === "runtime" && runtimeLoadError)) && (
<span role="status" className="text-xs text-destructive">
{t("settings:settingsNeedAttention")}
</span>
)}
</span>
),
}));
return (
<div className="space-y-8">
<TaskActionsSettings />
<Separator />
<SettingsTarget targetId={GENERAL_SETTINGS_TARGETS.messageQueue}>
<SettingsSection
icon={<IconMessage className="h-5 w-5" />}
title={t("system:messageQueueTitle")}
description={t("system:messageQueueDescription")}
>
<MessageQueueSettings />
</SettingsSection>
</SettingsTarget>
<Separator />
<SettingsTarget targetId={GENERAL_SETTINGS_TARGETS.sessionCapacity}>
<SettingsSection
icon={<IconGauge className="h-5 w-5" />}
title={t("system:sessionCapacityTitle")}
description={t("system:sessionCapacityDescription")}
>
<SessionCapacitySettings />
</SettingsSection>
</SettingsTarget>
<div className="space-y-6" data-testid="task-behavior-settings">
<SettingsTabs tabs={tabs} value={value} onValueChange={selectTab}>
<SettingsPageHeader
title={t("settings:taskBehavior")}
tabs={<SettingsTabsList ariaLabel={t("settings:taskBehavior")} />}
/>
<SettingsTabsPanel value="tasks" className="space-y-6 pt-4">
<SettingsGroup
title={t("settings:taskBehaviorCreating")}
titleTestId="task-behavior-creating-title"
data-testid="task-behavior-group"
>
<CreationAutoFocusSettings presentation="row" />
<AgentGeneratedTaskTitleSettings presentation="row" />
<MCPTaskAgentProfileDefaultSettings presentation="row" />
<PreventAutoStartAgentSettings presentation="row" />
</SettingsGroup>
<SettingsGroup
title={t("settings:taskBehaviorArchiving")}
titleTestId="task-behavior-archiving-title"
data-testid="task-behavior-group"
>
<ArchiveConfirmationSettings presentation="row" />
</SettingsGroup>
</SettingsTabsPanel>
<SettingsTabsPanel value="conversation" className="pt-4">
<SettingsGroup
title={t("settings:taskBehaviorConversation")}
titleTestId="task-behavior-conversation-title"
data-testid="task-behavior-group"
>
<UnreadDividerSettings presentation="row" />
<AnchoredPromptBarSettings presentation="row" />
<TodoListPanelSettings presentation="row" />
</SettingsGroup>
</SettingsTabsPanel>
<SettingsTabsPanel value="runtime" className="pt-4">
<SettingsGroup
title={t("settings:taskBehaviorRuntime")}
description={t("settings:taskBehaviorRuntimeScopeShort")}
titleTestId="task-behavior-runtime-title"
isDirty={queueState.isDirty || sessionState.isDirty}
data-testid="task-behavior-runtime"
>
<SettingsTarget targetId={GENERAL_SETTINGS_TARGETS.sessionCapacity}>
<SessionCapacitySettingsContent state={sessionState} withinGroup />
</SettingsTarget>
<SettingsTarget targetId={GENERAL_SETTINGS_TARGETS.messageQueue}>
<MessageQueueSettingsContent state={queueState} withinGroup />
</SettingsTarget>
<SleepInhibitionSettings withinGroup onAttentionChange={setSleepAttention} />
</SettingsGroup>
</SettingsTabsPanel>
</SettingsTabs>
</div>
);
}
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import {
taskBehaviorTab,
firstNewAttentionTab,
TASK_BEHAVIOR_TARGET_TABS,
} from "./task-behavior-tabs";
describe("Task behavior tab ownership", () => {
it("maps every existing discovery target to its owning tab", () => {
expect(taskBehaviorTab("general-creation-auto-focus")).toBe("tasks");
expect(taskBehaviorTab("general-transcript-navigation")).toBe("conversation");
expect(taskBehaviorTab("general-todo-list-panel")).toBe("conversation");
expect(taskBehaviorTab("system-message-queue")).toBe("runtime");
expect(taskBehaviorTab("general-task-sleep-inhibition")).toBe("runtime");
expect(taskBehaviorTab("unrelated")).toBeUndefined();
});
it("reveals new attention once in tab order without trapping navigation", () => {
expect(firstNewAttentionTab(["runtime", "tasks"], [])).toBe("tasks");
expect(firstNewAttentionTab(["runtime"], ["runtime"])).toBeUndefined();
expect(firstNewAttentionTab(["runtime", "conversation"], ["runtime"])).toBe("conversation");
expect(firstNewAttentionTab([], ["tasks"])).toBeUndefined();
});
});
it("preserves all nine existing discovery fragments", () => {
expect(TASK_BEHAVIOR_TARGET_TABS).toEqual({
"setting-creation-auto-focus": "tasks",
"setting-agent-generated-task-titles": "tasks",
"setting-agent-task-profile": "tasks",
"setting-prevent-auto-start-on-open": "tasks",
"setting-archive-confirmation": "tasks",
"setting-unread-messages": "conversation",
"setting-transcript-navigation": "conversation",
"setting-session-capacity": "runtime",
"setting-message-queue": "runtime",
});
});
@@ -0,0 +1,37 @@
import { GENERAL_SETTINGS_TARGETS as targets } from "@/lib/settings-discovery/catalog/preferences";
export const TASK_BEHAVIOR_TABS = ["tasks", "conversation", "runtime"] as const;
export type TaskBehaviorTab = (typeof TASK_BEHAVIOR_TABS)[number];
export const TASK_BEHAVIOR_TARGET_TABS: Readonly<Record<string, TaskBehaviorTab>> = {
[targets.creationAutoFocus]: "tasks",
[targets.agentGeneratedTitles]: "tasks",
[targets.agentTaskProfile]: "tasks",
[targets.preventAutoStartOnOpen]: "tasks",
[targets.archiveConfirmation]: "tasks",
[targets.unreadMessages]: "conversation",
[targets.transcriptNavigation]: "conversation",
[targets.sessionCapacity]: "runtime",
[targets.messageQueue]: "runtime",
};
const CONTRIBUTOR_TABS: Readonly<Record<string, TaskBehaviorTab>> = {
"general-creation-auto-focus": "tasks",
"general-agent-generated-task-titles": "tasks",
"general-mcp-task-agent-profile-default": "tasks",
"general-prevent-auto-start-on-open": "tasks",
"general-task-actions": "tasks",
"general-unread-divider": "conversation",
"general-transcript-navigation": "conversation",
"general-todo-list-panel": "conversation",
"system-session-capacity": "runtime",
"system-message-queue": "runtime",
"general-task-sleep-inhibition": "runtime",
};
export function taskBehaviorTab(id: string): TaskBehaviorTab | undefined {
return CONTRIBUTOR_TABS[id];
}
export function firstNewAttentionTab(
current: readonly string[],
previous: readonly string[],
): TaskBehaviorTab | undefined {
return TASK_BEHAVIOR_TABS.find((tab) => current.includes(tab) && !previous.includes(tab));
}
@@ -1,4 +1,5 @@
"use client";
import { SettingsInfo } from "./settings-info";
import { useEffect, useRef, useState } from "react";
import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
@@ -7,6 +8,7 @@ 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 { useSettingsSaveContributor } from "./settings-save-provider";
import { useTranslation } from "react-i18next";
@@ -15,6 +17,129 @@ type TodoListPanelDraft = {
onlyWhenNotEmpty: boolean;
};
function TodoListPanelRows({
draft,
saved,
onDraftChange,
}: {
draft: TodoListPanelDraft;
saved: TodoListPanelDraft;
onDraftChange: (update: Partial<TodoListPanelDraft>) => void;
}) {
const { t } = useTranslation();
return (
<div
data-testid="todo-list-panel-settings-card"
data-settings-dirty={
draft.show !== saved.show || draft.onlyWhenNotEmpty !== saved.onlyWhenNotEmpty
}
>
<SettingsRow
label={t("settings:showAgentTodoListPanel")}
description={t("settings:todoShort")}
info={
<SettingsInfo label={t("settings:showAgentTodoListPanel")}>
{t("settings:pinTheAgentsLiveTodoChecklistAs")}
</SettingsInfo>
}
controlId="show-todo-list-panel"
touchTarget="switch"
isDirty={draft.show !== saved.show}
control={
<Switch
id="show-todo-list-panel"
checked={draft.show}
data-settings-dirty={draft.show !== saved.show}
onCheckedChange={(show) => onDraftChange({ show })}
className="shrink-0 cursor-pointer"
/>
}
/>
{draft.show && (
<SettingsRow
label={t("settings:onlyPinWhenTodoListIsNotEmpty")}
description={t("settings:todoNonemptyShort")}
info={
<SettingsInfo label={t("settings:onlyPinWhenTodoListIsNotEmpty")}>
{t("settings:onlyPinWhenTodoListIsNotEmptyDescription")}
</SettingsInfo>
}
controlId="todo-list-panel-only-when-not-empty"
touchTarget="switch"
isDirty={draft.onlyWhenNotEmpty !== saved.onlyWhenNotEmpty}
control={
<Switch
id="todo-list-panel-only-when-not-empty"
checked={draft.onlyWhenNotEmpty}
data-settings-dirty={draft.onlyWhenNotEmpty !== saved.onlyWhenNotEmpty}
onCheckedChange={(onlyWhenNotEmpty) => onDraftChange({ onlyWhenNotEmpty })}
className="shrink-0 cursor-pointer"
/>
}
/>
)}
</div>
);
}
function TodoListPanelCard({
draft,
saved,
onDraftChange,
}: {
draft: TodoListPanelDraft;
saved: TodoListPanelDraft;
onDraftChange: (update: Partial<TodoListPanelDraft>) => void;
}) {
const { t } = useTranslation();
const isDirty = draft.show !== saved.show || draft.onlyWhenNotEmpty !== saved.onlyWhenNotEmpty;
return (
<SettingsCard isDirty={isDirty} data-testid="todo-list-panel-settings-card">
<CardHeader>
<CardTitle className="text-base">{t("settings:todoListPanel")}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex min-h-11 items-center justify-between gap-4">
<div className="min-w-0 space-y-0.5">
<Label htmlFor="show-todo-list-panel">{t("settings:showAgentTodoListPanel")}</Label>
<p className="text-xs text-muted-foreground">
{t("settings:pinTheAgentsLiveTodoChecklistAs")}
</p>
</div>
<Switch
id="show-todo-list-panel"
checked={draft.show}
data-settings-dirty={draft.show !== saved.show}
onCheckedChange={(show) => onDraftChange({ show })}
className="shrink-0 cursor-pointer"
/>
</div>
{draft.show && (
<div className="flex min-h-11 items-center justify-between gap-4 border-t pt-4">
<div className="min-w-0 space-y-0.5">
<Label htmlFor="todo-list-panel-only-when-not-empty">
{t("settings:onlyPinWhenTodoListIsNotEmpty")}
</Label>
<p className="text-xs text-muted-foreground">
{t("settings:onlyPinWhenTodoListIsNotEmptyDescription")}
</p>
</div>
<Switch
id="todo-list-panel-only-when-not-empty"
checked={draft.onlyWhenNotEmpty}
data-settings-dirty={draft.onlyWhenNotEmpty !== saved.onlyWhenNotEmpty}
onCheckedChange={(onlyWhenNotEmpty) => onDraftChange({ onlyWhenNotEmpty })}
className="shrink-0 cursor-pointer"
/>
</div>
)}
</div>
</CardContent>
</SettingsCard>
);
}
/**
* Edits the per-user preferences that control whether the agent's live todo
* checklist is auto-pinned as a Todos tab in the desktop task workbench's
@@ -24,8 +149,11 @@ type TodoListPanelDraft = {
* is re-enabled. Manual adds from the workbench's "+" menu are never gated by
* either preference.
*/
export function TodoListPanelSettings() {
const { t } = useTranslation();
export function TodoListPanelSettings({
presentation = "card",
}: {
presentation?: SettingsPresentation;
}) {
const showTodoListPanel = useAppStore((state) => state.userSettings.showTodoListPanel);
const onlyPinWhenNotEmpty = useAppStore(
(state) => state.userSettings.showTodoListPanelOnlyWhenNotEmpty,
@@ -85,51 +213,21 @@ export function TodoListPanelSettings() {
discard: () => setDraft(saved),
});
if (presentation === "row") {
return (
<TodoListPanelRows
draft={draft}
saved={saved}
onDraftChange={(update) => setDraft((current) => ({ ...current, ...update }))}
/>
);
}
return (
<SettingsCard isDirty={isDirty} data-testid="todo-list-panel-settings-card">
<CardHeader>
<CardTitle className="text-base">{t("settings:todoListPanel")}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex min-h-11 items-center justify-between gap-4">
<div className="min-w-0 space-y-0.5">
<Label htmlFor="show-todo-list-panel">{t("settings:showAgentTodoListPanel")}</Label>
<p className="text-xs text-muted-foreground">
{t("settings:pinTheAgentsLiveTodoChecklistAs")}
</p>
</div>
<Switch
id="show-todo-list-panel"
checked={draft.show}
data-settings-dirty={draft.show !== saved.show}
onCheckedChange={(show) => setDraft((current) => ({ ...current, show }))}
className="shrink-0 cursor-pointer"
/>
</div>
{draft.show && (
<div className="flex min-h-11 items-center justify-between gap-4 border-t pt-4">
<div className="min-w-0 space-y-0.5">
<Label htmlFor="todo-list-panel-only-when-not-empty">
{t("settings:onlyPinWhenTodoListIsNotEmpty")}
</Label>
<p className="text-xs text-muted-foreground">
{t("settings:onlyPinWhenTodoListIsNotEmptyDescription")}
</p>
</div>
<Switch
id="todo-list-panel-only-when-not-empty"
checked={draft.onlyWhenNotEmpty}
data-settings-dirty={draft.onlyWhenNotEmpty !== saved.onlyWhenNotEmpty}
onCheckedChange={(onlyWhenNotEmpty) =>
setDraft((current) => ({ ...current, onlyWhenNotEmpty }))
}
className="shrink-0 cursor-pointer"
/>
</div>
)}
</div>
</CardContent>
</SettingsCard>
<TodoListPanelCard
draft={draft}
saved={saved}
onDraftChange={(update) => setDraft((current) => ({ ...current, ...update }))}
/>
);
}
@@ -9,6 +9,7 @@ import { Badge } from "@kandev/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card";
import { useOrgUnits, type UnitRow } from "@/hooks/domains/org/use-org-units";
import type { OrgUnit } from "@/lib/api/domains/org-units-api";
import { SettingsGroup } from "@/components/settings/settings-group";
import { UnitMembersDialog } from "./unit-members-dialog";
function UnitIcon({ kind }: { kind: OrgUnit["kind"] }) {
@@ -104,28 +105,26 @@ export function UnitsPage() {
return (
<div className="flex flex-col gap-4" data-testid="units-page">
<Card>
<CardHeader>
<CardTitle>{t("settings:unitsTitle")}</CardTitle>
<CardDescription>{t("settings:unitsDescription")}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{units.loading && <p className="text-muted-foreground text-sm">{t("common:loading")}</p>}
{!units.loading && units.units.length === 0 && (
<p className="text-muted-foreground text-sm">{t("settings:unitsEmpty")}</p>
)}
{units.units.map((unit) => (
<UnitTreeRow
key={unit.id}
unit={unit}
busy={units.busy}
onAddChild={setParentForNew}
onManageMembers={setMembersFor}
onDelete={(u) => void units.remove(u.id)}
/>
))}
</CardContent>
</Card>
<SettingsGroup
title={t("settings:unitsTitle")}
description={t("settings:unitsDescription")}
contentClassName="flex flex-col gap-2 divide-y-0"
>
{units.loading && <p className="text-muted-foreground text-sm">{t("common:loading")}</p>}
{!units.loading && units.units.length === 0 && (
<p className="text-muted-foreground text-sm">{t("settings:unitsEmpty")}</p>
)}
{units.units.map((unit) => (
<UnitTreeRow
key={unit.id}
unit={unit}
busy={units.busy}
onAddChild={setParentForNew}
onManageMembers={setMembersFor}
onDelete={(u) => void units.remove(u.id)}
/>
))}
</SettingsGroup>
{parentForNew && (
<Card data-testid="unit-create-card">
@@ -1,12 +1,13 @@
"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";
@@ -16,7 +17,11 @@ import { useTranslation } from "react-i18next";
* read-cursor updates. It participates in the shared Settings save/discard
* lifecycle and commits the persisted preference to the app store on save.
*/
export function UnreadDividerSettings() {
export function UnreadDividerSettings({
presentation = "card",
}: {
presentation?: SettingsPresentation;
}) {
const { t } = useTranslation();
const unreadDivider = useAppStore((state) => state.userSettings.unreadDivider);
const setUserSettings = useAppStore((state) => state.setUserSettings);
@@ -47,6 +52,33 @@ export function UnreadDividerSettings() {
discard: () => setDraft(saved),
});
const row = (
<SettingsRow
label={t("settings:showNewDividerInTranscripts")}
description={t("settings:unreadShort")}
info={
<SettingsInfo label={t("settings:showNewDividerInTranscripts")}>
{t("settings:markMessagesThatArrivedWhileA")}
</SettingsInfo>
}
controlId="show-unread-divider"
touchTarget="switch"
discoveryTargetId={GENERAL_SETTINGS_TARGETS.unreadMessages}
isDirty={isDirty}
control={
<Switch
id="show-unread-divider"
checked={draft}
data-settings-dirty={isDirty}
onCheckedChange={setDraft}
className="shrink-0 cursor-pointer"
/>
}
/>
);
if (presentation === "row") return row;
return (
<SettingsCard
isDirty={isDirty}
@@ -56,23 +88,7 @@ export function UnreadDividerSettings() {
<CardHeader>
<CardTitle className="text-base">{t("settings:unreadMessages")}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex min-h-11 items-center justify-between gap-4">
<div className="min-w-0 space-y-0.5">
<Label htmlFor="show-unread-divider">{t("settings:showNewDividerInTranscripts")}</Label>
<p className="text-xs text-muted-foreground">
{t("settings:markMessagesThatArrivedWhileA")}
</p>
</div>
<Switch
id="show-unread-divider"
checked={draft}
data-settings-dirty={isDirty}
onCheckedChange={setDraft}
className="shrink-0 cursor-pointer"
/>
</div>
</CardContent>
<CardContent>{row}</CardContent>
</SettingsCard>
);
}
@@ -18,7 +18,11 @@ const dirtyButtonClass = "border-success/60 text-success hover:bg-success/10";
export function UnsavedChangesBadge() {
const { t } = useTranslation();
return <span className="text-xs text-success">{t("common:unsavedChanges")}</span>;
return (
<span role="status" className="text-xs text-success">
{t("common:unsavedChanges")}
</span>
);
}
export function UnsavedSaveButton({
@@ -3,10 +3,8 @@
import { useTranslation } from "react-i18next";
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { CardContent } from "@kandev/ui/card";
import type { UtilityAgent } from "@/lib/api/domains/utility-api";
import type { AgentProfileOption } from "@/lib/state/slices/settings/types";
import { SettingsCard } from "@/components/settings/settings-card";
import { useFeature } from "@/hooks/domains/features/use-feature";
import { SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/standalone";
import { isUtilityAgentDirty } from "@/components/settings/utility-dirty";
@@ -14,7 +12,7 @@ import {
UtilityAgentProfilePicker,
utilityProfileEligibility,
} from "@/components/settings/utility-agent-profile-picker";
import { SettingsCardHeader } from "@/components/settings/settings-card-header";
import { SettingsGroup } from "@/components/settings/settings-group";
import { SettingsFieldLabel } from "@/components/settings/settings-typography";
import { settingsActionClassName } from "@/components/settings/settings-control";
@@ -58,16 +56,14 @@ export function DefaultModelSection({
);
const selected = eligibleProfiles.find((profile) => profile.id === profileId);
return (
<SettingsCard
<SettingsGroup
isDirty={isDirty}
title={t("settings:utilityDefaultModelTitle")}
description={t("settings:utilityDefaultModelDescription")}
discoveryTargetId={SETTINGS_TARGETS.utilityDefaultModel}
data-testid="utility-default-model-card"
>
<SettingsCardHeader title={t("settings:utilityDefaultModelTitle")} />
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
{t("settings:utilityDefaultModelDescription")}
</p>
<div className="space-y-3">
<div className="space-y-2">
<SettingsFieldLabel>{t("settings:utilityAgentProfile")}</SettingsFieldLabel>
<UtilityAgentProfilePicker
@@ -83,8 +79,8 @@ export function DefaultModelSection({
<p className="text-xs text-destructive">{t("settings:utilityProfileNeedsRepair")}</p>
)}
</div>
</CardContent>
</SettingsCard>
</div>
</SettingsGroup>
);
}
@@ -159,21 +155,19 @@ export function PerActionOverridesSection({
const { t } = useTranslation();
if (builtins.length === 0) return null;
return (
<SettingsCard
<SettingsGroup
isDirty={builtins.some((agent) =>
isUtilityAgentDirty(
agent,
savedBuiltins.find((saved) => saved.id === agent.id),
),
)}
title={t("settings:utilityActionsTitle")}
description={t("settings:utilityActionsDescription")}
discoveryTargetId={SETTINGS_TARGETS.utilityActions}
data-testid="utility-actions-card"
>
<SettingsCardHeader title={t("settings:utilityActionsTitle")} />
<CardContent className="space-y-0">
<p className="pb-3 text-sm text-muted-foreground">
{t("settings:utilityActionsDescription")}
</p>
<div className="space-y-0">
{builtins.map((agent) => (
<BuiltinActionRow
key={agent.id}
@@ -188,8 +182,8 @@ export function PerActionOverridesSection({
)}
/>
))}
</CardContent>
</SettingsCard>
</div>
</SettingsGroup>
);
}
@@ -252,23 +246,19 @@ export function CustomAgentsSection({
}) {
const { t } = useTranslation();
return (
<SettingsCard
<SettingsGroup
title={t("settings:utilityCustomAgentsTitle")}
description={t("settings:utilityCustomAgentsDescription")}
discoveryTargetId={SETTINGS_TARGETS.utilityCustomAgents}
data-testid="utility-custom-agents-card"
action={
<Button onClick={onAdd} size="sm" className={settingsActionClassName("cursor-pointer")}>
<IconPlus className="h-4 w-4 mr-1" />
{t("settings:utilityAddCustomAgent")}
</Button>
}
>
<SettingsCardHeader
title={t("settings:utilityCustomAgentsTitle")}
actions={
<Button onClick={onAdd} size="sm" className={settingsActionClassName("cursor-pointer")}>
<IconPlus className="h-4 w-4 mr-1" />
{t("settings:utilityAddCustomAgent")}
</Button>
}
/>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("settings:utilityCustomAgentsDescription")}
</p>
<div className="space-y-4">
{agents.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">
{t("settings:utilityCustomAgentsEmpty")}
@@ -286,7 +276,7 @@ export function CustomAgentsSection({
))}
</div>
)}
</CardContent>
</SettingsCard>
</div>
</SettingsGroup>
);
}
@@ -0,0 +1,4 @@
import type { Page } from "@playwright/test";
export async function openTaskBehaviorRuntime(page: Page): Promise<void> {
await page.getByRole("tab", { name: "Runtime", exact: true }).click();
}
@@ -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(
@@ -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$/, {
@@ -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
@@ -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();
@@ -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
@@ -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",
@@ -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);
});
});
@@ -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();
});
@@ -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,
@@ -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();
});
});
@@ -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({
@@ -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")
@@ -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,
});
}
});
@@ -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);
@@ -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();
}
});
@@ -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();
@@ -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();
@@ -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",
@@ -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();
@@ -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) {
@@ -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",
});
@@ -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(),
@@ -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();
@@ -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();
@@ -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 = '<button data-settings-info="true">Info</button><input type="number" />';
document.body.append(row);
revealSettingsTarget(row, { settleDurationMs: 0 });
expect(document.activeElement).toBe(row.querySelector("input"));
row.remove();
});
+14 -1
View File
@@ -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<HTMLElement>(`[${SETTINGS_TARGET_FOCUS_ATTRIBUTE}]`) ??
element.querySelector<HTMLElement>(
`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;
+39 -2
View File
@@ -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</0> creates a separate task. <3>spawn_session_kandev</3> adds a session to the current task, so it does not use this preference.",
"mcpAffectedToolsHelp": "<0>create_task_kandev</0> creates a separate task. <2>spawn_session_kandev</2> adds a session to the current task, so it does not use this preference.",
"mcpCreateTaskScope": "<0>create_task_kandev</0> creates new tasks and subtasks. This setting applies only when the call omits <4>agent_profile_id</4>. In a live task-mode call, Creating session profile copies the verified session's effective model, mode, and options.",
"mcpSpawnSessionScope": "<0>spawn_session_kandev</0> 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."
}
+22
View File
@@ -421,6 +421,28 @@
"sshTrustThisHost": "<0>このホストを信頼します。</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": "成功",
+39 -2
View File
@@ -443,7 +443,7 @@
"manageSpritesDevRemoteSandboxIntegration": "実行中のエージェントを分離されたクラウド環境で動かすための、Sprites.dev リモートサンドボックス連携を管理します。",
"manualInstallRequired": "手動インストールが必要です",
"markMessagesThatArrivedWhileA": "タスクが非表示の間に届いたメッセージをマークします。すぐに反映されます。",
"mcpAffectedToolsHelp": "<0>create_task_kandev</0> は個別のタスクを作成します。<3>spawn_session_kandev</3> は現在のタスクにセッションを追加するため、この設定は使用されません。",
"mcpAffectedToolsHelp": "<0>create_task_kandev</0> は個別のタスクを作成します。<2>spawn_session_kandev</2> は現在のタスクにセッションを追加するため、この設定は使用されません。",
"mcpCreateTaskScope": "<0>create_task_kandev</0> は新しいタスクとサブタスクを作成します。この設定は、呼び出しで <4>agent_profile_id</4> が省略された場合にのみ適用されます。ライブの task-mode 呼び出しでは、Creating session profile が検証済みセッションの有効なモデル、モード、オプションをコピーします。",
"mcpSpawnSessionScope": "<0>spawn_session_kandev</0> と、自分で作成したタスクには影響しません。ワークフローで選択されたプロファイルが最優先です。それ以外の場合は明示的なプロファイルが優先されます。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 が動作するコンピューターのスリープを防ぎます。"
}
+10
View File
@@ -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}}",
+43 -6
View File
@@ -443,7 +443,7 @@
"manageSpritesDevRemoteSandboxIntegration": "Ḿàńàĝē Śƥŕĩţēś.ďēv ŕēḿōţē śàńďƀōx ĩńţēĝŕàţĩōń ƒōŕ ŕũńńĩńĝ àĝēńţś ĩń ĩśōĺàţēď ćĺōũď ēńvĩŕōńḿēńţś.",
"manualInstallRequired": "Ḿàńũàĺ ĩńśţàĺĺ ŕēqũĩŕēď",
"markMessagesThatArrivedWhileA": "Ḿàŕķ ḿēśśàĝēś ţĥàţ àŕŕĩvēď ŵĥĩĺē à ţàśķ ŵàś ōũţ ōƒ vĩēŵ. Ţàķēś ēƒƒēćţ ĩḿḿēďĩàţēĺŷ.",
"mcpAffectedToolsHelp": "<0>ćŕēàţē_ţàśķ_ķàńďēv</0> ćŕēàţēś à śēƥàŕàţē ţàśķ. <3>śƥàŵń_śēśśĩōń_ķàńďēv</3> àďďś à śēśśĩōń ţō ţĥē ćũŕŕēńţ ţàśķ, śō ĩţ ďōēś ńōţ ũśē ţĥĩś ƥŕēƒēŕēńćē.",
"mcpAffectedToolsHelp": "<0>ćŕēàţē_ţàśķ_ķàńďēv</0> ćŕēàţēś à śēƥàŕàţē ţàśķ. <2>śƥàŵń_śēśśĩōń_ķàńďēv</2> àďďś à śēśśĩōń ţō ţĥē ćũŕŕēńţ ţàśķ, śō ĩţ ďōēś ńōţ ũśē ţĥĩś ƥŕēƒēŕēńćē.",
"mcpCreateTaskScope": "<0>ćŕēàţē_ţàśķ_ķàńďēv</0> ćŕēàţēś ńēŵ ţàśķś àńď śũƀţàśķś. Ţĥĩś śēţţĩńĝ àƥƥĺĩēś ōńĺŷ ŵĥēń ţĥē ćàĺĺ ōḿĩţś <4>àĝēńţ_ƥŕōƒĩĺē_ĩď</4>. Ĩń à ĺĩvē ţàśķ-ḿōďē ćàĺĺ, Ćŕēàţĩńĝ śēśśĩōń ƥŕōƒĩĺē ćōƥĩēś ţĥē vēŕĩƒĩēď śēśśĩōń'ś ēƒƒēćţĩvē ḿōďēĺ, ḿōďē, àńď ōƥţĩōńś.",
"mcpSpawnSessionScope": "<0>śƥàŵń_śēśśĩōń_ķàńďēv</0> àńď ţàśķś ŷōũ ćŕēàţē ŷōũŕśēĺƒ àŕē ńōţ àƒƒēćţēď. Ŵōŕķƒĺōŵ-śēĺēćţēď ƥŕōƒĩĺēś ŵĩń ƒĩŕśţ. Ōţĥēŕŵĩśē àń ē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 ƒŕōḿ śĺēēƥĩńĝ ďũŕĩńĝ ţàśķś."
}
+39 -2
View File
@@ -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</0> cria uma tarefa separada. <3>spawn_session_kandev</3> adiciona uma sessão à tarefa atual, pelo que não usa esta preferência.",
"mcpAffectedToolsHelp": "<0>create_task_kandev</0> cria uma tarefa separada. <2>spawn_session_kandev</2> adiciona uma sessão à tarefa atual, pelo que não usa esta preferência.",
"mcpCreateTaskScope": "<0>create_task_kandev</0> cria novas tarefas e subtarefas. Esta definição aplica-se apenas quando a chamada omite <4>agent_profile_id</4>. 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</0> 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."
}

Some files were not shown because too many files have changed in this diff Show More