patchrelay 0.25.2 → 0.25.4

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.25.2",
4
- "commit": "9b4ed34e9645",
5
- "builtAt": "2026-03-26T19:10:03.366Z"
3
+ "version": "0.25.4",
4
+ "commit": "b0b9d22851d4",
5
+ "builtAt": "2026-03-26T20:41:12.209Z"
6
6
  }
@@ -177,6 +177,9 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
177
177
  else if (input === "t") {
178
178
  dispatch({ type: "switch-detail-tab", tab: "timeline" });
179
179
  }
180
+ else if (input === "v") {
181
+ dispatch({ type: "toggle-timeline-mode" });
182
+ }
180
183
  else if (input === "j" || key.downArrow) {
181
184
  dispatch({ type: "detail-navigate", direction: "next", filtered });
182
185
  }
@@ -190,5 +193,5 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
190
193
  }
191
194
  }
192
195
  });
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 })) }));
196
+ 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, timelineMode: state.timelineMode, 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 })) }));
194
197
  }
@@ -5,11 +5,14 @@ const HELP_TEXT = {
5
5
  detail: "",
6
6
  feed: "Esc: list q: quit",
7
7
  };
8
- export function HelpBar({ view, follow, detailTab }) {
8
+ export function HelpBar({ view, follow, detailTab, timelineMode }) {
9
9
  let text;
10
10
  if (view === "detail") {
11
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`;
12
+ const timelineHint = detailTab === "timeline" ? `v: ${timelineMode === "verbose" ? "compact" : "verbose"}` : undefined;
13
+ text = [tabHint, timelineHint, "j/k: prev/next", "Esc: list", `f: follow ${follow ? "on" : "off"}`, "p: prompt", "s: stop", "r: retry", "q: quit"]
14
+ .filter(Boolean)
15
+ .join(" ");
13
16
  }
14
17
  else {
15
18
  text = HELP_TEXT[view];
@@ -24,9 +24,9 @@ function ElapsedTime({ startedAt }) {
24
24
  const seconds = elapsed % 60;
25
25
  return _jsxs(Text, { dimColor: true, children: [minutes, "m ", String(seconds).padStart(2, "0"), "s"] });
26
26
  }
27
- export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, activeRunId, tokenUsage, diffSummary, plan, issueContext, detailTab, rawRuns, rawFeedEvents, }) {
27
+ export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, activeRunId, tokenUsage, diffSummary, plan, issueContext, detailTab, timelineMode, rawRuns, rawFeedEvents, }) {
28
28
  if (!issue) {
29
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", children: "Issue not found." }), _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab })] }));
29
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", children: "Issue not found." }), _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab, timelineMode: timelineMode })] }));
30
30
  }
31
31
  const key = issue.issueKey ?? issue.projectId;
32
32
  const meta = [];
@@ -37,5 +37,5 @@ export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, a
37
37
  if (issueContext?.runCount)
38
38
  meta.push(`${issueContext.runCount} runs`);
39
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 }) })] }));
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(" ") }), detailTab === "timeline" && _jsx(Text, { dimColor: true, children: timelineMode }), 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, mode: timelineMode }) })] })) : (_jsx(StateHistoryView, { history: history, plan: plan, activeRunId: activeRunId })), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab, timelineMode: timelineMode }) })] }));
41
41
  }
@@ -1,92 +1,66 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- const STATUS_SYMBOL = {
4
- completed: "\u2713",
5
- failed: "\u2717",
6
- declined: "\u2717",
7
- inProgress: "\u25cf",
8
- };
9
- function statusChar(status) {
10
- return STATUS_SYMBOL[status] ?? " ";
11
- }
12
- function statusColor(status) {
13
- if (status === "completed")
14
- return "green";
15
- if (status === "failed" || status === "declined")
16
- return "red";
17
- if (status === "inProgress")
18
- return "yellow";
19
- return "white";
20
- }
21
3
  function truncate(text, max) {
22
- const line = text.replace(/\n/g, " ").trim();
23
- return line.length > max ? `${line.slice(0, max - 3)}...` : line;
24
- }
25
- function renderAgentMessage(item) {
26
- return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "message: " }), _jsx(Text, { wrap: "wrap", children: item.text ?? "" })] }));
4
+ const line = text.replace(/\s+/g, " ").trim();
5
+ return line.length > max ? `${line.slice(0, Math.max(0, max - 3))}...` : line;
27
6
  }
28
7
  function cleanCommand(raw) {
29
- // Strip /bin/bash -lc '...' wrapper — show the inner command
30
8
  const bashMatch = raw.match(/^\/bin\/(?:ba)?sh\s+-\w*c\s+['"](.+?)['"]$/s);
31
9
  if (bashMatch?.[1])
32
10
  return bashMatch[1];
33
- // Strip /bin/bash -lc "..." (double quotes)
34
11
  const bashMatch2 = raw.match(/^\/bin\/(?:ba)?sh\s+-\w*c\s+"(.+?)"$/s);
35
12
  if (bashMatch2?.[1])
36
13
  return bashMatch2[1];
37
14
  return raw;
38
15
  }
39
- function renderCommand(item) {
40
- const cmd = cleanCommand(item.command ?? "?");
41
- const exitCode = item.exitCode;
42
- const exitLabel = exitCode !== undefined && exitCode !== 0 ? ` exit:${exitCode}` : "";
43
- const duration = item.durationMs !== undefined ? ` ${(item.durationMs / 1000).toFixed(1)}s` : "";
44
- const suffix = `${exitLabel}${duration}`;
45
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "$ " }), _jsx(Text, { children: cmd }), exitLabel && _jsx(Text, { color: "red", children: exitLabel }), !exitLabel && suffix && _jsx(Text, { dimColor: true, children: suffix })] }), item.output && item.status === "inProgress" && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", item.output.split("\n").filter(Boolean).at(-1) ?? ""] }))] }));
46
- }
47
- function renderFileChange(item) {
16
+ function summarizeFileChange(item) {
48
17
  const count = item.changes?.length ?? 0;
49
- return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "files: " }), _jsxs(Text, { children: [count, " change", count !== 1 ? "s" : ""] })] }));
18
+ return `updated ${count} file${count === 1 ? "" : "s"}`;
50
19
  }
51
- function renderToolCall(item) {
52
- return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "tool: " }), _jsx(Text, { children: item.toolName ?? item.type })] }));
20
+ function summarizeToolCall(item) {
21
+ return `used ${item.toolName ?? item.type}`;
53
22
  }
54
- function renderPlan(item) {
55
- return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "plan: " }), _jsx(Text, { children: truncate(item.text ?? "", 120) })] }));
23
+ function summarizeText(item) {
24
+ return truncate(item.text ?? "", 160);
56
25
  }
57
- function renderDefault(item) {
58
- return (_jsxs(Text, { dimColor: true, children: [item.type, item.text ? `: ${truncate(item.text, 80)}` : ""] }));
26
+ function itemPrefix(item) {
27
+ if (item.type === "commandExecution")
28
+ return "$ ";
29
+ return "";
59
30
  }
60
- export function ItemLine({ item, isLast }) {
61
- const prefix = isLast ? "\u2514" : "\u251c";
62
- let content;
31
+ function itemText(item) {
63
32
  switch (item.type) {
64
33
  case "agentMessage":
65
- content = renderAgentMessage(item);
66
- break;
34
+ case "plan":
35
+ case "reasoning":
36
+ return summarizeText(item);
67
37
  case "commandExecution":
68
- content = renderCommand(item);
69
- break;
38
+ return truncate(cleanCommand(item.command ?? "?"), 140);
70
39
  case "fileChange":
71
- content = renderFileChange(item);
72
- break;
40
+ return summarizeFileChange(item);
73
41
  case "mcpToolCall":
74
42
  case "dynamicToolCall":
75
- content = renderToolCall(item);
76
- break;
77
- case "plan":
78
- content = renderPlan(item);
79
- break;
80
- case "userMessage": {
81
- const userText = item.text?.trim();
82
- if (!userText)
83
- return _jsx(_Fragment, {});
84
- content = (_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "you: " }), _jsx(Text, { wrap: "wrap", children: userText })] }));
85
- break;
86
- }
43
+ return summarizeToolCall(item);
44
+ case "userMessage":
45
+ return `you: ${summarizeText(item)}`;
87
46
  default:
88
- content = renderDefault(item);
89
- break;
47
+ return item.text ? summarizeText(item) : item.type;
48
+ }
49
+ }
50
+ function itemColor(item) {
51
+ if (item.status === "failed" || item.status === "declined")
52
+ return "red";
53
+ if (item.status === "inProgress")
54
+ return "yellow";
55
+ if (item.type === "userMessage")
56
+ return "yellow";
57
+ return undefined;
58
+ }
59
+ export function ItemLine({ item }) {
60
+ const text = itemText(item);
61
+ if (!text) {
62
+ return _jsx(_Fragment, {});
90
63
  }
91
- return (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [prefix, " "] }), _jsxs(Text, { color: statusColor(item.status), children: [statusChar(item.status), " "] }), content] }));
64
+ const color = itemColor(item);
65
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { wrap: "wrap", ...(color ? { color } : {}), children: [itemPrefix(item), text] }), item.output && item.status === "inProgress" && (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: truncate(item.output.split("\n").filter(Boolean).at(-1) ?? "", 120) }))] }));
92
66
  }
@@ -1,29 +1,24 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo } from "react";
3
3
  import { Box, Static, Text, useStdout } from "ink";
4
+ import { buildTimelineRows } from "./timeline-presentation.js";
4
5
  import { TimelineRow } from "./TimelineRow.js";
5
6
  const ACTIVE_TAIL = 8;
6
- function isFinalized(entry) {
7
- if (entry.kind === "item" && entry.item?.status === "inProgress")
8
- return false;
9
- if (entry.kind === "run-start")
10
- return false; // keep run-start in active area until run ends
11
- return true;
12
- }
13
- export function Timeline({ entries, follow }) {
7
+ export function Timeline({ entries, follow, mode }) {
14
8
  const { stdout } = useStdout();
15
9
  const rows = stdout?.rows ?? 24;
16
10
  const maxActive = Math.max(ACTIVE_TAIL, rows - 12);
11
+ const displayRows = useMemo(() => buildTimelineRows(entries, mode), [entries, mode]);
17
12
  // Split: finalized entries go to Static (terminal scrollback), active entries re-render
18
13
  const splitIndex = useMemo(() => {
19
14
  if (!follow)
20
15
  return 0; // follow OFF: everything in active area (re-renders)
21
16
  // Find the boundary: keep the last maxActive entries in the active area
22
- return Math.max(0, entries.length - maxActive);
23
- }, [entries.length, follow, maxActive]);
24
- const finalized = entries.slice(0, splitIndex);
25
- const active = entries.slice(splitIndex);
26
- if (entries.length === 0) {
17
+ return Math.max(0, displayRows.length - maxActive);
18
+ }, [displayRows.length, follow, maxActive]);
19
+ const finalized = displayRows.slice(0, splitIndex);
20
+ const active = displayRows.slice(splitIndex);
21
+ if (displayRows.length === 0) {
27
22
  return _jsx(Text, { dimColor: true, children: "No timeline events yet." });
28
23
  }
29
24
  return (_jsxs(Box, { flexDirection: "column", children: [finalized.length > 0 && (_jsx(Static, { items: finalized, children: (entry) => _jsx(TimelineRow, { entry: entry }, entry.id) })), active.map((entry) => (_jsx(TimelineRow, { entry: entry }, entry.id)))] }));
@@ -11,7 +11,7 @@ function formatDuration(startedAt, endedAt) {
11
11
  return `${seconds}s`;
12
12
  const minutes = Math.floor(seconds / 60);
13
13
  const s = seconds % 60;
14
- return `${minutes}m${s > 0 ? ` ${s}s` : ""}`;
14
+ return `${minutes}m ${String(s).padStart(2, "0")}s`;
15
15
  }
16
16
  const CHECK_SYMBOLS = { passed: "\u2713", failed: "\u2717", pending: "\u25cf" };
17
17
  const CHECK_COLORS = { passed: "green", failed: "red", pending: "yellow" };
@@ -21,34 +21,62 @@ const RUN_LABELS = {
21
21
  review_fix: "review fix",
22
22
  queue_repair: "merge fix",
23
23
  };
24
- function FeedRow({ entry }) {
25
- const feed = entry.feed;
26
- const label = feed.status ?? feed.feedKind;
27
- return (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [formatTime(entry.at), " "] }), _jsx(Text, { color: "cyan", children: label.padEnd(14) }), _jsxs(Text, { children: [" ", feed.summary] })] }));
24
+ function runStatusColor(status) {
25
+ if (status === "completed")
26
+ return "green";
27
+ if (status === "failed")
28
+ return "red";
29
+ if (status === "released")
30
+ return "magenta";
31
+ if (status === "running")
32
+ return "yellow";
33
+ return "white";
28
34
  }
29
- function RunStartRow({ entry }) {
30
- const run = entry.run;
31
- return (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [formatTime(entry.at), " "] }), _jsx(Text, { bold: true, color: "yellow", children: (RUN_LABELS[run.runType] ?? run.runType).padEnd(14) }), _jsx(Text, { bold: true, children: " started" })] }));
35
+ function runStatusLabel(status) {
36
+ if (status === "running")
37
+ return "running";
38
+ if (status === "released")
39
+ return "released";
40
+ return status;
41
+ }
42
+ function detailColor(detail) {
43
+ if (detail.tone === "command")
44
+ return "white";
45
+ if (detail.tone === "user")
46
+ return "yellow";
47
+ return undefined;
48
+ }
49
+ function detailPrefix(detail) {
50
+ if (detail.tone === "command")
51
+ return "$ ";
52
+ return "";
53
+ }
54
+ function FeedRow({ entry }) {
55
+ const label = entry.feed.status ?? entry.feed.feedKind;
56
+ return (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [formatTime(entry.at), " "] }), _jsx(Text, { color: "cyan", children: label.padEnd(12) }), _jsxs(Text, { children: [" ", entry.feed.summary] })] }));
32
57
  }
33
- function RunEndRow({ entry }) {
58
+ function RunRow({ entry }) {
34
59
  const run = entry.run;
35
- const color = run.status === "completed" ? "green" : "red";
36
- const dur = run.endedAt ? ` ${formatDuration(run.startedAt, run.endedAt)}` : "";
37
- return (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [formatTime(entry.at), " "] }), _jsx(Text, { bold: true, color: color, children: (RUN_LABELS[run.runType] ?? run.runType).padEnd(14) }), _jsxs(Text, { bold: true, color: color, children: [" ", run.status] }), dur ? _jsx(Text, { dimColor: true, children: dur }) : null] }));
60
+ const color = runStatusColor(run.status);
61
+ const duration = run.endedAt ? formatDuration(run.startedAt, run.endedAt) : undefined;
62
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [formatTime(entry.at), " "] }), _jsx(Text, { bold: true, color: "yellow", children: (RUN_LABELS[run.runType] ?? run.runType).padEnd(12) }), _jsxs(Text, { color: color, children: [" ", runStatusLabel(run.status)] }), duration ? _jsx(Text, { dimColor: true, children: ` ${duration}` }) : null] }), entry.details.map((detail, index) => (_jsxs(Box, { paddingLeft: 6, children: [_jsx(Text, { dimColor: true, children: " " }), _jsxs(Text, { wrap: "wrap", ...(detailColor(detail) ? { color: detailColor(detail) } : {}), children: [detailPrefix(detail), detail.text] })] }, `${entry.id}-detail-${index}`)))] }));
38
63
  }
39
64
  function ItemRow({ entry }) {
40
- return (_jsx(Box, { paddingLeft: 2, children: _jsx(ItemLine, { item: entry.item, isLast: false }) }));
65
+ return (_jsx(Box, { paddingLeft: 6, children: _jsx(ItemLine, { item: entry.item }) }));
41
66
  }
42
67
  function CIChecksRow({ entry }) {
43
68
  const ci = entry.ciChecks;
44
- return (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [formatTime(entry.at), " "] }), _jsx(Text, { color: CHECK_COLORS[ci.overall] ?? "white", children: "checks".padEnd(14) }), _jsx(Text, { children: " " }), 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, " "] })] }, `c-${i}`)))] }));
69
+ return (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [formatTime(entry.at), " "] }), _jsx(Text, { color: CHECK_COLORS[ci.overall] ?? "white", children: "checks".padEnd(12) }), _jsx(Text, { children: " " }), 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, " "] })] }, `c-${i}`)))] }));
45
70
  }
46
71
  export function TimelineRow({ entry }) {
47
72
  switch (entry.kind) {
48
- case "feed": return _jsx(FeedRow, { entry: entry });
49
- case "run-start": return _jsx(RunStartRow, { entry: entry });
50
- case "run-end": return _jsx(RunEndRow, { entry: entry });
51
- case "item": return _jsx(ItemRow, { entry: entry });
52
- case "ci-checks": return _jsx(CIChecksRow, { entry: entry });
73
+ case "feed":
74
+ return _jsx(FeedRow, { entry: entry });
75
+ case "run":
76
+ return _jsx(RunRow, { entry: entry });
77
+ case "item":
78
+ return _jsx(ItemRow, { entry: entry });
79
+ case "ci-checks":
80
+ return _jsx(CIChecksRow, { entry: entry });
53
81
  }
54
82
  }
@@ -19,9 +19,14 @@ export function buildTimelineFromRehydration(runs, feedEvents, liveThread, activ
19
19
  run: { runType: run.runType, status: run.status, startedAt: run.startedAt, endedAt: run.endedAt },
20
20
  });
21
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));
22
+ // Items from completed run event history, with report fallback
23
+ if (run.id !== activeRunId) {
24
+ if (run.events && run.events.length > 0) {
25
+ entries.push(...itemsFromThreadEvents(run.id, run.events));
26
+ }
27
+ else if (run.report) {
28
+ entries.push(...itemsFromReport(run.id, run.report, run.startedAt, run.endedAt));
29
+ }
25
30
  }
26
31
  }
27
32
  // 2. Items from live thread (active run)
@@ -36,7 +41,10 @@ export function buildTimelineFromRehydration(runs, feedEvents, liveThread, activ
36
41
  if (cmp !== 0)
37
42
  return cmp;
38
43
  // Within same timestamp: run-start before items, items before run-end
39
- return kindOrder(a.kind) - kindOrder(b.kind);
44
+ const kindCmp = kindOrder(a.kind) - kindOrder(b.kind);
45
+ if (kindCmp !== 0)
46
+ return kindCmp;
47
+ return a.id.localeCompare(b.id);
40
48
  });
41
49
  return entries;
42
50
  }
@@ -178,6 +186,169 @@ function materializeItem(item) {
178
186
  return base;
179
187
  }
180
188
  }
189
+ function itemsFromThreadEvents(runId, events) {
190
+ const entries = [];
191
+ for (const event of events) {
192
+ const params = event.parsedEvent;
193
+ if (!params)
194
+ continue;
195
+ switch (event.method) {
196
+ case "item/started": {
197
+ const item = materializeNotificationItem(params.item);
198
+ if (!item)
199
+ break;
200
+ entries.push({
201
+ id: `event-${event.id}-item-${item.id}`,
202
+ at: event.createdAt,
203
+ kind: "item",
204
+ runId,
205
+ item,
206
+ });
207
+ break;
208
+ }
209
+ case "item/completed": {
210
+ const item = materializeNotificationItem(params.item);
211
+ if (!item)
212
+ break;
213
+ const existing = findTimelineItem(entries, item.id);
214
+ if (existing) {
215
+ existing.item = mergeDefinedItemFields(existing.item, item);
216
+ }
217
+ else {
218
+ entries.push({
219
+ id: `event-${event.id}-item-${item.id}`,
220
+ at: event.createdAt,
221
+ kind: "item",
222
+ runId,
223
+ item,
224
+ });
225
+ }
226
+ break;
227
+ }
228
+ case "item/agentMessage/delta":
229
+ case "item/plan/delta":
230
+ case "item/reasoning/summaryTextDelta": {
231
+ const itemId = typeof params.itemId === "string" ? params.itemId : undefined;
232
+ const delta = typeof params.delta === "string" ? params.delta : undefined;
233
+ if (!itemId || !delta)
234
+ break;
235
+ const existing = findTimelineItem(entries, itemId);
236
+ const target = existing ?? createReplayPlaceholder(entries, runId, event.createdAt, event.id, itemId, inferItemTypeFromDeltaMethod(event.method));
237
+ target.item = {
238
+ ...target.item,
239
+ text: `${target.item?.text ?? ""}${delta}`,
240
+ };
241
+ break;
242
+ }
243
+ case "item/commandExecution/outputDelta": {
244
+ const itemId = typeof params.itemId === "string" ? params.itemId : undefined;
245
+ const delta = typeof params.delta === "string" ? params.delta : undefined;
246
+ if (!itemId || !delta)
247
+ break;
248
+ const existing = findTimelineItem(entries, itemId);
249
+ const target = existing ?? createReplayPlaceholder(entries, runId, event.createdAt, event.id, itemId, "commandExecution");
250
+ target.item = {
251
+ ...target.item,
252
+ output: `${target.item?.output ?? ""}${delta}`,
253
+ };
254
+ break;
255
+ }
256
+ }
257
+ }
258
+ return entries;
259
+ }
260
+ function findTimelineItem(entries, itemId) {
261
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
262
+ const entry = entries[i];
263
+ if (entry.kind === "item" && entry.item?.id === itemId) {
264
+ return entry;
265
+ }
266
+ }
267
+ return undefined;
268
+ }
269
+ function createReplayPlaceholder(entries, runId, at, eventId, itemId, type) {
270
+ const entry = {
271
+ id: `event-${eventId}-item-${itemId}`,
272
+ at,
273
+ kind: "item",
274
+ runId,
275
+ item: { id: itemId, type, status: "inProgress" },
276
+ };
277
+ entries.push(entry);
278
+ return entry;
279
+ }
280
+ function inferItemTypeFromDeltaMethod(method) {
281
+ switch (method) {
282
+ case "item/agentMessage/delta":
283
+ return "agentMessage";
284
+ case "item/plan/delta":
285
+ return "plan";
286
+ case "item/reasoning/summaryTextDelta":
287
+ return "reasoning";
288
+ default:
289
+ return "unknown";
290
+ }
291
+ }
292
+ function materializeNotificationItem(raw) {
293
+ if (!raw || typeof raw !== "object")
294
+ return undefined;
295
+ const itemObj = raw;
296
+ const id = typeof itemObj.id === "string" ? itemObj.id : undefined;
297
+ const type = typeof itemObj.type === "string" ? itemObj.type : "unknown";
298
+ if (!id)
299
+ return undefined;
300
+ const item = {
301
+ id,
302
+ type,
303
+ status: typeof itemObj.status === "string" ? itemObj.status : "inProgress",
304
+ };
305
+ if ((type === "agentMessage" || type === "userMessage" || type === "plan") && typeof itemObj.text === "string") {
306
+ item.text = itemObj.text;
307
+ }
308
+ if (type === "reasoning") {
309
+ if (Array.isArray(itemObj.summary)) {
310
+ item.text = itemObj.summary.join("\n");
311
+ }
312
+ else if (typeof itemObj.text === "string") {
313
+ item.text = itemObj.text;
314
+ }
315
+ }
316
+ if (type === "commandExecution") {
317
+ const cmd = itemObj.command;
318
+ item.command = Array.isArray(cmd) ? cmd.join(" ") : typeof cmd === "string" ? cmd : undefined;
319
+ if (typeof itemObj.aggregatedOutput === "string")
320
+ item.output = itemObj.aggregatedOutput;
321
+ }
322
+ if (type === "fileChange" && Array.isArray(itemObj.changes)) {
323
+ item.changes = itemObj.changes;
324
+ }
325
+ if (type === "mcpToolCall") {
326
+ item.toolName = `${String(itemObj.server ?? "")}/${String(itemObj.tool ?? "")}`;
327
+ }
328
+ if (type === "dynamicToolCall" && typeof itemObj.tool === "string") {
329
+ item.toolName = itemObj.tool;
330
+ }
331
+ if (typeof itemObj.exitCode === "number")
332
+ item.exitCode = itemObj.exitCode;
333
+ if (typeof itemObj.durationMs === "number")
334
+ item.durationMs = itemObj.durationMs;
335
+ return item;
336
+ }
337
+ function mergeDefinedItemFields(base, patch) {
338
+ return {
339
+ ...base,
340
+ id: patch.id,
341
+ type: patch.type,
342
+ status: patch.status,
343
+ ...(patch.text !== undefined ? { text: patch.text } : {}),
344
+ ...(patch.command !== undefined ? { command: patch.command } : {}),
345
+ ...(patch.output !== undefined ? { output: patch.output } : {}),
346
+ ...(patch.exitCode !== undefined ? { exitCode: patch.exitCode } : {}),
347
+ ...(patch.durationMs !== undefined ? { durationMs: patch.durationMs } : {}),
348
+ ...(patch.changes !== undefined ? { changes: patch.changes } : {}),
349
+ ...(patch.toolName !== undefined ? { toolName: patch.toolName } : {}),
350
+ };
351
+ }
181
352
  // ─── Feed Events to Timeline Entries ──────────────────────────────
182
353
  function feedEventsToEntries(feedEvents) {
183
354
  const entries = [];
@@ -0,0 +1,289 @@
1
+ export function buildTimelineRows(entries, mode) {
2
+ return mode === "compact" ? buildCompactTimelineRows(entries) : buildVerboseTimelineRows(entries);
3
+ }
4
+ function buildVerboseTimelineRows(entries) {
5
+ return entries.flatMap((entry) => {
6
+ switch (entry.kind) {
7
+ case "run-start":
8
+ return [{
9
+ id: entry.id,
10
+ kind: "run",
11
+ at: entry.at,
12
+ finalized: false,
13
+ run: entry.run,
14
+ details: [],
15
+ }];
16
+ case "run-end":
17
+ return [{
18
+ id: entry.id,
19
+ kind: "run",
20
+ at: entry.at,
21
+ finalized: true,
22
+ run: entry.run,
23
+ details: [],
24
+ }];
25
+ case "feed":
26
+ return [{
27
+ id: entry.id,
28
+ kind: "feed",
29
+ at: entry.at,
30
+ finalized: true,
31
+ feed: entry.feed,
32
+ }];
33
+ case "ci-checks":
34
+ return [{
35
+ id: entry.id,
36
+ kind: "ci-checks",
37
+ at: entry.at,
38
+ finalized: true,
39
+ ciChecks: entry.ciChecks,
40
+ }];
41
+ case "item":
42
+ return [{
43
+ id: entry.id,
44
+ kind: "item",
45
+ at: entry.at,
46
+ finalized: entry.item?.status !== "inProgress",
47
+ item: entry.item,
48
+ }];
49
+ }
50
+ });
51
+ }
52
+ function buildCompactTimelineRows(entries) {
53
+ const rows = [];
54
+ const runs = new Map();
55
+ for (const entry of entries) {
56
+ if (entry.kind === "run-start" && entry.runId !== undefined) {
57
+ const existing = runs.get(entry.runId);
58
+ if (!existing) {
59
+ const run = { ...entry.run };
60
+ runs.set(entry.runId, {
61
+ id: `run-${entry.runId}`,
62
+ at: run.startedAt,
63
+ run,
64
+ items: [],
65
+ endedAt: run.endedAt,
66
+ });
67
+ }
68
+ continue;
69
+ }
70
+ if (entry.kind === "run-end" && entry.runId !== undefined) {
71
+ const existing = runs.get(entry.runId);
72
+ if (existing) {
73
+ existing.run = { ...entry.run };
74
+ existing.endedAt = entry.run?.endedAt;
75
+ }
76
+ else {
77
+ const run = { ...entry.run };
78
+ runs.set(entry.runId, {
79
+ id: `run-${entry.runId}`,
80
+ at: run.startedAt,
81
+ run,
82
+ items: [],
83
+ endedAt: run.endedAt,
84
+ });
85
+ }
86
+ continue;
87
+ }
88
+ if (entry.kind === "item" && entry.runId !== undefined && runs.has(entry.runId)) {
89
+ runs.get(entry.runId).items.push(entry.item);
90
+ continue;
91
+ }
92
+ if (entry.kind === "feed" && shouldHideFeedInCompact(entry.feed)) {
93
+ continue;
94
+ }
95
+ if (entry.kind === "feed") {
96
+ rows.push({
97
+ id: entry.id,
98
+ kind: "feed",
99
+ at: entry.at,
100
+ finalized: true,
101
+ feed: entry.feed,
102
+ });
103
+ continue;
104
+ }
105
+ if (entry.kind === "ci-checks") {
106
+ rows.push({
107
+ id: entry.id,
108
+ kind: "ci-checks",
109
+ at: entry.at,
110
+ finalized: true,
111
+ ciChecks: entry.ciChecks,
112
+ });
113
+ continue;
114
+ }
115
+ if (entry.kind === "item") {
116
+ rows.push({
117
+ id: entry.id,
118
+ kind: "item",
119
+ at: entry.at,
120
+ finalized: entry.item?.status !== "inProgress",
121
+ item: entry.item,
122
+ });
123
+ }
124
+ }
125
+ for (const run of runs.values()) {
126
+ const status = resolveCompactRunStatus(run.run, run.items);
127
+ rows.push({
128
+ id: run.id,
129
+ kind: "run",
130
+ at: run.at,
131
+ finalized: status !== "running",
132
+ run: { ...run.run, status, ...(run.endedAt ? { endedAt: run.endedAt } : {}) },
133
+ details: summarizeRunDetails(run.items, status),
134
+ });
135
+ }
136
+ rows.sort((left, right) => {
137
+ const cmp = left.at.localeCompare(right.at);
138
+ if (cmp !== 0)
139
+ return cmp;
140
+ const kindCmp = rowKindOrder(left.kind) - rowKindOrder(right.kind);
141
+ if (kindCmp !== 0)
142
+ return kindCmp;
143
+ return left.id.localeCompare(right.id);
144
+ });
145
+ return rows;
146
+ }
147
+ function shouldHideFeedInCompact(feed) {
148
+ if (feed.feedKind === "stage" && feed.status === "starting") {
149
+ return true;
150
+ }
151
+ if (feed.feedKind === "turn" && (feed.status === "completed" || feed.status === "failed")) {
152
+ return true;
153
+ }
154
+ return false;
155
+ }
156
+ function resolveCompactRunStatus(run, items) {
157
+ if (run.endedAt || run.status === "completed" || run.status === "failed" || run.status === "released") {
158
+ return run.status;
159
+ }
160
+ if (items.some((item) => item.status === "inProgress")) {
161
+ return "running";
162
+ }
163
+ return run.status === "queued" ? "queued" : "running";
164
+ }
165
+ function summarizeRunDetails(items, status) {
166
+ const details = [];
167
+ const latestAgentMessage = findLatest(items, (item) => item.type === "agentMessage" && Boolean(item.text?.trim()));
168
+ const latestUserMessage = findLatest(items, (item) => item.type === "userMessage" && Boolean(item.text?.trim()));
169
+ const activeCommand = findLatest(items, (item) => item.type === "commandExecution" && item.status === "inProgress");
170
+ const latestCommand = activeCommand ?? findLatest(items, (item) => item.type === "commandExecution" && Boolean(item.command?.trim()));
171
+ const latestFileChange = findLatest(items, (item) => item.type === "fileChange" && Array.isArray(item.changes) && item.changes.length > 0);
172
+ if (latestUserMessage && !latestAgentMessage) {
173
+ details.push({
174
+ tone: "user",
175
+ text: `you: ${summarizeNarrative(latestUserMessage.text ?? "", 120)}`,
176
+ });
177
+ }
178
+ if (latestAgentMessage) {
179
+ details.push({
180
+ tone: "message",
181
+ text: summarizeNarrative(latestAgentMessage.text ?? "", status === "running" ? 140 : 180),
182
+ });
183
+ }
184
+ if (latestCommand?.command) {
185
+ details.push({
186
+ tone: "command",
187
+ text: cleanCommand(latestCommand.command),
188
+ });
189
+ }
190
+ if (latestFileChange?.changes?.length) {
191
+ details.push({
192
+ tone: "meta",
193
+ text: summarizeFileChanges(latestFileChange.changes),
194
+ });
195
+ }
196
+ else {
197
+ const tools = summarizeToolCalls(items);
198
+ if (tools) {
199
+ details.push({
200
+ tone: "meta",
201
+ text: tools,
202
+ });
203
+ }
204
+ }
205
+ return dedupeDetails(details).slice(0, 3);
206
+ }
207
+ function summarizeNarrative(input, max) {
208
+ const normalized = input
209
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
210
+ .replace(/`([^`]+)`/g, "$1")
211
+ .replace(/\s+/g, " ")
212
+ .trim();
213
+ if (!normalized)
214
+ return "";
215
+ const sentence = normalized.match(/^(.+?[.!?])(?:\s|$)/)?.[1] ?? normalized;
216
+ return truncate(sentence, max);
217
+ }
218
+ function summarizeFileChanges(changes) {
219
+ const files = Array.from(new Set(changes
220
+ .map((change) => {
221
+ if (!change || typeof change !== "object")
222
+ return undefined;
223
+ const path = change.path;
224
+ return typeof path === "string" && path.trim() ? path : undefined;
225
+ })
226
+ .filter((path) => Boolean(path))));
227
+ if (files.length === 0) {
228
+ return `updated ${changes.length} file${changes.length === 1 ? "" : "s"}`;
229
+ }
230
+ const names = files.map((path) => path.split("/").at(-1) ?? path);
231
+ const preview = names.slice(0, 3).join(", ");
232
+ const remainder = names.length > 3 ? ` +${names.length - 3}` : "";
233
+ return `updated ${files.length} file${files.length === 1 ? "" : "s"}: ${preview}${remainder}`;
234
+ }
235
+ function summarizeToolCalls(items) {
236
+ const names = Array.from(new Set(items
237
+ .filter((item) => item.type === "mcpToolCall" || item.type === "dynamicToolCall")
238
+ .map((item) => item.toolName)
239
+ .filter((name) => Boolean(name))));
240
+ if (names.length === 0)
241
+ return undefined;
242
+ const preview = names.slice(0, 2).join(", ");
243
+ const remainder = names.length > 2 ? ` +${names.length - 2}` : "";
244
+ return `used ${names.length} tool${names.length === 1 ? "" : "s"}: ${preview}${remainder}`;
245
+ }
246
+ function dedupeDetails(details) {
247
+ const seen = new Set();
248
+ return details.filter((detail) => {
249
+ const key = `${detail.tone}:${detail.text.toLowerCase()}`;
250
+ if (!detail.text.trim() || seen.has(key)) {
251
+ return false;
252
+ }
253
+ seen.add(key);
254
+ return true;
255
+ });
256
+ }
257
+ function findLatest(items, predicate) {
258
+ for (let i = items.length - 1; i >= 0; i -= 1) {
259
+ const item = items[i];
260
+ if (predicate(item)) {
261
+ return item;
262
+ }
263
+ }
264
+ return undefined;
265
+ }
266
+ function rowKindOrder(kind) {
267
+ switch (kind) {
268
+ case "run":
269
+ return 0;
270
+ case "feed":
271
+ return 1;
272
+ case "ci-checks":
273
+ return 2;
274
+ case "item":
275
+ return 3;
276
+ }
277
+ }
278
+ function truncate(text, max) {
279
+ return text.length > max ? `${text.slice(0, Math.max(0, max - 3))}...` : text;
280
+ }
281
+ function cleanCommand(raw) {
282
+ const bashMatch = raw.match(/^\/bin\/(?:ba)?sh\s+-\w*c\s+['"](.+?)['"]$/s);
283
+ if (bashMatch?.[1])
284
+ return truncate(bashMatch[1], 120);
285
+ const bashMatch2 = raw.match(/^\/bin\/(?:ba)?sh\s+-\w*c\s+"(.+?)"$/s);
286
+ if (bashMatch2?.[1])
287
+ return truncate(bashMatch2[1], 120);
288
+ return truncate(raw, 120);
289
+ }
@@ -36,6 +36,7 @@ async function rehydrate(baseUrl, issueKey, headers, signal, dispatch) {
36
36
  startedAt: r.startedAt,
37
37
  endedAt: r.endedAt,
38
38
  threadId: r.threadId,
39
+ ...(r.events ? { events: r.events } : {}),
39
40
  ...(r.report ? { report: r.report } : {}),
40
41
  }));
41
42
  let issueContext = null;
@@ -8,6 +8,7 @@ function capArray(arr, max) {
8
8
  }
9
9
  const DETAIL_INITIAL = {
10
10
  detailTab: "timeline",
11
+ timelineMode: "compact",
11
12
  timeline: [],
12
13
  rawRuns: [],
13
14
  rawFeedEvents: [],
@@ -129,6 +130,8 @@ export function watchReducer(state, action) {
129
130
  return { ...state, feedEvents: capArray([...state.feedEvents, action.event], MAX_FEED_EVENTS) };
130
131
  case "switch-detail-tab":
131
132
  return { ...state, detailTab: action.tab };
133
+ case "toggle-timeline-mode":
134
+ return { ...state, timelineMode: state.timelineMode === "compact" ? "verbose" : "compact" };
132
135
  }
133
136
  }
134
137
  // ─── Feed Event → Issue List + Timeline ───────────────────────────
@@ -70,6 +70,12 @@ export class IssueQueryService {
70
70
  startedAt: run.startedAt,
71
71
  endedAt: run.endedAt,
72
72
  threadId: run.threadId,
73
+ events: this.db.listThreadEvents(run.id).map((event) => ({
74
+ id: event.id,
75
+ method: event.method,
76
+ createdAt: event.createdAt,
77
+ parsedEvent: safeJsonParse(event.eventJson),
78
+ })),
73
79
  ...(run.reportJson ? { report: JSON.parse(run.reportJson) } : {}),
74
80
  }));
75
81
  const feedEvents = this.db.operatorFeed.list({ issueKey, limit: 500 });
@@ -305,6 +305,13 @@ export class WebhookHandler {
305
305
  }
306
306
  if (!triggerEventAllowed(project, normalized.triggerEvent))
307
307
  return;
308
+ // Ignore PatchRelay's own comments to prevent self-triggering feedback loops.
309
+ // When a run completes, PatchRelay posts an activity to Linear, which fires a
310
+ // commentCreated webhook back — without this guard that re-enqueues a new run.
311
+ const installation = this.db.linearInstallations.getLinearInstallationForProject(project.id);
312
+ if (installation?.actorId && normalized.actor?.id === installation.actorId) {
313
+ return;
314
+ }
308
315
  const issue = this.db.getIssue(project.id, normalized.issue.id);
309
316
  if (!issue)
310
317
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.25.2",
3
+ "version": "0.25.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {