@kud/gh-ink 0.72.0 → 0.73.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
@@ -604,10 +604,31 @@ type JiraTransition = {
604
604
  type JiraAvailableTransition = {
605
605
  id: string;
606
606
  name: string;
607
+ /**
608
+ * The status the transition lands on. `category` is Jira's status category
609
+ * (`new`, `indeterminate`, `done`) where the host has it — the one field a
610
+ * `tabForStatus` can map without knowing every workflow's status names.
611
+ */
607
612
  to: {
608
613
  name: string;
614
+ category?: string;
609
615
  };
610
616
  };
617
+ /**
618
+ * Which tab a ticket belongs in once it lands on `to`, as the host lays its
619
+ * tabs out — the section id, or null when the ticket leaves the board or the
620
+ * host cannot say. Only the host knows: one board files by status category,
621
+ * another by a status-to-column table, and the inbox has neither.
622
+ *
623
+ * Supplying it makes a move optimistic: the row goes to that tab the moment
624
+ * the move is chosen instead of at the next fetch. Null keeps the move
625
+ * pessimistic, exactly as it is without the prop — a row guessed off the board
626
+ * that turns out to still be on it is worse than one that waits for the fetch.
627
+ */
628
+ type TabForStatus = (to: {
629
+ name: string;
630
+ category?: string;
631
+ }) => string | null;
611
632
  /**
612
633
  * Resolve the live transitions for a ticket, called lazily when its move
613
634
  * submenu opens — never at board fetch, where one call per visible ticket
@@ -810,7 +831,7 @@ declare const ActionMenu: ({ item, actions, cursor, cols, }: {
810
831
  cols?: number;
811
832
  }) => React.JSX.Element;
812
833
  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, }: {
834
+ 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
835
  fetcher: () => Promise<{
815
836
  sections: Section[];
816
837
  login: string;
@@ -851,6 +872,12 @@ declare const App: ({ fetcher, cacheKey, title, detailFor, origin, jiraBase, jir
851
872
  jiraSetupHint?: string;
852
873
  jiraTransitions?: JiraTransition[];
853
874
  jiraTransitionsFor?: JiraTransitionsFor;
875
+ /**
876
+ * Which tab a moved ticket lands in — see TabForStatus. Supplying it makes
877
+ * moves through `jiraTransitionsFor` optimistic; leaving it out keeps every
878
+ * move waiting on the next fetch, as before.
879
+ */
880
+ tabForStatus?: TabForStatus;
854
881
  hasCiStatus?: boolean;
855
882
  ciJob?: string;
856
883
  ciFetcher?: () => Promise<CiStatus | null>;
@@ -919,4 +946,4 @@ declare const matchesFilter: (repo: string, filter: RepoFilter) => boolean;
919
946
  */
920
947
  declare const parsePatterns: (value: string) => string[];
921
948
 
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 };
949
+ 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
@@ -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) => {
@@ -1468,11 +1468,11 @@ var indexRows = (sections) => {
1468
1468
  };
1469
1469
  var rowsOf = (index) => {
1470
1470
  const out = /* @__PURE__ */ new Map();
1471
- for (const [mark, placed] of index) {
1471
+ for (const [mark, placed2] of index) {
1472
1472
  const key = rowKeyOfMark(mark);
1473
1473
  const seen = out.get(key);
1474
- if (seen) seen.add(placed.sectionId);
1475
- else out.set(key, /* @__PURE__ */ new Set([placed.sectionId]));
1474
+ if (seen) seen.add(placed2.sectionId);
1475
+ else out.set(key, /* @__PURE__ */ new Set([placed2.sectionId]));
1476
1476
  }
1477
1477
  return out;
1478
1478
  };
@@ -1627,6 +1627,73 @@ var reconcile = (prev, fresh, now, holdMs = HOLD_MS) => {
1627
1627
  }
1628
1628
  return out;
1629
1629
  };
1630
+
1631
+ // src/inbox/overrides.ts
1632
+ var OVERRIDE_HOLD_MS = 3e4;
1633
+ var withFields = (item, fields) => fields ? { ...item, ...fields } : item;
1634
+ var placed = (item, fields) => {
1635
+ const row = withFields(item, fields);
1636
+ return { ...row, depth: 0, indent: false };
1637
+ };
1638
+ var applyOne = (sections, key, patch) => {
1639
+ const found = sections.flatMap((s) => s.items).find((item) => keyOf(item) === key);
1640
+ if (!found) return sections;
1641
+ if (patch.kind === "mutate")
1642
+ return sections.map((s) => ({
1643
+ ...s,
1644
+ items: s.items.map(
1645
+ (item) => keyOf(item) === key ? withFields(item, patch.fields) : item
1646
+ )
1647
+ }));
1648
+ const without = sections.map((s) => ({
1649
+ ...s,
1650
+ items: s.items.filter((item) => keyOf(item) !== key)
1651
+ }));
1652
+ if (patch.kind === "remove") return without;
1653
+ if (!without.some((s) => s.id === patch.toSection))
1654
+ return sections.map((s) => ({
1655
+ ...s,
1656
+ items: s.items.map(
1657
+ (item) => keyOf(item) === key ? withFields(item, patch.fields) : item
1658
+ )
1659
+ }));
1660
+ return without.map(
1661
+ (s) => s.id === patch.toSection ? { ...s, items: [...s.items, placed(found, patch.fields)] } : s
1662
+ );
1663
+ };
1664
+ var applyOverrides = (sections, overrides) => [...overrides].reduce(
1665
+ (out, [key, override]) => applyOne(out, key, override.patch),
1666
+ sections
1667
+ );
1668
+ var fieldsMatch = (item, fields) => !fields || Object.entries(fields).every(
1669
+ ([name, value]) => item[name] === value
1670
+ );
1671
+ var agrees = (sections, key, patch) => {
1672
+ const at = sections.flatMap(
1673
+ (s) => s.items.filter((item) => keyOf(item) === key).map((item) => ({ s, item }))
1674
+ );
1675
+ if (patch.kind === "remove") return at.length === 0;
1676
+ if (at.length === 0) return false;
1677
+ if (patch.kind === "mutate")
1678
+ return at.every(({ item }) => fieldsMatch(item, patch.fields));
1679
+ return at.some(
1680
+ ({ s, item }) => s.id === patch.toSection && fieldsMatch(item, patch.fields)
1681
+ );
1682
+ };
1683
+ var pruneOverrides = (overrides, fresh, now, holdMs = OVERRIDE_HOLD_MS) => new Map(
1684
+ [...overrides].filter(([key, override]) => {
1685
+ if (override.phase !== "sent") return true;
1686
+ if (now - override.since > holdMs) return false;
1687
+ return !agrees(fresh, key, override.patch);
1688
+ })
1689
+ );
1690
+ var failedOverrides = (overrides) => [...overrides].filter(([, o]) => o.phase === "failed").sort(([, a], [, b]) => (b.failure?.seq ?? 0) - (a.failure?.seq ?? 0));
1691
+ var failureNotice = (failed) => {
1692
+ const newest = failed[0]?.[1];
1693
+ if (!newest) return null;
1694
+ const more = failed.length > 1 ? ` +${failed.length - 1} more` : "";
1695
+ return `\u2717 Couldn't ${newest.action} ${newest.label}: ${newest.failure?.reason ?? "no reason reported"}${more} w restore`;
1696
+ };
1630
1697
  var quietly = $({ quiet: true });
1631
1698
  var isMenuGap = (row) => "gap" in row;
1632
1699
  var jiraMovesCache = /* @__PURE__ */ new Map();
@@ -1646,43 +1713,56 @@ var orderJiraMoves = (available, configured) => {
1646
1713
  if (!live) continue;
1647
1714
  known.add(live.name);
1648
1715
  ordered.push(
1649
- resolutions && resolutions.length > 0 ? { label, transition: live.name, resolutions } : { label, transition: live.name }
1716
+ resolutions && resolutions.length > 0 ? { label, transition: live.name, resolutions, to: live.to } : { label, transition: live.name, to: live.to }
1650
1717
  );
1651
1718
  }
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 }));
1719
+ 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
1720
  return [...ordered, ...rest];
