| 1 | import { |
| 2 | WORKTREE_SETUP_ACTIVITY_KIND, |
| 3 | WorktreeSetupSnapshot, |
| 4 | type ThreadId, |
| 5 | } from "@t3tools/contracts"; |
| 6 | import * as Option from "effect/Option"; |
| 7 | import * as Schema from "effect/Schema"; |
| 8 | |
| 9 | const decodeWorktreeSetupSnapshot = Schema.decodeUnknownOption(WorktreeSetupSnapshot); |
| 10 | |
| 11 | /** |
| 12 | * The worktree setup the server recorded on the thread, if any: running once |
| 13 | * the bootstrap created the thread, then the settled outcome. It is what a |
| 14 | * reload or a second client renders, and what tells them to attach the live |
| 15 | * stream while it still says running. |
| 16 | */ |
| 17 | export function findRecordedWorktreeSetup( |
| 18 | activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>, |
| 19 | threadId: ThreadId, |
| 20 | ): WorktreeSetupSnapshot | null { |
| 21 | for (let index = activities.length - 1; index >= 0; index -= 1) { |
| 22 | const activity = activities[index]!; |
| 23 | if (activity.kind !== WORKTREE_SETUP_ACTIVITY_KIND) continue; |
| 24 | const decoded = decodeWorktreeSetupSnapshot(activity.payload); |
| 25 | if (Option.isSome(decoded) && decoded.value.threadId === threadId) return decoded.value; |
| 26 | } |
| 27 | return null; |
| 28 | } |
| 29 | |
| 30 | /** |
| 31 | * Which setup snapshot the timeline shows, if any. The live stream wins while |
| 32 | * it has a newer sequence; the recorded activity covers everything else. A |
| 33 | * running setup always shows. The setup belongs to the thread's first turn: |
| 34 | * once the user has sent a follow-up it is history and nothing about it is |
| 35 | * shown again, whatever its outcome. Within that first turn, a clean finish |
| 36 | * leaves no trace once the turn is live (the setup is a means to the reply, |
| 37 | * not part of the conversation), while a failed script, a failed setup, or a |
| 38 | * cancelled one stays so the outcome, exit code, and terminal are reachable. |
| 39 | * Before the turn is live everything stays so nothing collapses in the |
| 40 | * handoff gap. Visibility never depends on whether a turn happens to be |
| 41 | * running, which would make the row come and go. |
| 42 | */ |
| 43 | export function resolveVisibleWorktreeSetup(input: { |
| 44 | live: WorktreeSetupSnapshot | null; |
| 45 | recorded: WorktreeSetupSnapshot | null; |
| 46 | turnStarted: boolean; |
| 47 | /** The user sent a message after the one that created the worktree. */ |
| 48 | followUpSent: boolean; |
| 49 | }): WorktreeSetupSnapshot | null { |
| 50 | const snapshot = |
| 51 | input.live && (!input.recorded || input.live.sequence >= input.recorded.sequence) |
| 52 | ? input.live |
| 53 | : input.recorded; |
| 54 | if (!snapshot) return null; |
| 55 | if (snapshot.phase === "running") return snapshot; |
| 56 | if (input.followUpSent) return null; |
| 57 | if (snapshot.phase !== "done") return snapshot; |
| 58 | if (!input.turnStarted) return snapshot; |
| 59 | return snapshot.stages.some((stage) => stage.status === "failed") ? snapshot : null; |
| 60 | } |
| 61 | |
| 62 | export function worktreeSetupAgentStarted(snapshot: WorktreeSetupSnapshot): boolean { |
| 63 | return snapshot.stages.some((stage) => stage.id === "agent" && stage.status === "done"); |
| 64 | } |