Skip to content

Commit cc87635

Browse files
authored
Merge pull request #50 from deepagent-ltd/dev
V3.9.1
2 parents d724ee4 + 591e007 commit cc87635

420 files changed

Lines changed: 5314 additions & 1274 deletions

File tree

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: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,21 @@ describe("DeepAgent settings UX", () => {
2323
expect(v2.indexOf('data-action="settings-deepagent-prompt-mode"')).toBeLessThan(
2424
v2.indexOf('data-action="settings-deepagent-intelligence-model"'),
2525
)
26-
expect(v2.indexOf('data-action="settings-deepagent-intelligence-model"')).toBeLessThan(
27-
v2.indexOf('data-action="settings-auto-accept-permissions"'),
28-
)
26+
})
27+
28+
test("moves the permission approval control out of settings into the composer toolbar", async () => {
29+
const v2 = await readFile(path.join(here, "settings-v2/general.tsx"), "utf8")
30+
const composer = await readFile(path.join(here, "prompt-input.tsx"), "utf8")
31+
const control = await readFile(path.join(here, "deepagent/approval-control.tsx"), "utf8")
32+
33+
// The auto-accept toggle no longer lives in settings…
34+
expect(v2).not.toContain('data-action="settings-auto-accept-permissions"')
35+
// …it is a composer control next to the agent selector, backed by directory-level auto-accept.
36+
expect(composer).toContain("ApprovalControl")
37+
expect(control).toContain('"data-action": "prompt-approval"')
38+
expect(control).toContain("toggleAutoAcceptDirectory")
39+
expect(control).toContain("composer.approval.request")
40+
expect(control).toContain("composer.approval.auto")
2941
})
3042

