patchrelay 0.13.0 → 0.14.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.13.0",
4
- "commit": "e106318f8abc",
5
- "builtAt": "2026-03-25T08:10:05.542Z"
3
+ "version": "0.14.0",
4
+ "commit": "e8b5806460ef",
5
+ "builtAt": "2026-03-25T09:41:38.220Z"
6
6
  }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useReducer } from "react";
2
+ import { useReducer, useMemo } from "react";
3
3
  import { Box, useApp, useInput } from "ink";
4
- import { watchReducer, initialWatchState } from "./watch-state.js";
4
+ import { watchReducer, initialWatchState, filterIssues } from "./watch-state.js";
5
5
  import { useWatchStream } from "./use-watch-stream.js";
6
6
  import { useDetailStream } from "./use-detail-stream.js";
7
7
  import { IssueListView } from "./IssueListView.js";
@@ -12,6 +12,7 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
12
12
  ...initialWatchState,
13
13
  ...(initialIssueKey ? { view: "detail", activeDetailKey: initialIssueKey } : {}),
14
14
  });
15
+ const filtered = useMemo(() => filterIssues(state.issues, state.filter), [state.issues, state.filter]);
15
16
  useWatchStream({ baseUrl, bearerToken, dispatch });
16
17
  useDetailStream({ baseUrl, bearerToken, issueKey: state.activeDetailKey, dispatch });
17
18
  useInput((input, key) => {
@@ -27,11 +28,14 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
27
28
  dispatch({ type: "select", index: state.selectedIndex - 1 });
28
29
  }
29
30
  else if (key.return) {
30
- const issue = state.issues[state.selectedIndex];
31
+ const issue = filtered[state.selectedIndex];
31
32
  if (issue?.issueKey) {
32
33
  dispatch({ type: "enter-detail", issueKey: issue.issueKey });
33
34
  }
34
35
  }
36
+ else if (key.tab) {
37
+ dispatch({ type: "cycle-filter" });
38
+ }
35
39
  }
36
40
  else if (state.view === "detail") {
37
41
  if (key.escape || key.backspace || key.delete) {
@@ -39,5 +43,5 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
39
43
  }
40
44
  }
41
45
  });
42
- return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { state: state })) : (_jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), thread: state.thread })) }));
46
+ 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 })) }));
43
47
  }
@@ -2,6 +2,6 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  export function HelpBar({ view }) {
4
4
  return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: view === "list"
5
- ? "j/k: navigate Enter: detail q: quit"
5
+ ? "j/k: navigate Enter: detail Tab: filter q: quit"
6
6
  : "Esc: back q: quit" }) }));
7
7
  }
@@ -2,10 +2,17 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { ThreadView } from "./ThreadView.js";
4
4
  import { HelpBar } from "./HelpBar.js";
5
- export function IssueDetailView({ issue, thread }) {
5
+ function truncate(text, max) {
6
+ const line = text.replace(/\n/g, " ").trim();
7
+ return line.length > max ? `${line.slice(0, max - 3)}...` : line;
8
+ }
9
+ function ReportView({ report }) {
10
+ 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" : ""] })] })] }));
11
+ }
12
+ export function IssueDetailView({ issue, thread, report }) {
6
13
  if (!issue) {
7
14
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", children: "Issue not found." }), _jsx(HelpBar, { view: "detail" })] }));
8
15
  }
9
16
  const key = issue.issueKey ?? issue.projectId;
10
- 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 }), _jsx(Text, { dimColor: true, children: "─".repeat(72) }), thread ? (_jsx(ThreadView, { thread: thread })) : (_jsx(Text, { dimColor: true, children: "Waiting for thread data..." })), _jsx(Text, { dimColor: true, children: "─".repeat(72) }), _jsx(HelpBar, { view: "detail" })] }));
17
+ 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 }), _jsx(Text, { dimColor: true, children: "─".repeat(72) }), thread ? (_jsx(ThreadView, { thread: thread })) : report ? (_jsx(ReportView, { report: report })) : (_jsx(Text, { dimColor: true, children: "Loading..." })), _jsx(Text, { dimColor: true, children: "─".repeat(72) }), _jsx(HelpBar, { view: "detail" })] }));
11
18
  }
@@ -3,7 +3,6 @@ import { Box, Text } from "ink";
3
3
  import { IssueRow } from "./IssueRow.js";
4
4
  import { StatusBar } from "./StatusBar.js";
5
5
  import { HelpBar } from "./HelpBar.js";
