patchrelay 0.16.0 → 0.17.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.16.0",
4
- "commit": "396ce21398c4",
5
- "builtAt": "2026-03-25T12:29:18.014Z"
3
+ "version": "0.17.0",
4
+ "commit": "972d51f6e0da",
5
+ "builtAt": "2026-03-25T12:55:22.635Z"
6
6
  }
@@ -64,5 +64,5 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
64
64
  }
65
65
  }
66
66
  });
67
- return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : (_jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), thread: state.thread, report: state.report, follow: state.follow, feedEntries: state.detailFeed })) }));
67
+ return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : (_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 })) }));
68
68
  }
@@ -1,12 +1,8 @@
1
- import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useReducer } from "react";
2
3
  import { Box, Text } from "ink";
3
- import { ThreadView } from "./ThreadView.js";
4
- import { FeedTimeline } from "./FeedTimeline.js";
4
+ import { Timeline } from "./Timeline.js";
5
5
  import { HelpBar } from "./HelpBar.js";
6
- function truncate(text, max) {
7
- const line = text.replace(/\n/g, " ").trim();
8
- return line.length > max ? `${line.slice(0, max - 3)}...` : line;
9
- }
10
6
  function formatTokens(n) {
11
7
  if (n >= 1_000_000)
12
8
  return `${(n / 1_000_000).toFixed(1)}M`;
@@ -14,16 +10,35 @@ function formatTokens(n) {
14
10
  return `${(n / 1_000).toFixed(1)}k`;
15
11
  return String(n);
16
12
  }
17
- function ThreadStatusBar({ thread, follow }) {
18
- return (_jsxs(Box, { gap: 2, children: [thread.tokenUsage && (_jsxs(Text, { dimColor: true, children: ["tokens: ", formatTokens(thread.tokenUsage.inputTokens), " in / ", formatTokens(thread.tokenUsage.outputTokens), " out"] })), thread.diffSummary && thread.diffSummary.filesChanged > 0 && (_jsxs(Text, { dimColor: true, children: ["diff: ", thread.diffSummary.filesChanged, " file", thread.diffSummary.filesChanged !== 1 ? "s" : "", " ", "+", thread.diffSummary.linesAdded, " -", thread.diffSummary.linesRemoved] })), follow && _jsx(Text, { color: "yellow", children: "follow" })] }));
13
+ function ElapsedTime({ startedAt }) {
14
+ const [, tick] = useReducer((c) => c + 1, 0);
15
+ useEffect(() => {
16
+ const id = setInterval(tick, 1000);
17
+ return () => clearInterval(id);
18
+ }, []);
19
+ const elapsed = Math.max(0, Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000));
20
+ const minutes = Math.floor(elapsed / 60);
21
+ const seconds = elapsed % 60;
22
+ return _jsxs(Text, { dimColor: true, children: [minutes, "m ", String(seconds).padStart(2, "0"), "s"] });
23
+ }
24
+ function planStepSymbol(status) {
25
+ if (status === "completed")
26
+ return "\u2713";
27
+ if (status === "inProgress")
28
+ return "\u25b8";
29
+ return " ";
19
30
  }
20
- function ReportView({ report }) {
21
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "Latest run:" }), _jsx(Text, { bold: true, children: report.runType }), _jsx(Text, { color: report.status === "completed" ? "green" : "red", children: report.status })] }), report.summary && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Summary:" }), _jsx(Text, { wrap: "wrap", children: truncate(report.summary, 300) })] })), report.commands.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { bold: true, children: ["Commands (", report.commands.length, "):"] }), report.commands.slice(-10).map((cmd, i) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: cmd.exitCode === 0 ? "green" : cmd.exitCode !== undefined ? "red" : "white", children: cmd.exitCode === 0 ? "\u2713" : cmd.exitCode !== undefined ? "\u2717" : " " }), _jsx(Text, { dimColor: true, children: "$ " }), _jsx(Text, { children: truncate(cmd.command, 60) }), cmd.durationMs !== undefined && _jsxs(Text, { dimColor: true, children: [" ", (cmd.durationMs / 1000).toFixed(1), "s"] })] }, `cmd-${i}`)))] })), _jsxs(Box, { marginTop: 1, gap: 2, children: [report.fileChanges > 0 && _jsxs(Text, { dimColor: true, children: [report.fileChanges, " file change", report.fileChanges !== 1 ? "s" : ""] }), report.toolCalls > 0 && _jsxs(Text, { dimColor: true, children: [report.toolCalls, " tool call", report.toolCalls !== 1 ? "s" : ""] }), report.assistantMessages.length > 0 && _jsxs(Text, { dimColor: true, children: [report.assistantMessages.length, " message", report.assistantMessages.length !== 1 ? "s" : ""] })] })] }));
31
+ function planStepColor(status) {
32
+ if (status === "completed")
33
+ return "green";
34
+ if (status === "inProgress")
35
+ return "yellow";
36
+ return "white";
22
37
  }
