Skip to content

Cockpit on jira-ink's board with gh-ink PR rows: bottom-up placement, behind, ownership fences, hybrid transitions #113

Description

@kud

Project: kud/gh (@kud/gh-ink) — with steps in kud/jira-ink, ambre's cockpit host, and kud/jira-cli. Order is the user's: jira first, then gh, then mix them in ambre's cockpit. gh-cockpit (GitHub only) stays on gh-ink's inbox and is not touched.

Direction (2026-09-14, the user's)

The work-laptop cockpit becomes @kud/jira-ink's IssueBoard as the surface, with each ticket's PRs drawn as child rows by a PrRow exported from @kud/gh-ink, and the drills reusing the detail views that already exist (IssueBody from jira-ink; PrView / DrillView from @kud/gh-cockpit, which ambre already imports). "Improve gh-ink and jira-ink, then use them in cockpit; gh-cockpit is already amazing."

The rules

Placement — bottom-up. The tree never changes: an epic is always over its tasks. A tab decides which tasks and stories show — a task sits in the tab of its own status — and a parent comes along as the head of whatever it has there, selectable, children beneath. An epic's own status never picks a tab: an epic In Development whose tasks are all in Backlog heads them Off board and is absent from In progress; split tasks → the epic heads a group in each tab. An epic of mine with no tasks of mine on the board sits Off board. The epic's own status, when it differs from the tab, is a dim note on its head (In progress), and behind when it lags.

Fences. A fence appears only over tasks whose parent is not on this board — someone else's epic, or nobody's. A group label, never a row: not counted, never a cursor stop, opens nothing. Trailing segment says where the parent is: a tab name when it is on this board in another tab (· Off board, a promise that pressing through finds it), the owner when it is not (· @Priya, · unassigned) — @ is the house glyph for scope, as in the title bar's @you. On a mine board a tab name means yours and @name means not yours; no fence says both. On a multi-assignee board fences carry the owner as rows do. behind goes on a fence only when the parent is yours; behind and @name never share a fence. If the owner lookup lands after first paint, the segment stays empty until it does. (jira needs one extra key in (…) search for parents it holds no row for; cockpit's foreign-epics pass already knows the assignee.)

Notes and behind. A head whose children are partly elsewhere carries a dim note naming those tabs with counts (2 Off board, 1 In progress · 1 Review); omitted when all are here. behind — bright, a word not a glyph — when a row's own status lags the evidence: an epic Off board with any child started; an epic still open with every child Done; in cockpit only, a task whose PR is ahead of it (open while the task is To do / Off board; merged while it is still In progress). Notes and behind count only rows the board holds, never the epic-children query.

Transitions — hybrid. The row stays put with ⋯ → QA; on the 204 its status / statusId / category are rewritten locally and the tab follows, sub-second; on failure the marker clears and the flash carries Jira's own first line. The board refetch stays as reconciliation. Not optimistic.

Steps, in the user's order

1 · jira — @kud/jira-ink

  • Leaf seam. — 303eee1, tree grammar per Iris (├─/└─ by last-ness, │ stems, 3 cols a level, no ┬).
    blocksFor(rows, under?) emits { kind: "leaf"; parent; depth: 1 | 2; data: L } for an opaque L; the board's cursor walks issues ∪ leaves so ↵ opens a leaf. IssueBoardProps.leaves?: { under(row) → L[]; keyOf(l); render(l, { active, width }); onOpen(l) }. jira-ink draws the tree cell for every row, leaf included, so the tree is one drawing; depth becomes 0 | 1 | 2. jira-ink stays GitHub-free. The depth-2 connector glyph is the designer's.
  • Notes, behind, fences with tab name / @owner per the rules above; BoardRow.behind?: string host-filled and meaning-named; IssueBoard draws them. jira-cli gains the one key in (…) search for missing parents. — 1d1b094. Placement is bottom-up: placementOf / rowsInTab / headNote, and countsFor counts by it. The head's own-status note is prefixed own (Iris) so it cannot be read as a child count. The search is parentsOf in jira-ink, called by both hosts.
  • Transitions. transitionsOf carries the full JiraStatus (id + name + category — @kud/jira already returns it). BoardRow.pending?: string; pendingRow / transitionRow / settleRow on BoardModel, tested beside board.test.ts; IssueBoard draws ⋯ → <tab>. — 1d1b094.
  • subtreeOf(blocks, i) — pure, for C / O URL walking in the host. — in 303eee1.
  • priorityMarker(name) → { marker, color } beside priorityGlyph, used by issue-board.tsx. — shipped in 0.7.0.
  • Release. — 0.9.0 (the above) and 0.10.0 (onCursor, added because the board owned its cursor and no host could learn which row it was on — the move menu and C/O both need it).

