Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
18 changes: 15 additions & 3 deletions packages/app/src/components/deepagent-settings-ux.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,21 @@ describe("DeepAgent settings UX", () => {
expect(v2.indexOf('data-action="settings-deepagent-prompt-mode"')).toBeLessThan(
v2.indexOf('data-action="settings-deepagent-intelligence-model"'),
)
expect(v2.indexOf('data-action="settings-deepagent-intelligence-model"')).toBeLessThan(
v2.indexOf('data-action="settings-auto-accept-permissions"'),
)
})

test("moves the permission approval control out of settings into the composer toolbar", async () => {
const v2 = await readFile(path.join(here, "settings-v2/general.tsx"), "utf8")
const composer = await readFile(path.join(here, "prompt-input.tsx"), "utf8")
const control = await readFile(path.join(here, "deepagent/approval-control.tsx"), "utf8")

// The auto-accept toggle no longer lives in settings…
expect(v2).not.toContain('data-action="settings-auto-accept-permissions"')
// …it is a composer control next to the agent selector, backed by directory-level auto-accept.
expect(composer).toContain("ApprovalControl")
expect(control).toContain('"data-action": "prompt-approval"')
expect(control).toContain("toggleAutoAcceptDirectory")
expect(control).toContain("composer.approval.request")
expect(control).toContain("composer.approval.auto")
})