23
- export function IssueDetailView({ issue, thread, report, follow, feedEntries }) {
38
+ export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, tokenUsage, diffSummary, plan, }) {
24
39
  if (!issue) {
25
40
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", children: "Issue not found." }), _jsx(HelpBar, { view: "detail", follow: follow })] }));
26
41
  }
27
42
  const key = issue.issueKey ?? issue.projectId;
28
- 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: ["PR #", issue.prNumber] })] }), issue.title && _jsx(Text, { dimColor: true, children: issue.title }), thread && _jsx(ThreadStatusBar, { thread: thread, follow: follow }), _jsx(Text, { dimColor: true, children: "".repeat(72) }), thread ? (_jsx(ThreadView, { thread: thread, follow: follow })) : report ? (_jsx(ReportView, { report: report })) : (_jsx(Text, { dimColor: true, children: "Loading..." })), feedEntries.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "".repeat(72) }), _jsx(Text, { bold: true, dimColor: true, children: "Events:" }), _jsx(FeedTimeline, { entries: feedEntries, maxEntries: follow ? 5 : undefined })] })), _jsx(Text, { dimColor: true, children: "─".repeat(72) }), _jsx(HelpBar, { view: "detail", follow: follow })] }));
43
+ 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: ["PR #", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt })] }), issue.title && _jsx(Text, { dimColor: true, children: issue.title }), _jsxs(Box, { gap: 2, children: [tokenUsage && (_jsxs(Text, { dimColor: true, children: ["tokens: ", formatTokens(tokenUsage.inputTokens), " in / ", formatTokens(tokenUsage.outputTokens), " out"] })), diffSummary && diffSummary.filesChanged > 0 && (_jsxs(Text, { dimColor: true, children: ["diff: ", diffSummary.filesChanged, " file", diffSummary.filesChanged !== 1 ? "s" : "", " ", "+", diffSummary.linesAdded, " -", diffSummary.linesRemoved] })), follow && _jsx(Text, { color: "yellow", children: "follow" })] }), plan && plan.length > 0 && (_jsx(Box, { flexDirection: "column", 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(Text, { dimColor: true, children: "".repeat(72) }), _jsx(Timeline, { entries: timeline, follow: follow }), _jsx(Text, { dimColor: true, children: "─".repeat(72) }), _jsx(HelpBar, { view: "detail", follow: follow })] }));
29
44
  }
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { TimelineRow } from "./TimelineRow.js";
4
+ const FOLLOW_TAIL_SIZE = 20;
5
+ export function Timeline({ entries, follow }) {
6
+ const visible = follow && entries.length > FOLLOW_TAIL_SIZE
7
+ ? entries.slice(-FOLLOW_TAIL_SIZE)
8
+ : entries;
9
+ const skipped = entries.length - visible.length;
10
+ if (entries.length === 0) {
11
+ return _jsx(Text, { dimColor: true, children: "No timeline events yet." });
12
+ }
13
+ return (_jsxs(Box, { flexDirection: "column", children: [skipped > 0 && _jsxs(Text, { dimColor: true, children: [" ... ", skipped, " earlier events"] }), visible.map((entry) => (_jsx(TimelineRow, { entry: entry }, entry.id)))] }));
14
+ }
@@ -0,0 +1,62 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { ItemLine } from "./ItemLine.js";
4
+ function formatTime(iso) {
5
+ return new Date(iso).toLocaleTimeString("en-GB", { hour12: false });
6
+ }
7
+ function formatDuration(startedAt, endedAt) {
8
+ const ms = new Date(endedAt).getTime() - new Date(startedAt).getTime();
9
+ const seconds = Math.floor(ms / 1000);
10
+ if (seconds < 60)
11
+ return `${seconds}s`;
12
+ const minutes = Math.floor(seconds / 60);
13
+ const remainingSeconds = seconds % 60;
14
+ return `${minutes}m ${remainingSeconds}s`;
15
+ }
16
+ const CHECK_SYMBOLS = {
17
+ passed: "\u2713",
18
+ failed: "\u2717",
19
+ pending: "\u25cf",
20
+ };
21
+ const CHECK_COLORS = {
22
+ passed: "green",
23
+ failed: "red",
24
+ pending: "yellow",
25
+ };
26
+ function FeedRow({ entry }) {
27
+ const feed = entry.feed;
28
+ const statusLabel = feed.status ?? feed.feedKind;
29
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { color: "cyan", children: statusLabel.padEnd(16) }), _jsx(Text, { children: feed.summary })] }));
30
+ }
31
+ function RunStartRow({ entry }) {
32
+ const run = entry.run;
33
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { bold: true, color: "yellow", children: run.runType.padEnd(16) }), _jsx(Text, { bold: true, children: "run started" })] }));
34
+ }
35
+ function RunEndRow({ entry }) {
36
+ const run = entry.run;
37
+ const color = run.status === "completed" ? "green" : "red";
38
+ const duration = run.endedAt ? formatDuration(run.startedAt, run.endedAt) : "";
39
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { bold: true, color: color, children: run.runType.padEnd(16) }), _jsx(Text, { bold: true, color: color, children: run.status }), duration ? _jsxs(Text, { dimColor: true, children: ["(", duration, ")"] }) : null] }));
40
+ }
41
+ function ItemRow({ entry }) {
42
+ const item = entry.item;
43
+ return (_jsx(Box, { paddingLeft: 2, children: _jsx(ItemLine, { item: item, isLast: false }) }));
44
+ }
45
+ function CIChecksRow({ entry }) {
46
+ const ci = entry.ciChecks;
47
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { color: CHECK_COLORS[ci.overall] ?? "white", children: "ci_checks".padEnd(16) }), ci.checks.map((check, i) => (_jsxs(Text, { children: [_jsx(Text, { color: CHECK_COLORS[check.status] ?? "white", children: CHECK_SYMBOLS[check.status] ?? " " }), _jsxs(Text, { dimColor: true, children: [" ", check.name, " "] })] }, `check-${i}`)))] }));
48
+ }
49
+ export function TimelineRow({ entry }) {
50
+ switch (entry.kind) {
51
+ case "feed":
52
+ return _jsx(FeedRow, { entry: entry });
53
+ case "run-start":
54
+ return _jsx(RunStartRow, { entry: entry });
55
+ case "run-end":
56
+ return _jsx(RunEndRow, { entry: entry });
57
+ case "item":
58
+ return _jsx(ItemRow, { entry: entry });
59
+ case "ci-checks":
60
+ return _jsx(CIChecksRow, { entry: entry });
61
+ }
62
+ }
@@ -0,0 +1,363 @@
1
+ // ─── Build Timeline from Rehydration Data ─────────────────────────
2
+ export function buildTimelineFromRehydration(runs, feedEvents, liveThread, activeRunId) {
3
+ const entries = [];
4
+ // 1. Add run boundaries and items from reports
5
+ for (const run of runs) {
6
+ entries.push({
7
+ id: `run-start-${run.id}`,
8
+ at: run.startedAt,
9
+ kind: "run-start",
10
+ runId: run.id,
11
+ run: { runType: run.runType, status: run.status, startedAt: run.startedAt, endedAt: run.endedAt },
12
+ });
13
+ if (run.endedAt) {
14
+ entries.push({
15
+ id: `run-end-${run.id}`,
16
+ at: run.endedAt,
17
+ kind: "run-end",
18
+ runId: run.id,
19
+ run: { runType: run.runType, status: run.status, startedAt: run.startedAt, endedAt: run.endedAt },
20
+ });
21
+ }
22
+ // Items from completed run reports
23
+ if (run.report && run.id !== activeRunId) {
24
+ entries.push(...itemsFromReport(run.id, run.report, run.startedAt, run.endedAt));
25
+ }
26
+ }
27
+ // 2. Items from live thread (active run)
28
+ if (liveThread && activeRunId) {
29
+ entries.push(...itemsFromThread(activeRunId, liveThread));
30
+ }
31
+ // 3. Feed events → feed entries + CI check aggregation
32
+ entries.push(...feedEventsToEntries(feedEvents));
33
+ // 4. Sort by timestamp, then by entry order for stability
34
+ entries.sort((a, b) => {
35
+ const cmp = a.at.localeCompare(b.at);
36
+ if (cmp !== 0)
37
+ return cmp;
38
+ // Within same timestamp: run-start before items, items before run-end
39
+ return kindOrder(a.kind) - kindOrder(b.kind);
40
+ });
41
+ return entries;
42
+ }
43
+ function kindOrder(kind) {
44
+ switch (kind) {
45
+ case "run-start": return 0;
46
+ case "feed": return 1;
47
+ case "ci-checks": return 2;
48
+ case "item": return 3;
49
+ case "run-end": return 4;
50
+ }
51
+ }
52
+ // ─── Items from Report ────────────────────────────────────────────
53
+ function itemsFromReport(runId, report, startedAt, endedAt) {
54
+ const entries = [];
55
+ const start = new Date(startedAt).getTime();
56
+ const end = endedAt ? new Date(endedAt).getTime() : start + 60000;
57
+ let idx = 0;
58
+ const total = report.commands.length + report.assistantMessages.length + report.toolCalls.length;
59
+ for (const msg of report.assistantMessages) {
60
+ entries.push({
61
+ id: `report-${runId}-msg-${idx}`,
62
+ at: syntheticTimestamp(start, end, idx, total),
63
+ kind: "item",
64
+ runId,
65
+ item: { id: `report-${runId}-msg-${idx}`, type: "agentMessage", status: "completed", text: msg },
66
+ });
67
+ idx++;
68
+ }
69
+ for (const cmd of report.commands) {
70
+ entries.push({
71
+ id: `report-${runId}-cmd-${idx}`,
72
+ at: syntheticTimestamp(start, end, idx, total),
73
+ kind: "item",
74
+ runId,
75
+ item: {
76
+ id: `report-${runId}-cmd-${idx}`,
77
+ type: "commandExecution",
78
+ status: "completed",
79
+ command: cmd.command,
80
+ ...(typeof cmd.exitCode === "number" ? { exitCode: cmd.exitCode } : {}),
81
+ ...(typeof cmd.durationMs === "number" ? { durationMs: cmd.durationMs } : {}),
82
+ },
83
+ });
84
+ idx++;
85
+ }
86
+ for (const tool of report.toolCalls) {
87
+ entries.push({
88
+ id: `report-${runId}-tool-${idx}`,
89
+ at: syntheticTimestamp(start, end, idx, total),
90
+ kind: "item",
91
+ runId,
92
+ item: {
93
+ id: `report-${runId}-tool-${idx}`,
94
+ type: tool.type === "mcp" ? "mcpToolCall" : "dynamicToolCall",
95
+ status: "completed",
96
+ toolName: tool.name,
97
+ ...(typeof tool.durationMs === "number" ? { durationMs: tool.durationMs } : {}),
98
+ },
99
+ });
100
+ idx++;
101
+ }
102
+ if (report.fileChanges.length > 0) {
103
+ entries.push({
104
+ id: `report-${runId}-files`,
105
+ at: syntheticTimestamp(start, end, idx, total),
106
+ kind: "item",
107
+ runId,
108
+ item: {
109
+ id: `report-${runId}-files`,
110
+ type: "fileChange",
111
+ status: "completed",
112
+ changes: report.fileChanges,
113
+ },
114
+ });
115
+ }
116
+ return entries;
117
+ }
118
+ function syntheticTimestamp(startMs, endMs, index, total) {
119
+ if (total <= 1)
120
+ return new Date(startMs).toISOString();
121
+ const fraction = index / (total - 1);
122
+ return new Date(startMs + fraction * (endMs - startMs)).toISOString();
123
+ }
124
+ // ─── Items from Live Thread ───────────────────────────────────────
125
+ function itemsFromThread(runId, thread) {
126
+ const entries = [];
127
+ for (const turn of thread.turns) {
128
+ for (const item of turn.items) {
129
+ entries.push({
130
+ id: `live-${item.id}`,
131
+ at: new Date().toISOString(), // live items don't have timestamps; they'll sort to the end
132
+ kind: "item",
133
+ runId,
134
+ item: materializeItem(item),
135
+ });
136
+ }
137
+ }
138
+ return entries;
139
+ }
140
+ function materializeItem(item) {
141
+ const r = item;
142
+ const id = String(r.id ?? "unknown");
143
+ const type = String(r.type ?? "unknown");
144
+ const base = { id, type, status: "completed" };
145
+ switch (type) {
146
+ case "agentMessage":
147
+ return { ...base, text: String(r.text ?? "") };
148
+ case "commandExecution":
149
+ return {
150
+ ...base,
151
+ command: String(r.command ?? ""),
152
+ status: String(r.status ?? "completed"),
153
+ ...(typeof r.exitCode === "number" ? { exitCode: r.exitCode } : {}),
154
+ ...(typeof r.durationMs === "number" ? { durationMs: r.durationMs } : {}),
155
+ ...(typeof r.aggregatedOutput === "string" ? { output: r.aggregatedOutput } : {}),
156
+ };
157
+ case "fileChange":
158
+ return { ...base, status: String(r.status ?? "completed"), changes: Array.isArray(r.changes) ? r.changes : [] };
159
+ case "mcpToolCall":
160
+ return {
161
+ ...base,
162
+ status: String(r.status ?? "completed"),
163
+ toolName: `${String(r.server ?? "")}/${String(r.tool ?? "")}`,
164
+ ...(typeof r.durationMs === "number" ? { durationMs: r.durationMs } : {}),
165
+ };
166
+ case "dynamicToolCall":
167
+ return {
168
+ ...base,
169
+ status: String(r.status ?? "completed"),
170
+ toolName: String(r.tool ?? ""),
171
+ ...(typeof r.durationMs === "number" ? { durationMs: r.durationMs } : {}),
172
+ };
173
+ case "plan":
174
+ return { ...base, text: String(r.text ?? "") };
175
+ case "reasoning":
176
+ return { ...base, text: Array.isArray(r.summary) ? r.summary.join("\n") : "" };
177
+ default:
178
+ return base;
179
+ }
180
+ }
181
+ // ─── Feed Events to Timeline Entries ──────────────────────────────
182
+ function feedEventsToEntries(feedEvents) {
183
+ const entries = [];
184
+ const ciAggregator = new CICheckAggregator();
185
+ for (const event of feedEvents) {
186
+ // GitHub check events get aggregated
187
+ if (event.kind === "github" && (event.status === "check_passed" || event.status === "check_failed") && event.detail) {
188
+ const ciEntry = ciAggregator.add(event);
189
+ if (ciEntry) {
190
+ // Replace the last ci-checks entry if it was updated
191
+ const lastIdx = entries.findLastIndex((e) => e.kind === "ci-checks" && e.id === ciEntry.id);
192
+ if (lastIdx >= 0) {
193
+ entries[lastIdx] = ciEntry;
194
+ }
195
+ else {
196
+ entries.push(ciEntry);
197
+ }
198
+ }
199
+ continue;
200
+ }
201
+ entries.push({
202
+ id: `feed-${event.id}`,
203
+ at: event.at,
204
+ kind: "feed",
205
+ feed: {
206
+ feedKind: event.kind,
207
+ ...(event.status ? { status: event.status } : {}),
208
+ summary: event.summary,
209
+ ...(event.detail ? { detail: event.detail } : {}),
210
+ },
211
+ });
212
+ }
213
+ return entries;
214
+ }
215
+ // ─── CI Check Aggregation ─────────────────────────────────────────
216
+ const CI_CHECK_WINDOW_MS = 60_000;
217
+ class CICheckAggregator {
218
+ currentGroup = null;
219
+ groupCounter = 0;
220
+ add(event) {
221
+ const name = event.detail ?? "unknown";
222
+ const status = event.status === "check_passed" ? "passed" : "failed";
223
+ const eventMs = new Date(event.at).getTime();
224
+ if (this.currentGroup && eventMs - this.currentGroup.windowStart < CI_CHECK_WINDOW_MS) {
225
+ this.currentGroup.checks.set(name, status);
226
+ return this.toEntry();
227
+ }
228
+ this.groupCounter++;
229
+ this.currentGroup = {
230
+ id: `ci-checks-${this.groupCounter}`,
231
+ at: event.at,
232
+ checks: new Map([[name, status]]),
233
+ windowStart: eventMs,
234
+ };
235
+ return this.toEntry();
236
+ }
237
+ toEntry() {
238
+ const group = this.currentGroup;
239
+ const checks = [...group.checks.entries()].map(([name, status]) => ({ name, status }));
240
+ const overall = checks.every((c) => c.status === "passed") ? "passed"
241
+ : checks.some((c) => c.status === "failed") ? "failed"
242
+ : "pending";
243
+ return {
244
+ id: group.id,
245
+ at: group.at,
246
+ kind: "ci-checks",
247
+ ciChecks: { checks, overall },
248
+ };
249
+ }
250
+ }
251
+ // ─── Live Append Helpers ──────────────────────────────────────────
252
+ export function appendFeedToTimeline(timeline, event) {
253
+ // GitHub check events: aggregate into existing ci-checks entry
254
+ if (event.kind === "github" && (event.status === "check_passed" || event.status === "check_failed") && event.detail) {
255
+ return aggregateCICheckIntoTimeline(timeline, event);
256
+ }
257
+ return [...timeline, {
258
+ id: `feed-${event.id}`,
259
+ at: event.at,
260
+ kind: "feed",
261
+ feed: {
262
+ feedKind: event.kind,
263
+ ...(event.status ? { status: event.status } : {}),
264
+ summary: event.summary,
265
+ ...(event.detail ? { detail: event.detail } : {}),
266
+ },
267
+ }];
268
+ }
269
+ function aggregateCICheckIntoTimeline(timeline, event) {
270
+ const name = event.detail ?? "unknown";
271
+ const status = event.status === "check_passed" ? "passed" : "failed";
272
+ const eventMs = new Date(event.at).getTime();
273
+ // Find the most recent ci-checks entry within the window
274
+ for (let i = timeline.length - 1; i >= 0; i--) {
275
+ const entry = timeline[i];
276
+ if (entry.kind === "ci-checks" && entry.ciChecks) {
277
+ const entryMs = new Date(entry.at).getTime();
278
+ if (eventMs - entryMs < CI_CHECK_WINDOW_MS) {
279
+ const updatedChecks = [...entry.ciChecks.checks.filter((c) => c.name !== name), { name, status }];
280
+ const overall = updatedChecks.every((c) => c.status === "passed") ? "passed"
281
+ : updatedChecks.some((c) => c.status === "failed") ? "failed"
282
+ : "pending";
283
+ const updated = [...timeline];
284
+ updated[i] = { ...entry, ciChecks: { checks: updatedChecks, overall } };
285
+ return updated;
286
+ }
287
+ break;
288
+ }
289
+ }
290
+ // No recent ci-checks entry; create new one
291
+ return [...timeline, {
292
+ id: `ci-checks-live-${event.id}`,
293
+ at: event.at,
294
+ kind: "ci-checks",
295
+ ciChecks: { checks: [{ name, status }], overall: status },
296
+ }];
297
+ }
298
+ export function appendCodexItemToTimeline(timeline, params, activeRunId) {
299
+ const itemObj = params.item;
300
+ if (!itemObj)
301
+ return timeline;
302
+ const id = typeof itemObj.id === "string" ? itemObj.id : "unknown";
303
+ const type = typeof itemObj.type === "string" ? itemObj.type : "unknown";
304
+ const status = typeof itemObj.status === "string" ? itemObj.status : "inProgress";
305
+ const item = { id, type, status };
306
+ if (type === "agentMessage" && typeof itemObj.text === "string")
307
+ item.text = itemObj.text;
308
+ if (type === "commandExecution") {
309
+ const cmd = itemObj.command;
310
+ item.command = Array.isArray(cmd) ? cmd.join(" ") : typeof cmd === "string" ? cmd : undefined;
311
+ }
312
+ if (type === "mcpToolCall") {
313
+ item.toolName = `${String(itemObj.server ?? "")}/${String(itemObj.tool ?? "")}`;
314
+ }
315
+ if (type === "dynamicToolCall") {
316
+ item.toolName = typeof itemObj.tool === "string" ? itemObj.tool : undefined;
317
+ }
318
+ return [...timeline, {
319
+ id: `live-${id}`,
320
+ at: new Date().toISOString(),
321
+ kind: "item",
322
+ runId: activeRunId ?? undefined,
323
+ item,
324
+ }];
325
+ }
326
+ export function completeCodexItemInTimeline(timeline, params) {
327
+ const itemObj = params.item;
328
+ if (!itemObj)
329
+ return timeline;
330
+ const id = typeof itemObj.id === "string" ? itemObj.id : undefined;
331
+ if (!id)
332
+ return timeline;
333
+ const status = typeof itemObj.status === "string" ? itemObj.status : "completed";
334
+ const exitCode = typeof itemObj.exitCode === "number" ? itemObj.exitCode : undefined;
335
+ const durationMs = typeof itemObj.durationMs === "number" ? itemObj.durationMs : undefined;
336
+ const text = typeof itemObj.text === "string" ? itemObj.text : undefined;
337
+ const changes = Array.isArray(itemObj.changes) ? itemObj.changes : undefined;
338
+ return timeline.map((entry) => {
339
+ if (entry.kind !== "item" || entry.item?.id !== id)
340
+ return entry;
341
+ return {
342
+ ...entry,
343
+ item: {
344
+ ...entry.item,
345
+ status,
346
+ ...(exitCode !== undefined ? { exitCode } : {}),
347
+ ...(durationMs !== undefined ? { durationMs } : {}),
348
+ ...(text !== undefined ? { text } : {}),
349
+ ...(changes !== undefined ? { changes } : {}),
350
+ },
351
+ };
352
+ });
353
+ }
354
+ export function appendDeltaToTimelineItem(timeline, itemId, field, delta) {
355
+ return timeline.map((entry) => {
356
+ if (entry.kind !== "item" || entry.item?.id !== itemId)
357
+ return entry;
358
+ return {
359
+ ...entry,
360
+ item: { ...entry.item, [field]: (entry.item[field] ?? "") + delta },
361
+ };
362
+ });
363
+ }
@@ -8,70 +8,50 @@ export function useDetailStream(options) {
8
8
  return;
9
9
  const abortController = new AbortController();
10
10
  const { baseUrl, bearerToken, dispatch } = optionsRef.current;
11
- const headers = { accept: "application/json" };
11
+ const headers = {};
12
12
  if (bearerToken) {
13
13
  headers.authorization = `Bearer ${bearerToken}`;
14
14
  }
15
- // Rehydrate from thread/read via /api/issues/:key/live
15
+ // Rehydrate from timeline endpoint
16
16
  void rehydrate(baseUrl, issueKey, headers, abortController.signal, dispatch);
17
- // Stream codex notifications via filtered SSE
18
- void streamCodexEvents(baseUrl, issueKey, headers, abortController.signal, dispatch);
17
+ // Stream codex notifications + feed events via filtered SSE
18
+ void streamEvents(baseUrl, issueKey, headers, abortController.signal, dispatch);
19
19
  return () => {
20
20
  abortController.abort();
21
21
  };
22
22
  }, [options.issueKey]);