6
- export function IssueListView({ state }) {
7
- const { issues, selectedIndex, connected } = state;
8
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { issues: issues, connected: connected }), _jsx(Text, { dimColor: true, children: "─".repeat(72) }), issues.length === 0 ? (_jsx(Text, { dimColor: true, children: "No tracked issues." })) : (_jsx(Box, { flexDirection: "column", children: issues.map((issue, index) => (_jsx(IssueRow, { issue: issue, selected: index === selectedIndex }, issue.issueKey ?? `${issue.projectId}-${index}`))) })), _jsx(Text, { dimColor: true, children: "─".repeat(72) }), _jsx(HelpBar, { view: "list" })] }));
6
+ export function IssueListView({ issues, selectedIndex, connected, filter, totalCount }) {
7
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { issues: issues, totalCount: totalCount, filter: filter, connected: connected }), _jsx(Text, { dimColor: true, children: "─".repeat(72) }), issues.length === 0 ? (_jsx(Text, { dimColor: true, children: "No issues match the current filter." })) : (_jsx(Box, { flexDirection: "column", children: issues.map((issue, index) => (_jsx(IssueRow, { issue: issue, selected: index === selectedIndex }, issue.issueKey ?? `${issue.projectId}-${index}`))) })), _jsx(Text, { dimColor: true, children: "─".repeat(72) }), _jsx(HelpBar, { view: "list" })] }));
9
8
  }
@@ -21,16 +21,31 @@ function stateColor(state) {
21
21
  function formatPr(issue) {
22
22
  if (!issue.prNumber)
23
23
  return "";
24
- const parts = [`PR #${issue.prNumber}`];
24
+ const parts = [`#${issue.prNumber}`];
25
25
  if (issue.prReviewState === "approved")
26
- parts.push("approved");
26
+ parts.push("\u2713");
27
27
  else if (issue.prReviewState === "changes_requested")
28
- parts.push("changes");
29
- if (issue.prCheckStatus === "passed")
30
- parts.push("checks ok");
31
- else if (issue.prCheckStatus === "failed")
32
- parts.push("checks fail");
33
- return parts.join(" ");
28
+ parts.push("\u2717");
29
+ return parts.join("");
30
+ }
31
+ function relativeTime(iso) {
32
+ const ms = Date.now() - new Date(iso).getTime();
33
+ if (ms < 0)
34
+ return "now";
35
+ const seconds = Math.floor(ms / 1000);
36
+ if (seconds < 60)
37
+ return `${seconds}s`;
38
+ const minutes = Math.floor(seconds / 60);
39
+ if (minutes < 60)
40
+ return `${minutes}m`;
41
+ const hours = Math.floor(minutes / 60);
42
+ if (hours < 24)
43
+ return `${hours}h`;
44
+ const days = Math.floor(hours / 24);
45
+ return `${days}d`;
46
+ }
47
+ function truncate(text, max) {
48
+ return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
34
49
  }
35
50
  export function IssueRow({ issue, selected }) {
36
51
  const key = issue.issueKey ?? issue.projectId;
@@ -38,5 +53,7 @@ export function IssueRow({ issue, selected }) {
38
53
  const run = issue.activeRunType ?? issue.latestRunType;
39
54
  const runStatus = issue.activeRunType ? "running" : issue.latestRunStatus;
40
55
  const pr = formatPr(issue);
41
- return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: selected ? "blueBright" : "white", bold: selected, children: selected ? "▸" : " " }), _jsx(Text, { bold: true, children: key.padEnd(10) }), _jsx(Text, { color: stateColor(state), children: state.padEnd(20) }), _jsx(Text, { dimColor: true, children: run ? `${run}${runStatus ? `:${runStatus}` : ""}`.padEnd(25) : "".padEnd(25) }), pr ? _jsx(Text, { dimColor: true, children: pr }) : null] }));
56
+ const ago = relativeTime(issue.updatedAt);
57
+ const title = issue.title ? truncate(issue.title, 40) : "";
58
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: selected ? "blueBright" : "white", bold: selected, children: selected ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: key.padEnd(10) }), _jsx(Text, { color: stateColor(state), children: state.padEnd(18) }), _jsx(Text, { dimColor: true, children: run ? `${run}:${runStatus ?? "?"}`.padEnd(22) : "".padEnd(22) }), pr ? _jsx(Text, { dimColor: true, children: pr.padEnd(6) }) : _jsx(Text, { dimColor: true, children: "".padEnd(6) }), _jsx(Text, { dimColor: true, children: ago.padStart(4) }), title ? _jsxs(Text, { dimColor: true, children: [" ", title] }) : null] }));
42
59
  }