2 · gh — @kud/gh-ink

  • Export PrRow (src/components/pr-row.tsx, props { item: GHItem; active; login?; cols; icon? }) lifted from ItemRow's PR branch; transient/merged/leaving frames stay in the inbox; useBackdropped to its own module so both read it. Standalone render test. Release. — ad28295, @kud/gh-ink 0.61.0 (gh-cockpit 0.8.0 alongside). The seam is three declarative props — icon, motion, announcements — so every ramp and sparkle stays in the inbox. truncate and the backdrop context moved to their own modules. It takes cols; the leaf seam hands it width, which is the columns left after the cursor and tree cells.
  • q → Ink's exit() — already shipped. inbox.tsx:4116 and 4761 both call Ink's exit(), rationale at 4114. The three remaining process.exit(0) sites are not quit bindings and must stay: two in inbox.tsx (1485, 4386) fire after spawning a detached osascript that writes git switch into iTerm, and ai-panel.tsx:175 after runHere() — a shell command is taking over the terminal. Whether Ink's async unmount can be sequenced ahead of a detached process claiming the tty is a separate, unsolved question, with a failure mode (terminal left in the alternate screen) only a manual pass catches.
  • Adopt useAppKeys at the cockpit root — done, 4d5f45b + 2e307ca. Neither of the two shapes first sketched was right: @kud/ink-ui's AGENTS.md prescribes that a *-ink view binds neither key and the host mounts the hook once with a peel. hidden was left intact (its listen guard sits below a deliberate idle-pulse re-arm, so splitting it would stop an idle return re-arming). Closed a hole where phase: "loading" bound q nowhere, and a live ordering bug where esc cleared the filter and left an inner layer standing. Awaiting a manual pass — ~/__tmp/qa-gh-113-keys.md.

3 · ambre cockpit, slice 1 — a second entry point, not a replacement

  • Mount Page + useAppKeys + IssueBoard: fetchJira → BoardRow[]; fetchInbox → fileTicketPRs → under; rows sorted by mostUrgent so blocksFor's order-preservation gives urgency-ordered groups for free (whose-move headers dropped); ↵ on a ticket → IssueBody, ↵ on a PR → PrView with the app's q stood down while it is mounted (PrView binds its own back); r, q. Transitions via pendingRow / transitionRow / settleRow around the jira issue move --json shell-out, reading transition.to. No menu, rail, Jenkins, mute, CI, cache yet. CONTAINER_ISSUE_TYPES already gone; PILL_VARIANTS → jira-ink's pillVariantFor. — app/src/cockpit-board.tsx, reachable as cockpit --board, beside the old one.
  • ticket-tree-probe.ts probes the sort and under instead of block assembly. — not done; the old probe still tests the old assembly, which is still in use.

4 · ambre cockpit, slice 2 — parity, then delete the old one

Scoped down, deliberately — see Cut for good below.

  • M actions via the exported ActionMenu / buildActions (PR leaves unchanged; tickets a three-item list); C / O over subtreeOf; signalPath + fs.watch + r; Jenkins J, mute (z, not m — m was wrong in this list), ticket T. — T needed no binding: ↵ on a ticket already mounts IssueBody, which is what the ticket extension existed to do. Moves live inside M rather than on their own key.
  • Delete the old cockpit.tsx. Held — see the escalation below. Everything else in slice 2 is done or cut.

Cut for good, not deferred:

  • The rail (SidePanel / railsOf). A full-width tree is the right shape for rows this indent-hungry, not a reduced one — the rail was earning its column on a flat list.
  • readCache / writeCache. Typed around gh-ink's CachedCockpit, which cannot hold BoardRow[]. A local cache for a board nobody has opened yet is machinery against an unmeasured cold start.
  • CiStatusLine. Replaced by one word in Page's own status slot, off the same fetchJenkinsJobBuilds poll — that is what the line was actually for, and without a rail beside it the full line reads as an orphan. J still opens the explorer.

Already dropped for good: transient/merged/leaving ramps, the budget notice.

5 · jira-cli

  • In-frame busy on transition (the detail stays mounted; only the transition key locked; error in-frame). Pin jira-ink. — b2ddc4f. Pinned 0.5.0 → 0.10.0, so it also picks up 0.6–0.8. No refetch after a move at all: the status is written from the transition's own target.

Out

Optimistic moves. A single-issue reload after a transition. A status word on rows that agree with their tab. Which statuses cockpit's stage tabs claim (the To do / Backlog question). Migrating gh-cockpit off the inbox. A plugin seam in jira-ink.

Acceptance

  1. Move an epic to In Development while its stories stay in Backlog: the epic is absent from In progress and heads the two stories Off board with the note In progress. Same in both boards.
  2. Leave an epic in Backlog while a story goes to Code Review: Review shows the epic heading that story with behind · Off board (bright behind); Off board shows it heading its remaining stories with 1 Review.
  3. Cockpit only: a task Off board with an open PR reads behind · PR #… open; the PR is a leaf row under it, ↵ opens PrView.
  4. Transition a row: within a second it reads ⋯ → <tab> then sits in the new tab, before any refetch; a rejected transition leaves it where it was with Jira's message in the flash. In jira, the detail stays on screen throughout.
  5. Notes and behind count only rows the board holds.
  6. A high-priority ticket shows ▲ before its key in both boards, in the same colour; a story pill is the same colour in both — trivially, since it is one component.
  7. Under a story whose epic is assigned to someone else, the fence reads · @<first name> and no tab name; under a story whose epic is yours and in another tab, the fence reads that tab's name and no @.
  8. Slice 1 runs beside the old cockpit; the old one is deleted only when slice 2 reaches the parity table.

Next steps

One, and it needs the work account: run cockpit --board on the work laptop, check it against the parity list, then delete app/src/cockpit.tsx. Nothing else is outstanding.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

decision-neededOnly kud can clear itplanScoped work ready to be picked up by a fresh session

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions