Skip to content

Commit 825bca4

Browse files
feat(goal-loop): one goal loop per session, and a goal panel bound to its session
The desktop held a single goal loop (`active` in goal-loop.ts; start threw "a goal loop is already running") and one app-wide record. The strip above every session showed that loop, or the last goal anyone started, whatever session you were on: Muse #1's goal on the Muse anomalyco#2 tab. And Muse anomalyco#2 could not have a loop at all while Muse #1's ran. Design: docs/accuracy-ui.md section 1. Engine (desktop main): - goal-loops.ts: a manager holding one createGoalLoop instance per session. start refuses only when that session already has a running loop; a cap (4) bounds how many run at once. stop/status take a session id; stop with none stops the only loop and refuses when several run. Ended loops stay listed for their session until dismissed. - goal-loop.ts: the state carries phase (turn | waiting), checkedAt and promptedAt, reported through a new onProgress hook (on a phase change, otherwise every 10s) and never persisted or sent as a lifecycle event, so the terminal event sequence is unchanged. - goal-loop-store.ts: one running record and one last input per session; the legacy single record is migrated as an orphan and cleared. The last input of a session is never another session's. - ipc/preload/renderer: stop/status/last take a session id; list, dismiss. App: - pages/session/goal-panel.tsx replaces pages/layout/sidebar-goal-loop.tsx (and last-input.ts, only used by it). It shows the page's own loop only: session title, a state tag in theme tokens, the goal in full (clamped to two lines, "Show all"), iteration, last check, last nudge, directory, ticket, and real Start / Stop / Dismiss buttons from the ui library. - goal-loop/panel-view.ts: the pure view (which loop belongs to the page, label, tone, times) and the per-session event fold; loops-store.ts is the one shared subscription behind the panel and the session-list badge. - the goal manager dialog takes the session it was opened from: it shows that loop, prefills that session's goal and directory, and starts there. - the ticket queue stops its own ticket's loop by session. - notifications fire only on terminal events; a progress event would otherwise have been toasted as a failure and silenced the real ending. Tests, red first: goal-loops.test.ts (13) and panel-view.test.ts (11) ran red before their modules existed; queue.test.ts "stop stops the loop of the ticket that is running, by its session" red before the queue change; one goal-loop.test.ts case for onProgress. goal-loop-store.test.ts (4) was written with its module. Typecheck: tsgo -b clean in packages/desktop and packages/app. Full app unit suite: 807 pass, 1 fail (i18n parity, fails on dev too; the new English keys add to its missing list). The desktop wait-timeout resilience test failed once in four runs (known goal-loop timing flake). Prettier also reflowed a few long lines it flagged in files touched here. The panel's look is not yet checked in a running app: the running dev app hosts the Muses and was not touched. Refs LDE-43, LDE-35 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01FekmejALvftCz87WMu2wY9
1 parent b71eca6 commit 825bca4

28 files changed

Lines changed: 1312 additions & 491 deletions

‎packages/app/src/components/dialog-goal-manager.tsx‎

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@ function glyph(status: string): string {
2828
}
2929
}
3030

