patchrelay 0.35.12 → 0.35.13

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.35.12",
4
- "commit": "d9cef8cb23dd",
5
- "builtAt": "2026-04-07T10:41:56.287Z"
3
+ "version": "0.35.13",
4
+ "commit": "f82366476725",
5
+ "builtAt": "2026-04-07T23:06:53.397Z"
6
6
  }
package/dist/cli/args.js CHANGED
@@ -55,6 +55,25 @@ export function resolveCommand(parsed) {
55
55
  return { command: "help", commandArgs: [] };
56
56
  }
57
57
  if (KNOWN_COMMANDS.has(requestedCommand)) {
58
+ if (requestedCommand === "attach") {
59
+ return { command: "repo", commandArgs: ["link", ...parsed.positionals.slice(1)] };
60
+ }
61
+ if (requestedCommand === "repos") {
62
+ const rest = parsed.positionals.slice(1);
63
+ if (rest.length === 0) {
64
+ return { command: "repo", commandArgs: ["list"] };
65
+ }
66
+ if (["list", "show", "link", "unlink", "sync"].includes(rest[0])) {
67
+ return { command: "repo", commandArgs: rest };
68
+ }
69
+ return { command: "repo", commandArgs: ["show", ...rest] };
70
+ }
71
+ if (requestedCommand === "connect") {
72
+ return { command: "linear", commandArgs: ["connect", ...parsed.positionals.slice(1)] };
73
+ }
74
+ if (requestedCommand === "installations") {
75
+ return { command: "linear", commandArgs: ["list", ...parsed.positionals.slice(1)] };
76
+ }
58
77
  const command = requestedCommand === "dash" || requestedCommand === "d"
59
78
  ? "dashboard"
60
79
  : requestedCommand;
@@ -2,7 +2,7 @@ import { setTimeout as delay } from "node:timers/promises";
2
2
  import { getRunTypeFlag } from "../args.js";
3
3
  import { CliUsageError } from "../errors.js";
4
4
  import { formatJson } from "../formatters/json.js";
5
- import { formatInspect, formatList, formatLive, formatOpen, formatRetry, formatWorktree } from "../formatters/text.js";
5
+ import { formatInspect, formatList, formatLive, formatOpen, formatRetry, formatSessionHistory, formatWorktree } from "../formatters/text.js";
6
6
  import { buildOpenCommand } from "../interactive.js";
7
7
  import { writeOutput } from "../output.js";
8
8
  export async function handleIssueCommand(params) {
@@ -34,6 +34,8 @@ export async function handleIssueCommand(params) {
34
34
  return await handleWorktreeCommand(nested);
35
35
  case "open":
36
36
  return await handleOpenCommand(nested);
37
+ case "sessions":
38
+ return await handleSessionsCommand(nested);
37
39
  case "retry":
38
40
  return await handleRetryCommand(nested);
39
41
  default:
@@ -112,6 +114,20 @@ export async function handleOpenCommand(params) {
112
114
  const openCommand = buildOpenCommand(params.config, result.worktreePath, result.resumeThreadId);
113
115
  return await params.runInteractive(openCommand.command, openCommand.args);
114
116
  }
117
+ export async function handleSessionsCommand(params) {
118
+ const issueKey = params.commandArgs[0];
119
+ if (!issueKey) {
120
+ throw new Error("sessions requires <issueKey>.");
121
+ }
122
+ const result = params.data.sessions(issueKey);
123
+ if (!result) {
124
+ throw new Error(`Issue not found: ${issueKey}`);
125
+ }
126
+ writeOutput(params.stdout, params.json
127
+ ? formatJson(result)
128
+ : formatSessionHistory(result, (threadId) => buildOpenCommand(params.config, result.worktreePath ?? "", threadId)));
129
+ return 0;
130
+ }
115
131
  export async function handleRetryCommand(params) {
116
132
  const issueKey = params.commandArgs[0];
117
133
  if (!issueKey) {
package/dist/cli/data.js CHANGED
@@ -56,6 +56,23 @@ function parseObjectJson(value) {
56
56
  return undefined;
57
57
  }
58
58
  }
59
+ function summarizeRun(run) {
60
+ const summary = parseObjectJson(run.summaryJson);
61
+ if (typeof summary?.latestAssistantMessage === "string" && summary.latestAssistantMessage.trim()) {
62
+ return summary.latestAssistantMessage.trim();
63
+ }
64
+ const report = parseObjectJson(run.reportJson);
65
+ const assistantMessages = report?.assistantMessages;
66
+ if (Array.isArray(assistantMessages)) {
67
+ for (let index = assistantMessages.length - 1; index >= 0; index -= 1) {
68
+ const value = assistantMessages[index];
69
+ if (typeof value === "string" && value.trim()) {
70
+ return value.trim();
71
+ }
72
+ }
73
+ }
74
+ return run.failureReason?.trim() || undefined;
75
+ }
59
76
  export class CliDataAccess extends CliOperatorApiClient {
60
77
  config;
61
78
  db;
@@ -192,6 +209,39 @@ export class CliDataAccess extends CliOperatorApiClient {
192
209
  const updated = this.db.getTrackedIssue(issue.projectId, issue.linearIssueId);
193
210
  return { issue: updated, runType, ...(options?.reason ? { reason: options.reason } : {}) };
194
211
  }
212
+ sessions(issueKey) {
213
+ const issue = this.db.getTrackedIssueByKey(issueKey);
214
+ if (!issue)
215
+ return undefined;
216
+ const dbIssue = this.db.getIssueByKey(issueKey);
217
+ const runs = this.db.listRunsForIssue(issue.projectId, issue.linearIssueId);
218
+ const sessions = runs
219
+ .slice()
220
+ .reverse()
221
+ .map((run) => {
222
+ const summary = summarizeRun(run);
223
+ return {
224
+ runId: run.id,
225
+ runType: run.runType,
226
+ status: run.status,
227
+ ...(run.threadId ? { threadId: run.threadId } : {}),
228
+ ...(run.turnId ? { turnId: run.turnId } : {}),
229
+ ...(run.parentThreadId ? { parentThreadId: run.parentThreadId } : {}),
230
+ ...(summary ? { summary } : {}),
231
+ ...(run.failureReason ? { failureReason: run.failureReason } : {}),
232
+ eventCount: this.db.listThreadEvents(run.id).length,
233
+ startedAt: run.startedAt,
234
+ ...(run.endedAt ? { endedAt: run.endedAt } : {}),
235
+ isCurrentThread: run.threadId !== undefined && run.threadId === dbIssue.threadId,
236
+ };
237
+ });
238
+ return {
239
+ issue,
240
+ ...(dbIssue.worktreePath ? { worktreePath: dbIssue.worktreePath } : {}),
241
+ ...(dbIssue.threadId ? { currentThreadId: dbIssue.threadId } : {}),
242
+ sessions,
243
+ };
244
+ }
195
245
  appendRetryWake(issue, runType) {
196
246
  if (runType === "queue_repair") {
197
247
  const queueIncident = parseObjectJson(issue.lastQueueIncidentJson);
@@ -75,6 +75,51 @@ export function formatRetry(result) {
75
75
  .filter(Boolean)
76
76
  .join("\n")}\n`;
77
77
  }
78
+ function formatTimestampRange(startedAt, endedAt) {
79
+ return endedAt ? `${startedAt} -> ${endedAt}` : `${startedAt} -> running`;
80
+ }
81
+ export function formatSessionHistory(result, buildOpenForThread) {
82
+ const lines = [
83
+ `${result.issue.issueKey ?? result.issue.linearIssueId}${result.issue.currentLinearState ? ` ${result.issue.currentLinearState}` : ""}`,
84
+ value("Worktree", result.worktreePath),
85
+ value("Current thread", result.currentThreadId),
86
+ ];
87
+ if (result.sessions.length === 0) {
88
+ lines.push("No recorded app-server sessions.");
89
+ return `${lines.join("\n")}\n`;
90
+ }
91
+ for (const session of result.sessions) {
92
+ lines.push("");
93
+ lines.push([
94
+ `run #${session.runId}`,
95
+ session.runType,
96
+ session.status,
97
+ formatTimestampRange(session.startedAt, session.endedAt),
98
+ session.isCurrentThread ? "current" : undefined,
99
+ ]
100
+ .filter(Boolean)
101
+ .join(" "));
102
+ lines.push(value("Thread", session.threadId));
103
+ if (session.parentThreadId) {
104
+ lines.push(value("Parent thread", session.parentThreadId));
105
+ }
106
+ if (session.turnId) {
107
+ lines.push(value("Turn", session.turnId));
108
+ }
109
+ lines.push(value("Events", session.eventCount));
110
+ if (session.summary) {
111
+ lines.push(value("Summary", truncateLine(session.summary)));
112
+ }
113
+ else if (session.failureReason) {
114
+ lines.push(value("Failure", truncateLine(session.failureReason)));
115
+ }
116
+ if (session.threadId && result.worktreePath && buildOpenForThread) {
117
+ const command = buildOpenForThread(session.threadId);
118
+ lines.push(value("Open", formatCommand(command.command, command.args)));
119
+ }
120
+ }
121
+ return `${lines.join("\n")}\n`;
122
+ }
78
123
  export function formatList(items) {
79
124
  return `${items
80
125
  .map((item) => [
package/dist/cli/help.js CHANGED
@@ -36,6 +36,7 @@ export function rootHelpText() {
36
36
  " issue show <issueKey> [--json] Show the latest known issue state",
37
37
  " issue watch <issueKey> [--json] Follow the active run until it settles",
38
38
  " issue open <issueKey> [--print] [--json] Open Codex in the issue worktree",
39
+ " issue sessions <issueKey> [--json] Show recorded Codex app-server sessions for one issue",
39
40
  " service status [--json] Show systemd state and local health",
40
41
  " service logs [--lines <count>] [--json] Show recent service logs",
41
42
  " serve Run the local PatchRelay service",
@@ -93,6 +94,10 @@ export function linearHelpText() {
93
94
  " patchrelay linear connect",
94
95
  " patchrelay linear list",
95
96
  " patchrelay linear sync usertold",
97
+ "",
98
+ "Compatibility aliases:",
99
+ " patchrelay connect Alias for `patchrelay linear connect`",
100
+ " patchrelay installations Alias for `patchrelay linear list`",
96
101
  ].join("\n");
97
102
  }
98
103
  export function repoHelpText() {
@@ -121,6 +126,11 @@ export function repoHelpText() {
121
126
  " patchrelay repo link krasnoperov/usertold --workspace usertold --team USE",
122
127
  " patchrelay repo show krasnoperov/usertold",
123
128
  " patchrelay repo sync",
129
+ "",
130
+ "Compatibility aliases:",
131
+ " patchrelay attach ... Alias for `patchrelay repo link ...`",
132
+ " patchrelay repos Alias for `patchrelay repo list`",
133
+ " patchrelay repos <repo> Alias for `patchrelay repo show <repo>`",
124
134
  ].join("\n");
125
135
  }
126
136
  export function issueHelpText() {
@@ -134,12 +144,14 @@ export function issueHelpText() {
134
144
  " watch <issueKey> Follow PatchRelay-owned activity until it settles",
135
145
  " path <issueKey> Print the issue worktree path",
136
146
  " open <issueKey> Open Codex in the issue worktree",
147
+ " sessions <issueKey> Show recorded Codex app-server sessions",
137
148
  " retry <issueKey> Requeue a run",
138
149
  "",
139
150
  "Examples:",
140
151
  " patchrelay issue list --active",
141
152
  " patchrelay issue show USE-54",
142
153
  " patchrelay issue watch USE-54",
154
+ " patchrelay issue sessions USE-54",
143
155
  ].join("\n");
144
156
  }
145
157
  export function serviceHelpText() {
package/dist/cli/index.js CHANGED
@@ -59,6 +59,9 @@ function validateFlags(command, commandArgs, parsed) {
59
59
  case "open":
60
60
  assertKnownFlags(parsed, "issue", ["print", "json"]);
61
61
  return;
62
+ case "sessions":
63
+ assertKnownFlags(parsed, "issue", ["json"]);
64
+ return;
62
65
  case "retry":
63
66
  assertKnownFlags(parsed, "issue", ["run-type", "reason", "json"]);
64
67
  return;
@@ -106,12 +109,6 @@ function validateFlags(command, commandArgs, parsed) {
106
109
  assertKnownFlags(parsed, "repo", []);
107
110
  return;
108
111
  }
109
- case "attach":
110
- case "repos":
111
- case "connect":
112
- case "installations":
113
- throw new CliUsageError(`${command} has been removed. Use \`patchrelay linear ...\` and \`patchrelay repo ...\` instead.`);
114
- return;
115
112
  case "service":
116
113
  if (commandArgs[0] === "install") {
117
114
  assertKnownFlags(parsed, "service", ["force", "write-only", "json"]);
@@ -314,10 +311,6 @@ export async function runCli(argv, options) {
314
311
  runInteractive,
315
312
  });
316
313
  }
317
- if (command === "attach" || command === "repos" || command === "connect" || command === "installations") {
318
- writeOutput(stderr, `${command} has been removed. Use \`patchrelay linear ...\` and \`patchrelay repo ...\` instead.\n`);
319
- return 1;
320
- }
321
314
  if (command === "dashboard") {
322
315
  const { handleWatchCommand } = await import("./commands/watch.js");
323
316
  return await handleWatchCommand({ config, parsed });
@@ -181,12 +181,32 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
181
181
  dispatch({ type: "switch-detail-tab", tab: "timeline" });
182
182
  }
183
183
  else if (input === "j" || key.downArrow) {
184
- dispatch({ type: "detail-navigate", direction: "next", filtered });
184
+ dispatch({ type: "detail-scroll", delta: 1 });
185
185
  }
186
186
  else if (input === "k" || key.upArrow) {
187
+ dispatch({ type: "detail-scroll", delta: -1 });
188
+ }
189
+ else if (key.pageDown || (key.ctrl && input === "d")) {
190
+ dispatch({ type: "detail-page", direction: "down" });
191
+ }
192
+ else if (key.pageUp || (key.ctrl && input === "u")) {
193
+ dispatch({ type: "detail-page", direction: "up" });
194
+ }
195
+ else if (key.home) {
196
+ dispatch({ type: "detail-jump", target: "start" });
197
+ }
198
+ else if (key.end) {
199
+ dispatch({ type: "detail-jump", target: "end" });
200
+ }
201
+ else if (input === "[" || key.leftArrow) {
187
202
  dispatch({ type: "detail-navigate", direction: "prev", filtered });
188
203
  }
204
+ else if (input === "]" || key.rightArrow) {
205
+ dispatch({ type: "detail-navigate", direction: "next", filtered });
206
+ }
189
207
  }
190
208
  });