23
23
  }
24
+ // ─── Rehydration ──────────────────────────────────────────────────
24
25
  async function rehydrate(baseUrl, issueKey, headers, signal, dispatch) {
25
26
  try {
26
- const url = new URL(`/api/issues/${encodeURIComponent(issueKey)}/live`, baseUrl);
27
- const response = await fetch(url, { headers, signal });
28
- if (!response.ok)
29
- return;
30
- const data = await response.json();
31
- const threadData = data.thread;
32
- if (threadData) {
33
- dispatch({ type: "thread-snapshot", thread: materializeThread(threadData) });
34
- return;
35
- }
36
- // No active thread — fall back to latest run report
37
- await rehydrateFromReport(baseUrl, issueKey, headers, signal, dispatch);
38
- }
39
- catch {
40
- // Rehydration is best-effort — SSE stream will provide updates
41
- }
42
- }
43
- async function rehydrateFromReport(baseUrl, issueKey, headers, signal, dispatch) {
44
- try {
45
- const url = new URL(`/api/issues/${encodeURIComponent(issueKey)}/report`, baseUrl);
46
- const response = await fetch(url, { headers, signal });
27
+ const url = new URL(`/api/issues/${encodeURIComponent(issueKey)}/timeline`, baseUrl);
28
+ const response = await fetch(url, { headers: { ...headers, accept: "application/json" }, signal });
47
29
  if (!response.ok)
48
30
  return;
49
31
  const data = await response.json();
50
- const latest = data.runs?.[0];
51
- if (!latest)
52
- return;
53
- const report = {
54
- runType: latest.run.runType,
55
- status: latest.run.status,
56
- summary: typeof latest.summary?.latestAssistantMessage === "string"
57
- ? latest.summary.latestAssistantMessage
58
- : latest.report?.assistantMessages.at(-1),
59
- commands: latest.report?.commands.map((c) => ({
60
- command: c.command,
61
- ...(typeof c.exitCode === "number" ? { exitCode: c.exitCode } : {}),
62
- ...(typeof c.durationMs === "number" ? { durationMs: c.durationMs } : {}),
63
- })) ?? [],
64
- fileChanges: latest.report?.fileChanges.length ?? 0,
65
- toolCalls: latest.report?.toolCalls.length ?? 0,
66
- assistantMessages: latest.report?.assistantMessages ?? [],
67
- };
68
- dispatch({ type: "report-snapshot", report });
32
+ const runs = (data.runs ?? []).map((r) => ({
33
+ id: r.id,
34
+ runType: r.runType,
35
+ status: r.status,
36
+ startedAt: r.startedAt,
37
+ endedAt: r.endedAt,
38
+ threadId: r.threadId,
39
+ ...(r.report ? { report: r.report } : {}),
40
+ }));
41
+ dispatch({
42
+ type: "timeline-rehydrate",
43
+ runs,
44
+ feedEvents: data.feedEvents ?? [],
45
+ liveThread: data.liveThread ?? null,
46
+ activeRunId: data.activeRunId ?? null,
47
+ });
69
48
  }
70
49
  catch {
71
- // Report fetch is best-effort
50
+ // Rehydration is best-effort
72
51
  }
73
52
  }
