Skip to content

Commit a4e3f27

Browse files
deepagent-aiclaude
andauthored
fix(goal): immediate goal.updated feedback + mode-aware start button (#59)
Goal Loop control changes updated in-memory state but emitted no immediate goal.updated event, so the client status bar never reflected them until the next tick (a full subagent turn). stop() was worst: it cancels the driver job, so no further tick ever fires and the UI stayed stuck on "running" forever. - goal-manager: extract publishGoalEvent + publishControlPhase; cache the live ledger/stall/gaps on GoalControl (updated each tick) so control transitions publish the real budget, not zeros. - start/pause/resume/stop now emit an immediate goal.updated. - finalizeOutcome guards the driver's terminal tap on a live control so a cancelled driver's late "needs_human" outcome cannot clobber the user's explicit "stopped". The "convert plan -> goal" button gated on session_plan, which only the plan tool populates — but loop/design modes author the plan as the repo file goal+plan.md, so the button never appeared in the modes it belongs to and did appear in auto (redundant/confusing). - New GoalManager.startable() mirrors start()'s plan precedence (session_plan -> goal+plan.md -> none) without side effects, flag-gated. - New GET /deepagent/goal/startable route + handler. - Button now gates on capability x mode in {loop,design} x startable, and shows a success toast on start. i18n goal.start.success (en/zh/zht). ### 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 (1M context) <noreply@anthropic.com>
1 parent fb6dd24 commit a4e3f27

8 files changed

Lines changed: 215 additions & 43 deletions

File tree

packages/app/src/components/deepagent/goal-start-button.tsx

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,17 @@ 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 { useLocal } from "@/context/local"
67
import { useLanguage } from "@/context/language"
78
import { showToast } from "@/utils/toast"
8-
import { fetchCapabilities, startGoal, type PanelGoalClient } from "./panel-goal.api"
9+
import { fetchCapabilities, fetchGoalStartable, startGoal, type PanelGoalClient } from "./panel-goal.api"
10+
11+
// The collaboration modes where "convert plan → supervised goal" makes sense. loop/design are BOTH
12+
// powered by the Goal Loop engine and are designed to have the human START the loop after the plan is
13+
// authored (loop: agent writes goal+plan.md; design: user writes it). auto is autonomous end-to-end in
14+
// the current turn — a supervised background goal would be a confusing, redundant second door there, so
15+
// the button must NOT appear in auto. plan is hidden and never the client-visible current mode.
16+
const GOAL_MODES = new Set(["loop", "design"])
917

1018
/**
1119
* V3.9 §D — "convert plan → goal" starter.
@@ -34,6 +42,7 @@ function errorMessage(err: unknown): string {
3442
export function GoalStartButton(props: { sessionID: string }) {
3543
const sdk = useSDK()
3644
const serverSync = useServerSync()
45+
const local = useLocal()
3746
const language = useLanguage()
3847
const [busy, setBusy] = createSignal(false)
3948

@@ -45,14 +54,31 @@ export function GoalStartButton(props: { sessionID: string }) {
4554
)
4655
const goalAvailable = createMemo(() => capabilities()?.goalLoop === true)
4756

48-
const plan = createMemo(() => (props.sessionID ? serverSync.data.session_plan[props.sessionID] : undefined))
49-
const hasPlan = createMemo(() => (plan()?.steps.length ?? 0) > 0)
57+
// The current collaboration mode (auto/loop/design) — the button only applies to loop/design. Sourced
58+
// from local.agent.current() (session-scoped mode selection), the same source the mode selector uses.
59+
const currentMode = createMemo(() => local.agent.current()?.name)
60+
const modeAllows = createMemo(() => GOAL_MODES.has(currentMode() ?? ""))
5061

51-
// A goal is "live" for this session iff the persistent session_goal pointer exists and is not in a
52-
// terminal phase the user has dismissed — while present, GoalStatusBar owns the surface.
62+
// A goal is "live" for this session iff the persistent session_goal pointer exists — while present,
63+
// GoalStatusBar owns the surface. Checked FIRST so we don't probe startability for an already-running
64+
// goal.
5365
const activeGoal = createMemo(() => (props.sessionID ? serverSync.data.session_goal[props.sessionID] : undefined))
5466

55-
const show = createMemo(() => goalAvailable() && hasPlan() && !activeGoal())
67+
// Whether a plan actually exists to start, resolved server-side (session_plan OR repo goal+plan.md).
68+
// Re-fetched when the session, mode-eligibility, active-goal, or the in-session plan changes — the last
69+
// dependency makes the button appear promptly after the agent writes a plan mid-conversation. Only
70+
// probed when the mode allows and no goal is live, to avoid needless requests.
71+
const [startable] = createResource(
72+
() =>
73+
props.sessionID && goalAvailable() && modeAllows() && !activeGoal()
74+
? ([props.sessionID, serverSync.data.session_plan[props.sessionID]?.steps.length ?? 0] as const)
75+
: undefined,
76+
([sessionID]) => fetchGoalStartable(client(), sessionID),
77+
)
78+
79+
const show = createMemo(
80+
() => goalAvailable() && modeAllows() && !activeGoal() && startable()?.startable === true,
81+
)
5682

5783
const onStart = async () => {
5884
if (busy() || !props.sessionID) return
@@ -61,8 +87,11 @@ export function GoalStartButton(props: { sessionID: string }) {
6187
const snapshot = await startGoal(client(), { sessionID: props.sessionID })
6288
if (!snapshot) {
6389
showToast({ title: language.t("goal.start.failed") })
90+
} else {
91+
// The server emits an immediate goal.updated (phase=running) on start, so GoalStatusBar takes
92+
// over this surface right away. This toast is the belt-and-suspenders confirmation for the click.
93+
showToast({ title: language.t("goal.start.success"), variant: "success" })
6494
}
65-
// On success the goal.updated event drives GoalStatusBar to appear; nothing to do here.
6695
} catch (err) {
6796
showToast({ title: language.t("goal.start.failed"), description: errorMessage(err) })
6897
} finally {

packages/app/src/components/deepagent/panel-goal.api.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,26 @@ export const goalStatus = async (
175175
})
176176
return response.data?.goal ?? null
177177
}
178+
179+
export type GoalStartable = { startable: boolean; source: "plan" | "file" | "none" }
180+
181+
/**
182+
* Whether a goal can be started for this session right now, resolved SERVER-SIDE with the same plan
183+
* precedence start() uses (session_plan → repo goal+plan.md → none). The button gates on this instead
184+
* of reading session_plan directly, because loop/design modes author the plan as the repo file (never
185+
* touching session_plan), so a client-only hasPlan() check would hide the button in exactly the modes
186+
* where it belongs. Tolerant of an older server that lacks the route (treated as not-startable).
187+
*/
188+
export const fetchGoalStartable = async (
189+
client: PanelGoalClient,
190+
sessionID: string,
191+
): Promise<GoalStartable> => {
192+
const response = await client.client.request<GoalStartable>({
193+
method: "GET",
194+
url: `/deepagent/goal/startable?sessionID=${encodeURIComponent(sessionID)}`,
195+
})
196+
return {
197+
startable: response.data?.startable ?? false,
198+
source: response.data?.source ?? "none",
199+
}
200+
}

packages/app/src/i18n/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -932,6 +932,7 @@ export const dict = {
932932
"sidebar.wiki": "Repo & Wiki",
933933
"goal.start.hint": "Plan ready — run it as a supervised goal",
934934
"goal.start.button": "Run as goal",
935+
"goal.start.success": "Goal started — running in the background",
935936
"goal.start.failed": "Couldn't start the goal",
936937
"wiki.title": "Repo & Wiki",
937938
"wiki.description": "Read, search, and govern the four graphs. Knowledge and Memory are editable; Documents and Code are read-only.",

packages/app/src/i18n/zh.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,7 @@ export const dict = {
793793
"sidebar.wiki": "仓库与百科",
794794
"goal.start.hint": "计划已就绪 — 转为受监督的长跑目标",
795795
"goal.start.button": "转为 Goal",
796+
"goal.start.success": "目标已启动,正在后台运行",
796797
"goal.start.failed": "无法启动目标",
797798
"wiki.title": "仓库与百科",
798799
"wiki.description": "阅读、检索、治理四张图。知识与记忆可编辑;文档与代码只读。",

packages/app/src/i18n/zht.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,7 @@ export const dict = {
646646
"review.scope.global": "全域",
647647
"goal.start.hint": "計劃已就緒 — 轉為受監督的長跑目標",
648648
"goal.start.button": "轉為 Goal",
649+
"goal.start.success": "目標已啟動,正在背景執行",
649650
"goal.start.failed": "無法啟動目標",
650651
"wiki.title": "倉庫與百科",
651652
"wiki.description": "閱讀、檢索、治理四張圖。知識與記憶可編輯;文件與程式碼唯讀。",

packages/deepagent-code/src/server/routes/instance/httpapi/groups/deepagent.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,10 @@ export const DeepAgentGoalSnapshot = Schema.Struct({
322322
})
323323
export const DeepAgentGoalStatusResult = Schema.Struct({ goal: Schema.NullOr(DeepAgentGoalSnapshot) })
324324
export const DeepAgentGoalMutateResult = Schema.Struct({ ok: Schema.Boolean })
325+
export const DeepAgentGoalStartableResult = Schema.Struct({
326+
startable: Schema.Boolean,
327+
source: Schema.Literals(["plan", "file", "none"]),
328+
})
325329

326330
// ── V3.9 §B Repo & Wiki ────────────────────────────────────────────────────
327331
// The human-facing projection of the four graphs. Read-only browse + governed knowledge edit +
@@ -650,6 +654,13 @@ export const DeepAgentApi = HttpApi.make("deepagent").add(
650654
error: DeepAgentPromotionError,
651655
}),
652656
)
657+
.add(
658+
HttpApiEndpoint.get("goalStartable", `${root}/goal/startable`, {
659+
query: Schema.Struct({ ...WorkspaceRoutingQueryFields, sessionID: Schema.String }),
660+
success: described(DeepAgentGoalStartableResult, "Whether a goal can be started + plan source"),
661+
error: DeepAgentPromotionError,
662+
}),
663+
)
653664
.add(
654665
HttpApiEndpoint.get("wikiPages", `${root}/wiki/pages`, {
655666
query: Schema.Struct({ ...WorkspaceRoutingQueryFields, type: Schema.optional(Schema.String) }),

packages/deepagent-code/src/server/routes/instance/httpapi/handlers/deepagent.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,9 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen
559559
const goalStatus = Effect.fn("DeepAgentHttpApi.goalStatus")(function* (ctx) {
560560
return { goal: yield* goals.status(ctx.query.sessionID) }
561561
})
562+
const goalStartable = Effect.fn("DeepAgentHttpApi.goalStartable")(function* (ctx) {
563+
return yield* goals.startable(ctx.query.sessionID)
564+
})
562565

563566
// ── V3.9 §B Repo & Wiki ─────────────────────────────────────────────────
564567
// Read-only projection + governed knowledge edit + full-text search. All fail-closed on the wiki
@@ -679,6 +682,7 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen
679682
.handle("goalResume", goalResume)
680683
.handle("goalStop", goalStop)
681684
.handle("goalStatus", goalStatus)
685+
.handle("goalStartable", goalStartable)
682686
.handle("wikiPages", wikiPages)
683687
.handle("wikiPage", wikiPage)
684688
.handle("wikiSearch", wikiSearch)

0 commit comments

Comments
 (0)