191
- return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt, filter: state.filter, totalCount: state.issues.length, frozen: frozen })) : state.view === "detail" ? (_jsxs(Box, { flexDirection: "column", children: [state.activeDetailKey && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Issues" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { bold: true, children: state.activeDetailKey }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { dimColor: true, children: state.detailTab === "timeline" ? "Timeline" : "History" })] })), _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, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt }), 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 }))] })) : null }));
209
+ return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt, filter: state.filter, totalCount: state.issues.length, frozen: frozen })) : state.view === "detail" ? (_jsxs(Box, { flexDirection: "column", children: [state.activeDetailKey && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Issues" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { bold: true, children: state.activeDetailKey }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { dimColor: true, children: state.detailTab === "timeline" ? "Timeline" : "History" })] })), _jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, scrollOffset: state.detailScrollOffset, unreadBelow: state.detailUnreadBelow, 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, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt, reservedRows: 1 + ((promptMode || promptStatus) ? 1 : 0), onLayoutChange: (viewportRows, contentRows) => {
210
+ dispatch({ type: "detail-layout-updated", viewportRows, contentRows });
211
+ } }), 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 }))] })) : null }));
192
212
  }
@@ -4,7 +4,7 @@ export function HelpBar({ view, follow, detailTab }) {
4
4
  let text;
5
5
  if (view === "detail") {
6
6
  const tabHint = detailTab === "history" ? "t: timeline" : "h: history";
7
- text = [tabHint, `f: follow ${follow ? "on" : "off"}`, "p: prompt", "s: stop", "r: retry"]
7
+ text = [tabHint, "j/k: scroll", "PgUp/PgDn: page", "[ ]: issue", "Home/End: jump", `f: live ${follow ? "on" : "off"}`, "p: prompt", "s: stop", "r: retry"]
8
8
  .filter(Boolean)
9
9
  .join(" ");
10
10
  }
@@ -1,169 +1,71 @@
1
- import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useReducer } from "react";
3
- import { Box, Text } from "ink";
4
- import { Timeline } from "./Timeline.js";
5
- import { StateHistoryView } from "./StateHistoryView.js";
6
- import { buildStateHistory } from "./history-builder.js";
3
+ import { Box, Text, useStdout } from "ink";
7
4
  import { HelpBar } from "./HelpBar.js";
