patchrelay 0.23.5 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.23.5",
4
- "commit": "65abb29863f7",
5
- "builtAt": "2026-03-26T16:08:25.878Z"
3
+ "version": "0.24.0",
4
+ "commit": "1bc0f4083571",
5
+ "builtAt": "2026-03-26T16:28:13.654Z"
6
6
  }
@@ -171,6 +171,12 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
171
171
  });
172
172
  }
173
173
  }
174
+ else if (input === "h") {
175
+ dispatch({ type: "switch-detail-tab", tab: "history" });
176
+ }
177
+ else if (input === "t") {
178
+ dispatch({ type: "switch-detail-tab", tab: "timeline" });
179
+ }
174
180
  else if (input === "j" || key.downArrow) {
175
181
  dispatch({ type: "detail-navigate", direction: "next", filtered });
176
182
  }
@@ -184,5 +190,5 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
184
190
  }
185
191
  }
186
192
  });
187
- return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : state.view === "detail" ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, activeRunStartedAt: state.activeRunStartedAt, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext }), promptMode && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "prompt> " }), _jsx(Text, { children: promptBuffer }), _jsx(Text, { dimColor: true, children: "_" })] })), promptStatus && !promptMode && (_jsx(Text, { dimColor: true, children: promptStatus }))] })) : (_jsx(FeedView, { events: state.feedEvents, connected: state.connected })) }));
193
+ return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : state.view === "detail" ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, activeRunStartedAt: state.activeRunStartedAt, activeRunId: state.activeRunId, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, detailTab: state.detailTab, rawRuns: state.rawRuns, rawFeedEvents: state.rawFeedEvents }), promptMode && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "prompt> " }), _jsx(Text, { children: promptBuffer }), _jsx(Text, { dimColor: true, children: "_" })] })), promptStatus && !promptMode && (_jsx(Text, { dimColor: true, children: promptStatus }))] })) : (_jsx(FeedView, { events: state.feedEvents, connected: state.connected })) }));
188
194
  }
@@ -5,9 +5,14 @@ const HELP_TEXT = {
5
5
  detail: "",
6
6
  feed: "Esc: list q: quit",
7
7
  };
8
- export function HelpBar({ view, follow }) {
9
- const text = view === "detail"
10
- ? `j/k: prev/next Esc: list f: follow ${follow ? "on" : "off"} p: prompt s: stop r: retry q: quit`
11
- : HELP_TEXT[view];
8
+ export function HelpBar({ view, follow, detailTab }) {
9
+ let text;
10
+ if (view === "detail") {
11
+ const tabHint = detailTab === "history" ? "t: timeline" : "h: history";
12
+ text = `${tabHint} j/k: prev/next Esc: list f: follow ${follow ? "on" : "off"} p: prompt s: stop r: retry q: quit`;
13
+ }
14
+ else {
15
+ text = HELP_TEXT[view];
16
+ }
12
17
  return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: text }) }));
13
18
  }
@@ -1,8 +1,11 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { useEffect, useReducer } from "react";
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useReducer } from "react";
3
3
  import { Box, Text } from "ink";
4
4
  import { Timeline } from "./Timeline.js";
5
+ import { StateHistoryView } from "./StateHistoryView.js";
6
+ import { buildStateHistory } from "./history-builder.js";
5
7
  import { HelpBar } from "./HelpBar.js";
