patchrelay 0.17.1 → 0.18.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.17.1",
4
- "commit": "ff6a8d2fcbef",
5
- "builtAt": "2026-03-25T14:45:25.511Z"
3
+ "version": "0.18.0",
4
+ "commit": "37f3bf8f344f",
5
+ "builtAt": "2026-03-25T19:56:49.875Z"
6
6
  }
@@ -4,8 +4,10 @@ import { Box, useApp, useInput } from "ink";
4
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
+ import { useFeedStream } from "./use-feed-stream.js";
7
8
  import { IssueListView } from "./IssueListView.js";
8
9
  import { IssueDetailView } from "./IssueDetailView.js";
10
+ import { FeedView } from "./FeedView.js";
9
11
  async function postRetry(baseUrl, issueKey, bearerToken) {
10
12
  const headers = { "content-type": "application/json" };
11
13
  if (bearerToken)
@@ -25,6 +27,7 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
25
27
  const filtered = useMemo(() => filterIssues(state.issues, state.filter), [state.issues, state.filter]);
26
28
  useWatchStream({ baseUrl, bearerToken, dispatch });
27
29
  useDetailStream({ baseUrl, bearerToken, issueKey: state.activeDetailKey, dispatch });
30
+ useFeedStream({ baseUrl, bearerToken, active: state.view === "feed", dispatch });
28
31
  const handleRetry = useCallback(() => {
29
32
  if (state.activeDetailKey) {
30
33
  void postRetry(baseUrl, state.activeDetailKey, bearerToken);
@@ -51,6 +54,9 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
51
54
  else if (key.tab) {
52
55
  dispatch({ type: "cycle-filter" });
53
56
  }
57
+ else if (input === "F" || input === "f") {
58
+ dispatch({ type: "enter-feed" });
59
+ }
54
60
  }
55
61
  else if (state.view === "detail") {
56
62
  if (key.escape || key.backspace || key.delete) {
@@ -62,7 +68,18 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
62
68
  else if (input === "r") {
63
69
  handleRetry();
64
70
  }
71
+ else if (input === "j" || key.downArrow) {
72
+ dispatch({ type: "detail-navigate", direction: "next", filtered });
73
+ }
74
+ else if (input === "k" || key.upArrow) {
75
+ dispatch({ type: "detail-navigate", direction: "prev", filtered });
76
+ }
77
+ }
78
+ else if (state.view === "feed") {
79
+ if (key.escape || key.backspace || key.delete) {
80
+ dispatch({ type: "exit-feed" });
81
+ }
65
82
  }
66
83
  });
67
- return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : (_jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, activeRunStartedAt: state.activeRunStartedAt, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan })) }));
84
+ 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" ? (_jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, activeRunStartedAt: state.activeRunStartedAt, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, allIssues: filtered, activeDetailKey: state.activeDetailKey })) : (_jsx(FeedView, { events: state.feedEvents, connected: state.connected })) }));
68
85
  }
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { HelpBar } from "./HelpBar.js";
4
+ const TAIL_SIZE = 30;
5
+ const LEVEL_COLORS = {
6
+ info: "white",
7
+ warn: "yellow",
8
+ error: "red",
9
+ };
10
+ const KIND_COLORS = {
11
+ stage: "cyan",
12
+ turn: "yellow",
13
+ github: "green",
14
+ webhook: "blue",
15
+ agent: "magenta",
16
+ service: "white",
17
+ workflow: "cyan",
18
+ linear: "blue",
19
+ };
20
+ function formatTime(iso) {
21
+ return new Date(iso).toLocaleTimeString("en-GB", { hour12: false });
22
+ }
23
+ function FeedEventRow({ event }) {
24
+ const kindColor = KIND_COLORS[event.kind] ?? "white";
25
+ const levelColor = LEVEL_COLORS[event.level] ?? "white";
26
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(event.at) }), _jsx(Text, { color: kindColor, children: event.kind.padEnd(10) }), event.issueKey && _jsx(Text, { bold: true, children: event.issueKey.padEnd(10) }), event.stage && _jsx(Text, { color: "cyan", children: event.stage.padEnd(16) }), _jsx(Text, { color: levelColor, children: event.summary })] }));
27
+ }
28
+ export function FeedView({ events, connected }) {
29
+ const visible = events.length > TAIL_SIZE ? events.slice(-TAIL_SIZE) : events;
30
+ const skipped = events.length - visible.length;
31
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: "Operator Feed" }), _jsx(Text, { color: connected ? "green" : "red", children: connected ? "\u25cf connected" : "\u25cb disconnected" })] }), _jsx(Text, { dimColor: true, children: "\u2500".repeat(72) }), events.length === 0 ? (_jsx(Text, { dimColor: true, children: "No feed events yet." })) : (_jsxs(Box, { flexDirection: "column", children: [skipped > 0 && _jsxs(Text, { dimColor: true, children: [" ... ", skipped, " earlier events"] }), visible.map((event) => (_jsx(FeedEventRow, { event: event }, event.id)))] })), _jsx(Text, { dimColor: true, children: "\u2500".repeat(72) }), _jsx(HelpBar, { view: "feed" })] }));
32
+ }
@@ -1,7 +1,13 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
+ const HELP_TEXT = {
4
+ list: "j/k: navigate Enter: detail F: feed Tab: filter q: quit",
5
+ detail: "",
6
+ feed: "Esc: list q: quit",
7
+ };
3
8
  export function HelpBar({ view, follow }) {
4
- return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: view === "list"
5
- ? "j/k: navigate Enter: detail Tab: filter q: quit"
6
- : `Esc: back f: follow ${follow ? "on" : "off"} r: retry q: quit` }) }));
9
+ const text = view === "detail"
10
+ ? `j/k: prev/next Esc: list f: follow ${follow ? "on" : "off"} r: retry q: quit`
11
+ : HELP_TEXT[view];
12
+ return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: text }) }));
7
13
  }