8
- import { planStepSymbol, planStepColor } from "./plan-helpers.js";
9
- import { progressBar } from "./format-utils.js";
10
- import { FreshnessBadge } from "./FreshnessBadge.js";
11
- function formatTokens(n) {
12
- if (n >= 1_000_000)
13
- return `${(n / 1_000_000).toFixed(1)}M`;
14
- if (n >= 1_000)
15
- return `${(n / 1_000).toFixed(1)}k`;
16
- return String(n);
17
- }
18
- function formatReviewState(reviewState) {
19
- switch (reviewState) {
20
- case "approved":
21
- return "approved";
22
- case "changes_requested":
23
- return "changes requested";
24
- case "commented":
25
- return "commented";
26
- default:
27
- return reviewState ? reviewState.replaceAll("_", " ") : null;
28
- }
29
- }
30
- function formatCheckState(checkState) {
31
- switch (checkState) {
32
- case "passed":
33
- case "success":
34
- return "checks passed";
35
- case "failed":
36
- case "failure":
37
- return "checks failed";
38
- case "pending":
39
- case "in_progress":
40
- case "queued":
41
- return "checks pending";
42
- default:
43
- return null;
44
- }
45
- }
46
- const SESSION_DISPLAY = {
47
- idle: { label: "idle", color: "blueBright" },
48
- running: { label: "running", color: "cyan" },
49
- waiting_input: { label: "needs input", color: "yellow" },
50
- done: { label: "done", color: "green" },
51
- failed: { label: "failed", color: "red" },
52
- };
53
- const STAGE_DISPLAY = {
54
- blocked: "blocked",
55
- ready: "ready",
56
- delegated: "delegated",
57
- implementing: "implementing",
58
- pr_open: "PR open",
59
- changes_requested: "review changes",
60
- repairing_ci: "repairing CI",
61
- awaiting_queue: "waiting downstream",
62
- repairing_queue: "repairing queue",
63
- done: "merged",
64
- failed: "failed",
65
- escalated: "escalated",
66
- awaiting_input: "needs input",
67
- };
68
- function effectiveState(issue) {
69
- if (issue.sessionState === "done")
70
- return "done";
71
- if (issue.sessionState === "failed")
72
- return "failed";
73
- if (issue.blockedByCount > 0 && !issue.activeRunType)
74
- return "blocked";
75
- if (issue.readyForExecution && !issue.activeRunType)
76
- return "ready";
77
- if (issue.sessionState === "waiting_input")
78
- return "awaiting_input";
79
- return issue.factoryState;
80
- }
81
- function sessionDisplay(issue) {
82
- const state = issue.sessionState ?? "unknown";
83
- return SESSION_DISPLAY[state] ?? { label: state, color: "white" };
84
- }
85
- function stageDisplay(issue) {
86
- const state = effectiveState(issue);
87
- return STAGE_DISPLAY[state] ?? issue.factoryState;
88
- }
89
- function blockerText(issue, issueContext) {
90
- const rereviewNeeded = issue.prReviewState === "changes_requested"
91
- && (issue.prCheckStatus === "passed" || issue.prCheckStatus === "success")
92
- && !issue.activeRunType;
93
- if (issue.sessionState === "waiting_input")
94
- return issue.waitingReason ?? "Waiting for input";
95
- if (issue.waitingReason && !issue.activeRunType)
96
- return issue.waitingReason;
97
- if (issue.blockedByCount > 0)
98
- return `Waiting on ${issue.blockedByKeys.join(", ")}`;
99
- if (effectiveState(issue) === "repairing_queue")
100
- return "Merge queue conflict, repairing branch";
101
- if (effectiveState(issue) === "repairing_ci") {
102
- const check = issueContext?.latestFailureCheckName ?? issue.latestFailureCheckName ?? "CI";
103
- return `Repairing ${check}`;
104
- }
105
- if (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure") {
106
- const check = issueContext?.latestFailureCheckName ?? issue.latestFailureCheckName ?? "checks";
107
- return `${check} failed`;
108
- }
109
- if (rereviewNeeded)
110
- return "Awaiting re-review after requested changes";
111
- if (issue.prReviewState === "changes_requested")
112
- return "Review changes requested";
113
- if (issue.prNumber !== undefined && !issue.prReviewState && effectiveState(issue) !== "done")
114
- return "Awaiting review";
115
- return null;
116
- }
117
- function ElapsedTime({ startedAt }) {
118
- const [, tick] = useReducer((c) => c + 1, 0);
5
+ import { buildDetailLines } from "./detail-rows.js";
6
+ export function IssueDetailView({ issue, timeline, follow, scrollOffset, unreadBelow, activeRunStartedAt, activeRunId, tokenUsage, diffSummary, plan, issueContext, detailTab, rawRuns, rawFeedEvents, connected, lastServerMessageAt, reservedRows = 0, onLayoutChange, }) {
7
+ const [, tick] = useReducer((value) => value + 1, 0);
8
+ const { stdout } = useStdout();
9
+ const width = Math.max(20, stdout?.columns ?? 80);
10
+ const totalRows = stdout?.rows ?? 24;
11
+ const footerRows = 1 + (unreadBelow > 0 ? 1 : 0);
12
+ const viewportRows = Math.max(4, totalRows - reservedRows - footerRows);
119
13
  useEffect(() => {
120
- const id = setInterval(tick, 1000);
14
+ const id = setInterval(tick, 1_000);
121
15
  return () => clearInterval(id);
122
16
  }, []);
123
- const elapsed = Math.max(0, Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000));
124
- const minutes = Math.floor(elapsed / 60);
125
- const seconds = elapsed % 60;
126
- return _jsxs(Text, { dimColor: true, children: [minutes, "m ", String(seconds).padStart(2, "0"), "s"] });
17
+ const lines = useMemo(() => {
18
+ if (!issue) {
19
+ return [{ key: "loading", segments: [{ text: "Loading issue…", dimColor: true }] }];
20
+ }
21
+ return buildDetailLines({
22
+ issue,
23
+ timeline,
24
+ activeRunStartedAt,
25
+ activeRunId,
26
+ tokenUsage,
27
+ diffSummary,
28
+ plan,
29
+ issueContext,
30
+ detailTab,
31
+ rawRuns,
32
+ rawFeedEvents,
33
+ follow,
34
+ connected,
35
+ lastServerMessageAt,
36
+ width,
37
+ });
38
+ }, [
39
+ issue,
40
+ timeline,
41
+ activeRunStartedAt,
42
+ activeRunId,
43
+ tokenUsage,
44
+ diffSummary,
45
+ plan,
46
+ issueContext,
47
+ detailTab,
48
+ rawRuns,
49
+ rawFeedEvents,
50
+ follow,
51
+ connected,
52
+ lastServerMessageAt,
53
+ width,
54
+ ]);
55
+ useEffect(() => {
56
+ onLayoutChange(viewportRows, lines.length);
57
+ }, [lines.length, onLayoutChange, viewportRows]);
58
+ const maxOffset = Math.max(0, lines.length - viewportRows);
59
+ const start = Math.min(scrollOffset, maxOffset);
60
+ const visibleLines = lines.slice(start, start + viewportRows);
61
+ const fillerCount = Math.max(0, viewportRows - visibleLines.length);
62
+ return (_jsxs(Box, { flexDirection: "column", children: [visibleLines.map((line) => (_jsx(RenderedLine, { line: line }, line.key))), Array.from({ length: fillerCount }, (_, index) => (_jsx(Text, { children: " " }, `detail-fill-${index}`))), unreadBelow > 0 && (_jsx(Text, { color: "yellow", children: `${unreadBelow} below · End jumps back to live` })), _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab })] }));
127
63
  }