3143
test("routes the legacy settings dialog import to the unified settings page", async () => {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { createMemo, type JSX } from "solid-js"
2+
import { Select } from "@deepagent-code/ui/select"
3+
import { useLanguage } from "@/context/language"
4+
import { usePermission } from "@/context/permission"
5+
6+
/**
7+
* Approval-mode control for the composer toolbar, next to the agent (build/plan) selector.
8+
*
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.
14+
*/
15+
16+
type ApprovalMode = "request" | "auto"
17+
18+
export function ApprovalControl(props: { directory: string; triggerStyle?: JSX.CSSProperties; onAfter?: () => void }) {
19+
const language = useLanguage()
20+
const permission = usePermission()
21+
22+
const auto = createMemo(() => (props.directory ? permission.isAutoAcceptingDirectory(props.directory) : false))
23+
const current = createMemo<ApprovalMode>(() => (auto() ? "auto" : "request"))
24+
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")
30+
31+
const onSelect = (mode: ApprovalMode | undefined) => {
32+
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)
37+
props.onAfter?.()
38+
}
39+
40+
return (
41+
<Select
42+
size="normal"
43+
data-component="prompt-approval-control"
44+
options={options}
45+
current={current()}
46+
value={(o) => o}
47+
label={label}
48+
onSelect={onSelect}
49+
class="capitalize max-w-[160px] text-text-base"
50+
valueClass="truncate text-13-regular text-text-base"
51+
triggerStyle={props.triggerStyle}
52+
triggerProps={{ "data-action": "prompt-approval" }}
53+
variant="ghost"
54+
/>
55+
)
56+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { Show, createMemo, createSignal } from "solid-js"
2+
import { Button } from "@deepagent-code/ui/button"
3+
import { Icon } from "@deepagent-code/ui/icon"
4+
import { useServerSync } from "@/context/server-sync"
5+
import { useSDK } from "@/context/sdk"
6+
import { pauseGoal, resumeGoal, stopGoal, type PanelGoalClient } from "./panel-goal.api"
7+
8+
/**
9+
* V3.9 §D — the Goal status bar. Renders above the composer when a goal is running for this session
10+
* (Codex thread-goal style): the phase, a live token/tick budget readout, and pause/resume/stop
11+
* controls. Reads the persistent session_goal store fed by the goal.updated event, so it stays visible
12+
* while the background loop ticks and after a terminal phase (until the user starts a new goal).
13+
*/
14+
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",
22+
}
23+
24+
const PHASE_ICON: Record<string, Parameters<typeof Icon>[0]["name"]> = {
25+
running: "status-active",
26+
paused: "circle-ban-sign",
27+
done: "circle-check",
28+
needs_human: "circle-x",
29+
rolled_back: "arrow-undo-down",
30+
stopped: "circle-x",
31+
}
32+
33+
const isTerminal = (phase: string) =>
34+
phase === "done" || phase === "rolled_back" || phase === "stopped" || phase === "needs_human"
35+
36+
export function GoalStatusBar(props: { sessionID: string }) {
37+
const serverSync = useServerSync()
38+
const sdk = useSDK()
39+
const [busy, setBusy] = createSignal(false)
40+
41+
const goal = createMemo(() => (props.sessionID ? serverSync.data.session_goal[props.sessionID] : undefined))
42+
const client = () => sdk.client as unknown as PanelGoalClient
43+
44+
const running = () => goal()?.phase === "running"
45+
const paused = () => goal()?.phase === "paused"
46+
const terminal = () => {
47+
const g = goal()
48+
return g ? isTerminal(g.phase) : false
49+
}
50+
51+
const withBusy = (fn: () => Promise<unknown>) => async () => {
52+
if (busy()) return
53+
setBusy(true)
54+
try {
55+
await fn()
56+
} finally {
57+
setBusy(false)
58+
}
59+
}
60+
61+
const onPause = withBusy(() => pauseGoal(client(), props.sessionID))
62+
const onResume = withBusy(() => resumeGoal(client(), props.sessionID))
63+
const onStop = withBusy(() => stopGoal(client(), props.sessionID))
64+
const onDismiss = () => serverSync.goal.set(props.sessionID, undefined)
65+
66+
const tokens = () => goal()?.ledger.tokens ?? 0
67+
const ticks = () => goal()?.ledger.ticks ?? 0
68+
69+
return (
70+
<Show when={goal()}>
71+
{(g) => (
72+
<div
73+
data-component="goal-status-bar"
74+
class="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-surface-raised border border-border-subtle text-13-regular"
75+
>
76+
<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>
78+
<span class="text-text-muted truncate">
79+
{ticks()} {ticks() === 1 ? "tick" : "ticks"} · {tokens().toLocaleString()} tokens
80+
</span>
81+
<Show when={g().gaps.length > 0}>
82+
<span class="text-text-muted truncate italic">{g().gaps[0]}</span>
83+
</Show>
84+
<div class="flex items-center gap-1 ml-auto shrink-0">
85+
<Show when={running()}>
86+
<Button variant="ghost" size="small" class="h-7 px-2" disabled={busy()} onClick={onPause}>
87+
Pause
88+
</Button>
89+
</Show>
90+
<Show when={paused()}>
91+
<Button variant="ghost" size="small" class="h-7 px-2" disabled={busy()} onClick={onResume}>
92+
Resume
93+
</Button>
94+
</Show>
95+
<Show when={!terminal()}>
96+
<Button variant="ghost" size="small" class="size-7 p-0" disabled={busy()} onClick={onStop} aria-label="Stop goal">
97+
<Icon name="circle-ban-sign" class="size-4" />
98+
</Button>
99+
</Show>
100+
<Show when={terminal()}>
101+
<Button variant="ghost" size="small" class="size-7 p-0" onClick={onDismiss} aria-label="Dismiss goal">
102+
<Icon name="close-small" class="size-4" />
103+
</Button>
104+
</Show>
105+
</div>
106+
</div>
107+
)}
108+
</Show>
109+
)
110+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { Show, createSignal, createResource } from "solid-js"
2+
import { Button } from "@deepagent-code/ui/button"
3+
import { Icon } from "@deepagent-code/ui/icon"
4+
import { Tooltip } from "@deepagent-code/ui/tooltip"
5+
import { useDialog } from "@deepagent-code/ui/context/dialog"
6+
import { useSDK } from "@/context/sdk"
7+
import { armPanel, consultPanel, fetchPanelStatus, type PanelGoalClient } from "./panel-goal.api"
8+
import { PanelVerdictDialog } from "./panel-verdict-dialog"
9+
10+
/**
11+
* V3.9 §C — the Expert Panel toggle button for the composer toolbar.
12+
*
13+
* Activation semantics (per the product spec):
14+
* - Armed state is per-conversation, seeded from the global `expertPanelDefault` setting.
15+
* - OFF → ON (user arms mid-conversation): immediately convene a panel on the CURRENT context and
16+
* show the verdict, then go quiet ("等待唤醒") — no per-turn re-runs.
17+
* - While ON, pressing again re-convenes on demand.
18+
* - ON → OFF: disarm (no consult).
19+
* The button reflects armed state; a spinner-ish disabled state covers the in-flight consult.
20+
*/
21+
export function PanelButton(props: { sessionID: string }) {
22+
const sdk = useSDK()
23+
const dialog = useDialog()
24+
const [busy, setBusy] = createSignal(false)
25+
const [armedOverride, setArmedOverride] = createSignal<boolean | undefined>(undefined)
26+
27+
const client = () => sdk.client as unknown as PanelGoalClient
28+
29+
// Seed the armed state from the SERVER's effective status (explicit toggle, else global default),
30+
// so the button reflects the server-configured default rather than a client-side guess. A local
31+
// override wins once the user toggles this session.
32+
const [status] = createResource(
33+
() => props.sessionID || undefined,
34+
(sessionID) => fetchPanelStatus(client(), sessionID),
35+
)
36+
const armed = () => armedOverride() ?? status()?.armed ?? false
37+
38+
const consultNow = async () => {
39+
const verdict = await consultPanel(client(), { sessionID: props.sessionID })
40+
if (verdict) dialog.show(() => <PanelVerdictDialog verdict={verdict} />)
41+
}
42+
43+
const onClick = async () => {
44+
if (busy() || !props.sessionID) return
45+
setBusy(true)
46+
try {
47+
if (!armed()) {
48+
// OFF → ON: arm, then convene once on the current context.
49+
await armPanel(client(), props.sessionID, true)
50+
setArmedOverride(true)
51+
await consultNow()
52+
} else {
53+
// Already armed: a press re-convenes on demand (stays armed).
54+
await consultNow()
55+
}
56+
} finally {
57+
setBusy(false)
58+
}
59+
}
60+
61+
const onDisarm = async (e: MouseEvent) => {
62+
e.stopPropagation()
63+
if (busy() || !props.sessionID) return
64+
setBusy(true)
65+
try {
66+
await armPanel(client(), props.sessionID, false)
67+
setArmedOverride(false)
68+
} finally {
69+
setBusy(false)
70+
}
71+
}
72+
73+
return (
74+
<Tooltip placement="top" gutter={4} value={armed() ? "Expert panel armed — click to consult now" : "Convene expert panel"}>
75+
<div class="flex items-center" data-component="prompt-panel-control">
76+
<Button
77+
data-action="prompt-panel"
78+
type="button"
79+
variant={armed() ? "primary" : "ghost"}
80+
size="normal"
81+
class="h-7 px-2 gap-1.5 text-13-regular"
82+
disabled={busy() || !props.sessionID}
83+
onClick={onClick}
84+
aria-pressed={armed()}
85+
aria-label="Expert panel"
86+
>
87+
<Icon name="speech-bubble" class="size-4" />
88+
<span>Panel</span>
89+
</Button>
90+
<Show when={armed()}>
91+
<Button
92+
variant="ghost"
93+
size="small"
94+
class="size-6 p-0"
95+
disabled={busy()}
96+
onClick={onDisarm}
97+
aria-label="Disarm expert panel"
98+
>
99+
<Icon name="close-small" class="size-3.5" />
100+
</Button>
101+
</Show>
102+
</div>
103+
</Tooltip>
104+
)
105+
}

0 commit comments

Comments
 (0)