@@ -35,10 +35,76 @@ function planStepColor(status) {
35
35
  return "yellow";
36
36
  return "white";
37
37
  }
38
- export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, tokenUsage, diffSummary, plan, }) {
38
+ // ─── Compact Issue Sidebar (#4 split-pane) ───────────────────────
39
+ const SIDEBAR_STATE_COLORS = {
40
+ delegated: "blue", preparing: "blue",
41
+ implementing: "yellow", awaiting_input: "yellow",
42
+ pr_open: "cyan", awaiting_review: "cyan",
43
+ changes_requested: "magenta", repairing_ci: "magenta", repairing_queue: "magenta",
44
+ awaiting_queue: "green", done: "green",
45
+ failed: "red", escalated: "red",
46
+ };
47
+ function CompactSidebar({ issues, activeKey }) {
48
+ return (_jsxs(Box, { flexDirection: "column", width: 28, borderStyle: "single", borderColor: "gray", paddingLeft: 1, paddingRight: 1, children: [_jsx(Text, { bold: true, dimColor: true, children: "Issues" }), issues.map((issue) => {
49
+ const key = issue.issueKey ?? issue.projectId;
50
+ const isCurrent = key === activeKey;
51
+ const stateColor = SIDEBAR_STATE_COLORS[issue.factoryState] ?? "white";
52
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: isCurrent ? "blueBright" : "white", bold: isCurrent, children: isCurrent ? "\u25b8" : " " }), _jsx(Text, { bold: isCurrent, children: key.padEnd(10) }), _jsx(Text, { color: stateColor, children: issue.factoryState.slice(0, 12) })] }, key));
53
+ })] }));
54
+ }
55
+ // ─── Issue Context Panel (#5) ────────────────────────────────────
56
+ const PRIORITY_LABELS = {
57
+ 0: { label: "none", color: "" },
58
+ 1: { label: "urgent", color: "red" },
59
+ 2: { label: "high", color: "yellow" },
60
+ 3: { label: "medium", color: "cyan" },
61
+ 4: { label: "low", color: "" },
62
+ };
63
+ function ContextPanel({ issue, ctx }) {
64
+ const parts = [];
65
+ if (ctx.priority != null && ctx.priority > 0) {
66
+ const p = PRIORITY_LABELS[ctx.priority] ?? { label: String(ctx.priority), color: "" };
67
+ parts.push({ label: "priority", value: p.label, color: p.color });
68
+ }
69
+ if (ctx.estimate != null) {
70
+ parts.push({ label: "estimate", value: String(ctx.estimate), color: "" });
71
+ }
72
+ if (ctx.currentLinearState) {
73
+ parts.push({ label: "linear", value: ctx.currentLinearState, color: "" });
74
+ }
75
+ if (issue.prNumber) {
76
+ const prInfo = `#${issue.prNumber}${issue.prReviewState === "approved" ? " \u2713" : issue.prReviewState === "changes_requested" ? " \u2717" : ""}${issue.prCheckStatus ? ` ci:${issue.prCheckStatus}` : ""}`;
77
+ const prColor = issue.prReviewState === "approved" ? "green" : issue.prReviewState === "changes_requested" ? "red" : "";
78
+ parts.push({ label: "pr", value: prInfo, color: prColor });
79
+ }
80
+ if (ctx.runCount > 0) {
81
+ parts.push({ label: "runs", value: String(ctx.runCount), color: "" });
82
+ }
83
+ const retries = [
84
+ ctx.ciRepairAttempts > 0 ? `ci:${ctx.ciRepairAttempts}` : "",
85
+ ctx.queueRepairAttempts > 0 ? `queue:${ctx.queueRepairAttempts}` : "",
86
+ ctx.reviewFixAttempts > 0 ? `review:${ctx.reviewFixAttempts}` : "",
87
+ ].filter(Boolean).join(" ");
88
+ if (retries) {
89
+ parts.push({ label: "retries", value: retries, color: "yellow" });
90
+ }
91
+ if (ctx.branchName) {
92
+ parts.push({ label: "branch", value: ctx.branchName, color: "" });
93
+ }
94
+ const hasDescription = Boolean(ctx.description);
95
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { gap: 2, flexWrap: "wrap", children: parts.map((p) => (_jsxs(Text, { dimColor: true, children: [p.label, ": ", p.color ? _jsx(Text, { color: p.color, children: p.value }) : _jsx(Text, { dimColor: true, children: p.value })] }, p.label))) }), hasDescription && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [ctx.description.slice(0, 200), ctx.description.length > 200 ? "\u2026" : ""] }))] }));
96
+ }
97
+ // ─── Detail Panel (right side of split) ──────────────────────────
98
+ function DetailPanel({ issue, timeline, follow, activeRunStartedAt, tokenUsage, diffSummary, plan, issueContext, }) {
39
99
  if (!issue) {
40
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", children: "Issue not found." }), _jsx(HelpBar, { view: "detail", follow: follow })] }));
100
+ return _jsx(Text, { color: "red", children: "Issue not found." });
41
101
  }
42
102
  const key = issue.issueKey ?? issue.projectId;
43
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["PR #", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt })] }), issue.title && _jsx(Text, { dimColor: true, children: issue.title }), _jsxs(Box, { gap: 2, children: [tokenUsage && (_jsxs(Text, { dimColor: true, children: ["tokens: ", formatTokens(tokenUsage.inputTokens), " in / ", formatTokens(tokenUsage.outputTokens), " out"] })), diffSummary && diffSummary.filesChanged > 0 && (_jsxs(Text, { dimColor: true, children: ["diff: ", diffSummary.filesChanged, " file", diffSummary.filesChanged !== 1 ? "s" : "", " ", "+", diffSummary.linesAdded, " -", diffSummary.linesRemoved] })), follow && _jsx(Text, { color: "yellow", children: "follow" })] }), plan && plan.length > 0 && (_jsx(Box, { flexDirection: "column", children: plan.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: planStepColor(entry.status), children: ["[", planStepSymbol(entry.status), "]"] }), _jsx(Text, { children: entry.step })] }, `plan-${i}`))) })), _jsx(Text, { dimColor: true, children: "".repeat(72) }), _jsx(Timeline, { entries: timeline, follow: follow }), _jsx(Text, { dimColor: true, children: "─".repeat(72) }), _jsx(HelpBar, { view: "detail", follow: follow })] }));
103
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["PR #", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt })] }), issue.title && _jsx(Text, { dimColor: true, children: issue.title }), _jsxs(Box, { gap: 2, children: [tokenUsage && (_jsxs(Text, { dimColor: true, children: ["tokens: ", formatTokens(tokenUsage.inputTokens), " in / ", formatTokens(tokenUsage.outputTokens), " out"] })), diffSummary && diffSummary.filesChanged > 0 && (_jsxs(Text, { dimColor: true, children: ["diff: ", diffSummary.filesChanged, " file", diffSummary.filesChanged !== 1 ? "s" : "", " ", "+", diffSummary.linesAdded, " -", diffSummary.linesRemoved] })), follow && _jsx(Text, { color: "yellow", children: "follow" })] }), issueContext && _jsx(ContextPanel, { issue: issue, ctx: issueContext }), plan && plan.length > 0 && (_jsx(Box, { flexDirection: "column", children: plan.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: planStepColor(entry.status), children: ["[", planStepSymbol(entry.status), "]"] }), _jsx(Text, { children: entry.step })] }, `plan-${i}`))) })), _jsx(Text, { dimColor: true, children: "\u2500".repeat(60) }), _jsx(Timeline, { entries: timeline, follow: follow })] }));
104
+ }
105
+ // ─── Main Detail View (split layout) ─────────────────────────────
106
+ export function IssueDetailView(props) {
107
+ const { allIssues, activeDetailKey, follow, ...detailProps } = props;
108
+ const showSidebar = allIssues.length > 1;
109
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", flexGrow: 1, children: [showSidebar && _jsx(CompactSidebar, { issues: allIssues, activeKey: activeDetailKey }), _jsx(DetailPanel, { ...detailProps, follow: follow })] }), _jsx(Text, { dimColor: true, children: "\u2500".repeat(72) }), _jsx(HelpBar, { view: "detail", follow: follow })] }));
44
110
  }
@@ -3,6 +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({ 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" })] }));
6
+ export function IssueListView({ issues, allIssues, selectedIndex, connected, filter, totalCount }) {
7
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { issues: issues, totalCount: totalCount, filter: filter, connected: connected, allIssues: allIssues }), _jsx(Text, { dimColor: true, children: "\u2500".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: "\u2500".repeat(72) }), _jsx(HelpBar, { view: "list" })] }));
8
8
  }
@@ -1,11 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
+ import { computeAggregates } from "./watch-state.js";
3
4
  const FILTER_LABELS = {
4
5
  "all": "all",
5
6
  "active": "active",
6
7
  "non-done": "in progress",
7
8
  };
8
- export function StatusBar({ issues, totalCount, filter, connected }) {
9
+ export function StatusBar({ issues, totalCount, filter, connected, allIssues }) {
9
10
  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" })] }));
11
+ const agg = computeAggregates(allIssues);
12
+ 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: "|" }), agg.active > 0 && _jsxs(Text, { color: "yellow", children: [agg.active, " active"] }), agg.done > 0 && _jsxs(Text, { color: "green", children: [agg.done, " done"] }), agg.failed > 0 && _jsxs(Text, { color: "red", children: [agg.failed, " failed"] })] }), _jsx(Text, { color: connected ? "green" : "red", children: connected ? "\u25cf connected" : "\u25cb disconnected" })] }));
11
13
  }
