mintree 0.2.1 → 0.2.3

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.
@@ -35,20 +35,6 @@ const MOUSE_ON = "\x1b[?1002h\x1b[?1006h";
35
35
  const MOUSE_OFF = "\x1b[?1006l\x1b[?1002l";
36
36
  const MOUSE_SGR_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
37
37
  const SCROLL_STEP = 3;
38
- function StateIcon({ state }) {
39
- if (!state)
40
- return _jsx(Text, { dimColor: true, children: "\u00B7" });
41
- switch (state) {
42
- case "active":
43
- return _jsx(Text, { color: "green", children: "\u25CF" });
44
- case "waiting":
45
- return _jsx(Text, { color: "yellow", children: "!" });
46
- case "idle":
47
- return _jsx(Text, { dimColor: true, children: "\u25CB" });
48
- case "exited":
49
- return _jsx(Text, { dimColor: true, children: "\u2014" });
50
- }
51
- }
52
38
  function truncate(s, max) {
53
39
  if (max <= 1)
54
40
  return s.slice(0, max);
@@ -112,11 +98,15 @@ function kebabize(title) {
112
98
  * Default prompt seeded into the overlay's Prompt field when the user opens
113
99
  * `w` for an issue. Single-line on purpose — `ink-text-input` is one-line,
114
100
  * so multi-line templates render weirdly when the user tabs in to edit.
115
- * Points the agent at `gh issue view` for the full body rather than dumping
116
- * the body inline (issue bodies can be long and contain markdown that
117
- * doesn't survive argv). User can clear or rewrite freely before Enter.
101
+ * Provider-aware: GitHub issues get the `#<n>` + `gh issue view` form;
102
+ * Plane work items (id like `DSGN-1`) get the bare id + the issue URL,
103
+ * since `gh` can't read Plane and `#` isn't Plane's notation.
118
104
  */
119
- function defaultPromptForIssue(id, title) {
105
+ function defaultPromptForIssue(id, title, url) {
106
+ const isPlane = /^[A-Z][A-Z0-9_]*-\d+$/.test(id);
107
+ if (isPlane) {
108
+ return `Empezá a trabajar el ticket ${id} (${title}). Abrí ${url} para leer el contexto completo y seguí las convenciones del repo.`;
109
+ }
120
110
  return `Empezá a trabajar el issue #${id} (${title}). Usá \`gh issue view ${id}\` para leer el contexto completo y seguí las convenciones del repo.`;
121
111
  }
122
112
  /**
@@ -165,7 +155,7 @@ function useTerminalSize() {
165
155
  function HeaderRow({ repoName, claudeVersion, issueCount, updateAvailable, }) {
166
156
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "green", children: "mintree" }), _jsx(Text, { dimColor: true, children: ` v${mintreeVersion}` }), updateAvailable && _jsx(Text, { color: "yellow", children: " (*)" }), claudeVersion && _jsx(Text, { dimColor: true, children: ` · claude ${claudeVersion}` }), repoName && _jsx(Text, { dimColor: true, children: ` · ${repoName}` })] }), _jsx(Box, { children: _jsx(Text, { bold: true, backgroundColor: "cyan", color: "black", children: ` Issues (${issueCount}) ` }) })] }));
167
157
  }
168
- function FooterRow({ phase, overlayKind, latestVersion, }) {
158
+ function FooterRow({ phase, overlayKind, latestVersion, listWidth, }) {
169
159
  if (phase === "error") {
170
160
  return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "q quit" }) }));
171
161
  }
@@ -175,7 +165,13 @@ function FooterRow({ phase, overlayKind, latestVersion, }) {
175
165
  if (overlayKind === "remove") {
176
166
  return (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "y/Y" }), _jsx(Text, { dimColor: true, children: " confirm " }), _jsx(Text, { bold: true, children: "n/Esc" }), _jsx(Text, { dimColor: true, children: " cancel" })] }));
177
167
  }
178
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: _jsxs(Text, { children: [_jsx(Text, { bold: true, children: "j/k" }), _jsx(Text, { dimColor: true, children: " nav " }), _jsx(Text, { bold: true, children: "\u21B5" }), _jsx(Text, { dimColor: true, children: " work (resume / create) " }), _jsx(Text, { bold: true, children: "w" }), _jsx(Text, { dimColor: true, children: " work (always create) " }), _jsx(Text, { bold: true, children: "d" }), _jsx(Text, { dimColor: true, children: " delete" })] }) }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "r" }), _jsx(Text, { dimColor: true, children: " refresh " }), _jsx(Text, { bold: true, children: "o" }), _jsx(Text, { dimColor: true, children: " open in browser " }), _jsx(Text, { bold: true, children: "PgUp/PgDn" }), _jsx(Text, { dimColor: true, children: "/" }), _jsx(Text, { bold: true, children: "wheel" }), _jsx(Text, { dimColor: true, children: " scroll detail " }), _jsx(Text, { bold: true, children: "q" }), _jsx(Text, { dimColor: true, children: " quit" })] }), latestVersion && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "(*)" }), _jsx(Text, { dimColor: true, children: ` new version available — v${latestVersion} · npm i -g mintree` })] }))] }));
168
+ // Two-column footer like santree: common navigation/dashboard commands
169
+ // align under the left (list) pane; ticket-specific actions align under
170
+ // the right (detail) pane. Falls back to a single inline row when no
171
+ // width hint is available (e.g. the error path).
172
+ const common = (_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "j/k" }), _jsx(Text, { dimColor: true, children: " nav " }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _jsx(Text, { bold: true, children: " PgUp/PgDn" }), _jsx(Text, { dimColor: true, children: " scroll " }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _jsx(Text, { bold: true, children: " r" }), _jsx(Text, { dimColor: true, children: " refresh " }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _jsx(Text, { bold: true, children: " q" }), _jsx(Text, { dimColor: true, children: " quit" })] }));
173
+ const ticket = (_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "\u21B5" }), _jsx(Text, { dimColor: true, children: " Switch " }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _jsx(Text, { bold: true, children: " w" }), _jsx(Text, { dimColor: true, children: " Work " }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _jsx(Text, { bold: true, children: " o" }), _jsx(Text, { dimColor: true, children: " Open " }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _jsx(Text, { bold: true, children: " d" }), _jsx(Text, { dimColor: true, children: " Remove" })] }));
174
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: listWidth, children: common }), _jsx(Box, { flexGrow: 1, children: ticket })] }), latestVersion && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "(*)" }), _jsx(Text, { dimColor: true, children: ` new version available — v${latestVersion} · npm i -g mintree` })] }))] }));
179
175
  }
180
176
  function RemoveOverlayView({ overlay }) {
181
177
  return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Remove worktree" }), _jsx(Text, { dimColor: true, children: ` for ${overlay.issue.issue.id}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Branch: " }), _jsx(Text, { color: "cyan", children: overlay.branch ?? `(detached) ${overlay.worktreePath}` })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "State: " }), overlay.dirty ? (_jsx(Text, { color: "yellow", children: "dirty (uncommitted changes will be lost)" })) : (_jsx(Text, { color: "green", children: "clean" }))] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Text, { dimColor: true, children: "Removing the worktree leaves the branch and the issue's session_id in place. You can re-attach later with `mintree worktree create`." }) }), _jsx(Box, { marginTop: 1, children: overlay.dirty ? (_jsxs(Text, { children: ["This worktree is dirty. Press", " ", _jsx(Text, { bold: true, color: "red", children: "Y" }), " ", "to force-remove, ", _jsx(Text, { bold: true, children: "N" }), "/", _jsx(Text, { bold: true, children: "Esc" }), " to cancel."] })) : (_jsxs(Text, { children: ["Press", " ", _jsx(Text, { bold: true, color: "green", children: "y" }), " ", "to remove, ", _jsx(Text, { bold: true, children: "N" }), "/", _jsx(Text, { bold: true, children: "Esc" }), " to cancel."] })) }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) }))] }));
@@ -192,41 +188,33 @@ function CreateOverlayView({ overlay, onDescChange, onPromptChange, }) {
192
188
  : `${overlay.issue.issue.id}-${detachedDesc}`;
193
189
  return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Create worktree" }), _jsx(Text, { dimColor: true, children: ` for ${overlay.issue.issue.id}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "branchMode", children: overlay.field === "branchMode" ? "▸ Branch:" : " Branch:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "branchMode" ? "cyan" : undefined, bold: overlay.field === "branchMode", children: isNewBranch ? "new" : `current (${overlay.currentBranch ?? "?"})` }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "branchMode" && _jsx(Text, { dimColor: true, children: " (use ← / → to toggle)" })] }), isNewBranch && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "type", children: overlay.field === "type" ? "▸ Type:" : " Type:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "type" ? "cyan" : undefined, bold: overlay.field === "type", children: overlay.type }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "type" && _jsx(Text, { dimColor: true, children: " (use ← / → to cycle)" })] }), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "desc", children: overlay.field === "desc" ? "▸ Description:" : " Description:" }) }), _jsx(Box, { children: overlay.field === "desc" ? (_jsx(TextInput, { value: overlay.desc, onChange: onDescChange, placeholder: "kebab-case" })) : (_jsx(Text, { children: overlay.desc || "(empty)" })) })] })] })), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "prompt", children: overlay.field === "prompt" ? "▸ Prompt:" : " Prompt:" }) }), _jsx(Box, { children: overlay.field === "prompt" ? (_jsx(TextInput, { value: overlay.prompt, onChange: onPromptChange, placeholder: "(empty = no initial message)" })) : (_jsx(Text, { dimColor: true, children: overlay.prompt || "(empty — Claude starts with no message)" })) })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Checkout:" }) }), _jsx(Text, { color: "green", children: branchPreview })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Worktree:" }) }), _jsxs(Text, { dimColor: true, children: [".mintree/worktrees/", dirPreview] })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Mode:" }) }), _jsx(Text, { dimColor: true, children: "--work (Claude launches in the new worktree)" })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [isNewBranch ? (_jsxs(Text, { dimColor: true, children: ["Suggestion is a kebab of the title (capped at ", SUGGESTED_DESC_MAX_WORDS, " words). Edit it to match your repo's branch conventions."] })) : (_jsxs(Text, { dimColor: true, children: ["Detached HEAD at the tip of ", overlay.currentBranch ?? "the current branch", ". No new branch is created \u2014 commit on a new one with `git switch -c` when ready."] })), isNewBranch && overlay.conventionDoc && (_jsx(Text, { dimColor: true, children: `This repo has \`${overlay.conventionDoc}\` — review it before creating.` }))] }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) })), overlay.pending && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", overlay.pending] })] }))] }));
194
190
  }
195
- function stateChar(state) {
196
- if (!state)
197
- return "·";
198
- if (state === "active")
199
- return "●";
200
- if (state === "waiting")
201
- return "!";
202
- if (state === "idle")
203
- return "○";
204
- return "—";
205
- }
206
- function IssueListRow({ d, selected, identifierWidth, maxTitleWidth, }) {
191
+ function IssueListRow({ d, selected, identifierWidth, rowWidth, }) {
207
192
  // Display the issue id raw (e.g. "AUTH-6", "100"). The `#` prefix is a
208
193
  // GitHub convention that reads as noise for Plane's already-prefixed
209
194
  // ids, and dropping it across the board keeps the dashboard provider-
210
195
  // agnostic.
211
196
  const idText = d.issue.id.padEnd(identifierWidth, " ");
212
- const icon = stateChar(d.session?.state ?? null);
213
- const title = truncate(d.issue.title, maxTitleWidth);
214
- // One single Text with a single string so the background highlight is
215
- // continuous across the whole row. Coloured per-state icons live in the
216
- // detail pane instead keeps the list selection visually solid. The two
217
- // leading spaces nest the row under its Status sub-header.
218
- const line = ` ${idText} ${icon} ${title}`;
219
- return (_jsx(Box, { children: _jsx(Text, { backgroundColor: selected ? "blue" : undefined, color: selected ? "white" : undefined, children: line }) }));
197
+ // Status-coloured leading dot — same convention as santree. Falls back to
198
+ // gray when the issue has no project board membership.
199
+ const dotColor = d.project?.statusColor ?? "gray";
200
+ const title = d.issue.title;
201
+ // The leading-dot Text and the rest are nested under a single Text so the
202
+ // selection background paints the whole row in one contiguous block.
203
+ // `wrap="truncate"` clamps the row to a single line and Ink renders an
204
+ // ellipsis at the cut. The outer Box has a fixed width so the wrap
205
+ // behaviour knows where to truncate.
206
+ return (_jsx(Box, { width: rowWidth, children: _jsxs(Text, { wrap: "truncate", backgroundColor: selected ? "blue" : undefined, color: selected ? "white" : undefined, children: [" ", _jsx(Text, { color: selected ? "white" : dotColor, children: "\u25CF" }), ` ${idText} ${title}`] }) }));
220
207
  }
221
208
  // A project board header — the top level of the grouped issue list. Mirrors
222
209
  // the bold project name + dim count seen in the santree dashboard.
223
210
  function ProjectHeaderRow({ title, count, width, }) {
224
211
  return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: truncate(title, Math.max(4, width - 6)) }), _jsx(Text, { dimColor: true, children: ` ${count}` })] }));
225
212
  }
226
- // A Status sub-header within a project group. The bullet and name take the
227
- // colour the board itself assigned to that Status option.
213
+ // A Status sub-header within a project group. Matches santree's look: just
214
+ // the status name in its board colour, no leading bullet the bullets live
215
+ // on the individual issue rows below it.
228
216
  function StatusHeaderRow({ name, color, count, width, }) {
229
- return (_jsxs(Box, { children: [_jsx(Text, { color: color, children: " ●" }), _jsx(Text, { bold: true, color: color, children: ` ${truncate(name, Math.max(4, width - 8))}` }), _jsx(Text, { dimColor: true, children: ` ${count}` })] }));
217
+ return (_jsxs(Box, { children: [_jsx(Text, { color: color, children: ` ${truncate(name, Math.max(4, width - 6))}` }), _jsx(Text, { dimColor: true, children: ` ${count}` })] }));
230
218
  }
231
219
  /**
232
220
  * Walks the already-grouped flat issue array (loadDashboard sorts it by
@@ -366,7 +354,7 @@ function windowListRows(listRows, selectedIndex, viewportRows) {
366
354
  }
367
355
  // Renders a single grouped-list row — used for both the sticky header region
368
356
  // and the scrollable body so the two stay visually identical.
369
- function ListRowView({ row, selectedIndex, identifierWidth, maxTitleWidth, width, }) {
357
+ function ListRowView({ row, selectedIndex, identifierWidth, width, }) {
370
358
  if (row.kind === "spacer")
371
359
  return _jsx(Text, { children: " " });
372
360
  if (row.kind === "project") {
@@ -375,7 +363,7 @@ function ListRowView({ row, selectedIndex, identifierWidth, maxTitleWidth, width
375
363
  if (row.kind === "status") {
376
364
  return _jsx(StatusHeaderRow, { name: row.name, color: row.color, count: row.count, width: width });
377
365
  }
378
- return (_jsx(IssueListRow, { d: row.d, selected: row.index === selectedIndex, identifierWidth: identifierWidth, maxTitleWidth: maxTitleWidth }));
366
+ return (_jsx(IssueListRow, { d: row.d, selected: row.index === selectedIndex, identifierWidth: identifierWidth, rowWidth: width }));
379
367
  }
380
368
  // Word-wraps a single line at `width` columns, breaking on the last space
381
369
  // before the limit when that yields a reasonable cut. Falls back to a hard
@@ -830,7 +818,9 @@ export default function Dashboard() {
830
818
  }
831
819
  if (input === "o") {
832
820
  const issue = state.issues[state.selectedIndex];
833
- if (issue)
821
+ // Orphan rows carry an empty URL — nothing to open. Skip silently
822
+ // rather than asking the OS to open an empty string.
823
+ if (issue && issue.issue.url)
834
824
  openInBrowser(issue.issue.url);
835
825
  return;
836
826
  }
@@ -893,7 +883,7 @@ export default function Dashboard() {
893
883
  currentBranch: root ? getCurrentBranch(root) : null,
894
884
  type: "feat",
895
885
  desc: kebabize(issue.issue.title) || `issue-${issue.issue.id}`,
896
- prompt: defaultPromptForIssue(issue.issue.id, issue.issue.title),
886
+ prompt: defaultPromptForIssue(issue.issue.id, issue.issue.title, issue.issue.url),
897
887
  field: "branchMode",
898
888
  error: null,
899
889
  conventionDoc: root ? findBranchConventionDoc(root) : null,
@@ -1104,15 +1094,13 @@ export default function Dashboard() {
1104
1094
  overlay: { ...state.overlay, prompt: next, error: null },
1105
1095
  });
1106
1096
  };
1107
- // Left pane is the issue list — it only needs room for "#N ICON title".
1108
- // We give it ~40% of the width so the detail pane (URLs, descriptions,
1109
- // branch paths) has the room it actually needs.
1110
- const listWidthPct = 0.4;
1097
+ // Left pane is the issue list — santree gives it ~half the width and the
1098
+ // detail pane still has room for URLs, descriptions and branch paths
1099
+ // because long lines wrap within the pane.
1100
+ const listWidthPct = 0.5;
1111
1101
  const listWidth = Math.max(32, Math.floor(columns * listWidthPct));
1112
1102
  const detailWidth = columns - listWidth - 2; // border slack
1113
1103
  const identifierWidth = Math.max(3, ...issues.map((d) => d.issue.id.length));
1114
- // Lista ocupa todo menos: " #N ICON " (2-space nest indent + id + icon).
1115
- const maxTitleWidth = Math.max(8, listWidth - identifierWidth - 9);
1116
1104
  // Reserve rows: header (2), top borders (1), footer (3).
1117
1105
  const listVisibleRows = Math.max(3, rows - 9);
1118
1106
  // Detail pane content height inside the bordered box. Header eats 2 rows,
@@ -1129,5 +1117,5 @@ export default function Dashboard() {
1129
1117
  const listRows = buildListRows(issues);
1130
1118
  const listView = windowListRows(listRows, selectedIndex, listVisibleRows);
1131
1119
  const listContentWidth = Math.max(8, listWidth - 4);
1132
- return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Box, { paddingX: 1, paddingTop: 0, flexDirection: "column", children: _jsx(HeaderRow, { repoName: repoName, claudeVersion: claudeVersion, issueCount: issues.length, updateAvailable: latestVersion !== null }) }), overlay ? (_jsx(Box, { flexGrow: 1, flexDirection: "column", borderStyle: "round", borderColor: overlay.kind === "remove" ? "yellow" : "cyan", children: overlay.kind === "create" ? (_jsx(CreateOverlayView, { overlay: overlay, onDescChange: onOverlayDescChange, onPromptChange: onOverlayPromptChange })) : (_jsx(RemoveOverlayView, { overlay: overlay })) })) : (_jsxs(Box, { flexGrow: 1, flexDirection: "row", children: [_jsx(Box, { width: listWidth, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: issues.length === 0 ? (_jsx(Text, { dimColor: true, children: "No open issues assigned to you in this repo." })) : (_jsxs(_Fragment, { children: [listView.sticky.map((row, i) => (_jsx(ListRowView, { row: row, selectedIndex: selectedIndex, identifierWidth: identifierWidth, maxTitleWidth: maxTitleWidth, width: listContentWidth }, `sticky-${i}`))), listView.issuesAbove > 0 && (_jsxs(Text, { dimColor: true, children: ["\u2191 ", listView.issuesAbove, " more above"] })), listView.body.map((row, i) => (_jsx(ListRowView, { row: row, selectedIndex: selectedIndex, identifierWidth: identifierWidth, maxTitleWidth: maxTitleWidth, width: listContentWidth }, `body-${i}`))), listView.issuesBelow > 0 && (_jsxs(Text, { dimColor: true, children: ["\u2193 ", listView.issuesBelow, " more below"] }))] })) }), _jsx(Box, { width: detailWidth, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: _jsx(DetailPane, { d: selected, contentWidth: detailWidth - 4, contentHeight: detailContentHeight, scrollOffset: state.detailScrollOffset }) })] })), _jsxs(Box, { paddingX: 1, flexDirection: "column", children: [toast && (_jsx(Box, { children: _jsxs(Text, { color: toast.kind === "success" ? "green" : toast.kind === "error" ? "red" : "cyan", children: [toast.kind === "success" ? "✓ " : toast.kind === "error" ? "✗ " : "· ", toast.text] }) })), refreshing && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " refreshing" })] })), _jsx(FooterRow, { phase: "ready", overlayKind: overlay?.kind, latestVersion: latestVersion })] })] }));
1120
+ return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Box, { paddingX: 1, paddingTop: 0, flexDirection: "column", children: _jsx(HeaderRow, { repoName: repoName, claudeVersion: claudeVersion, issueCount: issues.length, updateAvailable: latestVersion !== null }) }), overlay ? (_jsx(Box, { flexGrow: 1, flexDirection: "column", borderStyle: "round", borderColor: overlay.kind === "remove" ? "yellow" : "cyan", children: overlay.kind === "create" ? (_jsx(CreateOverlayView, { overlay: overlay, onDescChange: onOverlayDescChange, onPromptChange: onOverlayPromptChange })) : (_jsx(RemoveOverlayView, { overlay: overlay })) })) : (_jsxs(Box, { flexGrow: 1, flexDirection: "row", children: [_jsx(Box, { width: listWidth, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: issues.length === 0 ? (_jsx(Text, { dimColor: true, children: "No open issues assigned to you in this repo." })) : (_jsxs(_Fragment, { children: [listView.sticky.map((row, i) => (_jsx(ListRowView, { row: row, selectedIndex: selectedIndex, identifierWidth: identifierWidth, width: listContentWidth }, `sticky-${i}`))), listView.issuesAbove > 0 && (_jsxs(Text, { dimColor: true, children: ["\u2191 ", listView.issuesAbove, " more above"] })), listView.body.map((row, i) => (_jsx(ListRowView, { row: row, selectedIndex: selectedIndex, identifierWidth: identifierWidth, width: listContentWidth }, `body-${i}`))), listView.issuesBelow > 0 && (_jsxs(Text, { dimColor: true, children: ["\u2193 ", listView.issuesBelow, " more below"] }))] })) }), _jsx(Box, { width: detailWidth, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: _jsx(DetailPane, { d: selected, contentWidth: detailWidth - 4, contentHeight: detailContentHeight, scrollOffset: state.detailScrollOffset }) })] })), _jsxs(Box, { paddingX: 1, flexDirection: "column", children: [toast && (_jsx(Box, { children: _jsxs(Text, { color: toast.kind === "success" ? "green" : toast.kind === "error" ? "red" : "cyan", children: [toast.kind === "success" ? "✓ " : toast.kind === "error" ? "✗ " : "· ", toast.text] }) })), refreshing && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " refreshing" })] })), _jsx(FooterRow, { phase: "ready", overlayKind: overlay?.kind, latestVersion: latestVersion, listWidth: listWidth })] })] }));
1133
1121
  }
@@ -22,6 +22,7 @@ export type DashboardIssue = {
22
22
  session: SessionStateInfo | null;
23
23
  pr: PrInfo | null;
24
24
  project: IssueProjectInfo | null;
25
+ orphan?: boolean;
25
26
  };
26
27
  /**
27
28
  * Top-level loader: enriches each assigned issue with its worktree and
@@ -69,19 +69,22 @@ function isSessionState(v) {
69
69
  * its group headers by walking this already-ordered array.
70
70
  *
71
71
  * Project group order: a config-pinned project first, then other projects
72
- * alphabetically, then issues with no project ("Sin proyecto") last.
72
+ * alphabetically, then issues with no project ("Sin proyecto"), then orphan
73
+ * worktrees last.
73
74
  */
74
75
  function sortGroupedIssues(issues, configuredUrl) {
75
- const projectTier = (p) => {
76
- if (!p)
76
+ const projectTier = (d) => {
77
+ if (d.orphan)
78
+ return 3;
79
+ if (!d.project)
77
80
  return 2;
78
- if (configuredUrl && p.projectUrl === configuredUrl)
81
+ if (configuredUrl && d.project.projectUrl === configuredUrl)
79
82
  return 0;
80
83
  return 1;
81
84
  };
82
85
  return [...issues].sort((a, b) => {
83
- const ta = projectTier(a.project);
84
- const tb = projectTier(b.project);
86
+ const ta = projectTier(a);
87
+ const tb = projectTier(b);
85
88
  if (ta !== tb)
86
89
  return ta - tb;
87
90
  if (a.project && b.project) {
@@ -102,6 +105,57 @@ function sortGroupedIssues(issues, configuredUrl) {
102
105
  return b.issue.id.localeCompare(a.issue.id);
103
106
  });
104
107
  }
108
+ /**
109
+ * Synthetic DashboardIssue rows for worktrees on disk whose issueId isn't in
110
+ * the provider's assigned-issues list. These end up grouped under "Orphaned
111
+ * Worktrees" at the bottom of the dashboard so the user can find and `d`elete
112
+ * them.
113
+ *
114
+ * The `issue` stub uses the worktree directory name as the title (the part
115
+ * after the issue id, e.g. "claude-md-inicial") so the row is identifiable
116
+ * even when there's no live issue to fetch a title from.
117
+ */
118
+ function buildOrphanRows(worktreesByIssue, assignedIds, sessionLookup, prByBranch, metadataSessionId) {
119
+ const orphans = [];
120
+ for (const [issueId, w] of worktreesByIssue) {
121
+ if (assignedIds.has(issueId))
122
+ continue;
123
+ const dirName = path.basename(w.path);
124
+ // Strip the leading "<issueId>-" — that leaves the kebab description
125
+ // that originally seeded the branch name.
126
+ const desc = dirName.startsWith(`${issueId}-`)
127
+ ? dirName.slice(issueId.length + 1)
128
+ : dirName;
129
+ const sessionId = metadataSessionId(issueId);
130
+ const worktree = { ...w, sessionId };
131
+ const pr = w.branch ? (prByBranch.get(w.branch) ?? null) : null;
132
+ orphans.push({
133
+ issue: {
134
+ id: issueId,
135
+ title: desc || dirName,
136
+ state: "UNKNOWN",
137
+ url: "",
138
+ labels: [],
139
+ body: "",
140
+ createdAt: "",
141
+ updatedAt: "",
142
+ },
143
+ worktree,
144
+ session: sessionLookup(issueId),
145
+ pr,
146
+ project: {
147
+ projectTitle: "Orphaned Worktrees",
148
+ projectUrl: "",
149
+ projectNumber: 0,
150
+ status: "Orphaned",
151
+ statusColor: "gray",
152
+ statusOrder: 9999,
153
+ },
154
+ orphan: true,
155
+ });
156
+ }
157
+ return orphans;
158
+ }
105
159
  /**
106
160
  * Top-level loader: enriches each assigned issue with its worktree and
107
161
  * session snapshot. Designed to be called on dashboard mount and on every
@@ -152,5 +206,7 @@ export async function loadDashboard(repoRoot) {
152
206
  project: projectByIssue.get(issue.id) ?? null,
153
207
  };
154
208
  });
155
- return sortGroupedIssues(enriched, configuredUrl);
209
+ const assignedIds = new Set(issues.map((i) => i.id));
210
+ const orphans = buildOrphanRows(worktreesByIssue, assignedIds, (id) => readSessionState(repoRoot, id), prByBranch, (id) => metadata.issues[id]?.session_id);
211
+ return sortGroupedIssues([...enriched, ...orphans], configuredUrl);
156
212
  }
@@ -362,7 +362,7 @@ function mapWorkItemToProviderIssue(project, workspaceSlug, wi) {
362
362
  }
363
363
  }
364
364
  const state = normaliseState(wi.state);
365
- const url = `https://app.plane.so/${workspaceSlug}/projects/${project.id}/issues/${wi.id}`;
365
+ const url = `https://app.plane.so/${workspaceSlug}/browse/${project.identifier}-${wi.sequence_id}/`;
366
366
  return {
367
367
  id: toIssueId(project, wi.sequence_id),
368
368
  title: wi.name,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mintree",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Issue-driven git worktrees + Claude Code sessions for repos with an opinionated SDD+TDD flow.",
5
5
  "license": "MIT",
6
6
  "author": "Martin Mineo <mmineo@canarytechnologies.com>",