8
+ import { planStepSymbol, planStepColor } from "./plan-helpers.js";
6
9
  function formatTokens(n) {
7
10
  if (n >= 1_000_000)
8
11
  return `${(n / 1_000_000).toFixed(1)}M`;
@@ -21,23 +24,9 @@ function ElapsedTime({ startedAt }) {
21
24
  const seconds = elapsed % 60;
22
25
  return _jsxs(Text, { dimColor: true, children: [minutes, "m ", String(seconds).padStart(2, "0"), "s"] });
23
26
  }
24
- function planStepSymbol(status) {
25
- if (status === "completed")
26
- return "\u2713";
27
- if (status === "inProgress")
28
- return "\u25b8";
29
- return " ";
30
- }
31
- function planStepColor(status) {
32
- if (status === "completed")
33
- return "green";
34
- if (status === "inProgress")
35
- return "yellow";
36
- return "white";
37
- }
38
- export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, tokenUsage, diffSummary, plan, issueContext, }) {
27
+ export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, activeRunId, tokenUsage, diffSummary, plan, issueContext, detailTab, rawRuns, rawFeedEvents, }) {
39
28
  if (!issue) {
40
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", children: "Issue not found." }), _jsx(HelpBar, { view: "detail", follow: follow })] }));
29
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", children: "Issue not found." }), _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab })] }));
41
30
  }
42
31
  const key = issue.issueKey ?? issue.projectId;
43
32
  const meta = [];
@@ -47,5 +36,6 @@ export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, t
47
36
  meta.push(`${diffSummary.filesChanged}f +${diffSummary.linesAdded} -${diffSummary.linesRemoved}`);
48
37
  if (issueContext?.runCount)
49
38
  meta.push(`${issueContext.runCount} runs`);
50
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["#", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt }), meta.length > 0 && _jsx(Text, { dimColor: true, children: meta.join(" ") }), follow && _jsx(Text, { color: "yellow", children: "follow" })] }), issue.title && _jsx(Text, { children: issue.title }), plan && plan.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: plan.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: planStepColor(entry.status), children: ["[", planStepSymbol(entry.status), "]"] }), _jsx(Text, { children: entry.step })] }, `plan-${i}`))) })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Timeline, { entries: timeline, follow: follow }) }), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "detail", follow: follow }) })] }));
39
+ const history = useMemo(() => buildStateHistory(rawRuns, rawFeedEvents, issue.factoryState, activeRunId), [rawRuns, rawFeedEvents, issue.factoryState, activeRunId]);
40
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["#", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt }), meta.length > 0 && _jsx(Text, { dimColor: true, children: meta.join(" ") }), follow && _jsx(Text, { color: "yellow", children: "follow" })] }), issue.title && _jsx(Text, { children: issue.title }), detailTab === "timeline" ? (_jsxs(_Fragment, { children: [plan && plan.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: plan.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: planStepColor(entry.status), children: ["[", planStepSymbol(entry.status), "]"] }), _jsx(Text, { children: entry.step })] }, `plan-${i}`))) })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Timeline, { entries: timeline, follow: follow }) })] })) : (_jsx(StateHistoryView, { history: history, plan: plan, activeRunId: activeRunId })), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab }) })] }));
51
41
  }