74
- async function streamCodexEvents(baseUrl, issueKey, baseHeaders, signal, dispatch) {
53
+ // ─── Live SSE Stream ──────────────────────────────────────────────
54
+ async function streamEvents(baseUrl, issueKey, baseHeaders, signal, dispatch) {
75
55
  try {
76
56
  const url = new URL("/api/watch", baseUrl);
77
57
  url.searchParams.set("issue", issueKey);
@@ -96,7 +76,7 @@ async function streamCodexEvents(baseUrl, issueKey, baseHeaders, signal, dispatc
96
76
  const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
97
77
  if (!line) {
98
78
  if (dataLines.length > 0) {
99
- processDetailEvent(dispatch, eventType, dataLines.join("\n"));
79
+ processEvent(dispatch, eventType, dataLines.join("\n"));
100
80
  dataLines = [];
101
81
  eventType = "";
102
82
  }
@@ -121,73 +101,15 @@ async function streamCodexEvents(baseUrl, issueKey, baseHeaders, signal, dispatc
121
101
  // Stream ended or aborted
122
102
  }
123
103
  }
124
- function processDetailEvent(dispatch, eventType, data) {
104
+ function processEvent(dispatch, eventType, data) {
125
105
  try {
126
106
  if (eventType === "codex") {
127
107
  const parsed = JSON.parse(data);
128
108
  dispatch({ type: "codex-notification", method: parsed.method, params: parsed.params });
129
109
  }
130
- // Feed events are already handled by the main watch stream
110
+ // Feed events are handled by the main watch stream
131
111
  }
132
112
  catch {
133
113
  // Ignore parse errors
134
114
  }
135
115
  }
136
- // ─── Thread Materialization from thread/read ──────────────────────
137
- function materializeThread(summary) {
138
- return {
139
- threadId: summary.id,
140
- status: summary.status,
141
- turns: summary.turns.map(materializeTurn),
142
- };
143
- }
144
- function materializeTurn(turn) {
145
- return {
146
- id: turn.id,
147
- status: turn.status,
148
- items: turn.items.map(materializeItem),
149
- };
150
- }
151
- function materializeItem(item) {
152
- // CodexThreadItem has an index-signature catch-all that defeats narrowing.
153
- // Access fields via Record<string, unknown> and coerce explicitly.
154
- const r = item;
155
- const id = String(r.id ?? "unknown");
156
- const type = String(r.type ?? "unknown");
157
- const base = { id, type, status: "completed" };
158
- switch (type) {
159
- case "agentMessage":
160
- return { ...base, text: String(r.text ?? "") };
161
- case "commandExecution":
162
- return {
163
- ...base,
164
- command: String(r.command ?? ""),
165
- status: String(r.status ?? "completed"),
166
- ...(typeof r.exitCode === "number" ? { exitCode: r.exitCode } : {}),
167
- ...(typeof r.durationMs === "number" ? { durationMs: r.durationMs } : {}),
168
- ...(typeof r.aggregatedOutput === "string" ? { output: r.aggregatedOutput } : {}),
169
- };
170
- case "fileChange":
171
- return { ...base, status: String(r.status ?? "completed"), changes: Array.isArray(r.changes) ? r.changes : [] };
172
- case "mcpToolCall":
173
- return {
174
- ...base,
175
- status: String(r.status ?? "completed"),
176
- toolName: `${String(r.server ?? "")}/${String(r.tool ?? "")}`,
177
- ...(typeof r.durationMs === "number" ? { durationMs: r.durationMs } : {}),
178
- };
179
- case "dynamicToolCall":
180
- return {
181
- ...base,
182
- status: String(r.status ?? "completed"),
183
- toolName: String(r.tool ?? ""),
184
- ...(typeof r.durationMs === "number" ? { durationMs: r.durationMs } : {}),
185
- };
186
- case "plan":
187
- return { ...base, text: String(r.text ?? "") };
188
- case "reasoning":
189
- return { ...base, text: Array.isArray(r.summary) ? r.summary.join("\n") : "" };
190
- default:
191
- return base;
192
- }
193
- }
@@ -1,14 +1,21 @@
1
+ import { buildTimelineFromRehydration, appendFeedToTimeline, appendCodexItemToTimeline, completeCodexItemInTimeline, appendDeltaToTimelineItem, } from "./timeline-builder.js";
2
+ const DETAIL_INITIAL = {
3
+ timeline: [],
4
+ activeRunId: null,
5
+ activeRunStartedAt: null,
6
+ tokenUsage: null,
7
+ diffSummary: null,
8
+ plan: null,
9
+ };
1
10
  export const initialWatchState = {
2
11
  connected: false,
3
12
  issues: [],
4
13
  selectedIndex: 0,
5
14
  view: "list",
6
15
  activeDetailKey: null,
7
- thread: null,
8
- report: null,
9
16
  filter: "non-done",
10
17
  follow: true,
11
- detailFeed: [],
18
+ ...DETAIL_INITIAL,
12
19
  };
13
20
  const TERMINAL_FACTORY_STATES = new Set(["done", "failed"]);
14
21
  export function filterIssues(issues, filter) {
@@ -28,6 +35,7 @@ function nextFilter(filter) {
28
35
  case "all": return "non-done";
29
36
  }
30
37
  }
38
+ // ─── Reducer ──────────────────────────────────────────────────────
31
39
  export function watchReducer(state, action) {
32
40
  switch (action.type) {
33
41
  case "connected":
@@ -48,13 +56,19 @@ export function watchReducer(state, action) {
48
56
  selectedIndex: Math.max(0, Math.min(action.index, state.issues.length - 1)),
49
57
  };
50
58
  case "enter-detail":
51
- return { ...state, view: "detail", activeDetailKey: action.issueKey, thread: null, report: null, detailFeed: [] };
59
+ return { ...state, view: "detail", activeDetailKey: action.issueKey, ...DETAIL_INITIAL };
52
60
  case "exit-detail":
53
- return { ...state, view: "list", activeDetailKey: null, thread: null, report: null, detailFeed: [] };
54
- case "thread-snapshot":
55
- return { ...state, thread: action.thread };
56
- case "report-snapshot":
57
- return { ...state, report: action.report };
61
+ return { ...state, view: "list", activeDetailKey: null, ...DETAIL_INITIAL };
62
+ case "timeline-rehydrate": {
63
+ const timeline = buildTimelineFromRehydration(action.runs, action.feedEvents, action.liveThread, action.activeRunId);
64
+ const activeRun = action.runs.find((r) => r.id === action.activeRunId);
65
+ return {
66
+ ...state,
67
+ timeline,
68
+ activeRunId: action.activeRunId,
69
+ activeRunStartedAt: activeRun?.startedAt ?? null,
70
+ };
71
+ }
58
72
  case "codex-notification":
59
73
  return applyCodexNotification(state, action.method, action.params);
60
74
  case "cycle-filter":
@@ -63,7 +77,7 @@ export function watchReducer(state, action) {
63
77
  return { ...state, follow: !state.follow };
64
78
  }
65
79
  }
66
- // ─── Feed Event Application ───────────────────────────────────────
80
+ // ─── Feed Event Issue List + Timeline ───────────────────────────
67
81
  function applyFeedEvent(state, event) {
68
82
  if (!event.issueKey) {
69
83
  return state;
@@ -93,103 +107,52 @@ function applyFeedEvent(state, event) {
93
107
  }
94
108
  issue.updatedAt = event.at;
95
109
  updated[index] = issue;
96
- // Append to detail feed if this event matches the active detail issue
97
- const detailFeed = state.view === "detail" && state.activeDetailKey === event.issueKey
98
- ? [...state.detailFeed, {
99
- at: event.at,
100
- kind: event.kind,
101
- summary: event.summary,
102
- ...(event.status ? { status: event.status } : {}),
103
- ...(event.detail ? { detail: event.detail } : {}),
104
- }]
105
- : state.detailFeed;
106
- return { ...state, issues: updated, detailFeed };
110
+ // Append to timeline if this event matches the active detail issue
111
+ const timeline = state.view === "detail" && state.activeDetailKey === event.issueKey
112
+ ? appendFeedToTimeline(state.timeline, event)
113
+ : state.timeline;
114
+ return { ...state, issues: updated, timeline };
107
115
  }
108
- // ─── Codex Notification Application ───────────────────────────────
116
+ // ─── Codex Notification Timeline + Metadata ─────────────────────
109
117
  function applyCodexNotification(state, method, params) {
110
- if (!state.thread) {
111
- // No thread loaded yet — only turn/started can bootstrap one
112
- if (method === "turn/started") {
113
- return bootstrapThreadFromTurnStarted(state, params);
114
- }
115
- return state;
116
- }
117
118
  switch (method) {
118
- case "turn/started":
119
- return withThread(state, addTurn(state.thread, params));
120
- case "turn/completed":
121
- return withThread(state, completeTurn(state.thread, params));
122
- case "turn/plan/updated":
123
- return withThread(state, updatePlan(state.thread, params));
124
- case "turn/diff/updated":
125
- return withThread(state, updateDiff(state.thread, params));
126
119
  case "item/started":
127
- return withThread(state, addItem(state.thread, params));
120
+ return { ...state, timeline: appendCodexItemToTimeline(state.timeline, params, state.activeRunId) };
128
121
  case "item/completed":
129
- return withThread(state, completeItem(state.thread, params));
122
+ return { ...state, timeline: completeCodexItemInTimeline(state.timeline, params) };
130
123
  case "item/agentMessage/delta":
131
- return withThread(state, appendItemText(state.thread, params));
132
- case "item/commandExecution/outputDelta":
133
- return withThread(state, appendItemOutput(state.thread, params));
134
124
  case "item/plan/delta":
135
- return withThread(state, appendItemText(state.thread, params));
136
- case "item/reasoning/summaryTextDelta":
137
- return withThread(state, appendItemText(state.thread, params));
138
- case "thread/status/changed":
139
- return withThread(state, updateThreadStatus(state.thread, params));
125
+ case "item/reasoning/summaryTextDelta": {
126
+ const itemId = typeof params.itemId === "string" ? params.itemId : undefined;
127
+ const delta = typeof params.delta === "string" ? params.delta : undefined;
128
+ if (!itemId || !delta)
129
+ return state;
130
+ return { ...state, timeline: appendDeltaToTimelineItem(state.timeline, itemId, "text", delta) };
131
+ }
132
+ case "item/commandExecution/outputDelta": {
133
+ const itemId = typeof params.itemId === "string" ? params.itemId : undefined;
134
+ const delta = typeof params.delta === "string" ? params.delta : undefined;
135
+ if (!itemId || !delta)
136
+ return state;
137
+ return { ...state, timeline: appendDeltaToTimelineItem(state.timeline, itemId, "output", delta) };
138
+ }
139
+ case "turn/plan/updated":
140
+ return applyPlanUpdate(state, params);
141
+ case "turn/diff/updated":
142
+ return applyDiffUpdate(state, params);
140
143
  case "thread/tokenUsage/updated":
141
- return withThread(state, updateTokenUsage(state.thread, params));
144
+ return applyTokenUsageUpdate(state, params);
142
145
  default:
143
146
  return state;
144
147
  }
145
148
  }
146
- function withThread(state, thread) {
147
- return { ...state, thread };
148
- }
149
- function bootstrapThreadFromTurnStarted(state, params) {
150
- const turnObj = params.turn;
151
- const threadId = typeof params.threadId === "string" ? params.threadId : "unknown";
152
- const turnId = typeof turnObj?.id === "string" ? turnObj.id : "unknown";
153
- return {
154
- ...state,
155
- thread: {
156
- threadId,
157
- status: "active",
158
- turns: [{ id: turnId, status: "inProgress", items: [] }],
159
- },
160
- };
161
- }
162
- // ─── Turn Handlers ────────────────────────────────────────────────
163
- function addTurn(thread, params) {
164
- const turnObj = params.turn;
165
- const turnId = typeof turnObj?.id === "string" ? turnObj.id : "unknown";
166
- const existing = thread.turns.find((t) => t.id === turnId);
167
- if (existing) {
168
- return thread;
169
- }
170
- return {
171
- ...thread,
172
- status: "active",
173
- turns: [...thread.turns, { id: turnId, status: "inProgress", items: [] }],
174
- };
175
- }
176
- function completeTurn(thread, params) {
177
- const turnObj = params.turn;
178
- const turnId = typeof turnObj?.id === "string" ? turnObj.id : undefined;
179
- const status = typeof turnObj?.status === "string" ? turnObj.status : "completed";
180
- if (!turnId)
181
- return thread;
182
- return {
183
- ...thread,
184
- turns: thread.turns.map((t) => t.id === turnId ? { ...t, status } : t),
185
- };
186
- }
187
- function updatePlan(thread, params) {
149
+ // ─── Metadata Handlers (header, not timeline) ─────────────────────
150
+ function applyPlanUpdate(state, params) {
188
151
  const plan = params.plan;
189
152
  if (!Array.isArray(plan))
190
- return thread;
153
+ return state;
191
154
  return {
192
- ...thread,
155
+ ...state,
193
156
  plan: plan.map((entry) => {
194
157
  const e = entry;
195
158
  return {
@@ -199,9 +162,11 @@ function updatePlan(thread, params) {
199
162
  }),
200
163
  };
201
164
  }
202
- function updateDiff(thread, params) {
165
+ function applyDiffUpdate(state, params) {
203
166
  const diff = typeof params.diff === "string" ? params.diff : undefined;
204
- return { ...thread, diff, diffSummary: diff ? parseDiffSummary(diff) : undefined };
167
+ if (!diff)
168
+ return state;
169
+ return { ...state, diffSummary: parseDiffSummary(diff) };
205
170
  }
206
171
  function parseDiffSummary(diff) {
207
172
  const files = new Set();
@@ -220,109 +185,13 @@ function parseDiffSummary(diff) {
220
185
  }
221
186
  return { filesChanged: files.size, linesAdded: added, linesRemoved: removed };
222
187
  }
223
- function updateTokenUsage(thread, params) {
188
+ function applyTokenUsageUpdate(state, params) {
224
189
  const usage = params.usage;
225
190
  if (!usage)
226
- return thread;
191
+ return state;
227
192
  const inputTokens = typeof usage.inputTokens === "number" ? usage.inputTokens
228
193
  : typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
229
194
  const outputTokens = typeof usage.outputTokens === "number" ? usage.outputTokens
230
195
  : typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
231
- return { ...thread, tokenUsage: { inputTokens, outputTokens } };
232
- }
233
- function updateThreadStatus(thread, params) {
234
- const statusObj = params.status;
235
- const statusType = typeof statusObj?.type === "string" ? statusObj.type : undefined;
236
- if (!statusType)
237
- return thread;
238
- return { ...thread, status: statusType };
239
- }
240
- // ─── Item Handlers ────────────────────────────────────────────────
241
- function getLatestTurn(thread) {
242
- return thread.turns[thread.turns.length - 1];
243
- }
244
- function updateLatestTurn(thread, updater) {
245
- const last = getLatestTurn(thread);
246
- if (!last)
247
- return thread;
248
- return {
249
- ...thread,
250
- turns: [...thread.turns.slice(0, -1), updater(last)],
251
- };
252
- }
253
- function addItem(thread, params) {
254
- const itemObj = params.item;
255
- if (!itemObj)
256
- return thread;
257
- const id = typeof itemObj.id === "string" ? itemObj.id : "unknown";
258
- const type = typeof itemObj.type === "string" ? itemObj.type : "unknown";
259
- const status = typeof itemObj.status === "string" ? itemObj.status : "inProgress";
260
- const item = { id, type, status };
261
- if (type === "agentMessage" && typeof itemObj.text === "string") {
262
- item.text = itemObj.text;
263
- }
264
- if (type === "commandExecution") {
265
- const cmd = itemObj.command;
266
- item.command = Array.isArray(cmd) ? cmd.join(" ") : typeof cmd === "string" ? cmd : undefined;
267
- }
268
- if (type === "mcpToolCall") {
269
- const server = typeof itemObj.server === "string" ? itemObj.server : "";
270
- const tool = typeof itemObj.tool === "string" ? itemObj.tool : "";
271
- item.toolName = `${server}/${tool}`;
272
- }
273
- if (type === "dynamicToolCall") {
274
- item.toolName = typeof itemObj.tool === "string" ? itemObj.tool : undefined;
275
- }
276
- return updateLatestTurn(thread, (turn) => ({
277
- ...turn,
278
- items: [...turn.items, item],
279
- }));
280
- }
281
- function completeItem(thread, params) {
282
- const itemObj = params.item;
283
- if (!itemObj)
284
- return thread;
285
- const id = typeof itemObj.id === "string" ? itemObj.id : undefined;
286
- if (!id)
287
- return thread;
288
- const status = typeof itemObj.status === "string" ? itemObj.status : "completed";
289
- const exitCode = typeof itemObj.exitCode === "number" ? itemObj.exitCode : undefined;
290
- const durationMs = typeof itemObj.durationMs === "number" ? itemObj.durationMs : undefined;
291
- const text = typeof itemObj.text === "string" ? itemObj.text : undefined;
292
- const changes = Array.isArray(itemObj.changes) ? itemObj.changes : undefined;
293
- return updateLatestTurn(thread, (turn) => ({
294
- ...turn,
295
- items: turn.items.map((item) => {
296
- if (item.id !== id)
297
- return item;
298
- return {
299
- ...item,
300
- status,
301
- ...(exitCode !== undefined ? { exitCode } : {}),
302
- ...(durationMs !== undefined ? { durationMs } : {}),
303
- ...(text !== undefined ? { text } : {}),
304
- ...(changes !== undefined ? { changes } : {}),
305
- };
306
- }),
307
- }));
308
- }
309
- function appendItemText(thread, params) {
310
- const itemId = typeof params.itemId === "string" ? params.itemId : undefined;
311
- const delta = typeof params.delta === "string" ? params.delta : undefined;
312
- if (!itemId || !delta)
313
- return thread;
314
- return updateLatestTurn(thread, (turn) => ({
315
- ...turn,
316
- items: turn.items.map((item) => item.id === itemId ? { ...item, text: (item.text ?? "") + delta } : item),
317
- }));
318
- }
319
- function appendItemOutput(thread, params) {
320
- const itemId = typeof params.itemId === "string" ? params.itemId : undefined;
321
- const delta = typeof params.delta === "string" ? params.delta : undefined;
322
- if (!itemId || !delta)
323
- return thread;
324
- return updateLatestTurn(thread, (turn) => ({
325
- ...turn,
326
- items: turn.items.map((item) => item.id === itemId ? { ...item, output: (item.output ?? "") + delta } : item),
327
- }));
196
+ return { ...state, tokenUsage: { inputTokens, outputTokens } };
328
197
  }
package/dist/http.js CHANGED
@@ -259,6 +259,14 @@ export async function buildHttpServer(config, service, logger) {
259
259
  }
260
260
  return reply.send({ ok: true, ...result });
261
261
  });
262
+ app.get("/api/issues/:issueKey/timeline", async (request, reply) => {
263
+ const issueKey = request.params.issueKey;
264
+ const result = await service.getIssueTimeline(issueKey);
265
+ if (!result) {
266
+ return reply.code(404).send({ ok: false, reason: "issue_not_found" });
267
+ }
268
+ return reply.send({ ok: true, ...result });
269
+ });
262
270
  app.get("/api/issues/:issueKey/live", async (request, reply) => {
263
271
  const issueKey = request.params.issueKey;
264
272
  const result = await service.getActiveRunStatus(issueKey);
@@ -58,6 +58,29 @@ export class IssueQueryService {
58
58
  })),
59
59
  };
60
60
  }
61
+ async getIssueTimeline(issueKey) {
62
+ const issue = this.db.getTrackedIssueByKey(issueKey);
63
+ if (!issue)
64
+ return undefined;
65
+ const fullIssue = this.db.getIssueByKey(issueKey);
66
+ const runs = this.db.listRunsForIssue(issue.projectId, issue.linearIssueId).map((run) => ({
67
+ id: run.id,
68
+ runType: run.runType,
69
+ status: run.status,
70
+ startedAt: run.startedAt,
71
+ endedAt: run.endedAt,
72
+ threadId: run.threadId,
73
+ ...(run.reportJson ? { report: JSON.parse(run.reportJson) } : {}),
74
+ }));
75
+ const feedEvents = this.db.operatorFeed.list({ issueKey, limit: 500 });
76
+ let liveThread = undefined;
77
+ const activeRunId = fullIssue?.activeRunId;
78
+ const activeRun = activeRunId !== undefined ? runs.find((r) => r.id === activeRunId) : undefined;
79
+ if (activeRun?.threadId) {
80
+ liveThread = await this.codex.readThread(activeRun.threadId, true).catch(() => undefined);
81
+ }
82
+ return { issue, runs, feedEvents, liveThread, activeRunId };
83
+ }
61
84
  async getActiveRunStatus(issueKey) {
62
85
  return await this.runStatusProvider.getActiveRunStatus(issueKey);
63
86
  }
@@ -72,6 +72,7 @@ function buildRunPrompt(issue, runType, repoPath, context) {
72
72
  }
73
73
  return lines.join("\n");
74
74
  }
75
+ const PROGRESS_THROTTLE_MS = 10_000;
75
76
  export class RunOrchestrator {
76
77
  config;
77
78
  db;
@@ -81,6 +82,7 @@ export class RunOrchestrator {
81
82
  logger;
82
83
  feed;
83
84
  worktreeManager;
85
+ progressThrottle = new Map();
84
86
  botIdentity;
85
87
  constructor(config, db, codex, linearProvider, enqueueIssue, logger, feed) {
86
88
  this.config = config;
@@ -253,6 +255,8 @@ export class RunOrchestrator {
253
255
  eventJson: JSON.stringify(notification.params),
254
256
  });
255
257
  }
258
+ // Emit ephemeral progress activity to Linear for notable in-flight events
259
+ this.maybeEmitProgressActivity(notification, run);
256
260
  if (notification.method !== "turn/completed")
257
261
  return;
258
262
  const thread = await this.readThreadWithRetry(threadId);
@@ -286,6 +290,7 @@ export class RunOrchestrator {
286
290
  const failedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
287
291
  void this.emitLinearActivity(failedIssue, buildRunFailureActivity(run.runType));
288
292
  void this.syncLinearSession(failedIssue, { activeRunType: run.runType });
293
+ this.progressThrottle.delete(run.id);
289
294
  return;
290
295
  }
291
296
  // Complete the run
@@ -347,6 +352,45 @@ export class RunOrchestrator {
347
352
  ...(updatedIssue.prNumber !== undefined ? { prNumber: updatedIssue.prNumber } : {}),
348
353
  }));
349
354
  void this.syncLinearSession(updatedIssue);
355
+ this.progressThrottle.delete(run.id);
356
+ }
357
+ // ─── In-flight progress ──────────────────────────────────────────
358
+ maybeEmitProgressActivity(notification, run) {
359
+ const activity = this.resolveProgressActivity(notification);
360
+ if (!activity)
361
+ return;
362
+ const now = Date.now();
363
+ const lastEmit = this.progressThrottle.get(run.id) ?? 0;
364
+ if (now - lastEmit < PROGRESS_THROTTLE_MS)
365
+ return;
366
+ this.progressThrottle.set(run.id, now);
367
+ const issue = this.db.getIssue(run.projectId, run.linearIssueId);
368
+ if (issue) {
369
+ void this.emitLinearActivity(issue, activity, { ephemeral: true });
370
+ }
371
+ }
372
+ resolveProgressActivity(notification) {
373
+ if (notification.method === "item/started") {
374
+ const item = notification.params.item;
375
+ if (!item)
376
+ return undefined;
377
+ const type = typeof item.type === "string" ? item.type : undefined;
378
+ if (type === "commandExecution") {
379
+ const cmd = item.command;
380
+ const cmdStr = Array.isArray(cmd) ? cmd.join(" ") : typeof cmd === "string" ? cmd : undefined;
381
+ return { type: "action", action: "Running", parameter: cmdStr?.slice(0, 120) ?? "command" };
382
+ }
383
+ if (type === "mcpToolCall") {
384
+ const server = typeof item.server === "string" ? item.server : "";
385
+ const tool = typeof item.tool === "string" ? item.tool : "";
386
+ return { type: "action", action: "Using", parameter: `${server}/${tool}` };
387
+ }
388
+ if (type === "dynamicToolCall") {
389
+ const tool = typeof item.tool === "string" ? item.tool : "tool";
390
+ return { type: "action", action: "Using", parameter: tool };
391
+ }
392
+ }
393
+ return undefined;
350
394
  }
351
395
  // ─── Active status for query ──────────────────────────────────────
352
396
  async getActiveRunStatus(issueKey) {
package/dist/service.js CHANGED
@@ -289,6 +289,9 @@ export class PatchRelayService {
289
289
  async getIssueReport(issueKey) {
290
290
  return await this.queryService.getIssueReport(issueKey);
291
291
  }
292
+ async getIssueTimeline(issueKey) {
293
+ return await this.queryService.getIssueTimeline(issueKey);
294
+ }
292
295
  async getRunEvents(issueKey, runId) {
293
296
  return await this.queryService.getRunEvents(issueKey, runId);
294
297
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,23 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
3
- const KIND_COLORS = {
4
- stage: "cyan",
5
- turn: "yellow",
6
- github: "green",
7
- webhook: "blue",
8
- workflow: "magenta",
9
- hook: "white",
10
- };
11
- function kindColor(kind) {
12
- return KIND_COLORS[kind] ?? "white";
13
- }
14
- function formatTime(iso) {
15
- return new Date(iso).toLocaleTimeString("en-GB", { hour12: false });
16
- }
17
- export function FeedTimeline({ entries, maxEntries }) {
18
- const visible = maxEntries ? entries.slice(-maxEntries) : entries;
19
- if (visible.length === 0) {
20
- return _jsx(Text, { dimColor: true, children: "No events yet." });
21
- }
22
- return (_jsx(Box, { flexDirection: "column", children: visible.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { color: kindColor(entry.kind), children: (entry.status ?? entry.kind).padEnd(15) }), _jsx(Text, { children: entry.summary })] }, `feed-${i}`))) }));
23
- }
@@ -1,26 +0,0 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
3
- import { TurnSection } from "./TurnSection.js";
4
- function planStepSymbol(status) {
5
- if (status === "completed")
6
- return "\u2713";
7
- if (status === "inProgress")
8
- return "\u25b8";
9
- return " ";
10
- }
11
- function planStepColor(status) {
12
- if (status === "completed")
13
- return "green";
14
- if (status === "inProgress")
15
- return "yellow";
16
- return "white";
17
- }
18
- export function ThreadView({ thread, follow }) {
19
- const visibleTurns = follow && thread.turns.length > 1
20
- ? thread.turns.slice(-1)
21
- : thread.turns;
22
- const turnOffset = follow && thread.turns.length > 1
23
- ? thread.turns.length - 1
24
- : 0;
25
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsxs(Text, { dimColor: true, children: ["Thread: ", thread.threadId.slice(0, 16)] }), _jsxs(Text, { dimColor: true, children: ["Status: ", thread.status] }), _jsxs(Text, { dimColor: true, children: ["Turns: ", thread.turns.length] })] }), thread.plan && thread.plan.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Plan:" }), thread.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, { flexDirection: "column", marginTop: 1, children: visibleTurns.map((turn, i) => (_jsx(TurnSection, { turn: turn, index: i + turnOffset, follow: follow }, turn.id))) })] }));
26
- }
@@ -1,20 +0,0 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
3
- import { ItemLine } from "./ItemLine.js";
4
- function turnStatusColor(status) {
5
- if (status === "completed")
6
- return "green";
7
- if (status === "failed" || status === "interrupted")
8
- return "red";
9
- if (status === "inProgress")
10
- return "yellow";
11
- return "white";
12
- }
13
- const FOLLOW_TAIL_SIZE = 8;
14
- export function TurnSection({ turn, index, follow }) {
15
- const items = follow && turn.items.length > FOLLOW_TAIL_SIZE
16
- ? turn.items.slice(-FOLLOW_TAIL_SIZE)
17
- : turn.items;
18
- const skipped = turn.items.length - items.length;
19
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsxs(Text, { bold: true, children: ["Turn #", index + 1] }), _jsx(Text, { color: turnStatusColor(turn.status), children: turn.status }), _jsxs(Text, { dimColor: true, children: ["(", turn.items.length, " items)"] })] }), skipped > 0 && _jsxs(Text, { dimColor: true, children: [" ... ", skipped, " earlier items"] }), items.map((item, i) => (_jsx(ItemLine, { item: item, isLast: i === items.length - 1 }, item.id)))] }));
20
- }