test("routes the legacy settings dialog import to the unified settings page", async () => {
Expand Down
56 changes: 56 additions & 0 deletions packages/app/src/components/deepagent/approval-control.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { createMemo, type JSX } from "solid-js"
import { Select } from "@deepagent-code/ui/select"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"

/**
* Approval-mode control for the composer toolbar, next to the agent (build/plan) selector.
*
* Mirrors Codex's approval selector UX, simplified to two options: the button shows the CURRENT mode
* ("Request approval" by default, "Auto-approve" when armed); clicking opens a small picker to switch.
* The mode is DIRECTORY-scoped (persists across sessions in the same workspace), backed by the existing
* permission context (isAutoAcceptingDirectory / toggleAutoAcceptDirectory) — the same state the old
* settings toggle drove, now surfaced where the user acts.
*/

type ApprovalMode = "request" | "auto"

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

const auto = createMemo(() => (props.directory ? permission.isAutoAcceptingDirectory(props.directory) : false))
const current = createMemo<ApprovalMode>(() => (auto() ? "auto" : "request"))

const options: ApprovalMode[] = ["request", "auto"]
const label = (mode: ApprovalMode) =>
mode === "auto"
? language.t("composer.approval.auto")
: language.t("composer.approval.request")

const onSelect = (mode: ApprovalMode | undefined) => {
if (!mode || !props.directory) return
const isAuto = mode === "auto"
if (isAuto === auto()) return
// toggleAutoAcceptDirectory flips the directory-level state; only call it when the target differs.
permission.toggleAutoAcceptDirectory(props.directory)
props.onAfter?.()
}

return (
<Select
size="normal"
data-component="prompt-approval-control"
options={options}
current={current()}
value={(o) => o}
label={label}
onSelect={onSelect}
class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base"
triggerStyle={props.triggerStyle}
triggerProps={{ "data-action": "prompt-approval" }}
variant="ghost"
/>
)
}
110 changes: 110 additions & 0 deletions packages/app/src/components/deepagent/goal-status-bar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { Show, createMemo, createSignal } from "solid-js"
import { Button } from "@deepagent-code/ui/button"
import { Icon } from "@deepagent-code/ui/icon"
import { useServerSync } from "@/context/server-sync"
import { useSDK } from "@/context/sdk"
import { pauseGoal, resumeGoal, stopGoal, type PanelGoalClient } from "./panel-goal.api"

/**
* V3.9 §D — the Goal status bar. Renders above the composer when a goal is running for this session
* (Codex thread-goal style): the phase, a live token/tick budget readout, and pause/resume/stop
* controls. Reads the persistent session_goal store fed by the goal.updated event, so it stays visible
* while the background loop ticks and after a terminal phase (until the user starts a new goal).
*/

const PHASE_LABEL: Record<string, string> = {
running: "Running",
paused: "Paused",
done: "Complete",
needs_human: "Needs you",
rolled_back: "Rolled back",
stopped: "Stopped",
}

const PHASE_ICON: Record<string, Parameters<typeof Icon>[0]["name"]> = {
running: "status-active",
paused: "circle-ban-sign",
done: "circle-check",
needs_human: "circle-x",
rolled_back: "arrow-undo-down",
stopped: "circle-x",
}

const isTerminal = (phase: string) =>
phase === "done" || phase === "rolled_back" || phase === "stopped" || phase === "needs_human"

export function GoalStatusBar(props: { sessionID: string }) {
const serverSync = useServerSync()
const sdk = useSDK()
const [busy, setBusy] = createSignal(false)

const goal = createMemo(() => (props.sessionID ? serverSync.data.session_goal[props.sessionID] : undefined))
const client = () => sdk.client as unknown as PanelGoalClient

const running = () => goal()?.phase === "running"
const paused = () => goal()?.phase === "paused"
const terminal = () => {
const g = goal()
return g ? isTerminal(g.phase) : false
}

const withBusy = (fn: () => Promise<unknown>) => async () => {
if (busy()) return
setBusy(true)
try {
await fn()
} finally {
setBusy(false)
}
}

const onPause = withBusy(() => pauseGoal(client(), props.sessionID))
const onResume = withBusy(() => resumeGoal(client(), props.sessionID))
const onStop = withBusy(() => stopGoal(client(), props.sessionID))
const onDismiss = () => serverSync.goal.set(props.sessionID, undefined)

const tokens = () => goal()?.ledger.tokens ?? 0
const ticks = () => goal()?.ledger.ticks ?? 0

return (
<Show when={goal()}>
{(g) => (
<div
data-component="goal-status-bar"
class="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-surface-raised border border-border-subtle text-13-regular"
>
<Icon name={PHASE_ICON[g().phase] ?? "status-active"} class="size-4 shrink-0 text-text-muted" />
<span class="text-text-base font-medium">{PHASE_LABEL[g().phase] ?? g().phase}</span>
<span class="text-text-muted truncate">
{ticks()} {ticks() === 1 ? "tick" : "ticks"} · {tokens().toLocaleString()} tokens
</span>
<Show when={g().gaps.length > 0}>
<span class="text-text-muted truncate italic">— {g().gaps[0]}</span>
</Show>
<div class="flex items-center gap-1 ml-auto shrink-0">
<Show when={running()}>
<Button variant="ghost" size="small" class="h-7 px-2" disabled={busy()} onClick={onPause}>
Pause
</Button>
</Show>
<Show when={paused()}>
<Button variant="ghost" size="small" class="h-7 px-2" disabled={busy()} onClick={onResume}>
Resume
</Button>
</Show>
<Show when={!terminal()}>
<Button variant="ghost" size="small" class="size-7 p-0" disabled={busy()} onClick={onStop} aria-label="Stop goal">
<Icon name="circle-ban-sign" class="size-4" />
</Button>
</Show>
<Show when={terminal()}>
<Button variant="ghost" size="small" class="size-7 p-0" onClick={onDismiss} aria-label="Dismiss goal">
<Icon name="close-small" class="size-4" />
</Button>
</Show>
</div>
</div>
)}
</Show>
)
}
105 changes: 105 additions & 0 deletions packages/app/src/components/deepagent/panel-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { Show, createSignal, createResource } from "solid-js"
import { Button } from "@deepagent-code/ui/button"
import { Icon } from "@deepagent-code/ui/icon"
import { Tooltip } from "@deepagent-code/ui/tooltip"
import { useDialog } from "@deepagent-code/ui/context/dialog"
import { useSDK } from "@/context/sdk"
import { armPanel, consultPanel, fetchPanelStatus, type PanelGoalClient } from "./panel-goal.api"
import { PanelVerdictDialog } from "./panel-verdict-dialog"

/**
* V3.9 §C — the Expert Panel toggle button for the composer toolbar.
*
* Activation semantics (per the product spec):
* - Armed state is per-conversation, seeded from the global `expertPanelDefault` setting.
* - OFF → ON (user arms mid-conversation): immediately convene a panel on the CURRENT context and
* show the verdict, then go quiet ("等待唤醒") — no per-turn re-runs.
* - While ON, pressing again re-convenes on demand.
* - ON → OFF: disarm (no consult).
* The button reflects armed state; a spinner-ish disabled state covers the in-flight consult.
*/
export function PanelButton(props: { sessionID: string }) {
const sdk = useSDK()
const dialog = useDialog()
const [busy, setBusy] = createSignal(false)
const [armedOverride, setArmedOverride] = createSignal<boolean | undefined>(undefined)

const client = () => sdk.client as unknown as PanelGoalClient

// Seed the armed state from the SERVER's effective status (explicit toggle, else global default),
// so the button reflects the server-configured default rather than a client-side guess. A local
// override wins once the user toggles this session.
const [status] = createResource(
() => props.sessionID || undefined,
(sessionID) => fetchPanelStatus(client(), sessionID),
)
const armed = () => armedOverride() ?? status()?.armed ?? false

const consultNow = async () => {
const verdict = await consultPanel(client(), { sessionID: props.sessionID })
if (verdict) dialog.show(() => <PanelVerdictDialog verdict={verdict} />)
}

const onClick = async () => {
if (busy() || !props.sessionID) return
setBusy(true)
try {
if (!armed()) {
// OFF → ON: arm, then convene once on the current context.
await armPanel(client(), props.sessionID, true)
setArmedOverride(true)
await consultNow()
} else {
// Already armed: a press re-convenes on demand (stays armed).
await consultNow()
}
} finally {
setBusy(false)
}
}

const onDisarm = async (e: MouseEvent) => {
e.stopPropagation()
if (busy() || !props.sessionID) return
setBusy(true)
try {
await armPanel(client(), props.sessionID, false)
setArmedOverride(false)
} finally {
setBusy(false)
}
}

return (
<Tooltip placement="top" gutter={4} value={armed() ? "Expert panel armed — click to consult now" : "Convene expert panel"}>
<div class="flex items-center" data-component="prompt-panel-control">
<Button
data-action="prompt-panel"
type="button"
variant={armed() ? "primary" : "ghost"}
size="normal"
class="h-7 px-2 gap-1.5 text-13-regular"
disabled={busy() || !props.sessionID}
onClick={onClick}
aria-pressed={armed()}
aria-label="Expert panel"
>
<Icon name="speech-bubble" class="size-4" />
<span>Panel</span>
</Button>
<Show when={armed()}>
<Button
variant="ghost"
size="small"
class="size-6 p-0"
disabled={busy()}
onClick={onDisarm}
aria-label="Disarm expert panel"
>
<Icon name="close-small" class="size-3.5" />
</Button>
</Show>
</div>
</Tooltip>
)
}
Loading
Loading