31-
export const DialogGoalManager: Component = () => {
31+
/**
32+
* With `sessionID`, the dialog belongs to that session: it shows that session's loop, prefills
33+
* that session's last goal and directory, and starts the loop in that session. Without it, it
34+
* starts a loop in a new session, prefilled from the newest goal of any session.
35+
*/
36+
export const DialogGoalManager: Component<{ sessionID?: string; directory?: string }> = (props) => {
3237
const dialog = useDialog()
3338
const language = useLanguage()
3439
const platform = usePlatform()
@@ -39,7 +44,7 @@ export const DialogGoalManager: Component = () => {
3944
const [loop, setLoop] = createSignal<GoalLoopState | null>(null)
4045
const [queue, setQueue] = createSignal<QueueStatusSnapshot | null>(getQueueStatus())
4146
const [goal, setGoal] = createSignal("")
42-
const [directory, setDirectory] = createSignal("")
47+
const [directory, setDirectory] = createSignal(props.directory ?? "")
4348
const [busy, setBusy] = createSignal(false)
4449

4550
const api = () => platform.goalLoop
@@ -53,7 +58,7 @@ export const DialogGoalManager: Component = () => {
5358
const current = api()
5459
if (!current) return
5560
void current
56-
.status()
61+
.status(props.sessionID)
5762
.then((state) => {
5863
if (state?.status === "running") {
5964
setLoop(state)
@@ -64,16 +69,20 @@ export const DialogGoalManager: Component = () => {
6469
})
6570
.catch(() => undefined)
6671
void Promise.resolve()
67-
.then(() => current.last?.())
72+
.then(() => current.last?.(props.sessionID))
6873
.then((last: GoalLoopStartInput | null | undefined) => {
6974
if (!last) return
7075
if (goal().trim().length === 0 && last.goal) setGoal(last.goal)
7176
if (directory().trim().length === 0 && last.directory) setDirectory(last.directory)
7277
})
7378
.catch(() => undefined)
74-
const unsubscribe = current.subscribe((event) =>
75-
setLoop(event.state.status === "running" ? event.state : null),
76-
)
79+
// Other sessions run their own loops: only this dialog's loop may change what it shows.
80+
const unsubscribe = current.subscribe((event) => {
81+
if (props.sessionID && event.state.sessionID !== props.sessionID) return
82+
const shown = loop()
83+
if (!props.sessionID && shown && event.state.id !== shown.id) return
84+
setLoop(event.state.status === "running" ? event.state : null)
85+
})
7786
const unsubscribeQueue = subscribeQueueStatus((next) => setQueue(next))
7887
onCleanup(unsubscribe)
7988
onCleanup(unsubscribeQueue)
@@ -87,7 +96,11 @@ export const DialogGoalManager: Component = () => {
8796
if (!text || !dir) return
8897
setBusy(true)
8998
try {
90-
const next = await current.start({ directory: dir, goal: text })
99+
const next = await current.start({
100+
directory: dir,
101+
goal: text,
102+
...(props.sessionID ? { sessionID: props.sessionID } : {}),
103+
})
91104
setLoop(next)
92105
setTab("active")
93106
showToast({ title: language.t("toast.goalLoop.started.title") })
@@ -107,7 +120,7 @@ export const DialogGoalManager: Component = () => {
107120
if (!current) return
108121
setBusy(true)
109122
try {
110-
await current.stop()
123+
await current.stop(running()?.sessionID ?? props.sessionID)
111124
setLoop(null)
112125
} catch (err) {
113126
showToast({

‎packages/app/src/components/dialog-goal-wizard.tsx‎

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,7 @@ export const DialogGoalWizard: Component = () => {
7474
}
7575
})
7676

77-
const canStart = createMemo(
78-
() => Boolean(goalApi()) && chosen().length > 0 && directory().trim().length > 0,
79-
)
77+
const canStart = createMemo(() => Boolean(goalApi()) && chosen().length > 0 && directory().trim().length > 0)
8078

8179
const loadTeams = async () => {
8280
setTeamsLoading(true)
@@ -183,9 +181,9 @@ export const DialogGoalWizard: Component = () => {
183181
const total = queueTickets.length
184182
try {
185183
const runner = createQueueRunner({
186-
start: (input) => api.start(input).then((state) => ({ id: state.id })),
184+
start: (input) => api.start(input).then((state) => ({ id: state.id, sessionID: state.sessionID })),
187185
subscribe: (cb) => api.subscribe((event) => cb(event)),
188-
stop: () => api.stop(),
186+
stop: (sessionID) => api.stop(sessionID),
189187
})
190188
let unsubscribe: () => void = () => undefined
191189
const handle = {
@@ -248,19 +246,13 @@ export const DialogGoalWizard: Component = () => {
248246
return (
249247
<Dialog title={language.t("dialog.goalWizard.title")}>
250248
<div class="flex flex-col gap-3 px-4 py-3">
251-
<Show
252-
when={available()}
253-
fallback={<p class="text-sm">{language.t("dialog.goalWizard.unavailable")}</p>}
254-
>
249+
<Show when={available()} fallback={<p class="text-sm">{language.t("dialog.goalWizard.unavailable")}</p>}>
255250
<p class="text-xs text-text-weak">
256251
{step() + 1} / 4 · {stepTitle()}
257252
</p>
258253
<Show when={step() === 0}>
259254
<p class="text-sm font-medium">{language.t("dialog.goalWizard.team.title")}</p>
260-
<Show
261-
when={!teamsLoading()}
262-
fallback={<p class="text-sm">{language.t("common.loading")}</p>}
263-
>
255+
<Show when={!teamsLoading()} fallback={<p class="text-sm">{language.t("common.loading")}</p>}>
264256
<Show
265257
when={teamOptions().length > 0}
266258
fallback={<p class="text-sm">{language.t("dialog.goalWizard.team.empty")}</p>}
@@ -295,10 +287,7 @@ export const DialogGoalWizard: Component = () => {
295287
</Button>
296288
</div>
297289
</div>
298-
<Show
299-
when={!ticketsLoading()}
300-
fallback={<p class="text-sm">{language.t("common.loading")}</p>}
301-
>
290+
<Show when={!ticketsLoading()} fallback={<p class="text-sm">{language.t("common.loading")}</p>}>
302291
<Show
303292
when={tickets().length > 0}
304293
fallback={<p class="text-sm">{language.t("dialog.goalWizard.tickets.empty")}</p>}

‎packages/app/src/goal-loop/last-input.test.ts‎

Lines changed: 0 additions & 75 deletions
This file was deleted.

‎packages/app/src/goal-loop/last-input.ts‎

Lines changed: 0 additions & 34 deletions
This file was deleted.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { createRoot, createSignal, type Accessor } from "solid-js"
2+
import { usePlatform } from "@/context/platform"
3+
import { applyEvent } from "./panel-view"
4+
import type { GoalLoopPlatform, GoalLoopState } from "./types"
5+
6+
// One app-wide view of every session's goal loop, fed by the desktop shell's
7+
// list and its events. The goal panel and the session-list badge both read it,
8+
// so a page never needs its own subscription to find out which loop is whose.
9+
10+
export type GoalLoopsStore = {
11+
states: Accessor<GoalLoopState[]>
12+
dismiss(sessionID: string): void
13+
}
14+
15+
const stores = new WeakMap<GoalLoopPlatform, GoalLoopsStore>()
16+
17+
function create(api: GoalLoopPlatform): GoalLoopsStore {
18+
return createRoot(() => {
19+
const [map, setMap] = createSignal(new Map<string, GoalLoopState>())
20+
api.subscribe((event) => setMap((current) => applyEvent(current, event)))
21+
void Promise.resolve()
22+
.then(() => api.list?.())
23+
.then((list) => {
24+
if (!list) return
25+
setMap((current) => {
26+
const next = new Map(current)
27+
// events that arrived while the list was loading are newer; keep them
28+
for (const state of list) if (state.sessionID && !next.has(state.sessionID)) next.set(state.sessionID, state)
29+
return next
30+
})
31+
})
32+
.catch(() => undefined)
33+
return {
34+
states: () => [...map().values()],
35+
dismiss(sessionID: string) {
36+
setMap((current) => {
37+
if (current.get(sessionID)?.status === "running") return current
38+
const next = new Map(current)
39+
next.delete(sessionID)
40+
return next
41+
})
42+
void Promise.resolve(api.dismiss?.(sessionID)).catch(() => undefined)
43+
},
44+
}
45+
})
46+
}
47+
48+
/** The shared store, or undefined where there is no goal loop (the web app). */
49+
export function useGoalLoops(): GoalLoopsStore | undefined {
50+
const api = usePlatform().goalLoop
51+
if (!api) return undefined
52+
const existing = stores.get(api)
53+
if (existing) return existing
54+
const store = create(api)
55+
stores.set(api, store)
56+
return store
57+
}

‎packages/app/src/goal-loop/notify.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { onCleanup, onMount } from "solid-js"
22
import { useLanguage } from "@/context/language"
33
import { usePlatform } from "@/context/platform"
44
import { showToast } from "@/utils/toast"
5-
import type { GoalLoopEvent } from "./types"
5+
import { TERMINAL_EVENTS, type GoalLoopEvent } from "./types"
66

77
const notifiedLoops = new Set<string>()
88

@@ -80,13 +80,13 @@ export function useGoalLoopNotifications() {
8080
const goalLoop = platform.goalLoop
8181
if (!goalLoop) return
8282
const unsubscribe = goalLoop.subscribe((event) => {
83-
if (event.type === "started" || event.type === "iteration") return
83+
// Only terminal events notify. A "progress" event arrives every few seconds and
84+
// must never be taken for an ending (it would also mark the loop as notified).
85+
if (!(TERMINAL_EVENTS as readonly string[]).includes(event.type)) return
8486
if (notifiedLoops.has(event.loopID)) return
8587
notifiedLoops.add(event.loopID)
8688
terminalToast(language, event)
87-
void platform
88-
.notify(terminalTitle(language, event), terminalBody(language, event))
89-
.catch(() => undefined)
89+
void platform.notify(terminalTitle(language, event), terminalBody(language, event)).catch(() => undefined)
9090
})
9191
onCleanup(unsubscribe)
9292
})

0 commit comments

Comments
 (0)