@@ -0,0 +1,96 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { planStepSymbol, planStepColor } from "./plan-helpers.js";
4
+ // ─── Formatting helpers ──────────────────────────────────────────
5
+ function formatTime(iso) {
6
+ return new Date(iso).toLocaleTimeString("en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
7
+ }
8
+ function formatDuration(startedAt, endedAt) {
9
+ const ms = new Date(endedAt).getTime() - new Date(startedAt).getTime();
10
+ const seconds = Math.floor(ms / 1000);
11
+ if (seconds < 60)
12
+ return `${seconds}s`;
13
+ const minutes = Math.floor(seconds / 60);
14
+ const s = seconds % 60;
15
+ return `${minutes}m${s > 0 ? `${String(s).padStart(2, "0")}s` : ""}`;
16
+ }
17
+ const RUN_LABELS = {
18
+ implementation: "implementation",
19
+ ci_repair: "ci repair",
20
+ review_fix: "review fix",
21
+ queue_repair: "queue repair",
22
+ };
23
+ function runStatusSymbol(status) {
24
+ if (status === "completed")
25
+ return "\u2713";
26
+ if (status === "failed")
27
+ return "\u2717";
28
+ if (status === "running")
29
+ return "\u25b8";
30
+ return " ";
31
+ }
32
+ function runStatusColor(status) {
33
+ if (status === "completed")
34
+ return "green";
35
+ if (status === "failed")
36
+ return "red";
37
+ if (status === "running")
38
+ return "yellow";
39
+ return "white";
40
+ }
41
+ const STATE_LABELS = {
42
+ delegated: "delegated",
43
+ preparing: "preparing",
44
+ implementing: "implementing",
45
+ pr_open: "pr open",
46
+ changes_requested: "changes requested",
47
+ repairing_ci: "repairing ci",
48
+ awaiting_queue: "awaiting queue",
49
+ repairing_queue: "repairing queue",
50
+ awaiting_input: "awaiting input",
51
+ escalated: "escalated",
52
+ done: "done",
53
+ failed: "failed",
54
+ };
55
+ // ─── Sub-components ──────────────────────────────────────────────
56
+ function RunLine({ run, index }) {
57
+ const label = RUN_LABELS[run.runType] ?? run.runType;
58
+ const dur = run.endedAt
59
+ ? formatDuration(run.startedAt, run.endedAt)
60
+ : undefined;
61
+ const isActive = run.status === "running";
62
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: runStatusColor(run.status), children: [runStatusSymbol(run.status), " "] }), _jsxs(Text, { dimColor: true, children: ["run #", index + 1, " "] }), _jsxs(Text, { children: ["(", label, ")"] }), dur && _jsxs(Text, { dimColor: true, children: [" ", dur] }), isActive && _jsx(Text, { dimColor: true, children: " ..." })] }));
63
+ }
64
+ function PlanSteps({ plan }) {
65
+ return (_jsx(Box, { flexDirection: "column", paddingLeft: 2, children: plan.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: planStepColor(entry.status), children: ["[", planStepSymbol(entry.status), "]"] }), _jsx(Text, { children: entry.step })] }, `plan-${i}`))) }));
66
+ }
67
+ function SideTripBlock({ trip, runOffset, isLast, }) {
68
+ const stateLabel = STATE_LABELS[trip.state] ?? trip.state;
69
+ const hasReturn = trip.returnedAt && trip.returnState !== trip.state;
70
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " \u2502 \u250c " }), _jsx(Text, { color: "magenta", bold: true, children: stateLabel }), _jsxs(Text, { dimColor: true, children: [" ", formatTime(trip.enteredAt)] })] }), trip.reason && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " \u2502 \u2502 " }), _jsx(Text, { dimColor: true, children: trip.reason })] })), trip.runs.map((run, ri) => (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " \u2502 \u2502 " }), _jsx(RunLine, { run: run, index: runOffset + ri })] }, `st-run-${run.id}`))), hasReturn ? (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " \u2502 \u2514\u2192 " }), _jsx(Text, { children: STATE_LABELS[trip.returnState] ?? trip.returnState }), trip.returnedAt && _jsxs(Text, { dimColor: true, children: [" ", formatTime(trip.returnedAt)] })] })) : (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " \u2502 \u2514\u2500" }), _jsx(Text, { dimColor: true, children: " (active)" })] })), !isLast && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: " \u2502" }) }))] }));
71
+ }
72
+ function MainPathNode({ node, isLast, runOffset, plan, activeRunId, }) {
73
+ const stateLabel = STATE_LABELS[node.state] ?? node.state;
74
+ const marker = node.isCurrent ? "\u25c9" : "\u25cb";
75
+ const stateColor = node.isCurrent ? "green" : "white";
76
+ const hasActiveRun = node.runs.some((r) => r.id === activeRunId);
77
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { color: stateColor, bold: node.isCurrent, children: [" ", marker, " "] }), _jsx(Text, { color: stateColor, bold: node.isCurrent, children: stateLabel }), _jsxs(Text, { dimColor: true, children: [" ", formatTime(node.enteredAt)] })] }), node.reason && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: isLast && node.sideTrips.length === 0 ? " " : " \u2502 " }), _jsx(Text, { dimColor: true, children: node.reason })] })), node.runs.map((run, ri) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: isLast && node.sideTrips.length === 0 ? " " : " \u2502 " }), _jsx(RunLine, { run: run, index: runOffset + ri })] }), run.id === activeRunId && plan && plan.length > 0 && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: isLast ? " " : " \u2502 " }), _jsx(PlanSteps, { plan: plan })] }))] }, `run-${run.id}`))), node.sideTrips.length > 0 && (_jsx(Box, { flexDirection: "column", children: node.sideTrips.map((trip, ti) => {
78
+ // Count runs before this side-trip for numbering
79
+ const priorSideTripRuns = node.sideTrips.slice(0, ti).reduce((acc, st) => acc + st.runs.length, 0);
80
+ const tripRunOffset = runOffset + node.runs.length + priorSideTripRuns;
81
+ return (_jsx(SideTripBlock, { trip: trip, runOffset: tripRunOffset, isLast: ti === node.sideTrips.length - 1 }, `trip-${ti}`));
82
+ }) })), !isLast && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: " \u2502" }) }))] }));
83
+ }
84
+ // ─── Main component ──────────────────────────────────────────────
85
+ export function StateHistoryView({ history, plan, activeRunId }) {
86
+ if (history.length === 0) {
87
+ return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "No state history available." }) }));
88
+ }
89
+ // Compute global run numbering (sequential across all nodes)
90
+ let runCounter = 0;
91
+ return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: history.map((node, i) => {
92
+ const offset = runCounter;
93
+ runCounter += node.runs.length + node.sideTrips.reduce((acc, st) => acc + st.runs.length, 0);
94
+ return (_jsx(MainPathNode, { node: node, isLast: i === history.length - 1, runOffset: offset, plan: plan, activeRunId: activeRunId }, `node-${i}`));
95
+ }) }));
96
+ }
@@ -0,0 +1,204 @@
1
+ // ─── Constants ───────────────────────────────────────────────────
2
+ const SIDE_TRIP_STATES = new Set(["changes_requested", "repairing_ci", "repairing_queue"]);
3
+ const RUN_TYPE_TO_STATE = {
4
+ implementation: "implementing",
5
+ ci_repair: "repairing_ci",
6
+ review_fix: "changes_requested",
7
+ queue_repair: "repairing_queue",
8
+ };
9
+ function extractTransitions(feedEvents) {
10
+ const transitions = [];
11
+ for (const event of feedEvents) {
12
+ if (!event.stage)
13
+ continue;
14
+ let state;
15
+ let reason;
16
+ if (event.kind === "stage") {
17
+ if (event.status === "starting") {
18
+ // stage field is runType, map to factory state
19
+ state = RUN_TYPE_TO_STATE[event.stage] ?? event.stage;
20
+ reason = event.summary;
21
+ }
22
+ else if (event.status === "reconciled" || event.status === "retry" || event.status === "queued") {
23
+ state = event.stage;
24
+ reason = event.summary;
25
+ }
26
+ }
27
+ else if (event.kind === "github") {
28
+ // stage field is the factory state AFTER the transition
29
+ state = event.stage;
30
+ reason = event.summary;
31
+ }
32
+ if (state) {
33
+ // Deduplicate consecutive identical states
34
+ if (transitions.length > 0 && transitions[transitions.length - 1].state === state) {
35
+ continue;
36
+ }
37
+ transitions.push({ state, at: event.at, reason });
38
+ }
39
+ }
40
+ return transitions;
41
+ }
42
+ // ─── Run matching ────────────────────────────────────────────────
43
+ function buildRunQueue(runs) {
44
+ // Group runs by their corresponding factory state, preserving chronological order.
45
+ // Each call to consumeNextRun() pops from the front.
46
+ const map = new Map();
47
+ for (const run of runs) {
48
+ const state = RUN_TYPE_TO_STATE[run.runType] ?? run.runType;
49
+ const info = {
50
+ id: run.id,
51
+ runType: run.runType,
52
+ status: run.status,
53
+ startedAt: run.startedAt,
54
+ endedAt: run.endedAt,
55
+ };
56
+ const list = map.get(state);
57
+ if (list) {
58
+ list.push(info);
59
+ }
60
+ else {
61
+ map.set(state, [info]);
62
+ }
63
+ }
64
+ return map;
65
+ }
66
+ // ─── Tree builder ────────────────────────────────────────────────
67
+ export function buildStateHistory(runs, feedEvents, currentFactoryState, activeRunId) {
68
+ const transitions = extractTransitions(feedEvents);
69
+ const runQueue = buildRunQueue(runs);
70
+ function consumeNextRun(state) {
71
+ const queue = runQueue.get(state);
72
+ if (!queue || queue.length === 0)
73
+ return [];
74
+ const run = queue.shift();
75
+ return run ? [run] : [];
76
+ }
77
+ // Walk transitions and build nodes
78
+ const nodes = [];
79
+ let currentMainNode = null;
80
+ let currentSideTrip = null;
81
+ for (let i = 0; i < transitions.length; i++) {
82
+ const t = transitions[i];
83
+ const isSideTrip = SIDE_TRIP_STATES.has(t.state);
84
+ if (isSideTrip) {
85
+ // Start or continue a side-trip
86
+ if (currentSideTrip) {
87
+ // Close previous side-trip first (nested side-trip is rare but handle it)
88
+ closeSideTrip(currentMainNode, currentSideTrip, t.state, t.at);
89
+ }
90
+ currentSideTrip = {
91
+ state: t.state,
92
+ enteredAt: t.at,
93
+ reason: t.reason,
94
+ returnState: "",
95
+ runs: [],
96
+ };
97
+ }
98
+ else {
99
+ // Main-path state
100
+ if (currentSideTrip && currentMainNode) {
101
+ // Close the active side-trip — we're returning to the main path
102
+ // Consume runs for the side-trip state now
103
+ currentSideTrip.runs = consumeNextRun(currentSideTrip.state);
104
+ currentSideTrip.returnState = t.state;
105
+ currentSideTrip.returnedAt = t.at;
106
+ currentMainNode.sideTrips.push(currentSideTrip);
107
+ currentSideTrip = null;
108
+ }
109
+ // Skip duplicate main-path nodes if returning to the same state (e.g., pr_open → changes_requested → pr_open)
110
+ if (currentMainNode && currentMainNode.state === t.state) {
111
+ // Same main-path state revisited — don't create a new node
112
+ continue;
113
+ }
114
+ currentMainNode = {
115
+ state: t.state,
116
+ enteredAt: t.at,
117
+ reason: t.reason,
118
+ isCurrent: false,
119
+ runs: consumeNextRun(t.state),
120
+ sideTrips: [],
121
+ };
122
+ nodes.push(currentMainNode);
123
+ }
124
+ }
125
+ // If we ended in a side-trip (e.g., currently repairing_ci), close it
126
+ if (currentSideTrip && currentMainNode) {
127
+ currentSideTrip.runs = consumeNextRun(currentSideTrip.state);
128
+ currentSideTrip.returnState = currentFactoryState;
129
+ currentMainNode.sideTrips.push(currentSideTrip);
130
+ }
131
+ // Handle edge case: no transitions extracted but we have runs
132
+ if (nodes.length === 0 && runs.length > 0) {
133
+ // Seed with delegated state from earliest run
134
+ const earliest = runs[0];
135
+ nodes.push({
136
+ state: "delegated",
137
+ enteredAt: earliest.startedAt,
138
+ isCurrent: currentFactoryState === "delegated",
139
+ runs: [],
140
+ sideTrips: [],
141
+ });
142
+ const implState = RUN_TYPE_TO_STATE[earliest.runType] ?? "implementing";
143
+ nodes.push({
144
+ state: implState,
145
+ enteredAt: earliest.startedAt,
146
+ isCurrent: currentFactoryState === implState,
147
+ runs: consumeNextRun(implState),
148
+ sideTrips: [],
149
+ });
150
+ }
151
+ // Mark the current state
152
+ markCurrent(nodes, currentFactoryState);
153
+ // Mark active run
154
+ if (activeRunId !== null) {
155
+ markActiveRun(nodes, activeRunId);
156
+ }
157
+ return nodes;
158
+ }
159
+ function closeSideTrip(mainNode, sideTrip, returnState, returnedAt) {
160
+ if (!mainNode)
161
+ return;
162
+ sideTrip.returnState = returnState;
163
+ sideTrip.returnedAt = returnedAt;
164
+ mainNode.sideTrips.push(sideTrip);
165
+ }
166
+ function markCurrent(nodes, currentState) {
167
+ // If current state is a side-trip state, mark the last main node as current
168
+ // (the side-trip is "in progress" from that main node)
169
+ if (SIDE_TRIP_STATES.has(currentState)) {
170
+ if (nodes.length > 0) {
171
+ nodes[nodes.length - 1].isCurrent = true;
172
+ }
173
+ return;
174
+ }
175
+ // Find the last node matching the current state
176
+ for (let i = nodes.length - 1; i >= 0; i--) {
177
+ if (nodes[i].state === currentState) {
178
+ nodes[i].isCurrent = true;
179
+ return;
180
+ }
181
+ }
182
+ // Fallback: mark the last node
183
+ if (nodes.length > 0) {
184
+ nodes[nodes.length - 1].isCurrent = true;
185
+ }
186
+ }
187
+ function markActiveRun(nodes, activeRunId) {
188
+ for (const node of nodes) {
189
+ for (const run of node.runs) {
190
+ if (run.id === activeRunId) {
191
+ run.status = "running";
192
+ return;
193
+ }
194
+ }
195
+ for (const trip of node.sideTrips) {
196
+ for (const run of trip.runs) {
197
+ if (run.id === activeRunId) {
198
+ run.status = "running";
199
+ return;
200
+ }
201
+ }
202
+ }
203
+ }
204
+ }
@@ -0,0 +1,14 @@
1
+ export function planStepSymbol(status) {
2
+ if (status === "completed")
3
+ return "\u2713";
4
+ if (status === "inProgress")
5
+ return "\u25b8";
6
+ return " ";
7
+ }
8
+ export function planStepColor(status) {
9
+ if (status === "completed")
10
+ return "green";
11
+ if (status === "inProgress")
12
+ return "yellow";
13
+ return "white";
14
+ }
@@ -1,6 +1,9 @@
1
1
  import { buildTimelineFromRehydration, appendFeedToTimeline, appendCodexItemToTimeline, completeCodexItemInTimeline, appendDeltaToTimelineItem, } from "./timeline-builder.js";
