@kud/gh-ink 0.72.0 → 0.74.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.
package/dist/index.d.ts CHANGED
@@ -526,6 +526,27 @@ declare const writeCache: (key: string, data: {
526
526
  sidebar?: Rails;
527
527
  }) => void;
528
528
 
529
+ /**
530
+ * What an action did to a row, said before the server has agreed to it.
531
+ *
532
+ * Three shapes cover every action the inbox takes on a row: it leaves the list
533
+ * (close, done), it changes tab (a Jira move), or it changes in place (labels,
534
+ * assignee, read). A patch names the END STATE rather than the step that gets
535
+ * there, which is what lets it be reapplied on top of every fetch: "SHOP-1234
536
+ * belongs in review, wearing In Review" is as true on the third fetch as on the
537
+ * first, where "move it one tab right" would walk it off the board.
538
+ */
539
+ type Patch = {
540
+ kind: "remove";
541
+ } | {
542
+ kind: "move";
543
+ toSection: string;
544
+ fields?: Record<string, unknown>;
545
+ } | {
546
+ kind: "mutate";
547
+ fields: Record<string, unknown>;
548
+ };
549
+
529
550
  type DetailContext = {
530
551
  item: GHItem;
531
552
  kind: "pr" | "issue";
@@ -604,10 +625,50 @@ type JiraTransition = {
604
625
  type JiraAvailableTransition = {
605
626
  id: string;
606
627
  name: string;
628
+ /**
629
+ * The status the transition lands on. `category` is Jira's status category
630
+ * (`new`, `indeterminate`, `done`) where the host has it — the one field a
631
+ * `tabForStatus` can map without knowing every workflow's status names.
632
+ */
607
633
  to: {
608
634
  name: string;
635
+ category?: string;
609
636
  };
610
637
  };
638
+ /**
639
+ * Which tab a ticket belongs in once it lands on `to`, as the host lays its
640
+ * tabs out — the section id, or null when the ticket leaves the board or the
641
+ * host cannot say. Only the host knows: one board files by status category,
642
+ * another by a status-to-column table, and the inbox has neither.
643
+ *
644
+ * Supplying it makes a move optimistic: the row goes to that tab the moment
645
+ * the move is chosen instead of at the next fetch. Null keeps the move
646
+ * pessimistic, exactly as it is without the prop — a row guessed off the board
647
+ * that turns out to still be on it is worse than one that waits for the fetch.
648
+ */
649
+ type TabForStatus = (to: {
650
+ name: string;
651
+ category?: string;
652
+ }) => string | null;
653
+ /**
654
+ * What a move hands back when the screen took it optimistically: the request's
655
+ * two possible answers, reported so the pending patch can stop showing `◌` or
656
+ * be marked failed. Absent when the move was not taken optimistically, and the
657
+ * caller falls back to its own flashes.
658
+ */
659
+ type OptimisticHandle = {
660
+ settled: () => void;
661
+ failed: (err: unknown) => void;
662
+ };
663
+ /**
664
+ * A row action said ahead of the server: the end state it leaves the row in,
665
+ * and the verb the failure line names it by — `close` reads back as
666
+ * "Couldn't close #412".
667
+ */
668
+ type OptimisticChange = {
669
+ patch: Patch;
670
+ action: string;
671
+ };
611
672
  /**
612
673
  * Resolve the live transitions for a ticket, called lazily when its move
613
674
  * submenu opens — never at board fetch, where one call per visible ticket
@@ -745,6 +806,15 @@ declare const buildActions: (item: AnyItem, login: string, showFlash: (msg: stri
745
806
  * the settlement signal.
746
807
  */
747
808
  onSettled?: (item: GHItem, result: SettledResult) => void;
809
+ /**
810
+ * Take the row's action ahead of the server — see `OptimisticChange`. A
811
+ * handle back means the row is already where the action puts it, so the
812
+ * request's answer goes to the handle and a failure goes ONLY there: the
813
+ * failed patch has its own sticky line with `w` beside it, and a flash
814
+ * naming the same failure would be a second voice saying it less well.
815
+ * Absent, every action keeps the pessimistic shape it had.
816
+ */
817
+ optimistic?: (item: AnyItem, change: OptimisticChange) => OptimisticHandle | undefined;
748
818
  /**
749
819
  * Open the ticket's move submenu through the live-transitions hook. Passed
750
820
  * by the browse screen (which owns the menu the rows open into) only when
@@ -810,7 +880,7 @@ declare const ActionMenu: ({ item, actions, cursor, cols, }: {
810
880
  cols?: number;
811
881
  }) => React.JSX.Element;
812
882
  declare const signatureOf: (sections: Section[]) => string;
813
- declare const App: ({ fetcher, cacheKey, title, detailFor, origin, jiraBase, jiraKeyRe, jiraSetupHint, jiraTransitions, jiraTransitionsFor, hasCiStatus, ciJob, ciFetcher, ciPollMs, stripFetcher, stripLabel, stripPollMs, watchPath, watchDebounceMs, watchJitterMs, extensions, tabHelp, emptyHint, liveLabel, onSettled, }: {
883
+ declare const App: ({ fetcher, cacheKey, title, detailFor, origin, jiraBase, jiraKeyRe, jiraSetupHint, jiraTransitions, jiraTransitionsFor, tabForStatus, hasCiStatus, ciJob, ciFetcher, ciPollMs, stripFetcher, stripLabel, stripPollMs, watchPath, watchDebounceMs, watchJitterMs, extensions, tabHelp, emptyHint, liveLabel, onSettled, }: {
814
884
  fetcher: () => Promise<{
815
885
  sections: Section[];
816
886
  login: string;
@@ -851,6 +921,12 @@ declare const App: ({ fetcher, cacheKey, title, detailFor, origin, jiraBase, jir
851
921
  jiraSetupHint?: string;
852
922
  jiraTransitions?: JiraTransition[];
853
923
  jiraTransitionsFor?: JiraTransitionsFor;
924
+ /**
925
+ * Which tab a moved ticket lands in — see TabForStatus. Supplying it makes
926
+ * moves through `jiraTransitionsFor` optimistic; leaving it out keeps every
927
+ * move waiting on the next fetch, as before.
928
+ */
929
+ tabForStatus?: TabForStatus;
854
930
  hasCiStatus?: boolean;
855
931
  ciJob?: string;
856
932
  ciFetcher?: () => Promise<CiStatus | null>;
@@ -919,4 +995,4 @@ declare const matchesFilter: (repo: string, filter: RepoFilter) => boolean;
919
995
  */
920
996
  declare const parsePatterns: (value: string) => string[];
921
997
 
922
- export { type Action, type ActionGroup, ActionMenu, App, COLS, type ChromeHints, type ChromeSpec, type ChromeState, CiStatusLine, type CiStatusState, type Command, CommentsPanel, type CommentsPanelProps, type DetailContext, type ExplainSection, type ExtensionTarget, HealthPanel, type HealthPanelProps, type InboxBudget, type InboxExtension, type JiraAvailableTransition, type JiraTransition, type JiraTransitionsFor, type LiveLabel, LookupMiss, MIN_SIDEBAR_COLS, type MenuGap, type MenuRow, PrRow, type PrRowProps, type Rails, type RepoFilter, type RowAnnouncement, type RowMotion, SIDEBAR_COLS, type SettledResult, SidePanel, type Sidebar, type SidebarRow, type StatusStrip, StatusStripLine, type StripItem, type StripState, type StripStatusState, type TrailingColumns, type TrailingShed, ageLabel, budgetNotice, buildActions, buildCheckoutCmd, clipboard, counts, drillCmd, explainGhAction, explainItem, fitCount, gapsAbove, healthColor, healthDisplay, healthGlyph, healthLegend, isMenuGap, itermRun, jumpToRepo, jumpToRepoPane, matchesFilter, maxViewStart, menuRowsOf, menuTone, moveCursor, numberColumnsOf, openInTab, parsePatterns, railCapacity, railHeights, railRowsOf, railWidth, railsOf, readCache, renderMarkdown, reposInSections, resolveRepoPath, runHere, runInPane, runInPaneHorizontal, sameCiStatusState, sectionShedOf, signatureOf, stripGlyph, stripLayout, stripSignature, toCiStatusState, trailingColumnsOf, truncate, useActionMenu, useChrome, windowCount, withHeaders, withoutItem, workCount, writeCache };
998
+ export { type Action, type ActionGroup, ActionMenu, App, COLS, type ChromeHints, type ChromeSpec, type ChromeState, CiStatusLine, type CiStatusState, type Command, CommentsPanel, type CommentsPanelProps, type DetailContext, type ExplainSection, type ExtensionTarget, HealthPanel, type HealthPanelProps, type InboxBudget, type InboxExtension, type JiraAvailableTransition, type JiraTransition, type JiraTransitionsFor, type LiveLabel, LookupMiss, MIN_SIDEBAR_COLS, type MenuGap, type MenuRow, PrRow, type PrRowProps, type Rails, type RepoFilter, type RowAnnouncement, type RowMotion, SIDEBAR_COLS, type SettledResult, SidePanel, type Sidebar, type SidebarRow, type StatusStrip, StatusStripLine, type StripItem, type StripState, type StripStatusState, type TabForStatus, type TrailingColumns, type TrailingShed, ageLabel, budgetNotice, buildActions, buildCheckoutCmd, clipboard, counts, drillCmd, explainGhAction, explainItem, fitCount, gapsAbove, healthColor, healthDisplay, healthGlyph, healthLegend, isMenuGap, itermRun, jumpToRepo, jumpToRepoPane, matchesFilter, maxViewStart, menuRowsOf, menuTone, moveCursor, numberColumnsOf, openInTab, parsePatterns, railCapacity, railHeights, railRowsOf, railWidth, railsOf, readCache, renderMarkdown, reposInSections, resolveRepoPath, runHere, runInPane, runInPaneHorizontal, sameCiStatusState, sectionShedOf, signatureOf, stripGlyph, stripLayout, stripSignature, toCiStatusState, trailingColumnsOf, truncate, useActionMenu, useChrome, windowCount, withHeaders, withoutItem, workCount, writeCache };
package/dist/index.js CHANGED
@@ -1054,9 +1054,9 @@ var PrRow = ({
1054
1054
  justifyContent: "flex-end",
1055
1055
  flexShrink: 0,
1056
1056
  children: sizeParts ? /* @__PURE__ */ jsxs(Text3, { children: [
1057
- /* @__PURE__ */ jsx(Text3, { color: colors.success, children: sizeParts.added }),
1057
+ /* @__PURE__ */ jsx(Text3, { color: colors.added, children: sizeParts.added }),
1058
1058
  " ",
1059
- /* @__PURE__ */ jsx(Text3, { color: colors.error, children: sizeParts.removed })
1059
+ /* @__PURE__ */ jsx(Text3, { color: colors.removed, children: sizeParts.removed })
1060
1060
  ] }) : null
1061
1061
  }
1062
1062
  ) : null,
@@ -1180,7 +1180,7 @@ var searchBarState = ({
1180
1180
  glyph: searchGlyph(nerd),
1181
1181
  glyphColor: typing ? colors.info : colors.muted,
1182
1182
  caret: typing,
1183
- hints: typing ? "\u2191\u2193 move \xB7 \u21B5 keep \xB7 esc clear" : "filtered \xB7 keys active \xB7 esc clear"
1183
+ hints: typing ? "\u2191\u2193 move \xB7 \u21B5/esc done \xB7 \u2303u clear" : "filtered \xB7 keys active \xB7 esc clear"
1184
1184
  });
1185
1185
  var InboxChromeContext = createContext(null);
1186
1186
  var useChrome = (spec) => {
@@ -1335,20 +1335,12 @@ var lookupRows = (claim, lookup, query, start, openUrl) => {
1335
1335
  if (lookup?.phase === "failed")
1336
1336
  return {
1337
1337
  exclusive: true,
1338
- rows: [
1339
- {
1340
- id: "launcher:retry",
1341
- title: `Couldn't look up ${claim.label}: ${lookup.message}`,
1342
- label: /* @__PURE__ */ jsxs(Text3, { color: colors.error, children: [
1343
- "\u2717 couldn't look up ",
1344
- claim.label,
1345
- " \xB7 ",
1346
- lookup.message
1347
- ] }),
1348
- run: () => start(query, claim),
1349
- keepOpen: true
1350
- }
1351
- ]
1338
+ rows: [],
1339
+ message: {
1340
+ text: `couldn't look up ${claim.label} \xB7 ${lookup.message} \xB7 \u21B5 retry`,
1341
+ tone: "error",
1342
+ onSubmit: () => start(query, claim)
1343
+ }
1352
1344
  };
1353
1345
  const rows = [
1354
1346
  {
@@ -1468,11 +1460,11 @@ var indexRows = (sections) => {
1468
1460
  };
1469
1461
  var rowsOf = (index) => {
1470
1462
  const out = /* @__PURE__ */ new Map();
1471
- for (const [mark, placed] of index) {
1463
+ for (const [mark, placed2] of index) {
1472
1464
  const key = rowKeyOfMark(mark);
1473
1465
  const seen = out.get(key);
1474
- if (seen) seen.add(placed.sectionId);
1475
- else out.set(key, /* @__PURE__ */ new Set([placed.sectionId]));
1466
+ if (seen) seen.add(placed2.sectionId);
1467
+ else out.set(key, /* @__PURE__ */ new Set([placed2.sectionId]));
1476
1468
  }
1477
1469
  return out;
1478
1470
  };
@@ -1627,8 +1619,76 @@ var reconcile = (prev, fresh, now, holdMs = HOLD_MS) => {
1627
1619
  }
1628
1620
  return out;
1629
1621
  };
1622
+
1623
+ // src/inbox/overrides.ts
1624
+ var OVERRIDE_HOLD_MS = 3e4;
1625
+ var withFields = (item, fields) => fields ? { ...item, ...fields } : item;
1626
+ var placed = (item, fields) => {
1627
+ const row = withFields(item, fields);
1628
+ return { ...row, depth: 0, indent: false };
1629
+ };
1630
+ var applyOne = (sections, key, patch) => {
1631
+ const found = sections.flatMap((s) => s.items).find((item) => keyOf(item) === key);
1632
+ if (!found) return sections;
1633
+ if (patch.kind === "mutate")
1634
+ return sections.map((s) => ({
1635
+ ...s,
1636
+ items: s.items.map(
1637
+ (item) => keyOf(item) === key ? withFields(item, patch.fields) : item
1638
+ )
1639
+ }));
1640
+ const without = sections.map((s) => ({
1641
+ ...s,
1642
+ items: s.items.filter((item) => keyOf(item) !== key)
1643
+ }));
1644
+ if (patch.kind === "remove") return without;
1645
+ if (!without.some((s) => s.id === patch.toSection))
1646
+ return sections.map((s) => ({
1647
+ ...s,
1648
+ items: s.items.map(
1649
+ (item) => keyOf(item) === key ? withFields(item, patch.fields) : item
1650
+ )
1651
+ }));
1652
+ return without.map(
1653
+ (s) => s.id === patch.toSection ? { ...s, items: [...s.items, placed(found, patch.fields)] } : s
1654
+ );
1655
+ };
1656
+ var applyOverrides = (sections, overrides) => [...overrides].reduce(
1657
+ (out, [key, override]) => applyOne(out, key, override.patch),
1658
+ sections
1659
+ );
1660
+ var fieldsMatch = (item, fields) => !fields || Object.entries(fields).every(
1661
+ ([name, value]) => item[name] === value
1662
+ );
1663
+ var agrees = (sections, key, patch) => {
1664
+ const at = sections.flatMap(
1665
+ (s) => s.items.filter((item) => keyOf(item) === key).map((item) => ({ s, item }))
1666
+ );
1667
+ if (patch.kind === "remove") return at.length === 0;
1668
+ if (at.length === 0) return false;
1669
+ if (patch.kind === "mutate")
1670
+ return at.every(({ item }) => fieldsMatch(item, patch.fields));
1671
+ return at.some(
1672
+ ({ s, item }) => s.id === patch.toSection && fieldsMatch(item, patch.fields)
1673
+ );
1674
+ };
1675
+ var pruneOverrides = (overrides, fresh, now, holdMs = OVERRIDE_HOLD_MS) => new Map(
1676
+ [...overrides].filter(([key, override]) => {
1677
+ if (override.phase !== "sent") return true;
1678
+ if (now - override.since > holdMs) return false;
1679
+ return !agrees(fresh, key, override.patch);
1680
+ })
1681
+ );
1682
+ var failedOverrides = (overrides) => [...overrides].filter(([, o]) => o.phase === "failed").sort(([, a], [, b]) => (b.failure?.seq ?? 0) - (a.failure?.seq ?? 0));
1683
+ var failureNotice = (failed) => {
1684
+ const newest = failed[0]?.[1];
1685
+ if (!newest) return null;
1686
+ const more = failed.length > 1 ? ` +${failed.length - 1} more` : "";
1687
+ return `\u2717 Couldn't ${newest.action} ${newest.label}: ${newest.failure?.reason ?? "no reason reported"}${more} w restore`;
1688
+ };
1630
1689
  var quietly = $({ quiet: true });
1631
1690
  var isMenuGap = (row) => "gap" in row;
1691
+ var rowLabelOf = (item) => item.kind === "task" ? item.ticket ?? item.key : item.kind === "pr" || item.kind === "issue" ? `#${item.number}` : "";
1632
1692
  var jiraMovesCache = /* @__PURE__ */ new Map();
1633
1693
  var cachedJiraMoves = (ticket, status) => {
1634
1694
  const entry = jiraMovesCache.get(ticket);
@@ -1646,43 +1706,56 @@ var orderJiraMoves = (available, configured) => {
1646
1706
  if (!live) continue;
1647
1707
  known.add(live.name);
1648
1708
  ordered.push(
1649
- resolutions && resolutions.length > 0 ? { label, transition: live.name, resolutions } : { label, transition: live.name }
1709
+ resolutions && resolutions.length > 0 ? { label, transition: live.name, resolutions, to: live.to } : { label, transition: live.name, to: live.to }
1650
1710
  );
1651
1711
  }
1652
- const rest = available.filter((t) => !known.has(t.name)).sort((a, b) => a.name.localeCompare(b.name)).map((t) => ({ label: t.name, transition: t.name }));
1712
+ const rest = available.filter((t) => !known.has(t.name)).sort((a, b) => a.name.localeCompare(b.name)).map((t) => ({ label: t.name, transition: t.name, to: t.to }));
1653
1713
  return [...ordered, ...rest];
1654
1714
  };
1655
- var jiraMoveActions = (ticket, moves, showFlash, onActed) => moves.map(
1656
- ({ label, transition, resolutions }) => resolutions && resolutions.length > 0 ? {
1657
- label,
1658
- hint: "",
1659
- run: () => {
1660
- },
1661
- subActions: resolutions.map((resolution) => ({
1662
- label: resolution,
1715
+ var jiraMoveActions = (ticket, moves, showFlash, onActed, optimistic) => {
1716
+ const execute = (to, request, done, failed) => {
1717
+ const held = to ? optimistic?.(to) : void 0;
1718
+ void request().then(() => {
1719
+ invalidateJiraMoves(ticket);
1720
+ held?.settled();
1721
+ showFlash(done);
1722
+ onActed?.();
1723
+ }).catch((err) => held ? held.failed(err) : showFlash(failed));
1724
+ };
1725
+ return moves.map(
1726
+ ({ label, transition, resolutions, to }) => resolutions && resolutions.length > 0 ? {
1727
+ label,
1728
+ hint: "",
1729
+ run: () => {
1730
+ },
1731
+ subActions: resolutions.map((resolution) => ({
1732
+ label: resolution,
1733
+ hint: "",
1734
+ run: () => {
1735
+ showFlash(`\u22EF ${label} \xB7 ${resolution}\u2026`);
1736
+ execute(
1737
+ to,
1738
+ () => quietly`jira issue move ${ticket} ${transition} --resolution ${resolution}`,
1739
+ `\u2713 ${label} \xB7 ${resolution}`,
1740
+ `\u2717 Move to ${label} failed`
1741
+ );
1742
+ }
1743
+ }))
1744
+ } : {
1745
+ label,
1663
1746
  hint: "",
1664
1747
  run: () => {
1665
- showFlash(`\u22EF ${label} \xB7 ${resolution}\u2026`);
1666
- void quietly`jira issue move ${ticket} ${transition} --resolution ${resolution}`.then(() => {
1667
- invalidateJiraMoves(ticket);
1668
- showFlash(`\u2713 ${label} \xB7 ${resolution}`);
1669
- onActed?.();
1670
- }).catch(() => showFlash(`\u2717 Move to ${label} failed`));
1748
+ showFlash(`\u22EF Moving to ${label}\u2026`);
1749
+ execute(
1750
+ to,
1751
+ () => quietly`jira issue move ${ticket} ${transition}`,
1752
+ `\u2713 Moved to ${label}`,
1753
+ `\u2717 Move to ${label} failed`
1754
+ );
1671
1755
  }
1672
- }))
1673
- } : {
1674
- label,
1675
- hint: "",
1676
- run: () => {
1677
- showFlash(`\u22EF Moving to ${label}\u2026`);
1678
- void quietly`jira issue move ${ticket} ${transition}`.then(() => {
1679
- invalidateJiraMoves(ticket);
1680
- showFlash(`\u2713 Moved to ${label}`);
1681
- onActed?.();
1682
- }).catch(() => showFlash(`\u2717 Move to ${label} failed`));
1683
1756
  }
1684
- }
1685
- );
1757
+ );
1758
+ };
1686
1759
  var jiraLoadingActions = () => [
1687
1760
  { label: "Loading transitions\u2026", hint: "", tone: "dim", run: () => {
1688
1761
  } }
@@ -1696,12 +1769,13 @@ var openJiraMoves = (deps) => {
1696
1769
  open,
1697
1770
  isOpen,
1698
1771
  showFlash,
1699
- onActed
1772
+ onActed,
1773
+ optimistic
1700
1774
  } = deps;
1701
1775
  const openOrdered = (moves) => {
1702
1776
  const ordered = orderJiraMoves(moves, configured);
1703
1777
  open(
1704
- ordered.length > 0 ? jiraMoveActions(ticket, ordered, showFlash, onActed) : [
1778
+ ordered.length > 0 ? jiraMoveActions(ticket, ordered, showFlash, onActed, optimistic) : [
1705
1779
  {
1706
1780
  label: "No transitions available",
1707
1781
  hint: "",
@@ -2194,7 +2268,7 @@ var ROW_MAX_COLS = 140;
2194
2268
  var OVERLAY_BG = "#22222e";
2195
2269
  var SELECTION_BG = "#16161f";
2196
2270
  var selectionBackground = (active, backdropped) => active && !backdropped ? SELECTION_BG : void 0;
2197
- var TICKET_KEY_COLOUR = "#FFCB6B";
2271
+ var TICKET_KEY_COLOUR = colors.ticket;
2198
2272
  var taskKeyStyle = () => ({
2199
2273
  color: TICKET_KEY_COLOUR,
2200
2274
  bold: true
@@ -2216,6 +2290,19 @@ var workCount = (s) => {
2216
2290
  }
2217
2291
  return n;
2218
2292
  };
2293
+ var countedSections = (sections, departing) => {
2294
+ if (!departing?.size) return sections;
2295
+ const sent = new Set(sections.map((s) => s.id));
2296
+ return sections.map((s) => {
2297
+ const items = s.items.filter((item) => {
2298
+ const key = keyOf(item);
2299
+ if (!key || !departing.has(key)) return true;
2300
+ const to = departing.get(key);
2301
+ return to != null && (to === s.id || !sent.has(to));
2302
+ });
2303
+ return items.length === s.items.length ? s : { ...s, items };
2304
+ });
2305
+ };
2219
2306
  var drillCmd = (item) => {
2220
2307
  if (item.kind === "task")
2221
2308
  return item.ticket ? `jira issue view ${item.ticket}` : null;
@@ -2279,6 +2366,7 @@ var openInJira = (jiraBase, key, showFlash) => {
2279
2366
  });
2280
2367
  showFlash(`\u2197 Opened ${key} in Jira`);
2281
2368
  };
2369
+ var REMOVE_ME_AS_REVIEWER = "Remove me as reviewer";
2282
2370
  var buildActions = (item, login, showFlash, jiraBase, jiraKeyRe, jiraTransitions, onRefresh, onRemove, onOpenView, ext) => {
2283
2371
  if (item.kind === "repo-header" || item.kind === "subgroup-header" || item.kind === "show-more" || item.kind === "show-less")
2284
2372
  return [];
@@ -2383,6 +2471,32 @@ var buildActions = (item, login, showFlash, jiraBase, jiraKeyRe, jiraTransitions
2383
2471
  }
2384
2472
  const actions = drillAction ? [drillAction, open, copyUrl] : [open, copyUrl];
2385
2473
  const closing = [];
2474
+ const removeAhead = (opts) => {
2475
+ const row = item;
2476
+ const held = ext?.optimistic?.(row, {
2477
+ patch: { kind: "remove" },
2478
+ action: opts.action
2479
+ });
2480
+ onRemove?.(row);
2481
+ showFlash(opts.taking);
2482
+ void opts.request().then(
2483
+ () => {
2484
+ held?.settled();
2485
+ if (opts.done) showFlash(opts.done);
2486
+ if (held || opts.refreshes) ext?.onActed?.();
2487
+ if (opts.settles) ext?.onSettled?.(row, { ok: true });
2488
+ },
2489
+ (e) => {
2490
+ if (held) held.failed(e);
2491
+ else {
2492
+ showFlash(opts.refused(e));
2493
+ onRefresh?.();
2494
+ }
2495
+ if (opts.settles)
2496
+ ext?.onSettled?.(row, { ok: false, reason: firstLine(e) });
2497
+ }
2498
+ );
2499
+ };
2386
2500
  actions.push({
2387
2501
  label: "Copy repo name",
2388
2502
  hint: "r",
@@ -2405,7 +2519,7 @@ var buildActions = (item, login, showFlash, jiraBase, jiraKeyRe, jiraTransitions
2405
2519
  });
2406
2520
  if (item.kind === "pr" && item.standing === "queued" && login) {
2407
2521
  actions.push({
2408
- label: "Remove me as reviewer",
2522
+ label: REMOVE_ME_AS_REVIEWER,
2409
2523
  hint: "x",
2410
2524
  // Quiet, beside Unsubscribe: both are "less of this PR for me" — no more
2411
2525
  // review requests, no more notifications. Guarded by the caller's own
@@ -2413,25 +2527,17 @@ var buildActions = (item, login, showFlash, jiraBase, jiraKeyRe, jiraTransitions
2413
2527
  // review request there is no such row, and none is invented here.
2414
2528
  group: "quiet",
2415
2529
  icon: "\uF468",
2416
- run: () => {
2417
- onRemove?.(item);
2418
- showFlash(`\u22EF Removing you from #${item.number}\u2026`);
2419
- void quietly`gh pr edit ${item.number} --repo ${item.repo} --remove-reviewer ${login}`.then(
2420
- () => {
2421
- showFlash(`\u2713 Removed you as reviewer on #${item.number}`);
2422
- ext?.onActed?.();
2423
- ext?.onSettled?.(item, { ok: true });
2424
- },
2425
- (e) => {
2426
- showFlash(`\u2717 #${item.number}: ${explainGhAction(e)}`);
2427
- onRefresh?.();
2428
- ext?.onSettled?.(item, {
2429
- ok: false,
2430
- reason: firstLine(e)
2431
- });
2432
- }
2433
- );
2434
- }
2530
+ // Optimistic, the same shape Close uses. A row that lingers for a round
2531
+ // trip reads as a broken keypress.
2532
+ run: () => removeAhead({
2533
+ action: "remove you from",
2534
+ request: () => quietly`gh pr edit ${item.number} --repo ${item.repo} --remove-reviewer ${login}`,
2535
+ taking: `\u22EF Removing you from #${item.number}\u2026`,
2536
+ done: `\u2713 Removed you as reviewer on #${item.number}`,
2537
+ refused: (e) => `\u2717 #${item.number}: ${explainGhAction(e)}`,
2538
+ settles: true,
2539
+ refreshes: true
2540
+ })
2435
2541
  });
2436
2542
  }
2437
2543
  if (item.kind === "pr" && item.branch) {
@@ -2503,23 +2609,13 @@ var buildActions = (item, login, showFlash, jiraBase, jiraKeyRe, jiraTransitions
2503
2609
  label: `Close #${item.number}`,
2504
2610
  hint: "",
2505
2611
  group: "confirm",
2506
- run: () => {
2507
- onRemove?.(item);
2508
- showFlash(`\u2713 Closed #${item.number}`);
2509
- void quietly`gh issue close ${item.number} --repo ${item.repo}`.then(
2510
- () => {
2511
- ext?.onSettled?.(item, { ok: true });
2512
- },
2513
- (e) => {
2514
- showFlash(`\u2717 Close failed \u2014 restoring #${item.number}`);
2515
- onRefresh?.();
2516
- ext?.onSettled?.(item, {
2517
- ok: false,
2518
- reason: firstLine(e)
2519
- });
2520
- }
2521
- );
2522
- }
2612
+ run: () => removeAhead({
2613
+ action: "close",
2614
+ request: () => quietly`gh issue close ${item.number} --repo ${item.repo}`,
2615
+ taking: `\u2713 Closed #${item.number}`,
2616
+ refused: () => `\u2717 Close failed \u2014 restoring #${item.number}`,
2617
+ settles: true
2618
+ })
2523
2619
  },
2524
2620
  { label: "Cancel", hint: "", group: "confirm", run: () => {
2525
2621
  } }
@@ -2570,23 +2666,13 @@ var buildActions = (item, login, showFlash, jiraBase, jiraKeyRe, jiraTransitions
2570
2666
  label: `Close #${item.number}`,
2571
2667
  hint: "",
2572
2668
  group: "confirm",
2573
- run: () => {
2574
- onRemove?.(item);
2575
- showFlash(`\u2713 Closed #${item.number}`);
2576
- void quietly`gh pr close ${item.number} --repo ${item.repo}`.then(
2577
- () => {
2578
- ext?.onSettled?.(item, { ok: true });
2579
- },
2580
- (e) => {
2581
- showFlash(`\u2717 Close failed \u2014 restoring #${item.number}`);
2582
- onRefresh?.();
2583
- ext?.onSettled?.(item, {
2584
- ok: false,
2585
- reason: firstLine(e)
2586
- });
2587
- }
2588
- );
2589
- }
2669
+ run: () => removeAhead({
2670
+ action: "close",
2671
+ request: () => quietly`gh pr close ${item.number} --repo ${item.repo}`,
2672
+ taking: `\u2713 Closed #${item.number}`,
2673
+ refused: () => `\u2717 Close failed \u2014 restoring #${item.number}`,
2674
+ settles: true
2675
+ })
2590
2676
  },
2591
2677
  { label: "Cancel", hint: "", group: "confirm", run: () => {
2592
2678
  } }
@@ -2607,16 +2693,23 @@ var buildActions = (item, login, showFlash, jiraBase, jiraKeyRe, jiraTransitions
2607
2693
  label: `Close #${item.number} and delete ${item.branch}`,
2608
2694
  hint: "",
2609
2695
  group: "confirm",
2610
- run: () => {
2611
- onRemove?.(item);
2612
- showFlash(`\u2713 Closed #${item.number} and deleted ${item.branch}`);
2613
- void quietly`gh pr close ${item.number} --repo ${item.repo}`.then(
2614
- () => quietly`gh api -X DELETE ${`repos/${item.repo}/git/refs/heads/${item.branch}`}`
2615
- ).catch(() => {
2616
- showFlash(`\u2717 Close + delete failed \u2014 restoring`);
2617
- onRefresh?.();
2618
- });
2619
- }
2696
+ // The patch covers the close and nothing else. A branch that will
2697
+ // not delete leaves a PR that IS closed, so reporting "Couldn't
2698
+ // close #412" and offering `w` would restore a row the server no
2699
+ // longer has; the branch gets its own flash instead.
2700
+ run: () => removeAhead({
2701
+ action: "close",
2702
+ request: async () => {
2703
+ await quietly`gh pr close ${item.number} --repo ${item.repo}`;
2704
+ void quietly`gh api -X DELETE ${`repos/${item.repo}/git/refs/heads/${item.branch}`}`.catch(
2705
+ () => showFlash(
2706
+ `\u2717 Closed #${item.number}, but couldn't delete ${item.branch}`
2707
+ )
2708
+ );
2709
+ },
2710
+ taking: `\u2713 Closed #${item.number} and deleted ${item.branch}`,
2711
+ refused: () => `\u2717 Close + delete failed \u2014 restoring`
2712
+ })
2620
2713
  },
2621
2714
  { label: "Cancel", hint: "", group: "confirm", run: () => {
2622
2715
  } }
@@ -2778,6 +2871,8 @@ var PULSE_IDLE_MS = 6e4;
2778
2871
  var PULSE_SETTLED_FRAME = 3;
2779
2872
  var NO_TRANSIENTS = /* @__PURE__ */ new Map();
2780
2873
  var NO_HOLDS = /* @__PURE__ */ new Map();
2874
+ var PENDING_GLYPH = "\u25CC";
2875
+ var FAILURE_NOTICE_MS = 8e3;
2781
2876
  var TRANSIT_OUT_FRAMES = ["\u25C9", "\u25CE", "\u25CB", "\xB7"];
2782
2877
  var TRANSIT_IN_FRAMES = ["\xB7", "\u25CB", "\u25CE", "\u25C9"];
2783
2878
  var TRANSIT_COLOUR = {
@@ -2806,6 +2901,7 @@ var ItemRow = ({
2806
2901
  merged,
2807
2902
  transient: refreshMark,
2808
2903
  leaving,
2904
+ pending,
2809
2905
  transitAt,
2810
2906
  prefix = "",
2811
2907
  parent,
@@ -2856,7 +2952,7 @@ var ItemRow = ({
2856
2952
  ] });
2857
2953
  if (item.kind === "task") {
2858
2954
  const note = item.note ?? "";
2859
- const transitIcon = !transient ? " " : isDeparture(transient) ? TRANSIT_OUT_FRAMES[rampAt(TRANSIT_OUT_FRAMES.length)] : isArrival(transient) ? TRANSIT_IN_FRAMES[rampAt(TRANSIT_IN_FRAMES.length)] : "\u25C9";
2955
+ const transitIcon = pending ? PENDING_GLYPH : !transient ? " " : isDeparture(transient) ? TRANSIT_OUT_FRAMES[rampAt(TRANSIT_OUT_FRAMES.length)] : isArrival(transient) ? TRANSIT_IN_FRAMES[rampAt(TRANSIT_IN_FRAMES.length)] : "\u25C9";
2860
2956
  const titleMax = Math.max(
2861
2957
  1,
2862
2958
  cols - item.key.length - note.length - (item.pill ? pillWidth(item.pill) + 1 : 0) - (farewellLabel ? pillWidth(farewellLabel) + 2 : 0) - prefix.length - (markerCols > 0 ? markerCols + 1 : 0) - 12
@@ -2865,7 +2961,7 @@ var ItemRow = ({
2865
2961
  /* @__PURE__ */ jsx(Text3, { color: colors.info, children: active ? "\u276F " : " " }),
2866
2962
  /* @__PURE__ */ jsx(Text3, { dimColor: true, children: prefix }),
2867
2963
  /* @__PURE__ */ jsx(Text3, { dimColor: true, children: parent ? "\u252C " : " " }),
2868
- /* @__PURE__ */ jsx(Text3, { bold: true, color: transient ? TRANSIT_COLOUR[transient] : void 0, children: transitIcon + " " }),
2964
+ pending ? /* @__PURE__ */ jsx(Text3, { dimColor: true, children: transitIcon + " " }) : /* @__PURE__ */ jsx(Text3, { bold: true, color: transient ? TRANSIT_COLOUR[transient] : void 0, children: transitIcon + " " }),
2869
2965
  markerCols > 0 ? /* @__PURE__ */ jsx(Text3, { color: item.markerColor, children: (item.marker ?? "").padEnd(markerCols) + " " }) : null,
2870
2966
  /* @__PURE__ */ jsx(Text3, { ...taskKeyStyle(), children: item.key + " " }),
2871
2967
  /* @__PURE__ */ jsx(
@@ -2899,7 +2995,7 @@ var ItemRow = ({
2899
2995
  const icon = merged ? {
2900
2996
  glyph: MERGED_FRAMES[sparkFrame % MERGED_FRAMES.length],
2901
2997
  color: MERGED_COLOUR
2902
- } : transient ? {
2998
+ } : pending ? { glyph: PENDING_GLYPH, color: colors.muted } : transient ? {
2903
2999
  glyph: isDeparture(transient) ? TRANSIT_OUT_FRAMES[rampAt(TRANSIT_OUT_FRAMES.length)] : isArrival(transient) ? TRANSIT_IN_FRAMES[rampAt(TRANSIT_IN_FRAMES.length)] : displayFor(item.health).glyph,
2904
3000
  color: TRANSIT_COLOUR[transient]
2905
3001
  } : void 0;
@@ -2959,17 +3055,18 @@ var useActionMenu = () => {
2959
3055
  };
2960
3056
  const handleKey = (key) => {
2961
3057
  if (!actions) return false;
3058
+ const live = (n) => n >= 0 && n < actions.length && !isMenuGap(actions[n]);
2962
3059
  if (key.upArrow)
2963
3060
  setCursor((c) => {
2964
3061
  let n = c - 1;
2965
3062
  while (n > 0 && isMenuGap(actions[n])) n -= 1;
2966
- return isMenuGap(actions[n]) ? c : n;
3063
+ return live(n) ? n : c;
2967
3064
  });
2968
3065
  if (key.downArrow)
2969
3066
  setCursor((c) => {
2970
3067
  let n = c + 1;
2971
3068
  while (n < actions.length - 1 && isMenuGap(actions[n])) n += 1;
2972
- return isMenuGap(actions[n]) ? c : n;
3069
+ return live(n) ? n : c;
2973
3070
  });
2974
3071
  if (key.return) {
2975
3072
  const row = actions[cursor];
@@ -3159,6 +3256,7 @@ var HelpModal = ({
3159
3256
  ["/", "search"],
3160
3257
  ["f", "filter by repo"],
3161
3258
  ["r", "refresh"],
3259
+ ["w", "restore a change that failed"],
3162
3260
  ...extensionLegend(extensions),
3163
3261
  ["?", "this help"],
3164
3262
  ["q", "quit"]
@@ -3366,7 +3464,13 @@ var BrowseScreen = ({
3366
3464
  transients,
3367
3465
  onTabChange,
3368
3466
  sidebar,
3369
- liveLabel
3467
+ liveLabel,
3468
+ optimisticMove,
3469
+ optimistic,
3470
+ departing,
3471
+ pendingKeys,
3472
+ failure,
3473
+ onRestore
3370
3474
  }) => {
3371
3475
  const { rows } = useWindowSize();
3372
3476
  const applyWork = (secs) => origin ? filterByOrigin(secs, origin.show, origin.match, login) : secs;
@@ -3481,6 +3585,22 @@ var BrowseScreen = ({
3481
3585
  if (flashTimer.current) clearTimeout(flashTimer.current);
3482
3586
  flashTimer.current = setTimeout(() => setFlash(null), 1500);
3483
3587
  };
3588
+ const [dismissedSeq, setDismissedSeq] = useState(null);
3589
+ const noticeUp = !!failure && dismissedSeq !== failure.seq;
3590
+ useEffect(() => {
3591
+ if (!failure) return;
3592
+ const seq = failure.seq;
3593
+ const timer = setTimeout(() => setDismissedSeq(seq), FAILURE_NOTICE_MS);
3594
+ return () => clearTimeout(timer);
3595
+ }, [failure?.seq]);
3596
+ const counted = useMemo(
3597
+ () => countedSections(localSections, departing),
3598
+ [localSections, departing]
3599
+ );
3600
+ const countOf = (id) => {
3601
+ const s = counted.find((c) => c.id === id);
3602
+ return s ? workCount(s) : 0;
3603
+ };
3484
3604
  const markedTabs = useMemo(() => {
3485
3605
  const out = /* @__PURE__ */ new Set();
3486
3606
  if (!transients?.size) return out;
@@ -3506,6 +3626,7 @@ var BrowseScreen = ({
3506
3626
  if (repoPicker) return setRepoPicker(false), true;
3507
3627
  if (railActive) return setRailFocus(false), true;
3508
3628
  if (menu.actions !== null) return menu.close(), true;
3629
+ if (noticeUp && failure) return setDismissedSeq(failure.seq), true;
3509
3630
  if (search != null) return filter.clear(), true;
3510
3631
  if (repoFilter.size > 0) return setRepoFilter(/* @__PURE__ */ new Set()), true;
3511
3632
  return false;
@@ -3611,34 +3732,38 @@ var BrowseScreen = ({
3611
3732
  open: (rows2) => menu.open(rows2),
3612
3733
  isOpen: () => menuActionsRef.current !== null,
3613
3734
  showFlash,
3614
- onActed
3735
+ onActed,
3736
+ optimistic: optimisticMove ? (to) => optimisticMove(task, to) : void 0
3615
3737
  });
3616
3738
  };
3739
+ const actionsFor = (item, flash2) => buildActions(
3740
+ item,
3741
+ login,
3742
+ flash2,
3743
+ jiraBase,
3744
+ jiraKeyRe,
3745
+ jiraTransitions,
3746
+ onRefresh,
3747
+ dismissItem,
3748
+ openDrillView,
3749
+ {
3750
+ extensions,
3751
+ onOpenExt,
3752
+ onActed,
3753
+ onSettled,
3754
+ optimistic,
3755
+ onOpenMove: jiraTransitionsFor ? openMoveFor : void 0
3756
+ }
3757
+ );
3617
3758
  const openMenu = () => {
3618
3759
  if (!activeItem || activeItem.kind === "repo-header" || activeItem.kind === "subgroup-header")
3619
3760
  return;
3620
- const actions = buildActions(
3621
- activeItem,
3622
- login,
3623
- (msg) => {
3761
+ menu.open(
3762
+ actionsFor(activeItem, (msg) => {
3624
3763
  menu.close();
3625
3764
  showFlash(msg);
3626
- },
3627
- jiraBase,
3628
- jiraKeyRe,
3629
- jiraTransitions,
3630
- onRefresh,
3631
- dismissItem,
3632
- openDrillView,
3633
- {
3634
- extensions,
3635
- onOpenExt,
3636
- onActed,
3637
- onSettled,
3638
- onOpenMove: jiraTransitionsFor ? openMoveFor : void 0
3639
- }
3765
+ })
3640
3766
  );
3641
- menu.open(actions);
3642
3767
  };
3643
3768
  useInput((input, key) => {
3644
3769
  const idleFor = Date.now() - lastKeyAt.current;
@@ -3687,6 +3812,10 @@ var BrowseScreen = ({
3687
3812
  return;
3688
3813
  }
3689
3814
  if (searchInput && !key.upArrow && !key.downArrow) return;
3815
+ if (input === "w" && failure && onRestore) {
3816
+ onRestore();
3817
+ return;
3818
+ }
3690
3819
  if (input === "f" && allRepos.length > 0) {
3691
3820
  setRepoCursor(0);
3692
3821
  setRepoPicker(true);
@@ -3926,20 +4055,7 @@ var BrowseScreen = ({
3926
4055
  return;
3927
4056
  }
3928
4057
  if (input === "x" && activeItem.kind === "pr" && activeItem.standing === "queued" && login) {
3929
- dismissItem(activeItem);
3930
- showFlash(`\u22EF Removing you from #${activeItem.number}\u2026`);
3931
- void quietly`gh pr edit ${activeItem.number} --repo ${activeItem.repo} --remove-reviewer ${login}`.then(
3932
- () => {
3933
- showFlash(`\u2713 Removed you as reviewer on #${activeItem.number}`);
3934
- onActed?.();
3935
- onSettled?.(activeItem, { ok: true });
3936
- },
3937
- (e) => {
3938
- showFlash(`\u2717 #${activeItem.number}: ${explainGhAction(e)}`);
3939
- onRefresh?.();
3940
- onSettled?.(activeItem, { ok: false, reason: firstLine(e) });
3941
- }
3942
- );
4058
+ actionsFor(activeItem, showFlash).find((a) => a.label === REMOVE_ME_AS_REVIEWER)?.run();
3943
4059
  return;
3944
4060
  }
3945
4061
  if (input === "b" && activeItem.kind === "pr" && activeItem.branch) {
@@ -4228,7 +4344,7 @@ var BrowseScreen = ({
4228
4344
  InboxHeader,
4229
4345
  {
4230
4346
  brand,
4231
- sections: localSections,
4347
+ sections: counted,
4232
4348
  login,
4233
4349
  scopeLabel: overlayOpen ? chrome?.scope ?? origin?.label : origin?.label,
4234
4350
  budgetLabel: budgetNotice(budget)?.label,
@@ -4260,7 +4376,7 @@ var BrowseScreen = ({
4260
4376
  items: localSections.map((s) => ({
4261
4377
  value: s.id,
4262
4378
  label: s.label,
4263
- count: workCount(s),
4379
+ count: countOf(s.id),
4264
4380
  /*
4265
4381
  * The denominator, and only where it can still be true.
4266
4382
  *
@@ -4334,6 +4450,7 @@ var BrowseScreen = ({
4334
4450
  merged: (item.kind === "pr" || item.kind === "issue") && !!mergedUrls?.includes(item.url),
4335
4451
  leaving: (item.kind === "pr" || item.kind === "issue") && !!leavingUrls?.includes(item.url),
4336
4452
  transient: transientOf(transients, item, section.id),
4453
+ pending: !!pendingKeys?.has(keyOf(item) ?? ""),
4337
4454
  transitAt: "url" in item ? transitSince?.get(item.url) : void 0,
4338
4455
  prefix: treePrefix(section.items, viewStart + i),
4339
4456
  parent: item.kind === "task" && depthOf(section.items[viewStart + i + 1]) > depthOf(item),
@@ -4382,7 +4499,7 @@ var BrowseScreen = ({
4382
4499
  }
4383
4500
  ) : null
4384
4501
  ] }),
4385
- /* @__PURE__ */ jsx(Box, { marginTop: 1, children: flash ? /* @__PURE__ */ jsx(Text3, { color: colors.success, children: flash }) : overlayOpen ? (
4502
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: noticeUp && failure ? /* @__PURE__ */ jsx(Text3, { color: colors.error, children: failure.notice }) : flash ? /* @__PURE__ */ jsx(Text3, { color: colors.success, children: flash }) : overlayOpen ? (
4386
4503
  // The overlay's own verbs, plus the way back. `page="nested"` is what
4387
4504
  // appends `⌫ back` and `q quit`, so a body passes only its own keys
4388
4505
  // through `useChrome` — the same contract ink-ui's `Page` offers its
@@ -4431,6 +4548,7 @@ var App = ({
4431
4548
  jiraSetupHint,
4432
4549
  jiraTransitions,
4433
4550
  jiraTransitionsFor,
4551
+ tabForStatus,
4434
4552
  hasCiStatus,
4435
4553
  ciJob,
4436
4554
  ciFetcher,
@@ -4506,12 +4624,14 @@ var App = ({
4506
4624
  const transitTabs = useRef(/* @__PURE__ */ new Map());
4507
4625
  const [heldSince, setHeldSince] = useState(NO_HOLDS);
4508
4626
  const [visibleTab, setVisibleTab] = useState("");
4509
- const showData = (sections, login) => {
4627
+ const showData = (sections, login, fetched = true) => {
4510
4628
  const before = displayedSections.current;
4511
4629
  displayedSections.current = sections;
4512
4630
  displayedKey.current = signatureOf(sections);
4513
- setPending(null);
4514
- setFetchedAt(Date.now());
4631
+ if (fetched) {
4632
+ setPending(null);
4633
+ setFetchedAt(Date.now());
4634
+ }
4515
4635
  if (before.length === 0) {
4516
4636
  transitTabs.current = /* @__PURE__ */ new Map();
4517
4637
  setTransients(NO_TRANSIENTS);
@@ -4600,7 +4720,7 @@ var App = ({
4600
4720
  if (new Date(known.resetAt).getTime() <= Date.now()) return true;
4601
4721
  return known.remaining >= Math.max(known.cost, 1) * 2;
4602
4722
  };
4603
- const revalidate = (manual = false) => {
4723
+ const revalidate = (manual = false, apply = false) => {
4604
4724
  const gated = !manual && !!displayedKey.current;
4605
4725
  if (gated && !canAffordAuto()) {
4606
4726
  setSkippedForBudget(true);
@@ -4624,7 +4744,7 @@ var App = ({
4624
4744
  setFetchedAt(Date.now());
4625
4745
  setRefreshError(null);
4626
4746
  if (hasCiStatus) applyCiStatus(fresh.ciStatus ?? null);
4627
- receive(fresh);
4747
+ receive(fresh, apply);
4628
4748
  }).catch((err) => {
4629
4749
  setRefreshing(false);
4630
4750
  const message = explainGhAction(err);
@@ -4635,8 +4755,22 @@ var App = ({
4635
4755
  setRefreshError({ message, at: Date.now() });
4636
4756
  });
4637
4757
  };
4638
- const receive = (fresh) => {
4758
+ const receive = (truth, apply = false) => {
4759
+ overrides.current = pruneOverrides(
4760
+ overrides.current,
4761
+ truth.sections,
4762
+ Date.now()
4763
+ );
4764
+ setOverrideRev((n) => n + 1);
4765
+ const fresh = {
4766
+ ...truth,
4767
+ sections: applyOverrides(truth.sections, overrides.current)
4768
+ };
4639
4769
  const freshKey = signatureOf(fresh.sections);
4770
+ if (apply && displayedKey.current) {
4771
+ showData(fresh.sections, fresh.login);
4772
+ return;
4773
+ }
4640
4774
  if (!displayedKey.current) {
4641
4775
  if (fresh.sections.length === 0) {
4642
4776
  setState({ phase: "empty" });
@@ -4656,6 +4790,94 @@ var App = ({
4656
4790
  if (pending) showData(pending.sections, pending.login);
4657
4791
  else revalidate(true);
4658
4792
  };
4793
+ const overrides = useRef(/* @__PURE__ */ new Map());
4794
+ const [overrideRev, setOverrideRev] = useState(0);
4795
+ const failureSeq = useRef(0);
4796
+ const pendingKeys = useMemo(
4797
+ () => new Set(
4798
+ [...overrides.current].filter(([, o]) => o.phase === "inflight").map(([key]) => key)
4799
+ ),
4800
+ // eslint-disable-next-line react-hooks/exhaustive-deps
4801
+ [overrideRev]
4802
+ );
4803
+ const failure = useMemo(() => {
4804
+ const failed = failedOverrides(overrides.current);
4805
+ const notice = failureNotice(failed);
4806
+ const seq = failed[0]?.[1].failure?.seq;
4807
+ return notice && seq != null ? { notice, seq } : void 0;
4808
+ }, [overrideRev]);
4809
+ const hold = (key, override) => {
4810
+ overrides.current = new Map(overrides.current).set(key, override);
4811
+ setOverrideRev((n) => n + 1);
4812
+ const one = /* @__PURE__ */ new Map([[key, override]]);
4813
+ if (state.phase === "browse")
4814
+ showData(
4815
+ applyOverrides(displayedSections.current, one),
4816
+ state.login,
4817
+ false
4818
+ );
4819
+ setPending(
4820
+ (p) => p ? { ...p, sections: applyOverrides(p.sections, one) } : p
4821
+ );
4822
+ const answer = (next) => {
4823
+ if (overrides.current.get(key) !== override) return;
4824
+ overrides.current = new Map(overrides.current).set(key, {
4825
+ ...override,
4826
+ ...next
4827
+ });
4828
+ setOverrideRev((n) => n + 1);
4829
+ };
4830
+ return {
4831
+ // Answered yes: only now does the hold clock matter — see OVERRIDE_HOLD_MS.
4832
+ settled: () => answer({ phase: "sent", since: Date.now() }),
4833
+ failed: (err) => answer({
4834
+ phase: "failed",
4835
+ failure: { reason: explainGhAction(err), seq: ++failureSeq.current }
4836
+ })
4837
+ };
4838
+ };
4839
+ const optimisticMove = tabForStatus ? (task, to) => {
4840
+ const key = keyOf(task);
4841
+ const toSection = tabForStatus(to);
4842
+ if (!key || !toSection) return void 0;
4843
+ return hold(key, {
4844
+ patch: { kind: "move", toSection, fields: { status: to.name } },
4845
+ action: "move",
4846
+ label: task.ticket ?? task.key,
4847
+ since: Date.now(),
4848
+ phase: "inflight"
4849
+ });
4850
+ } : void 0;
4851
+ const optimistic = (item, change) => {
4852
+ const key = keyOf(item);
4853
+ if (!key) return void 0;
4854
+ return hold(key, {
4855
+ patch: change.patch,
4856
+ action: change.action,
4857
+ label: rowLabelOf(item),
4858
+ since: Date.now(),
4859
+ phase: "inflight"
4860
+ });
4861
+ };
4862
+ const departing = useMemo(
4863
+ () => new Map(
4864
+ [...overrides.current].flatMap(
4865
+ ([key, { patch }]) => patch.kind === "remove" ? [[key, null]] : patch.kind === "move" ? [[key, patch.toSection]] : []
4866
+ )
4867
+ ),
4868
+ // eslint-disable-next-line react-hooks/exhaustive-deps
4869
+ [overrideRev]
4870
+ );
4871
+ const restore = () => {
4872
+ const newest = failedOverrides(overrides.current)[0];
4873
+ if (!newest) return;
4874
+ const next = new Map(overrides.current);
4875
+ next.delete(newest[0]);
4876
+ overrides.current = next;
4877
+ setOverrideRev((n) => n + 1);
4878
+ if (cacheKey) invalidateCache(cacheKey);
4879
+ revalidate(true, true);
4880
+ };
4659
4881
  const onActed = () => {
4660
4882
  if (cacheKey) invalidateCache(cacheKey);
4661
4883
  setTimeout(() => applyOrRefresh(), 1500);
@@ -4880,6 +5102,12 @@ var App = ({
4880
5102
  jiraSetupHint,
4881
5103
  jiraTransitions,
4882
5104
  jiraTransitionsFor,
5105
+ optimisticMove,
5106
+ optimistic,
5107
+ departing,
5108
+ pendingKeys,
5109
+ failure,
5110
+ onRestore: restore,
4883
5111
  onRefresh: applyOrRefresh,
4884
5112
  onActed,
4885
5113
  onSettled,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kud/gh-ink",
3
- "version": "0.72.0",
3
+ "version": "0.74.0",
4
4
  "description": "Ink components for rendering GitHub PR review comments and health — controlled, presentation-only, built on @kud/ink-ui and fed by @kud/gh.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -44,7 +44,7 @@
44
44
  "access": "public"
45
45
  },
46
46
  "peerDependencies": {
47
- "@kud/ink-ui": ">=0.33.0 <1",
47
+ "@kud/ink-ui": ">=0.38.0 <1",
48
48
  "ink": ">=7",
49
49
  "react": ">=19"
50
50
  },
@@ -54,7 +54,7 @@
54
54
  "zx": "8.8.5"
55
55
  },
56
56
  "devDependencies": {
57
- "@kud/ink-ui": "0.35.0",
57
+ "@kud/ink-ui": "0.38.0",
58
58
  "@types/node": "22.20.1",
59
59
  "@types/react": "19.2.17",
60
60
  "ink": "7.1.0",