@@ -1,6 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- export function StatusBar({ issues, connected }) {
4
- const active = issues.filter((i) => i.activeRunType).length;
5
- return (_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: issues.length }), _jsx(Text, { children: " issues tracked" }), active > 0 && (_jsxs(Text, { children: [", ", _jsx(Text, { bold: true, color: "green", children: active }), " active"] }))] }), _jsx(Text, { color: connected ? "green" : "red", children: connected ? "● connected" : "○ disconnected" })] }));
3
+ const FILTER_LABELS = {
4
+ "all": "all",
5
+ "active": "active",
6
+ "non-done": "in progress",
7
+ };
8
+ export function StatusBar({ issues, totalCount, filter, connected }) {
9
+ const showing = filter === "all" ? `${totalCount} issues` : `${issues.length}/${totalCount} issues`;
10
+ return (_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: showing }), _jsxs(Text, { dimColor: true, children: [" [", FILTER_LABELS[filter], "]"] })] }), _jsx(Text, { color: connected ? "green" : "red", children: connected ? "\u25cf connected" : "\u25cb disconnected" })] }));
6
11
  }
@@ -29,15 +29,48 @@ async function rehydrate(baseUrl, issueKey, headers, signal, dispatch) {
29
29
  return;
30
30
  const data = await response.json();
31
31
  const threadData = data.thread;
32
- if (!threadData)
32
+ if (threadData) {
33
+ dispatch({ type: "thread-snapshot", thread: materializeThread(threadData) });
33
34
  return;
34
- const thread = materializeThread(threadData);
35
- dispatch({ type: "thread-snapshot", thread });
35
+ }
36
+ // No active thread — fall back to latest run report
37
+ await rehydrateFromReport(baseUrl, issueKey, headers, signal, dispatch);
36
38
  }
37
39
  catch {
38
40
  // Rehydration is best-effort — SSE stream will provide updates
39
41
  }
40
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 });
47
+ if (!response.ok)
48
+ return;
49
+ 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 });
69
+ }
70
+ catch {
71
+ // Report fetch is best-effort
72
+ }
73
+ }
41
74
  async function streamCodexEvents(baseUrl, issueKey, baseHeaders, signal, dispatch) {
42
75
  try {
43
76
  const url = new URL("/api/watch", baseUrl);
@@ -5,7 +5,27 @@ export const initialWatchState = {
5
5
  view: "list",
6
6
  activeDetailKey: null,
7
7
  thread: null,
8
+ report: null,
9
+ filter: "non-done",
8
10
  };
11
+ const TERMINAL_FACTORY_STATES = new Set(["done", "failed"]);
12
+ export function filterIssues(issues, filter) {
13
+ switch (filter) {
14
+ case "all":
15
+ return issues;
16
+ case "active":
17
+ return issues.filter((i) => i.activeRunType !== undefined);
18
+ case "non-done":
19
+ return issues.filter((i) => !TERMINAL_FACTORY_STATES.has(i.factoryState));
20
+ }
21
+ }
22
+ function nextFilter(filter) {
23
+ switch (filter) {
24
+ case "non-done": return "active";
25
+ case "active": return "all";
26
+ case "all": return "non-done";
27
+ }
28
+ }
9
29
  export function watchReducer(state, action) {
10
30
  switch (action.type) {
11
31
  case "connected":
@@ -26,13 +46,17 @@ export function watchReducer(state, action) {
26
46
  selectedIndex: Math.max(0, Math.min(action.index, state.issues.length - 1)),
27
47
  };
28
48
  case "enter-detail":
29
- return { ...state, view: "detail", activeDetailKey: action.issueKey, thread: null };
49
+ return { ...state, view: "detail", activeDetailKey: action.issueKey, thread: null, report: null };
30
50
  case "exit-detail":
31
- return { ...state, view: "list", activeDetailKey: null, thread: null };
51
+ return { ...state, view: "list", activeDetailKey: null, thread: null, report: null };
32
52
  case "thread-snapshot":
33
53
  return { ...state, thread: action.thread };
54
+ case "report-snapshot":
55
+ return { ...state, report: action.report };
34
56
  case "codex-notification":
35
57
  return applyCodexNotification(state, action.method, action.params);
58
+ case "cycle-filter":
59
+ return { ...state, filter: nextFilter(state.filter), selectedIndex: 0 };
36
60
  }
37
61
  }
38
62
  // ─── Feed Event Application ───────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {