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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 36 additions & 7 deletions packages/app/src/components/deepagent/goal-start-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,17 @@ 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 { useLocal } from "@/context/local"
import { useLanguage } from "@/context/language"
import { showToast } from "@/utils/toast"
import { fetchCapabilities, startGoal, type PanelGoalClient } from "./panel-goal.api"
import { fetchCapabilities, fetchGoalStartable, startGoal, type PanelGoalClient } from "./panel-goal.api"

// The collaboration modes where "convert plan → supervised goal" makes sense. loop/design are BOTH
// powered by the Goal Loop engine and are designed to have the human START the loop after the plan is
// authored (loop: agent writes goal+plan.md; design: user writes it). auto is autonomous end-to-end in
// the current turn — a supervised background goal would be a confusing, redundant second door there, so
// the button must NOT appear in auto. plan is hidden and never the client-visible current mode.
const GOAL_MODES = new Set(["loop", "design"])

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

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

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

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

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

const show = createMemo(
() => goalAvailable() && modeAllows() && !activeGoal() && startable()?.startable === true,
)

const onStart = async () => {
if (busy() || !props.sessionID) return
Expand All @@ -61,8 +87,11 @@ export function GoalStartButton(props: { sessionID: string }) {
const snapshot = await startGoal(client(), { sessionID: props.sessionID })
if (!snapshot) {
showToast({ title: language.t("goal.start.failed") })
} else {
// The server emits an immediate goal.updated (phase=running) on start, so GoalStatusBar takes
// over this surface right away. This toast is the belt-and-suspenders confirmation for the click.
showToast({ title: language.t("goal.start.success"), variant: "success" })
}
// On success the goal.updated event drives GoalStatusBar to appear; nothing to do here.
} catch (err) {
showToast({ title: language.t("goal.start.failed"), description: errorMessage(err) })
} finally {
Expand Down
23 changes: 23 additions & 0 deletions packages/app/src/components/deepagent/panel-goal.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,26 @@ export const goalStatus = async (
})
return response.data?.goal ?? null
}

export type GoalStartable = { startable: boolean; source: "plan" | "file" | "none" }

/**
* Whether a goal can be started for this session right now, resolved SERVER-SIDE with the same plan
* precedence start() uses (session_plan → repo goal+plan.md → none). The button gates on this instead
* of reading session_plan directly, because loop/design modes author the plan as the repo file (never
* touching session_plan), so a client-only hasPlan() check would hide the button in exactly the modes
* where it belongs. Tolerant of an older server that lacks the route (treated as not-startable).
*/
export const fetchGoalStartable = async (
client: PanelGoalClient,
sessionID: string,
): Promise<GoalStartable> => {
const response = await client.client.request<GoalStartable>({
method: "GET",
url: `/deepagent/goal/startable?sessionID=${encodeURIComponent(sessionID)}`,
})
return {
startable: response.data?.startable ?? false,
source: response.data?.source ?? "none",
}
}
1 change: 1 addition & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -932,6 +932,7 @@ export const dict = {
"sidebar.wiki": "Repo & Wiki",
"goal.start.hint": "Plan ready — run it as a supervised goal",
"goal.start.button": "Run as goal",
"goal.start.success": "Goal started — running in the background",
"goal.start.failed": "Couldn't start the goal",
"wiki.title": "Repo & Wiki",
"wiki.description": "Read, search, and govern the four graphs. Knowledge and Memory are editable; Documents and Code are read-only.",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,7 @@ export const dict = {
"sidebar.wiki": "仓库与百科",
"goal.start.hint": "计划已就绪 — 转为受监督的长跑目标",
"goal.start.button": "转为 Goal",
"goal.start.success": "目标已启动,正在后台运行",
"goal.start.failed": "无法启动目标",
"wiki.title": "仓库与百科",
"wiki.description": "阅读、检索、治理四张图。知识与记忆可编辑;文档与代码只读。",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/zht.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,7 @@ export const dict = {
"review.scope.global": "全域",
"goal.start.hint": "計劃已就緒 — 轉為受監督的長跑目標",
"goal.start.button": "轉為 Goal",
"goal.start.success": "目標已啟動,正在背景執行",
"goal.start.failed": "無法啟動目標",
"wiki.title": "倉庫與百科",
"wiki.description": "閱讀、檢索、治理四張圖。知識與記憶可編輯;文件與程式碼唯讀。",
Expand Down
Loading
Loading