2
2
  const DETAIL_INITIAL = {
3
+ detailTab: "timeline",
3
4
  timeline: [],
5
+ rawRuns: [],
6
+ rawFeedEvents: [],
4
7
  activeRunId: null,
5
8
  activeRunStartedAt: null,
6
9
  tokenUsage: null,
@@ -96,6 +99,8 @@ export function watchReducer(state, action) {
96
99
  return {
97
100
  ...state,
98
101
  timeline,
102
+ rawRuns: action.runs,
103
+ rawFeedEvents: action.feedEvents,
99
104
  activeRunId: action.activeRunId,
100
105
  activeRunStartedAt: activeRun?.startedAt ?? null,
101
106
  issueContext: action.issueContext,
@@ -115,6 +120,8 @@ export function watchReducer(state, action) {
115
120
  return { ...state, feedEvents: action.events };
116
121
  case "feed-new-event":
117
122
  return { ...state, feedEvents: [...state.feedEvents, action.event] };
123
+ case "switch-detail-tab":
124
+ return { ...state, detailTab: action.tab };
118
125
  }
119
126
  }
120
127
  // ─── Feed Event → Issue List + Timeline ───────────────────────────
@@ -147,11 +154,15 @@ function applyFeedEvent(state, event) {
147
154
  }
148
155
  issue.updatedAt = event.at;
149
156
  updated[index] = issue;
150
- // Append to timeline if this event matches the active detail issue
151
- const timeline = state.view === "detail" && state.activeDetailKey === event.issueKey
157
+ // Append to timeline and raw feed events if this event matches the active detail issue
158
+ const isActiveDetail = state.view === "detail" && state.activeDetailKey === event.issueKey;
159
+ const timeline = isActiveDetail
152
160
  ? appendFeedToTimeline(state.timeline, event)
153
161
  : state.timeline;
154
- return { ...state, issues: updated, timeline };
162
+ const rawFeedEvents = isActiveDetail
163
+ ? [...state.rawFeedEvents, event]
164
+ : state.rawFeedEvents;
165
+ return { ...state, issues: updated, timeline, rawFeedEvents };
155
166
  }
156
167
  // ─── Codex Notification → Timeline + Metadata ─────────────────────
157
168
  function applyCodexNotification(state, method, params) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.23.5",
3
+ "version": "0.24.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {