feat: organize settings with tabs and concise help (#3850)
This commit is contained in:
+2
-5
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "成功",
|
||||
|
||||
@@ -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 が動作するコンピューターのスリープを防ぎます。"
|
||||
}
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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 ƒŕōḿ śĺēēƥĩńĝ ďũŕĩńĝ ţàśķś."
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user