@@ -38,12 +38,31 @@ async function rehydrate(baseUrl, issueKey, headers, signal, dispatch) {
38
38
  threadId: r.threadId,
39
39
  ...(r.report ? { report: r.report } : {}),
40
40
  }));
41
+ let issueContext = null;
42
+ if (data.issue) {
43
+ const i = data.issue;
44
+ issueContext = {
45
+ description: typeof i.description === "string" ? i.description : undefined,
46
+ currentLinearState: typeof i.currentLinearState === "string" ? i.currentLinearState : undefined,
47
+ issueUrl: typeof i.issueUrl === "string" ? i.issueUrl : undefined,
48
+ worktreePath: typeof i.worktreePath === "string" ? i.worktreePath : undefined,
49
+ branchName: typeof i.branchName === "string" ? i.branchName : undefined,
50
+ prUrl: typeof i.prUrl === "string" ? i.prUrl : undefined,
51
+ priority: typeof i.priority === "number" ? i.priority : undefined,
52
+ estimate: typeof i.estimate === "number" ? i.estimate : undefined,
53
+ ciRepairAttempts: typeof i.ciRepairAttempts === "number" ? i.ciRepairAttempts : 0,
54
+ queueRepairAttempts: typeof i.queueRepairAttempts === "number" ? i.queueRepairAttempts : 0,
55
+ reviewFixAttempts: typeof i.reviewFixAttempts === "number" ? i.reviewFixAttempts : 0,
56
+ runCount: runs.length,
57
+ };
58
+ }
41
59
  dispatch({
42
60
  type: "timeline-rehydrate",
43
61
  runs,
44
62
  feedEvents: data.feedEvents ?? [],
45
63
  liveThread: data.liveThread ?? null,
46
64
  activeRunId: data.activeRunId ?? null,
65
+ issueContext,
47
66
  });
48
67
  }