128
- export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, activeRunId, tokenUsage, diffSummary, plan, issueContext, detailTab, rawRuns, rawFeedEvents, connected, lastServerMessageAt, }) {
129
- if (!issue) {
130
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Loading issue\u2026" }), _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab })] }));
131
- }
132
- const key = issue.issueKey ?? issue.projectId;
133
- const meta = [];
134
- if (tokenUsage)
135
- meta.push(`${formatTokens(tokenUsage.inputTokens)} in / ${formatTokens(tokenUsage.outputTokens)} out`);
136
- if (diffSummary && diffSummary.filesChanged > 0)
137
- meta.push(`${diffSummary.filesChanged}f +${diffSummary.linesAdded} -${diffSummary.linesRemoved}`);
138
- if (issueContext?.runCount)
139
- meta.push(`${issueContext.runCount} runs`);
140
- const session = sessionDisplay(issue);
141
- const stage = stageDisplay(issue);
142
- const blocker = blockerText(issue, issueContext);
143
- const history = useMemo(() => buildStateHistory(rawRuns, rawFeedEvents, issue.factoryState, activeRunId), [rawRuns, rawFeedEvents, issue.factoryState, activeRunId]);
144
- // Build compact facts for the header
145
- const facts = [];
146
- const rereviewNeeded = issue.prReviewState === "changes_requested"
147
- && (issue.prCheckStatus === "passed" || issue.prCheckStatus === "success")
148
- && !issue.activeRunType;
149
- if (issue.prNumber !== undefined)
150
- facts.push(`PR #${issue.prNumber}`);
151
- if (issue.prReviewState === "approved")
152
- facts.push("approved");
153
- else if (rereviewNeeded)
154
- facts.push("re-review needed");
155
- else if (issue.prReviewState === "changes_requested")
156
- facts.push("changes requested");
157
- if (issue.waitingReason && issue.sessionState === "waiting_input")
158
- facts.push(issue.waitingReason);
159
- if (issue.prCheckStatus === "passed" || issue.prCheckStatus === "success")
160
- facts.push("checks passed");
161
- else if (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure") {
162
- const check = issueContext?.latestFailureCheckName ?? issue.latestFailureCheckName ?? "checks";
163
- facts.push(`${check} failed`);
164
- }
165
- else if (issue.prChecksSummary?.total) {
166
- facts.push(`checks ${issue.prChecksSummary.completed}/${issue.prChecksSummary.total}`);
64
+ function RenderedLine({ line }) {
65
+ if (line.segments.length === 0) {
66
+ return _jsx(Text, { children: " " });
167
67
  }
168
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: session.color, children: session.label }), _jsx(Text, { dimColor: true, children: ` debug stage ${stage}` }), facts.length > 0 && _jsx(Text, { dimColor: true, children: facts.join(" \u00b7 ") }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt }), meta.length > 0 && _jsx(Text, { dimColor: true, children: meta.join(" ") }), follow && _jsx(Text, { color: "yellow", children: "follow" }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }), issue.title && _jsx(Text, { children: issue.title }), blocker && _jsx(Text, { color: "yellow", children: blocker }), issue.statusNote && issue.statusNote !== blocker && (_jsx(Text, { dimColor: true, wrap: "wrap", children: issue.statusNote })), issueContext?.latestFailureSummary && (_jsxs(Text, { color: issueContext.latestFailureSource === "queue_eviction" ? "yellow" : "red", children: ["Latest failure: ", issueContext.latestFailureSummary, issueContext.latestFailureHeadSha ? ` @ ${issueContext.latestFailureHeadSha.slice(0, 8)}` : ""] })), detailTab === "timeline" ? (_jsxs(_Fragment, { children: [plan && plan.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "Plan" }), _jsx(Text, { children: progressBar(plan.filter((s) => s.status === "completed").length, plan.length, 16) }), _jsxs(Text, { dimColor: true, children: [plan.filter((s) => s.status === "completed").length, "/", plan.length] })] }), 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 }) })] })) : (_jsxs(_Fragment, { children: [_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "PatchRelay activity history." }), _jsx(Text, { dimColor: true, children: "Runs, waits, and wake-ups are shown here in PatchRelay order." })] }), _jsx(Box, { marginTop: 1, children: _jsx(StateHistoryView, { history: history, plan: plan, activeRunId: activeRunId }) })] })), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab }) })] }));
68
+ return (_jsx(Text, { children: line.segments.map((segment, index) => (_jsx(Text
69
+ // eslint-disable-next-line react/no-array-index-key
70
+ , { ...(segment.color ? { color: segment.color } : {}), ...(segment.dimColor ? { dimColor: true } : {}), ...(segment.bold ? { bold: true } : {}), children: segment.text }, `${line.key}-${index}`))) }));
169
71
  }