1654
1721
  };
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,
1722
+ var jiraMoveActions = (ticket, moves, showFlash, onActed, optimistic) => {
1723
+ const execute = (to, request, done, failed) => {
1724
+ const held = to ? optimistic?.(to) : void 0;
1725
+ void request().then(() => {
1726
+ invalidateJiraMoves(ticket);
1727
+ held?.settled();
1728
+ showFlash(done);
1729
+ onActed?.();
1730
+ }).catch((err) => held ? held.failed(err) : showFlash(failed));
1731
+ };
1732
+ return moves.map(
1733
+ ({ label, transition, resolutions, to }) => resolutions && resolutions.length > 0 ? {
1734
+ label,
1663
1735
  hint: "",
1664
1736
  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`));
1737
+ },
1738
+ subActions: resolutions.map((resolution) => ({
1739
+ label: resolution,
1740
+ hint: "",
1741
+ run: () => {
1742
+ showFlash(`\u22EF ${label} \xB7 ${resolution}\u2026`);
1743
+ execute(
1744
+ to,
1745
+ () => quietly`jira issue move ${ticket} ${transition} --resolution ${resolution}`,
1746
+ `\u2713 ${label} \xB7 ${resolution}`,
1747
+ `\u2717 Move to ${label} failed`
1748
+ );
1749
+ }
1750
+ }))
1751
+ } : {
1752
+ label,
1753
+ hint: "",
1754
+ run: () => {
1755
+ showFlash(`\u22EF Moving to ${label}\u2026`);
1756
+ execute(
1757
+ to,
1758
+ () => quietly`jira issue move ${ticket} ${transition}`,
1759
+ `\u2713 Moved to ${label}`,
1760
+ `\u2717 Move to ${label} failed`
1761
+ );
1671
1762
  }
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
1763
  }
1684
- }
1685
- );
1764
+ );
1765
+ };
1686
1766
  var jiraLoadingActions = () => [
1687
1767
  { label: "Loading transitions\u2026", hint: "", tone: "dim", run: () => {
1688
1768
  } }
@@ -1696,12 +1776,13 @@ var openJiraMoves = (deps) => {
1696
1776
  open,
1697
1777
  isOpen,
1698
1778
  showFlash,
1699
- onActed
1779
+ onActed,
1780
+ optimistic
1700
1781
  } = deps;
1701
1782
  const openOrdered = (moves) => {
1702
1783
  const ordered = orderJiraMoves(moves, configured);
1703
1784
  open(
1704
- ordered.length > 0 ? jiraMoveActions(ticket, ordered, showFlash, onActed) : [
1785
+ ordered.length > 0 ? jiraMoveActions(ticket, ordered, showFlash, onActed, optimistic) : [
1705
1786
  {
1706
1787
  label: "No transitions available",
1707
1788
  hint: "",
@@ -2194,7 +2275,7 @@ var ROW_MAX_COLS = 140;
2194
2275
  var OVERLAY_BG = "#22222e";
2195
2276
  var SELECTION_BG = "#16161f";
2196
2277
  var selectionBackground = (active, backdropped) => active && !backdropped ? SELECTION_BG : void 0;
2197
- var TICKET_KEY_COLOUR = "#FFCB6B";
2278
+ var TICKET_KEY_COLOUR = colors.ticket;
2198
2279
  var taskKeyStyle = () => ({
2199
2280
  color: TICKET_KEY_COLOUR,
2200
2281
  bold: true
@@ -2778,6 +2859,8 @@ var PULSE_IDLE_MS = 6e4;
2778
2859
  var PULSE_SETTLED_FRAME = 3;
2779
2860
  var NO_TRANSIENTS = /* @__PURE__ */ new Map();
2780
2861
  var NO_HOLDS = /* @__PURE__ */ new Map();
2862
+ var PENDING_GLYPH = "\u25CC";
2863
+ var FAILURE_NOTICE_MS = 8e3;
2781
2864
  var TRANSIT_OUT_FRAMES = ["\u25C9", "\u25CE", "\u25CB", "\xB7"];
2782
2865
  var TRANSIT_IN_FRAMES = ["\xB7", "\u25CB", "\u25CE", "\u25C9"];
2783
2866
  var TRANSIT_COLOUR = {
@@ -2806,6 +2889,7 @@ var ItemRow = ({
2806
2889
  merged,
2807
2890
  transient: refreshMark,
2808
2891
  leaving,
2892
+ pending,
2809
2893
  transitAt,
2810
2894
  prefix = "",
2811
2895
  parent,
@@ -2856,7 +2940,7 @@ var ItemRow = ({
2856
2940
  ] });
2857
2941
  if (item.kind === "task") {
2858
2942
  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";
2943
+ 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
2944
  const titleMax = Math.max(
2861
2945
  1,
2862
2946
  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 +2949,7 @@ var ItemRow = ({
2865
2949
  /* @__PURE__ */ jsx(Text3, { color: colors.info, children: active ? "\u276F " : " " }),
2866
2950
  /* @__PURE__ */ jsx(Text3, { dimColor: true, children: prefix }),
2867
2951
  /* @__PURE__ */ jsx(Text3, { dimColor: true, children: parent ? "\u252C " : " " }),
2868
- /* @__PURE__ */ jsx(Text3, { bold: true, color: transient ? TRANSIT_COLOUR[transient] : void 0, children: transitIcon + " " }),
2952
+ pending ? /* @__PURE__ */ jsx(Text3, { dimColor: true, children: transitIcon + " " }) : /* @__PURE__ */ jsx(Text3, { bold: true, color: transient ? TRANSIT_COLOUR[transient] : void 0, children: transitIcon + " " }),
2869
2953
  markerCols > 0 ? /* @__PURE__ */ jsx(Text3, { color: item.markerColor, children: (item.marker ?? "").padEnd(markerCols) + " " }) : null,
2870
2954
  /* @__PURE__ */ jsx(Text3, { ...taskKeyStyle(), children: item.key + " " }),
2871
2955
  /* @__PURE__ */ jsx(
@@ -3159,6 +3243,7 @@ var HelpModal = ({
3159
3243
  ["/", "search"],
3160
3244
  ["f", "filter by repo"],
3161
3245
  ["r", "refresh"],
3246
+ ["w", "restore a change that failed"],
3162
3247
  ...extensionLegend(extensions),
3163
3248
  ["?", "this help"],
3164
3249
  ["q", "quit"]
@@ -3366,7 +3451,11 @@ var BrowseScreen = ({
3366
3451
  transients,
3367
3452
  onTabChange,
3368
3453
  sidebar,
3369
- liveLabel
3454
+ liveLabel,
3455
+ optimisticMove,
3456
+ pendingKeys,
3457
+ failure,
3458
+ onRestore
3370
3459
  }) => {
3371
3460
  const { rows } = useWindowSize();
3372
3461
  const applyWork = (secs) => origin ? filterByOrigin(secs, origin.show, origin.match, login) : secs;
@@ -3481,6 +3570,14 @@ var BrowseScreen = ({
3481
3570
  if (flashTimer.current) clearTimeout(flashTimer.current);
3482
3571
  flashTimer.current = setTimeout(() => setFlash(null), 1500);
3483
3572
  };
3573
+ const [dismissedSeq, setDismissedSeq] = useState(null);
3574
+ const noticeUp = !!failure && dismissedSeq !== failure.seq;
3575
+ useEffect(() => {
3576
+ if (!failure) return;
3577
+ const seq = failure.seq;
3578
+ const timer = setTimeout(() => setDismissedSeq(seq), FAILURE_NOTICE_MS);
3579
+ return () => clearTimeout(timer);
3580
+ }, [failure?.seq]);
3484
3581
  const markedTabs = useMemo(() => {
3485
3582
  const out = /* @__PURE__ */ new Set();
3486
3583
  if (!transients?.size) return out;
@@ -3506,6 +3603,7 @@ var BrowseScreen = ({
3506
3603
  if (repoPicker) return setRepoPicker(false), true;
3507
3604
  if (railActive) return setRailFocus(false), true;
3508
3605
  if (menu.actions !== null) return menu.close(), true;
3606
+ if (noticeUp && failure) return setDismissedSeq(failure.seq), true;
3509
3607
  if (search != null) return filter.clear(), true;
3510
3608
  if (repoFilter.size > 0) return setRepoFilter(/* @__PURE__ */ new Set()), true;
3511
3609
  return false;
@@ -3611,7 +3709,8 @@ var BrowseScreen = ({
3611
3709
  open: (rows2) => menu.open(rows2),
3612
3710
  isOpen: () => menuActionsRef.current !== null,
3613
3711
  showFlash,
3614
- onActed
3712
+ onActed,
3713
+ optimistic: optimisticMove ? (to) => optimisticMove(task, to) : void 0
3615
3714
  });
3616
3715
  };
3617
3716
  const openMenu = () => {
@@ -3687,6 +3786,10 @@ var BrowseScreen = ({
3687
3786
  return;
3688
3787
  }
3689
3788
  if (searchInput && !key.upArrow && !key.downArrow) return;
3789
+ if (input === "w" && failure && onRestore) {
3790
+ onRestore();
3791
+ return;
3792
+ }
3690
3793
  if (input === "f" && allRepos.length > 0) {
3691
3794
  setRepoCursor(0);
3692
3795
  setRepoPicker(true);
@@ -4334,6 +4437,7 @@ var BrowseScreen = ({
4334
4437
  merged: (item.kind === "pr" || item.kind === "issue") && !!mergedUrls?.includes(item.url),
4335
4438
  leaving: (item.kind === "pr" || item.kind === "issue") && !!leavingUrls?.includes(item.url),
4336
4439
  transient: transientOf(transients, item, section.id),
4440
+ pending: !!pendingKeys?.has(keyOf(item) ?? ""),
4337
4441
  transitAt: "url" in item ? transitSince?.get(item.url) : void 0,
4338
4442
  prefix: treePrefix(section.items, viewStart + i),
4339
4443
  parent: item.kind === "task" && depthOf(section.items[viewStart + i + 1]) > depthOf(item),
@@ -4382,7 +4486,7 @@ var BrowseScreen = ({
4382
4486
  }
4383
4487
  ) : null
4384
4488
  ] }),
4385
- /* @__PURE__ */ jsx(Box, { marginTop: 1, children: flash ? /* @__PURE__ */ jsx(Text3, { color: colors.success, children: flash }) : overlayOpen ? (
4489
+ /* @__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
4490
  // The overlay's own verbs, plus the way back. `page="nested"` is what
4387
4491
  // appends `⌫ back` and `q quit`, so a body passes only its own keys
4388
4492
  // through `useChrome` — the same contract ink-ui's `Page` offers its
@@ -4431,6 +4535,7 @@ var App = ({
4431
4535
  jiraSetupHint,
4432
4536
  jiraTransitions,
4433
4537
  jiraTransitionsFor,
4538
+ tabForStatus,
4434
4539
  hasCiStatus,
4435
4540
  ciJob,
4436
4541
  ciFetcher,
@@ -4506,12 +4611,14 @@ var App = ({
4506
4611
  const transitTabs = useRef(/* @__PURE__ */ new Map());
4507
4612
  const [heldSince, setHeldSince] = useState(NO_HOLDS);
4508
4613
  const [visibleTab, setVisibleTab] = useState("");
4509
- const showData = (sections, login) => {
4614
+ const showData = (sections, login, fetched = true) => {
4510
4615
  const before = displayedSections.current;
4511
4616
  displayedSections.current = sections;
4512
4617
  displayedKey.current = signatureOf(sections);
4513
- setPending(null);
4514
- setFetchedAt(Date.now());
4618
+ if (fetched) {
4619
+ setPending(null);
4620
+ setFetchedAt(Date.now());
4621
+ }
4515
4622
  if (before.length === 0) {
4516
4623
  transitTabs.current = /* @__PURE__ */ new Map();
4517
4624
  setTransients(NO_TRANSIENTS);
@@ -4600,7 +4707,7 @@ var App = ({
4600
4707
  if (new Date(known.resetAt).getTime() <= Date.now()) return true;
4601
4708
  return known.remaining >= Math.max(known.cost, 1) * 2;
4602
4709
  };
4603
- const revalidate = (manual = false) => {
4710
+ const revalidate = (manual = false, apply = false) => {
4604
4711
  const gated = !manual && !!displayedKey.current;
4605
4712
  if (gated && !canAffordAuto()) {
4606
4713
  setSkippedForBudget(true);
@@ -4624,7 +4731,7 @@ var App = ({
4624
4731
  setFetchedAt(Date.now());
4625
4732
  setRefreshError(null);
4626
4733
  if (hasCiStatus) applyCiStatus(fresh.ciStatus ?? null);
4627
- receive(fresh);
4734
+ receive(fresh, apply);
4628
4735
  }).catch((err) => {
4629
4736
  setRefreshing(false);
4630
4737
  const message = explainGhAction(err);
@@ -4635,8 +4742,22 @@ var App = ({
4635
4742
  setRefreshError({ message, at: Date.now() });
4636
4743
  });
4637
4744
  };
4638
- const receive = (fresh) => {
4745
+ const receive = (truth, apply = false) => {
4746
+ overrides.current = pruneOverrides(
4747
+ overrides.current,
4748
+ truth.sections,
4749
+ Date.now()
4750
+ );
4751
+ setOverrideRev((n) => n + 1);
4752
+ const fresh = {
4753
+ ...truth,
4754
+ sections: applyOverrides(truth.sections, overrides.current)
4755
+ };
4639
4756
  const freshKey = signatureOf(fresh.sections);
4757
+ if (apply && displayedKey.current) {
4758
+ showData(fresh.sections, fresh.login);
4759
+ return;
4760
+ }
4640
4761
  if (!displayedKey.current) {
4641
4762
  if (fresh.sections.length === 0) {
4642
4763
  setState({ phase: "empty" });
@@ -4656,6 +4777,74 @@ var App = ({
4656
4777
  if (pending) showData(pending.sections, pending.login);
4657
4778
  else revalidate(true);
4658
4779
  };
4780
+ const overrides = useRef(/* @__PURE__ */ new Map());
4781
+ const [overrideRev, setOverrideRev] = useState(0);
4782
+ const failureSeq = useRef(0);
4783
+ const pendingKeys = useMemo(
4784
+ () => new Set(
4785
+ [...overrides.current].filter(([, o]) => o.phase === "inflight").map(([key]) => key)
4786
+ ),
4787
+ // eslint-disable-next-line react-hooks/exhaustive-deps
4788
+ [overrideRev]
4789
+ );
4790
+ const failure = useMemo(() => {
4791
+ const failed = failedOverrides(overrides.current);
4792
+ const notice = failureNotice(failed);
4793
+ const seq = failed[0]?.[1].failure?.seq;
4794
+ return notice && seq != null ? { notice, seq } : void 0;
4795
+ }, [overrideRev]);
4796
+ const hold = (key, override) => {
4797
+ overrides.current = new Map(overrides.current).set(key, override);
4798
+ setOverrideRev((n) => n + 1);
4799
+ const one = /* @__PURE__ */ new Map([[key, override]]);
4800
+ if (state.phase === "browse")
4801
+ showData(
4802
+ applyOverrides(displayedSections.current, one),
4803
+ state.login,
4804
+ false
4805
+ );
4806
+ setPending(
4807
+ (p) => p ? { ...p, sections: applyOverrides(p.sections, one) } : p
4808
+ );
4809
+ const answer = (next) => {
4810
+ if (overrides.current.get(key) !== override) return;
4811
+ overrides.current = new Map(overrides.current).set(key, {
4812
+ ...override,
4813
+ ...next
4814
+ });
4815
+ setOverrideRev((n) => n + 1);
4816
+ };
4817
+ return {
4818
+ // Answered yes: only now does the hold clock matter — see OVERRIDE_HOLD_MS.
4819
+ settled: () => answer({ phase: "sent", since: Date.now() }),
4820
+ failed: (err) => answer({
4821
+ phase: "failed",
4822
+ failure: { reason: explainGhAction(err), seq: ++failureSeq.current }
4823
+ })
4824
+ };
4825
+ };
4826
+ const optimisticMove = tabForStatus ? (task, to) => {
4827
+ const key = keyOf(task);
4828
+ const toSection = tabForStatus(to);
4829
+ if (!key || !toSection) return void 0;
4830
+ return hold(key, {
4831
+ patch: { kind: "move", toSection, fields: { status: to.name } },
4832
+ action: "move",
4833
+ label: task.ticket ?? task.key,
4834
+ since: Date.now(),
4835
+ phase: "inflight"
4836
+ });
4837
+ } : void 0;
4838
+ const restore = () => {
4839
+ const newest = failedOverrides(overrides.current)[0];
4840
+ if (!newest) return;
4841
+ const next = new Map(overrides.current);
4842
+ next.delete(newest[0]);
4843
+ overrides.current = next;
4844
+ setOverrideRev((n) => n + 1);
4845
+ if (cacheKey) invalidateCache(cacheKey);
4846
+ revalidate(true, true);
4847
+ };
4659
4848
  const onActed = () => {
4660
4849
  if (cacheKey) invalidateCache(cacheKey);
4661
4850
  setTimeout(() => applyOrRefresh(), 1500);
@@ -4880,6 +5069,10 @@ var App = ({
4880
5069
  jiraSetupHint,
4881
5070
  jiraTransitions,
4882
5071
  jiraTransitionsFor,
5072
+ optimisticMove,
5073
+ pendingKeys,
5074
+ failure,
5075
+ onRestore: restore,
4883
5076
  onRefresh: applyOrRefresh,
4884
5077
  onActed,
4885
5078
  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.73.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.36.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.36.0",
58
58
  "@types/node": "22.20.1",
59
59
  "@types/react": "19.2.17",
60
60
  "ink": "7.1.0",