Skip to content

Commit ecae49f

Browse files
deepagent-aiclaude
andauthored
Feat/mode redesign (#56)
### Issue for this PR Closes # ### Type of change - [ ] Bug fix - [ ] New feature - [ ] Refactor / code improvement - [ ] Documentation ### What does this PR do? Please provide a description of the issue, the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR. **If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!** ### How did you verify your code works? ### Screenshots / recordings _If this is a UI change, please include a screenshot or recording._ ### Checklist - [ ] I have tested my changes locally - [ ] I have not included unrelated changes in this PR _If you do not follow this template your PR will be automatically rejected._ --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 20d5570 commit ecae49f

47 files changed

Lines changed: 1882 additions & 354 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/app/src/components/deepagent-settings-ux.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,11 @@ describe("DeepAgent settings UX", () => {
3535
// …it is a composer control next to the agent selector, backed by directory-level auto-accept.
3636
expect(composer).toContain("ApprovalControl")
3737
expect(control).toContain('"data-action": "prompt-approval"')
38-
expect(control).toContain("toggleAutoAcceptDirectory")
38+
// Tri-state Read-Only / Request / Full-Access selector backed by the permission context.
39+
expect(control).toContain("setDirectoryApprovalMode")
3940
expect(control).toContain("composer.approval.request")
40-
expect(control).toContain("composer.approval.auto")
41+
expect(control).toContain("composer.approval.readOnly")
42+
expect(control).toContain("composer.approval.fullAccess")
4143
})
4244

4345
test("routes the legacy settings dialog import to the unified settings page", async () => {

packages/app/src/components/deepagent/approval-control.tsx

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,49 @@
11
import { createMemo, type JSX } from "solid-js"
22
import { Select } from "@deepagent-code/ui/select"
33
import { useLanguage } from "@/context/language"
4-
import { usePermission } from "@/context/permission"
4+
import { usePermission, type DirectoryApprovalMode } from "@/context/permission"
55

66
/**
77
* Approval-mode control for the composer toolbar, next to the agent (build/plan) selector.
88
*
9-
* Mirrors Codex's approval selector UX, simplified to two options: the button shows the CURRENT mode
10-
* ("Request approval" by default, "Auto-approve" when armed); clicking opens a small picker to switch.
11-
* The mode is DIRECTORY-scoped (persists across sessions in the same workspace), backed by the existing
12-
* permission context (isAutoAcceptingDirectory / toggleAutoAcceptDirectory) — the same state the old
13-
* settings toggle drove, now surfaced where the user acts.
9+
* Mirrors Codex's approval selector UX: it collapses the approval×sandbox space into three named
10+
* presets. The button shows the CURRENT mode; clicking opens a small picker to switch:
11+
* - "Read-Only" — the agent may read/search but write/edit/bash requests are auto-rejected.
12+
* - "Request" (default) — normal permission flow; the agent asks before write/execute.
13+
* - "Full-Access" — auto-approve everything (the old directory-level auto-accept).
14+
*
15+
* The mode is DIRECTORY-scoped (persists across sessions in the same workspace), backed by the
16+
* permission context tri-state (directoryApprovalMode / setDirectoryApprovalMode). Full-Access maps
17+
* onto the existing isAutoAcceptingDirectory / toggleAutoAcceptDirectory state the old settings
18+
* toggle drove, now surfaced where the user acts.
1419
*/
1520

16-
type ApprovalMode = "request" | "auto"
21+
type ApprovalMode = DirectoryApprovalMode
1722

1823
export function ApprovalControl(props: { directory: string; triggerStyle?: JSX.CSSProperties; onAfter?: () => void }) {
1924
const language = useLanguage()
2025
const permission = usePermission()
2126

22-
const auto = createMemo(() => (props.directory ? permission.isAutoAcceptingDirectory(props.directory) : false))
23-
const current = createMemo<ApprovalMode>(() => (auto() ? "auto" : "request"))
27+
const current = createMemo<ApprovalMode>(() =>
28+
props.directory ? permission.directoryApprovalMode(props.directory) : "request",
29+
)
2430

25-
const options: ApprovalMode[] = ["request", "auto"]
26-
const label = (mode: ApprovalMode) =>
27-
mode === "auto"
28-
? language.t("composer.approval.auto")
29-
: language.t("composer.approval.request")
31+
const options: ApprovalMode[] = ["read-only", "request", "full-access"]
32+
const label = (mode: ApprovalMode) => {
33+
switch (mode) {
34+
case "read-only":
35+
return language.t("composer.approval.readOnly")
36+
case "full-access":
37+
return language.t("composer.approval.fullAccess")
38+
default:
39+
return language.t("composer.approval.request")
40+
}
41+
}
3042

3143
const onSelect = (mode: ApprovalMode | undefined) => {
3244
if (!mode || !props.directory) return
33-
const isAuto = mode === "auto"
34-
if (isAuto === auto()) return
35-
// toggleAutoAcceptDirectory flips the directory-level state; only call it when the target differs.
36-
permission.toggleAutoAcceptDirectory(props.directory)
45+
if (mode === current()) return
46+
permission.setDirectoryApprovalMode(props.directory, mode)
3747
props.onAfter?.()
3848
}
3949

@@ -46,7 +56,7 @@ export function ApprovalControl(props: { directory: string; triggerStyle?: JSX.C
4656
value={(o) => o}
4757
label={label}
4858
onSelect={onSelect}
49-
class="capitalize max-w-[160px] text-text-base"
59+
class="max-w-[180px] text-text-base"
5060
valueClass="truncate text-13-regular text-text-base"
5161
triggerStyle={props.triggerStyle}
5262
triggerProps={{ "data-action": "prompt-approval" }}

packages/app/src/components/deepagent/goal-status-bar.tsx

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Button } from "@deepagent-code/ui/button"
33
import { Icon } from "@deepagent-code/ui/icon"
44
import { useServerSync } from "@/context/server-sync"
55
import { useSDK } from "@/context/sdk"
6+
import { useLanguage } from "@/context/language"
67
import { pauseGoal, resumeGoal, stopGoal, type PanelGoalClient } from "./panel-goal.api"
78

89
/**
@@ -12,13 +13,13 @@ import { pauseGoal, resumeGoal, stopGoal, type PanelGoalClient } from "./panel-g
1213
* while the background loop ticks and after a terminal phase (until the user starts a new goal).
1314
*/
1415

15-
const PHASE_LABEL: Record<string, string> = {
16-
running: "Running",
17-
paused: "Paused",
18-
done: "Complete",
19-
needs_human: "Needs you",
20-
rolled_back: "Rolled back",
21-
stopped: "Stopped",
16+
const PHASE_LABEL_KEY: Record<string, string> = {
17+
running: "composer.goal.phase.running",
18+
paused: "composer.goal.phase.paused",
19+
done: "composer.goal.phase.done",
20+
needs_human: "composer.goal.phase.needsHuman",
21+
rolled_back: "composer.goal.phase.rolledBack",
22+
stopped: "composer.goal.phase.stopped",
2223
}
2324

2425
const PHASE_ICON: Record<string, Parameters<typeof Icon>[0]["name"]> = {
@@ -36,6 +37,7 @@ const isTerminal = (phase: string) =>
3637
export function GoalStatusBar(props: { sessionID: string }) {
3738
const serverSync = useServerSync()
3839
const sdk = useSDK()
40+
const language = useLanguage()
3941
const [busy, setBusy] = createSignal(false)
4042

4143
const goal = createMemo(() => (props.sessionID ? serverSync.data.session_goal[props.sessionID] : undefined))
@@ -74,31 +76,36 @@ export function GoalStatusBar(props: { sessionID: string }) {
7476
class="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-surface-raised border border-border-subtle text-13-regular"
7577
>
7678
<Icon name={PHASE_ICON[g().phase] ?? "status-active"} class="size-4 shrink-0 text-text-muted" />
77-
<span class="text-text-base font-medium">{PHASE_LABEL[g().phase] ?? g().phase}</span>
79+
<span class="text-text-base font-medium">
80+
{PHASE_LABEL_KEY[g().phase] ? language.t(PHASE_LABEL_KEY[g().phase] as never) : g().phase}
81+
</span>
7882
<span class="text-text-muted truncate">
79-
{ticks()} {ticks() === 1 ? "tick" : "ticks"} · {tokens().toLocaleString()} tokens
83+
{language.t(ticks() === 1 ? "composer.goal.budget.one" : "composer.goal.budget.other", {
84+
ticks: ticks(),
85+
tokens: tokens().toLocaleString(),
86+
})}
8087
</span>
8188
<Show when={g().gaps.length > 0}>
8289
<span class="text-text-muted truncate italic">{g().gaps[0]}</span>
8390
</Show>
8491
<div class="flex items-center gap-1 ml-auto shrink-0">
8592
<Show when={running()}>
8693
<Button variant="ghost" size="small" class="h-7 px-2" disabled={busy()} onClick={onPause}>
87-
Pause
94+
{language.t("composer.goal.pause")}
8895
</Button>
8996
</Show>
9097
<Show when={paused()}>
9198
<Button variant="ghost" size="small" class="h-7 px-2" disabled={busy()} onClick={onResume}>
92-
Resume
99+
{language.t("composer.goal.resume")}
93100
</Button>
94101
</Show>
95102
<Show when={!terminal()}>
96-
<Button variant="ghost" size="small" class="size-7 p-0" disabled={busy()} onClick={onStop} aria-label="Stop goal">
103+
<Button variant="ghost" size="small" class="size-7 p-0" disabled={busy()} onClick={onStop} aria-label={language.t("composer.goal.stop")}>
97104
<Icon name="circle-ban-sign" class="size-4" />
98105
</Button>
99106
</Show>
100107
<Show when={terminal()}>
101-
<Button variant="ghost" size="small" class="size-7 p-0" onClick={onDismiss} aria-label="Dismiss goal">
108+
<Button variant="ghost" size="small" class="size-7 p-0" onClick={onDismiss} aria-label={language.t("composer.goal.dismiss")}>
102109
<Icon name="close-small" class="size-4" />
103110
</Button>
104111
</Show>
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { Popover as Kobalte } from "@kobalte/core/popover"
2+
import { type Component, type ComponentProps, createMemo, type JSX, Show, type ValidComponent } from "solid-js"
3+
import { createStore } from "solid-js/store"
4+
import { useLocal } from "@/context/local"
5+
import { useLanguage } from "@/context/language"
6+
import { List, type ListRef } from "@deepagent-code/ui/list"
7+
8+
/**
9+
* Composer collaboration-mode selector.
10+
*
11+
* Replaces the old label-only <Select> mode switcher with a LARGE popup (mirroring the model
12+
* picker) where each mode is a bold title + a dimmed one-line description. It lists the primary
13+
* agents (auto / loop / design — subagents and hidden agents are already filtered out by
14+
* local.agent.list()), marks the active one with a trailing check, and switches on select.
15+
*
16+
* The mode is SESSION-scoped: local.agent.set(name) persists per session via the model-selection
17+
* store. The command.agent.cycle keybind (local.agent.move) remains the keyboard way to cycle; this
18+
* popup is an additional pointer/keyboard way to pick.
19+
*/
20+
21+
type Agent = ReturnType<ReturnType<typeof useLocal>["agent"]["list"]>[number]
22+
type Dismiss = "escape" | "outside" | "select"
23+
type ModeName = "auto" | "loop" | "design"
24+
25+
const MODE_LABEL_KEY = {
26+
auto: "composer.mode.auto",
27+
loop: "composer.mode.loop",
28+
design: "composer.mode.design",
29+
} as const
30+
31+
const MODE_DESC_KEY = {
32+
auto: "composer.mode.auto.desc",
33+
loop: "composer.mode.loop.desc",
34+
design: "composer.mode.design.desc",
35+
} as const
36+
37+
const isModeName = (name: string): name is ModeName => name in MODE_LABEL_KEY
38+
39+
/**
40+
* The localized display label for a mode name — used both by the popup rows and the composer trigger
41+
* (so the closed trigger shows "自动/目标/设计", not the raw agent name). Falls back to the raw name
42+
* for custom primary agents that have no composer.mode.* key.
43+
*/
44+
export const useModeLabel = () => {
45+
const language = useLanguage()
46+
return (name: string | undefined): string => {
47+
if (!name) return language.t("command.agent.cycle")
48+
if (isModeName(name)) {
49+
const label = language.t(MODE_LABEL_KEY[name])
50+
if (label) return label
51+
}
52+
return name
53+
}
54+
}
55+
56+
type ModeSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
57+
58+
export function ModeSelector(props: {
59+
children?: JSX.Element
60+
triggerAs?: ValidComponent
61+
triggerProps?: ModeSelectorTriggerProps
62+
onClose?: (cause: "escape" | "select") => void
63+
}) {
64+
const local = useLocal()
65+
const language = useLanguage()
66+
67+
const [store, setStore] = createStore<{ open: boolean; dismiss: Dismiss | null }>({
68+
open: false,
69+
dismiss: null,
70+
})
71+
72+
const close = (dismiss: Dismiss) => {
73+
setStore("dismiss", dismiss)
74+
setStore("open", false)
75+
}
76+
77+
const modes = createMemo(() => local.agent.list())
78+
79+
const modeLabel = useModeLabel()
80+
const title = (agent: Agent) => modeLabel(agent.name)
81+
82+
const description = (agent: Agent) => {
83+
if (isModeName(agent.name)) {
84+
const desc = language.t(MODE_DESC_KEY[agent.name])
85+
if (desc) return desc
86+
}
87+
return agent.description ?? ""
88+
}
89+
90+
let listRef: ListRef | undefined
91+
92+
return (
93+
<Kobalte
94+
open={store.open}
95+
onOpenChange={(next) => {
96+
if (next) setStore("dismiss", null)
97+
setStore("open", next)
98+
}}
99+
modal={false}
100+
placement="top-start"
101+
gutter={4}
102+
>
103+
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
104+
{props.children}
105+
</Kobalte.Trigger>
106+
<Kobalte.Portal>
107+
<Kobalte.Content
108+
class="w-80 max-h-96 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden
109+
[&_[data-slot=list-item-selected-icon]]:absolute
110+
[&_[data-slot=list-item-selected-icon]]:right-3
111+
[&_[data-slot=list-item-selected-icon]]:top-1/2
112+
[&_[data-slot=list-item-selected-icon]]:-translate-y-1/2"
113+
onKeyDown={(event) => listRef?.onKeyDown(event)}
114+
onEscapeKeyDown={(event) => {
115+
close("escape")
116+
event.preventDefault()
117+
event.stopPropagation()
118+
}}
119+
onPointerDownOutside={() => close("outside")}
120+
onFocusOutside={() => close("outside")}
121+
onCloseAutoFocus={(event) => {
122+
const dismiss = store.dismiss
123+
if (dismiss === "outside") event.preventDefault()
124+
if (dismiss === "escape" || dismiss === "select") {
125+
event.preventDefault()
126+
props.onClose?.(dismiss)
127+
}
128+
setStore("dismiss", null)
129+
}}
130+
>
131+
<Kobalte.Title class="sr-only">{language.t("command.agent.cycle")}</Kobalte.Title>
132+
<List
133+
ref={(ref) => (listRef = ref)}
134+
class="flex-1 min-h-0 p-1 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
135+
key={(agent) => agent.name}
136+
items={modes()}
137+
current={local.agent.current()}
138+
onSelect={(agent) => {
139+
if (!agent) return
140+
local.agent.set(agent.name)
141+
close("select")
142+
}}
143+
>
144+
{(agent) => (
145+
// flex-1 + text-left so content is left-aligned. pr-7 reserves a fixed right column for
146+
// the check on EVERY row (selected or not), so text has a uniform right margin and never
147+
// runs under the check — the check is absolutely positioned into that reserved column.
148+
<div class="flex flex-1 min-w-0 flex-col gap-0.5 pr-7 text-left">
149+
<span class="text-13-medium text-text-base capitalize">{title(agent)}</span>
150+
<Show when={description(agent)}>
151+
<span class="text-11-regular text-text-weaker whitespace-normal leading-snug">{description(agent)}</span>
152+
</Show>
153+
</div>
154+
)}
155+
</List>
156+
</Kobalte.Content>
157+
</Kobalte.Portal>
158+
</Kobalte>
159+
)
160+
}

packages/app/src/components/deepagent/panel-button.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Icon } from "@deepagent-code/ui/icon"
44
import { Tooltip } from "@deepagent-code/ui/tooltip"
55
import { useDialog } from "@deepagent-code/ui/context/dialog"
66
import { useSDK } from "@/context/sdk"
7+
import { useLanguage } from "@/context/language"
78
import { armPanel, consultPanel, fetchPanelStatus, type PanelGoalClient } from "./panel-goal.api"
89
import { PanelVerdictDialog } from "./panel-verdict-dialog"
910

@@ -21,6 +22,7 @@ import { PanelVerdictDialog } from "./panel-verdict-dialog"
2122
export function PanelButton(props: { sessionID: string }) {
2223
const sdk = useSDK()
2324
const dialog = useDialog()
25+
const language = useLanguage()
2426
const [busy, setBusy] = createSignal(false)
2527
const [armedOverride, setArmedOverride] = createSignal<boolean | undefined>(undefined)
2628

@@ -71,7 +73,11 @@ export function PanelButton(props: { sessionID: string }) {
7173
}
7274

7375
return (
74-
<Tooltip placement="top" gutter={4} value={armed() ? "Expert panel armed — click to consult now" : "Convene expert panel"}>
76+
<Tooltip
77+
placement="top"
78+
gutter={4}
79+
value={armed() ? language.t("composer.panel.armed") : language.t("composer.panel.convene")}
80+
>
7581
<div class="flex items-center" data-component="prompt-panel-control">
7682
<Button
7783
data-action="prompt-panel"
@@ -82,10 +88,10 @@ export function PanelButton(props: { sessionID: string }) {
8288
disabled={busy() || !props.sessionID}
8389
onClick={onClick}
8490
aria-pressed={armed()}
85-
aria-label="Expert panel"
91+
aria-label={language.t("composer.panel.label")}
8692
>
8793
<Icon name="speech-bubble" class="size-4" />
88-
<span>Panel</span>
94+
<span>{language.t("composer.panel.label")}</span>
8995
</Button>
9096
<Show when={armed()}>
9197
<Button
@@ -94,7 +100,7 @@ export function PanelButton(props: { sessionID: string }) {
94100
class="size-6 p-0"
95101
disabled={busy()}
96102
onClick={onDisarm}
97-
aria-label="Disarm expert panel"
103+
aria-label={language.t("composer.panel.disarm")}
98104
>
99105
<Icon name="close-small" class="size-3.5" />
100106
</Button>

0 commit comments

Comments
 (0)