@@ -4,6 +4,9 @@ import { summarizeIssueStatusNote } from "./issue-status-note.js";
4
4
  import { relativeTime, truncate } from "./format-utils.js";
5
5
  // ─── State display ──────────────────────────────────────────────
6
6
  const TERMINAL_STATES = new Set(["done", "failed", "escalated"]);
7
+ function needsOperatorIntervention(issue) {
8
+ return issue.sessionState === "failed" || issue.factoryState === "failed" || issue.factoryState === "escalated";
9
+ }
7
10
  function effectiveState(issue) {
8
11
  if (issue.sessionState === "done")
9
12
  return "done";
@@ -18,6 +21,9 @@ function effectiveState(issue) {
18
21
  return issue.factoryState;
19
22
  }
20
23
  function sessionDisplay(issue) {
24
+ if (needsOperatorIntervention(issue)) {
25
+ return { label: "needs help", color: "red" };
26
+ }
21
27
  switch (issue.sessionState) {
22
28
  case "running":
23
29
  return { label: "running", color: "cyan" };
@@ -71,6 +77,9 @@ function buildFacts(issue, selected) {
71
77
  if (issue.waitingReason && issue.sessionState === "waiting_input") {
72
78
  facts.push({ text: issue.waitingReason, color: "yellow" });
73
79
  }
80
+ if (needsOperatorIntervention(issue)) {
81
+ facts.push({ text: "operator action needed", color: "red" });
82
+ }
74
83
  // Review state — only show when it matters (not yet approved, or changes requested)
75
84
  if (issue.prReviewState === "approved") {
76
85
  facts.push({ text: "approved", color: "green" });
@@ -116,6 +125,8 @@ function blockerText(issue) {
116
125
  && !issue.activeRunType;
117
126
  if (issue.sessionState === "waiting_input")
118
127
  return issue.waitingReason ?? "Waiting for input";
128
+ if (needsOperatorIntervention(issue))
129
+ return issue.statusNote ?? issue.waitingReason ?? "Needs operator intervention";
119
130
  if (issue.waitingReason && !issue.activeRunType)
120
131
  return issue.waitingReason;
121
132
  if (issue.blockedByCount > 0)
@@ -13,7 +13,8 @@ export function StatusBar({ issues, totalCount, filter, connected, lastServerMes
13
13
  const agg = computeAggregates(aggregateSource);
14
14
  const withPr = aggregateSource.filter((i) => i.prNumber !== undefined).length;
15
15
  const waitingInput = aggregateSource.filter((i) => i.sessionState === "waiting_input" || i.factoryState === "awaiting_input").length;
16
+ const intervention = aggregateSource.filter((i) => i.sessionState === "failed" || i.factoryState === "failed" || i.factoryState === "escalated").length;
16
17
  const running = aggregateSource.filter((i) => i.sessionState === "running").length;
17
18
  const idle = aggregateSource.filter((i) => i.sessionState === "idle").length;
18
- return (_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, children: showing }), _jsxs(Text, { dimColor: true, children: ["[", FILTER_LABELS[filter], "]"] }), _jsx(Text, { dimColor: true, children: "|" }), running > 0 && _jsxs(Text, { color: "cyan", children: [running, " running"] }), idle > 0 && _jsxs(Text, { color: "blueBright", children: [idle, " idle"] }), agg.ready > 0 && _jsxs(Text, { color: "blueBright", children: [agg.ready, " ready"] }), agg.blocked > 0 && _jsxs(Text, { color: "yellow", children: [agg.blocked, " blocked"] }), withPr > 0 && _jsxs(Text, { dimColor: true, children: [withPr, " PRs"] }), waitingInput > 0 && _jsxs(Text, { color: "yellow", children: [waitingInput, " needs input"] }), agg.done > 0 && _jsxs(Text, { color: "green", children: [agg.done, " done"] }), agg.failed > 0 && _jsxs(Text, { color: "red", children: [agg.failed, " failed"] }), frozen && _jsx(Text, { color: "magenta", children: "frozen" })] }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }));
19
+ return (_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, children: showing }), _jsxs(Text, { dimColor: true, children: ["[", FILTER_LABELS[filter], "]"] }), _jsx(Text, { dimColor: true, children: "|" }), running > 0 && _jsxs(Text, { color: "cyan", children: [running, " running"] }), idle > 0 && _jsxs(Text, { color: "blueBright", children: [idle, " idle"] }), agg.ready > 0 && _jsxs(Text, { color: "blueBright", children: [agg.ready, " ready"] }), agg.blocked > 0 && _jsxs(Text, { color: "yellow", children: [agg.blocked, " blocked"] }), withPr > 0 && _jsxs(Text, { dimColor: true, children: [withPr, " PRs"] }), waitingInput > 0 && _jsxs(Text, { color: "yellow", children: [waitingInput, " needs input"] }), intervention > 0 && _jsxs(Text, { color: "red", children: [intervention, " needs help"] }), agg.done > 0 && _jsxs(Text, { color: "green", children: [agg.done, " done"] }), agg.failed > 0 && _jsxs(Text, { color: "red", children: [agg.failed, " failed"] }), frozen && _jsx(Text, { color: "magenta", children: "frozen" })] }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }));
19
20
  }