49
68
  catch {
@@ -0,0 +1,92 @@
1
+ import { useEffect, useRef } from "react";
2
+ export function useFeedStream(options) {
3
+ const optionsRef = useRef(options);
4
+ optionsRef.current = options;
5
+ useEffect(() => {
6
+ if (!options.active)
7
+ return;
8
+ const abortController = new AbortController();
9
+ const { baseUrl, bearerToken, dispatch } = optionsRef.current;
10
+ void (async () => {
11
+ try {
12
+ const url = new URL("/api/feed", baseUrl);
13
+ url.searchParams.set("follow", "1");
14
+ url.searchParams.set("limit", "100");
15
+ const headers = { accept: "text/event-stream" };
16
+ if (bearerToken)
17
+ headers.authorization = `Bearer ${bearerToken}`;
18
+ const response = await fetch(url, { headers, signal: abortController.signal });
19
+ if (!response.ok || !response.body)
20
+ return;
21
+ const reader = response.body.getReader();
22
+ const decoder = new TextDecoder();
23
+ let buffer = "";
24
+ let eventType = "";
25
+ let dataLines = [];
26
+ let initialBatch = [];
27
+ let snapshotSent = false;
28
+ while (true) {
29
+ const { done, value } = await reader.read();
30
+ if (done)
31
+ break;
32
+ buffer += decoder.decode(value, { stream: true });
33
+ let newlineIndex = buffer.indexOf("\n");
34
+ while (newlineIndex !== -1) {
35
+ const rawLine = buffer.slice(0, newlineIndex);
36
+ buffer = buffer.slice(newlineIndex + 1);
37
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
38
+ if (!line) {
39
+ if (dataLines.length > 0 && eventType === "feed") {
40
+ try {
41
+ const event = JSON.parse(dataLines.join("\n"));
42
+ if (!snapshotSent) {
43
+ initialBatch.push(event);
44
+ }
45
+ else {
46
+ dispatch({ type: "feed-new-event", event });
47
+ }
48
+ }
49
+ catch { /* ignore parse errors */ }
50
+ dataLines = [];
51
+ eventType = "";
52
+ }
53
+ // After processing a batch of initial events, flush snapshot
54
+ if (!snapshotSent && initialBatch.length > 0) {
55
+ // Use a microtask to batch initial events
56
+ const batch = initialBatch;
57
+ initialBatch = [];
58
+ snapshotSent = true;
59
+ dispatch({ type: "feed-snapshot", events: batch });
60
+ }
61
+ newlineIndex = buffer.indexOf("\n");
62
+ continue;
63
+ }
64
+ if (line.startsWith(":")) {
65
+ // Keepalive or comment - flush initial batch if pending
66
+ if (!snapshotSent && initialBatch.length > 0) {
67
+ snapshotSent = true;
68
+ dispatch({ type: "feed-snapshot", events: initialBatch });
69
+ initialBatch = [];
70
+ }
71
+ newlineIndex = buffer.indexOf("\n");
72
+ continue;
73
+ }
74
+ if (line.startsWith("event:")) {
75
+ eventType = line.slice(6).trim();
76
+ }
77
+ else if (line.startsWith("data:")) {
78
+ dataLines.push(line.slice(5).trimStart());
79
+ }
80
+ newlineIndex = buffer.indexOf("\n");
81
+ }
82
+ }
83
+ }
84
+ catch {
85
+ // Stream ended or aborted
86
+ }
87
+ })();
88
+ return () => {
89
+ abortController.abort();
90
+ };
91
+ }, [options.active]);
92
+ }
@@ -6,6 +6,7 @@ const DETAIL_INITIAL = {
6
6
  tokenUsage: null,
7
7
  diffSummary: null,
8
8
  plan: null,
9
+ issueContext: null,
9
10
  };
10
11
  export const initialWatchState = {
11
12
  connected: false,
@@ -16,6 +17,7 @@ export const initialWatchState = {
16
17
  filter: "non-done",
17
18
  follow: true,
18
19
  ...DETAIL_INITIAL,
20
+ feedEvents: [],
19
21
  };
20
22
  const TERMINAL_FACTORY_STATES = new Set(["done", "failed"]);
21
23
  export function filterIssues(issues, filter) {
@@ -28,6 +30,22 @@ export function filterIssues(issues, filter) {
28
30
  return issues.filter((i) => !TERMINAL_FACTORY_STATES.has(i.factoryState));
29
31
  }
30
32
  }
33
+ const DONE_STATES = new Set(["done"]);
34
+ const FAILED_STATES = new Set(["failed", "escalated"]);
35
+ export function computeAggregates(issues) {
36
+ let active = 0;
37
+ let done = 0;
38
+ let failed = 0;
39
+ for (const issue of issues) {
40
+ if (issue.activeRunType)
41
+ active++;
42
+ if (DONE_STATES.has(issue.factoryState))
43
+ done++;
44
+ if (FAILED_STATES.has(issue.factoryState))
45
+ failed++;
46
+ }
47
+ return { active, done, failed, total: issues.length };
48
+ }
31
49
  function nextFilter(filter) {
32
50
  switch (filter) {
33
51
  case "non-done": return "active";
@@ -59,6 +77,19 @@ export function watchReducer(state, action) {
59
77
  return { ...state, view: "detail", activeDetailKey: action.issueKey, ...DETAIL_INITIAL };
60
78
  case "exit-detail":
61
79
  return { ...state, view: "list", activeDetailKey: null, ...DETAIL_INITIAL };
80
+ case "detail-navigate": {
81
+ const list = action.filtered;
82
+ if (list.length === 0)
83
+ return state;
84
+ const curIdx = list.findIndex((i) => i.issueKey === state.activeDetailKey);
85
+ const nextIdx = action.direction === "next"
86
+ ? (curIdx + 1) % list.length
87
+ : (curIdx - 1 + list.length) % list.length;
88
+ const nextIssue = list[nextIdx];
89
+ if (!nextIssue?.issueKey || nextIssue.issueKey === state.activeDetailKey)
90
+ return state;
91
+ return { ...state, activeDetailKey: nextIssue.issueKey, selectedIndex: nextIdx, ...DETAIL_INITIAL };
92
+ }
62
93
  case "timeline-rehydrate": {
63
94
  const timeline = buildTimelineFromRehydration(action.runs, action.feedEvents, action.liveThread, action.activeRunId);
64
95
  const activeRun = action.runs.find((r) => r.id === action.activeRunId);
@@ -67,6 +98,7 @@ export function watchReducer(state, action) {
67
98
  timeline,
68
99
  activeRunId: action.activeRunId,
69
100
  activeRunStartedAt: activeRun?.startedAt ?? null,
101
+ issueContext: action.issueContext,
70
102
  };
71
103
  }
72
104
  case "codex-notification":
@@ -75,6 +107,14 @@ export function watchReducer(state, action) {
75
107
  return { ...state, filter: nextFilter(state.filter), selectedIndex: 0 };
76
108
  case "toggle-follow":
77
109
  return { ...state, follow: !state.follow };
110
+ case "enter-feed":
111
+ return { ...state, view: "feed", activeDetailKey: null, ...DETAIL_INITIAL };
112
+ case "exit-feed":
113
+ return { ...state, view: "list" };
114
+ case "feed-snapshot":
115
+ return { ...state, feedEvents: action.events };
116
+ case "feed-new-event":
117
+ return { ...state, feedEvents: [...state.feedEvents, action.event] };
78
118
  }
79
119
  }
80
120
  // ─── Feed Event → Issue List + Timeline ───────────────────────────
@@ -137,6 +137,10 @@ export function runPatchRelayMigrations(connection) {
137
137
  addColumnIfMissing(connection, "issues", "review_fix_attempts", "INTEGER NOT NULL DEFAULT 0");
138
138
  // Collapse awaiting_review into pr_open (state normalization)
139
139
  connection.prepare("UPDATE issues SET factory_state = 'pr_open' WHERE factory_state = 'awaiting_review'").run();
140
+ // Add Linear issue description, priority, estimate
141
+ addColumnIfMissing(connection, "issues", "description", "TEXT");
142
+ addColumnIfMissing(connection, "issues", "priority", "INTEGER");
143
+ addColumnIfMissing(connection, "issues", "estimate", "REAL");
140
144
  }
141
145
  function addColumnIfMissing(connection, table, column, definition) {
142
146
  const cols = connection.prepare(`PRAGMA table_info(${table})`).all();
package/dist/db.js CHANGED
@@ -81,10 +81,22 @@ export class PatchRelayDatabase {
81
81
  sets.push("title = COALESCE(@title, title)");
82
82
  values.title = params.title;
83
83
  }
84
+ if (params.description !== undefined) {
85
+ sets.push("description = COALESCE(@description, description)");
86
+ values.description = params.description;
87
+ }
84
88
  if (params.url !== undefined) {
85
89
  sets.push("url = COALESCE(@url, url)");
86
90
  values.url = params.url;
87
91
  }
92
+ if (params.priority !== undefined) {
93
+ sets.push("priority = @priority");
94
+ values.priority = params.priority;
95
+ }
96
+ if (params.estimate !== undefined) {
97
+ sets.push("estimate = @estimate");
98
+ values.estimate = params.estimate;
99
+ }
88
100
  if (params.currentLinearState !== undefined) {
89
101
  sets.push("current_linear_state = COALESCE(@currentLinearState, current_linear_state)");
90
102
  values.currentLinearState = params.currentLinearState;
@@ -166,13 +178,15 @@ export class PatchRelayDatabase {
166
178
  else {
167
179
  this.connection.prepare(`
168
180
  INSERT INTO issues (
169
- project_id, linear_issue_id, issue_key, title, url,
181
+ project_id, linear_issue_id, issue_key, title, description, url,
182
+ priority, estimate,
170
183
  current_linear_state, factory_state, pending_run_type, pending_run_context_json,
171
184
  branch_name, worktree_path, thread_id, active_run_id,
172
185
  agent_session_id,
173
186
  updated_at
174
187
  ) VALUES (
175
- @projectId, @linearIssueId, @issueKey, @title, @url,
188
+ @projectId, @linearIssueId, @issueKey, @title, @description, @url,
189
+ @priority, @estimate,
176
190
  @currentLinearState, @factoryState, @pendingRunType, @pendingRunContextJson,
177
191
  @branchName, @worktreePath, @threadId, @activeRunId,
178
192
  @agentSessionId,
@@ -183,7 +197,10 @@ export class PatchRelayDatabase {
183
197
  linearIssueId: params.linearIssueId,
184
198
  issueKey: params.issueKey ?? null,
185
199
  title: params.title ?? null,
200
+ description: params.description ?? null,
186
201
  url: params.url ?? null,
202
+ priority: params.priority ?? null,
203
+ estimate: params.estimate ?? null,
187
204
  currentLinearState: params.currentLinearState ?? null,
188
205
  factoryState: params.factoryState ?? "delegated",
189
206
  pendingRunType: params.pendingRunType ?? null,
@@ -375,7 +392,10 @@ function mapIssueRow(row) {
375
392
  linearIssueId: String(row.linear_issue_id),
376
393
  ...(row.issue_key !== null ? { issueKey: String(row.issue_key) } : {}),
377
394
  ...(row.title !== null ? { title: String(row.title) } : {}),
395
+ ...(row.description !== null && row.description !== undefined ? { description: String(row.description) } : {}),
378
396
  ...(row.url !== null ? { url: String(row.url) } : {}),
397
+ ...(row.priority !== null && row.priority !== undefined ? { priority: Number(row.priority) } : {}),
398
+ ...(row.estimate !== null && row.estimate !== undefined ? { estimate: Number(row.estimate) } : {}),
379
399
  ...(row.current_linear_state !== null ? { currentLinearState: String(row.current_linear_state) } : {}),
380
400
  factoryState: String(row.factory_state ?? "delegated"),
381
401
  ...(row.pending_run_type !== null && row.pending_run_type !== undefined ? { pendingRunType: String(row.pending_run_type) } : {}),
@@ -79,7 +79,24 @@ export class IssueQueryService {
79
79
  if (activeRun?.threadId) {
80
80
  liveThread = await this.codex.readThread(activeRun.threadId, true).catch(() => undefined);
81
81
  }
82
- return { issue, runs, feedEvents, liveThread, activeRunId };
82
+ return {
83
+ issue: {
84
+ ...issue,
85
+ ...(fullIssue?.description ? { description: fullIssue.description } : {}),
86
+ ...(fullIssue?.branchName ? { branchName: fullIssue.branchName } : {}),
87
+ ...(fullIssue?.worktreePath ? { worktreePath: fullIssue.worktreePath } : {}),
88
+ ...(fullIssue?.prUrl ? { prUrl: fullIssue.prUrl } : {}),
89
+ ...(fullIssue?.priority != null ? { priority: fullIssue.priority } : {}),
90
+ ...(fullIssue?.estimate != null ? { estimate: fullIssue.estimate } : {}),
91
+ ciRepairAttempts: fullIssue?.ciRepairAttempts ?? 0,
92
+ queueRepairAttempts: fullIssue?.queueRepairAttempts ?? 0,
93
+ reviewFixAttempts: fullIssue?.reviewFixAttempts ?? 0,
94
+ },
95
+ runs,
96
+ feedEvents,
97
+ liveThread,
98
+ activeRunId,
99
+ };
83
100
  }
84
101
  async getActiveRunStatus(issueKey) {
85
102
  return await this.runStatusProvider.getActiveRunStatus(issueKey);
@@ -14,7 +14,10 @@ export class LinearGraphqlClient {
14
14
  id
15
15
  identifier
16
16
  title
17
+ description
17
18
  url
19
+ priority
20
+ estimate
18
21
  delegate {
19
22
  id
20
23
  name
@@ -68,7 +71,10 @@ export class LinearGraphqlClient {
68
71
  id
69
72
  identifier
70
73
  title
74
+ description
71
75
  url
76
+ priority
77
+ estimate
72
78
  delegate {
73
79
  id
74
80
  name
@@ -205,7 +211,10 @@ export class LinearGraphqlClient {
205
211
  id
206
212
  identifier
207
213
  title
214
+ description
208
215
  url
216
+ priority
217
+ estimate
209
218
  state {
210
219
  id
211
220
  name
@@ -293,7 +302,10 @@ export class LinearGraphqlClient {
293
302
  id: issue.id,
294
303
  ...(issue.identifier ? { identifier: issue.identifier } : {}),
295
304
  ...(issue.title ? { title: issue.title } : {}),
305
+ ...(issue.description ? { description: issue.description } : {}),
296
306
  ...(issue.url ? { url: issue.url } : {}),
307
+ ...(issue.priority != null ? { priority: issue.priority } : {}),
308
+ ...(issue.estimate != null ? { estimate: issue.estimate } : {}),
297
309
  ...(issue.state?.id ? { stateId: issue.state.id } : {}),
298
310
  ...(issue.state?.name ? { stateName: issue.state.name } : {}),
299
311
  ...(issue.team?.id ? { teamId: issue.team.id } : {}),
@@ -155,7 +155,10 @@ export class WebhookHandler {
155
155
  linearIssueId: normalizedIssue.id,
156
156
  ...(normalizedIssue.identifier ? { issueKey: normalizedIssue.identifier } : {}),
157
157
  ...(normalizedIssue.title ? { title: normalizedIssue.title } : {}),
158
+ ...(normalizedIssue.description ? { description: normalizedIssue.description } : {}),
158
159
  ...(normalizedIssue.url ? { url: normalizedIssue.url } : {}),
160
+ ...(normalizedIssue.priority != null ? { priority: normalizedIssue.priority } : {}),
161
+ ...(normalizedIssue.estimate != null ? { estimate: normalizedIssue.estimate } : {}),
159
162
  ...(normalizedIssue.stateName ? { currentLinearState: normalizedIssue.stateName } : {}),
160
163
  ...(pendingRunType ? { pendingRunType, factoryState: "delegated" } : {}),
161
164
  ...((pendingRunType || existingIssue?.pendingRunType === "implementation") && pendingRunContextJson
package/dist/webhooks.js CHANGED
@@ -207,10 +207,16 @@ function extractIssueMetadata(payload) {
207
207
  const stateType = getString(stateRecord ?? {}, "type");
208
208
  const delegateId = getString(issueRecord, "delegateId") ?? getString(delegateRecord ?? {}, "id");
209
209
  const delegateName = getString(delegateRecord ?? {}, "name");
210
+ const description = getString(issueRecord, "description");
211
+ const rawPriority = issueRecord.priority;
212
+ const priority = typeof rawPriority === "number" ? rawPriority : undefined;
213
+ const rawEstimate = issueRecord.estimate;
214
+ const estimate = typeof rawEstimate === "number" ? rawEstimate : undefined;
210
215
  return {
211
216
  id,
212
217
  ...(identifier ? { identifier } : {}),
213
218
  ...(title ? { title } : {}),
219
+ ...(description ? { description } : {}),
214
220
  ...(url ? { url } : {}),
215
221
  ...(teamId ? { teamId } : {}),
216
222
  ...(teamKey ? { teamKey } : {}),
@@ -219,6 +225,8 @@ function extractIssueMetadata(payload) {
219
225
  ...(stateType ? { stateType } : {}),
220
226
  ...(delegateId ? { delegateId } : {}),
221
227
  ...(delegateName ? { delegateName } : {}),
228
+ ...(priority != null ? { priority } : {}),
229
+ ...(estimate != null ? { estimate } : {}),
222
230
  labelNames: extractLabelNames(issueRecord),
223
231
  };
224
232
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {