@phenx-inc/ctlsurf 0.12.0 → 0.13.1

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.
Files changed (32) hide show
  1. package/out/headless/index.mjs +29 -19
  2. package/out/headless/index.mjs.map +2 -2
  3. package/out/main/index.js +35 -19
  4. package/out/renderer/assets/{cssMode-R8fTFLae.js → cssMode-KhYyY-BM.js} +3 -3
  5. package/out/renderer/assets/{freemarker2-CyjWzWoP.js → freemarker2-BFsSgcWk.js} +1 -1
  6. package/out/renderer/assets/{handlebars-CrhCGIRM.js → handlebars-Be6_aBsm.js} +1 -1
  7. package/out/renderer/assets/{html-Cc3dPNZr.js → html-G8mvsyfc.js} +1 -1
  8. package/out/renderer/assets/{htmlMode-BWtG6ywk.js → htmlMode-Cfd5VCjw.js} +3 -3
  9. package/out/renderer/assets/{index-BL9njXIL.css → index-Bi_MjBqs.css} +233 -8
  10. package/out/renderer/assets/{index-kkG56aKv.js → index-MqgBcJzy.js} +392 -120
  11. package/out/renderer/assets/{javascript-C8T_SdLC.js → javascript-XyDWi6dC.js} +2 -2
  12. package/out/renderer/assets/{jsonMode-D1zR4VVU.js → jsonMode-DZT5gBVW.js} +3 -3
  13. package/out/renderer/assets/{liquid-COSMe9Uf.js → liquid-BuajCNJ9.js} +1 -1
  14. package/out/renderer/assets/{lspLanguageFeatures-CXGfrtzl.js → lspLanguageFeatures-BYkg2Yc-.js} +1 -1
  15. package/out/renderer/assets/{mdx-RfS44T-L.js → mdx-D0Qx2GMS.js} +1 -1
  16. package/out/renderer/assets/{python-DFSWKHUu.js → python-ChncNy6S.js} +1 -1
  17. package/out/renderer/assets/{razor-DtH2_uvo.js → razor-C6MUtfaS.js} +1 -1
  18. package/out/renderer/assets/{tsMode-BYIQ-S9Z.js → tsMode-C2aMdzdU.js} +1 -1
  19. package/out/renderer/assets/{typescript-HhQUr1Rb.js → typescript-CB71AtAT.js} +1 -1
  20. package/out/renderer/assets/{xml-KuhgdKfD.js → xml-tHS1Ec9w.js} +1 -1
  21. package/out/renderer/assets/{yaml-W_kzCFH7.js → yaml-CZcp5ueF.js} +1 -1
  22. package/out/renderer/index.html +2 -2
  23. package/package.json +1 -1
  24. package/src/main/index.ts +13 -1
  25. package/src/main/orchestrator.ts +8 -1
  26. package/src/main/ticketStore.ts +26 -20
  27. package/src/renderer/App.tsx +19 -3
  28. package/src/renderer/components/CtlsurfPanel.tsx +169 -35
  29. package/src/renderer/components/SpeakControls.tsx +8 -2
  30. package/src/renderer/components/TerminalPanel.tsx +141 -44
  31. package/src/renderer/components/TicketPanel.tsx +126 -29
  32. package/src/renderer/styles.css +233 -8
@@ -22,29 +22,15 @@ function copySelectionAsEmailTable(terminal: Terminal): void {
22
22
  )
23
23
  }
24
24
 
25
- // Open a URL in the system browser, mirroring WebLinksAddon's safe default
26
- // (clear the opener so the new page can't reach back into this window).
27
- function openExternal(uri: string): void {
28
- const win = window.open()
29
- if (win) {
30
- try { (win as { opener: unknown }).opener = null } catch { /* ignore */ }
31
- win.location.href = uri
32
- }
33
- }
34
-
35
- // Custom handler for links clicked in the terminal. ctlsurf page links are
36
- // routed into the embedded (authenticated) ctlsurf panel via a window event;
37
- // everything else opens in the default browser as before.
25
+ // Custom handler for links clicked in the terminal. Every clicked URL is routed
26
+ // into the embedded ctlsurf browser panel via a window event: ctlsurf links are
27
+ // first resolved to an authenticated embed URL, anything else loads as-is.
38
28
  function handleTerminalLink(_event: MouseEvent, uri: string): void {
29
+ const openInPanel = (url: string) =>
30
+ window.dispatchEvent(new CustomEvent('ctlsurf-open-url', { detail: { url } }))
39
31
  window.worker.getEmbedUrl(uri)
40
- .then((embedUrl) => {
41
- if (embedUrl) {
42
- window.dispatchEvent(new CustomEvent('ctlsurf-open-url', { detail: { url: embedUrl } }))
43
- } else {
44
- openExternal(uri)
45
- }
46
- })
47
- .catch(() => openExternal(uri))
32
+ .then((embedUrl) => openInPanel(embedUrl || uri))
33
+ .catch(() => openInPanel(uri))
48
34
  }
49
35
 
50
36
  interface AgentConfig {
@@ -67,27 +53,72 @@ interface TerminalPanelProps {
67
53
  // Keyed by tabId so each tab has its own xterm + pty listeners.
68
54
  // Survives React re-renders but cleaned up when tab is closed.
69
55
 
70
- const _terminals = new Map<string, {
56
+ type TermState = {
71
57
  terminal: Terminal
72
58
  fitAddon: FitAddon
73
59
  unsubData: (() => void) | null
74
60
  unsubExit: (() => void) | null
75
61
  spawnedAgentId: string | null
62
+ // Follow mode: while true, new output keeps the view pinned to the bottom.
76
63
  pinnedToBottom: boolean
77
- }>()
64
+ // When NOT following, the absolute buffer line held at the top of the
65
+ // viewport — restored after each write so redraws can't drag the view
66
+ // (neither down to the bottom, nor up to the top — the old bug).
67
+ anchorLine: number | null
68
+ // One-shot flag set by a real user scroll gesture (wheel / scroll keys) and
69
+ // consumed by the very next onScroll, so a redraw firing onScroll right after
70
+ // a gesture isn't mistaken for one. Spinner redraws (~10x/s while "thinking")
71
+ // would otherwise be misread as user intent and re-pin to the bottom.
72
+ userScrollPending: boolean
73
+ // Guard so our own programmatic scrolls don't feed back through onScroll.
74
+ holding: boolean
75
+ // Subscribers (the jump-to-latest button) notified when follow mode flips.
76
+ pinListeners: Set<(pinned: boolean) => void>
77
+ }
78
78
 
79
+ const _terminals = new Map<string, TermState>()
80
+
81
+ // Within a couple of lines of the bottom still counts as "at bottom", so a
82
+ // small redraw delta doesn't strand the view just shy of the end.
79
83
  function isAtBottom(terminal: Terminal): boolean {
80
84
  const buf = terminal.buffer.active
81
- return buf.viewportY >= buf.baseY
85
+ return buf.baseY - buf.viewportY <= 2
82
86
  }
83
87
 
84
- function scrollIfPinned(tabId: string, terminal: Terminal) {
85
- const state = _terminals.get(tabId)
86
- if (state?.pinnedToBottom) {
87
- terminal.scrollToBottom()
88
+ function notifyPin(state: TermState): void {
89
+ state.pinListeners.forEach((fn) => fn(state.pinnedToBottom))
90
+ }
91
+
92
+ function setPinned(state: TermState, pinned: boolean): void {
93
+ if (pinned) state.anchorLine = null
94
+ if (state.pinnedToBottom === pinned) return
95
+ state.pinnedToBottom = pinned
96
+ notifyPin(state)
97
+ }
98
+
99
+ // Re-assert the intended scroll position: follow the bottom while pinned,
100
+ // otherwise hold the user's anchored line. Guarded so the resulting onScroll
101
+ // is recognised as ours and ignored. This both keeps output pinned AND stops
102
+ // involuntary redraw jumps (the old "scrolls to the top" bug) from sticking.
103
+ function reassert(state: TermState): void {
104
+ const { terminal } = state
105
+ state.holding = true
106
+ try {
107
+ if (state.pinnedToBottom) {
108
+ terminal.scrollToBottom()
109
+ } else if (state.anchorLine != null && terminal.buffer.active.viewportY !== state.anchorLine) {
110
+ terminal.scrollToLine(state.anchorLine)
111
+ }
112
+ } finally {
113
+ state.holding = false
88
114
  }
89
115
  }
90
116
 
117
+ function markGesture(tabId: string): void {
118
+ const s = _terminals.get(tabId)
119
+ if (s) s.userScrollPending = true
120
+ }
121
+
91
122
  function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): { terminal: Terminal; fitAddon: FitAddon } {
92
123
  const existing = _terminals.get(tabId)
93
124
  if (existing) {
@@ -137,6 +168,10 @@ function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): {
137
168
  // is running, where right-click is forwarded to the program instead.
138
169
  terminal.attachCustomKeyEventHandler((e) => {
139
170
  if (e.type !== 'keydown') return true
171
+ // Buffer-scroll keys count as a user gesture (see onScroll).
172
+ if (e.key === 'PageUp' || e.key === 'PageDown' || e.key === 'Home' || e.key === 'End') {
173
+ markGesture(tabId)
174
+ }
140
175
  const isCombo = (e.metaKey || e.ctrlKey) && e.shiftKey && e.code === 'KeyE'
141
176
  if (!isCombo) return true
142
177
  e.preventDefault()
@@ -144,32 +179,53 @@ function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): {
144
179
  return false // consume — don't forward to the pty
145
180
  })
146
181
 
147
- const state = {
182
+ const state: TermState = {
148
183
  terminal,
149
184
  fitAddon,
150
- unsubData: null as (() => void) | null,
151
- unsubExit: null as (() => void) | null,
152
- spawnedAgentId: null as string | null,
185
+ unsubData: null,
186
+ unsubExit: null,
187
+ spawnedAgentId: null,
153
188
  pinnedToBottom: true,
189
+ anchorLine: null,
190
+ userScrollPending: false,
191
+ holding: false,
192
+ pinListeners: new Set(),
154
193
  }
155
194
 
156
- // Track scroll position
195
+ // Track scroll position. Distinguish a real user scroll (recent wheel/key
196
+ // gesture) from the involuntary viewport jumps TUI redraws cause: trust the
197
+ // former to set follow/anchor; for the latter, re-assert our target so the
198
+ // view neither snaps to the bottom nor jumps to the top.
157
199
  terminal.onScroll(() => {
158
- state.pinnedToBottom = isAtBottom(terminal)
200
+ if (state.holding) return
201
+ if (!state.userScrollPending) {
202
+ // Not tied to a user gesture → an involuntary scroll from a redraw.
203
+ // Hold our target instead of trusting it (this is the spinner case:
204
+ // every screen update would otherwise drag the view back to the bottom).
205
+ reassert(state)
206
+ return
207
+ }
208
+ state.userScrollPending = false
209
+ if (isAtBottom(terminal)) {
210
+ setPinned(state, true)
211
+ } else {
212
+ state.anchorLine = terminal.buffer.active.viewportY
213
+ setPinned(state, false)
214
+ }
159
215
  })
160
216
 
161
- // Send keystrokes to pty
217
+ // Send keystrokes to pty — typing always returns to (and follows) the bottom.
162
218
  terminal.onData((data) => {
163
- state.pinnedToBottom = true
219
+ setPinned(state, true)
164
220
  terminal.scrollToBottom()
165
221
  window.worker.writePty(tabId, data)
166
222
  })
167
223
 
168
- // Receive pty output for this tab
224
+ // Receive pty output for this tab. Re-assert AFTER the write is parsed (the
225
+ // write callback) so we read settled buffer coordinates.
169
226
  state.unsubData = window.worker.onPtyData((eventTabId, data) => {
170
227
  if (eventTabId === tabId) {
171
- terminal.write(data)
172
- scrollIfPinned(tabId, terminal)
228
+ terminal.write(data, () => reassert(state))
173
229
  }
174
230
  })
175
231
 
@@ -206,6 +262,17 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
206
262
  const containerRef = useRef<HTMLDivElement>(null)
207
263
  const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
208
264
  const [toast, setToast] = useState<string | null>(null)
265
+ // Mirrors the terminal's follow-mode; drives the "jump to latest" overlay,
266
+ // which only shows while the user has scrolled up.
267
+ const [followBottom, setFollowBottom] = useState(true)
268
+
269
+ const jumpToLatest = () => {
270
+ const s = _terminals.get(tabId)
271
+ if (!s) return
272
+ setPinned(s, true)
273
+ s.terminal.scrollToBottom()
274
+ s.terminal.focus()
275
+ }
209
276
 
210
277
  // Right-click → offer "Copy as email table", but only when the current
211
278
  // selection actually parses as a table; otherwise fall through to default.
@@ -270,7 +337,13 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
270
337
  }
271
338
 
272
339
  fitAddon.fit()
273
- scrollIfPinned(tabId, terminal)
340
+ const attached = _terminals.get(tabId)
341
+ if (attached) reassert(attached)
342
+
343
+ // Wheel over the terminal is an explicit user scroll — stamp it so onScroll
344
+ // treats the resulting move as intentional (not an involuntary redraw jump).
345
+ const onWheel = () => markGesture(tabId)
346
+ terminal.element?.addEventListener('wheel', onWheel, { passive: true })
274
347
 
275
348
  // Resize handling — skip when the container is hidden (display:none → 0x0),
276
349
  // otherwise fit() reports a tiny size and the PTY gets stuck at ~4 cols
@@ -286,7 +359,7 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
286
359
  state.fitAddon.fit()
287
360
  const { cols, rows } = state.terminal
288
361
  if (cols < 10 || rows < 5) return
289
- scrollIfPinned(tabId, state.terminal)
362
+ reassert(state)
290
363
  window.worker.resizePty(tabId, cols, rows)
291
364
  }, 150)
292
365
  }
@@ -299,6 +372,7 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
299
372
  clearTimeout(resizeTimeout)
300
373
  window.removeEventListener('resize', handleResize)
301
374
  observer.disconnect()
375
+ terminal.element?.removeEventListener('wheel', onWheel)
302
376
  // Do NOT dispose terminal or unsub PTY listeners — they persist across remounts
303
377
  }
304
378
  }, [tabId, onExit])
@@ -317,7 +391,7 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
317
391
  const s = _terminals.get(tabId)
318
392
  if (s) {
319
393
  s.fitAddon.fit()
320
- s.pinnedToBottom = true
394
+ setPinned(s, true)
321
395
  s.terminal.scrollToBottom()
322
396
  const { cols, rows } = s.terminal
323
397
  window.worker.resizePty(tabId, cols, rows)
@@ -337,7 +411,7 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
337
411
  if (state) {
338
412
  state.fitAddon.fit()
339
413
  state.terminal.focus()
340
- scrollIfPinned(tabId, state.terminal)
414
+ reassert(state)
341
415
  const { cols, rows } = state.terminal
342
416
  window.worker.resizePty(tabId, cols, rows)
343
417
  }
@@ -346,9 +420,32 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
346
420
  }
347
421
  }, [isActive, tabId])
348
422
 
423
+ // Subscribe to follow-mode flips so the jump button shows/hides. Runs after
424
+ // the attach effect has created the terminal for this tab.
425
+ useEffect(() => {
426
+ const s = _terminals.get(tabId)
427
+ if (!s) return
428
+ setFollowBottom(s.pinnedToBottom)
429
+ const fn = (p: boolean) => setFollowBottom(p)
430
+ s.pinListeners.add(fn)
431
+ return () => { s.pinListeners.delete(fn) }
432
+ }, [tabId])
433
+
349
434
  return (
350
435
  <>
351
- <div className="terminal-container" ref={containerRef} onContextMenu={handleContextMenu} />
436
+ <div className="terminal-wrap">
437
+ <div className="terminal-container" ref={containerRef} onContextMenu={handleContextMenu} />
438
+ {!followBottom && (
439
+ <button
440
+ type="button"
441
+ className="term-jump-latest"
442
+ onMouseDown={(e) => { e.preventDefault(); jumpToLatest() }}
443
+ title="Jump to latest output"
444
+ >
445
+ ↓ Latest
446
+ </button>
447
+ )}
448
+ </div>
352
449
  {menu && (
353
450
  <div className="term-context-menu" style={{ position: 'fixed', left: menu.x, top: menu.y }}>
354
451
  <button type="button" onMouseDown={(e) => { e.preventDefault(); runConvert() }}>
@@ -46,6 +46,44 @@ function TagIcon({ size = 16 }: { size?: number }) {
46
46
  )
47
47
  }
48
48
 
49
+ // Paper-plane "send" glyph for the per-ticket dispatch button.
50
+ function SendIcon({ size = 15 }: { size?: number }) {
51
+ return (
52
+ <svg viewBox="0 0 24 24" width={size} height={size} fill="none"
53
+ stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
54
+ <line x1="22" y1="2" x2="11" y2="13" />
55
+ <polygon points="22 2 15 22 11 13 2 9 22 2" />
56
+ </svg>
57
+ )
58
+ }
59
+
60
+ interface TicketRefs { blockId: string; pageId: string }
61
+
62
+ // Formats a ticket into a prompt the coding agent can act on directly. Includes
63
+ // the ctlsurf identifiers (row / datastore block / page) so the agent can read
64
+ // the full ticket or flip its status via the ctlsurf MCP tools.
65
+ function ticketPrompt(t: Ticket, refs: TicketRefs | null): string {
66
+ const lines = [
67
+ 'Please work on this ticket:',
68
+ `Title: ${t.title || 'Untitled'}`,
69
+ `Priority: ${t.priority}`,
70
+ ]
71
+ const desc = t.description?.trim()
72
+ if (desc) lines.push('', desc)
73
+ lines.push(
74
+ '',
75
+ 'ctlsurf references (use the ctlsurf MCP tools to read details or update status):',
76
+ `- Ticket row ID: ${t.id}`,
77
+ )
78
+ if (refs) {
79
+ lines.push(
80
+ `- Tickets datastore block ID: ${refs.blockId}`,
81
+ `- Agent Datastore page ID: ${refs.pageId}`,
82
+ )
83
+ }
84
+ return lines.join('\n')
85
+ }
86
+
49
87
  interface SegmentedProps {
50
88
  options: Option[]
51
89
  value: string
@@ -74,17 +112,61 @@ function Segmented({ options, value, onChange }: SegmentedProps) {
74
112
  )
75
113
  }
76
114
 
115
+ // A single ticket row. Shared between the open list and the (collapsible)
116
+ // Done section so both render identically. The card body opens the editor; the
117
+ // trailing send button dispatches the ticket straight to the coding terminal.
118
+ function TicketCard({ ticket, onClick, onSend }: { ticket: Ticket; onClick: () => void; onSend: () => void }) {
119
+ return (
120
+ <div className="ticket-card">
121
+ <button className="ticket-card-open" onClick={onClick} title="Edit ticket">
122
+ <span className="ticket-card-bar" style={{ background: colorOf(STATUS_OPTIONS, ticket.status) }} />
123
+ <div className="ticket-card-main">
124
+ <span className="ticket-card-title">{ticket.title || 'Untitled'}</span>
125
+ <div className="ticket-card-meta">
126
+ <span className="ticket-card-status" style={{ color: colorOf(STATUS_OPTIONS, ticket.status) }}>
127
+ <span className="ticket-seg-dot" style={{ background: colorOf(STATUS_OPTIONS, ticket.status) }} />
128
+ {ticket.status}
129
+ </span>
130
+ {ticket.created && <span className="ticket-card-date">{formatDate(ticket.created)}</span>}
131
+ </div>
132
+ </div>
133
+ <span
134
+ className="ticket-card-pri"
135
+ style={{ color: colorOf(PRIORITY_OPTIONS, ticket.priority), borderColor: `${colorOf(PRIORITY_OPTIONS, ticket.priority)}66` }}
136
+ >
137
+ {ticket.priority}
138
+ </span>
139
+ </button>
140
+ <button
141
+ type="button"
142
+ className="ticket-card-send"
143
+ onClick={onSend}
144
+ title="Send to coding terminal"
145
+ aria-label="Send ticket to coding terminal"
146
+ >
147
+ <SendIcon />
148
+ </button>
149
+ </div>
150
+ )
151
+ }
152
+
77
153
  // Docked side-pane variant of the ticket UI: header + list/form body + footer.
78
154
  // It only mounts when its pane is visible, so it loads on mount and refreshes
79
155
  // when the connected project (cwd) changes. Closed via the pane's own header.
80
- export function TicketPane() {
156
+ export function TicketPane({ onSendToTerminal }: { onSendToTerminal: (text: string) => void }) {
81
157
  const [project, setProject] = useState<string | null>(null)
82
158
  const [view, setView] = useState<View>('list')
83
159
 
84
160
  // list state
85
161
  const [tickets, setTickets] = useState<Ticket[]>([])
162
+ // ctlsurf identifiers for this project's Tickets datastore, sent along when
163
+ // dispatching a ticket so the agent can locate it via the MCP tools.
164
+ const [refs, setRefs] = useState<TicketRefs | null>(null)
86
165
  const [listLoading, setListLoading] = useState(false)
87
166
  const [listError, setListError] = useState<string | null>(null)
167
+ // Done tickets are tucked into a collapsed section so the list stays focused
168
+ // on what's still open; the user can expand to review completed work.
169
+ const [showDone, setShowDone] = useState(false)
88
170
 
89
171
  // form state
90
172
  const [editingId, setEditingId] = useState<string | null>(null)
@@ -99,10 +181,13 @@ export function TicketPane() {
99
181
  setListError(null)
100
182
  try {
101
183
  const r = await window.worker.listTickets()
102
- if (r?.ok) setTickets(r.tickets || [])
103
- else { setTickets([]); setListError(r?.error || 'Failed to load tickets') }
184
+ if (r?.ok) {
185
+ setTickets(r.tickets || [])
186
+ setRefs(r.blockId && r.pageId ? { blockId: r.blockId, pageId: r.pageId } : null)
187
+ } else { setTickets([]); setRefs(null); setListError(r?.error || 'Failed to load tickets') }
104
188
  } catch (err: any) {
105
189
  setTickets([])
190
+ setRefs(null)
106
191
  setListError(err?.message || 'Failed to load tickets')
107
192
  } finally {
108
193
  setListLoading(false)
@@ -178,6 +263,10 @@ export function TicketPane() {
178
263
  return () => window.removeEventListener('keydown', onKey)
179
264
  }, [view, handleSave])
180
265
 
266
+ // Split out Done tickets so they can live in their own collapsed section.
267
+ const activeTickets = tickets.filter(t => t.status !== 'Done')
268
+ const doneTickets = tickets.filter(t => t.status === 'Done')
269
+
181
270
  return (
182
271
  <div className="ticket-pane" role="region" aria-label="Tickets">
183
272
  <div className="ticket-drawer-header">
@@ -198,6 +287,9 @@ export function TicketPane() {
198
287
  </span>
199
288
  </div>
200
289
  </div>
290
+ {view === 'list' && (
291
+ <button className="ticket-drawer-new" onClick={openNewForm} title="New ticket">+ New</button>
292
+ )}
201
293
  </div>
202
294
 
203
295
  {view === 'list' ? (
@@ -214,34 +306,39 @@ export function TicketPane() {
214
306
  <span className="ticket-empty-hint">Log the first one for this project.</span>
215
307
  </div>
216
308
  )}
217
- {!listLoading && tickets.map(t => (
218
- <button key={t.id} className="ticket-card" onClick={() => openEditForm(t)}>
219
- <span className="ticket-card-bar" style={{ background: colorOf(STATUS_OPTIONS, t.status) }} />
220
- <div className="ticket-card-main">
221
- <span className="ticket-card-title">{t.title || 'Untitled'}</span>
222
- <div className="ticket-card-meta">
223
- <span className="ticket-card-status" style={{ color: colorOf(STATUS_OPTIONS, t.status) }}>
224
- <span className="ticket-seg-dot" style={{ background: colorOf(STATUS_OPTIONS, t.status) }} />
225
- {t.status}
226
- </span>
227
- {t.created && <span className="ticket-card-date">{formatDate(t.created)}</span>}
228
- </div>
229
- </div>
230
- <span
231
- className="ticket-card-pri"
232
- style={{ color: colorOf(PRIORITY_OPTIONS, t.priority), borderColor: `${colorOf(PRIORITY_OPTIONS, t.priority)}66` }}
233
- >
234
- {t.priority}
235
- </span>
236
- </button>
309
+ {!listLoading && activeTickets.map(t => (
310
+ <TicketCard key={t.id} ticket={t} onClick={() => openEditForm(t)} onSend={() => onSendToTerminal(ticketPrompt(t, refs))} />
237
311
  ))}
312
+ {!listLoading && activeTickets.length === 0 && doneTickets.length > 0 && (
313
+ <div className="ticket-list-msg">Nothing open — all caught up.</div>
314
+ )}
315
+ {!listLoading && doneTickets.length > 0 && (
316
+ <>
317
+ <button
318
+ type="button"
319
+ className="ticket-done-toggle"
320
+ onClick={() => setShowDone(v => !v)}
321
+ aria-expanded={showDone}
322
+ >
323
+ <span className={`ticket-done-caret ${showDone ? 'open' : ''}`} aria-hidden="true">▸</span>
324
+ <span className="ticket-done-label">Done</span>
325
+ <span className="ticket-done-count">{doneTickets.length}</span>
326
+ </button>
327
+ {showDone && doneTickets.map(t => (
328
+ <TicketCard key={t.id} ticket={t} onClick={() => openEditForm(t)} onSend={() => onSendToTerminal(ticketPrompt(t, refs))} />
329
+ ))}
330
+ </>
331
+ )}
238
332
  </div>
239
- <div className="ticket-drawer-footer">
240
- <span className="ticket-drawer-hint">
241
- {tickets.length > 0 && `${tickets.length} ticket${tickets.length === 1 ? '' : 's'}`}
242
- </span>
243
- <button className="ticket-btn-primary" onClick={openNewForm}>+ New ticket</button>
244
- </div>
333
+ {tickets.length > 0 && (
334
+ <div className="ticket-drawer-footer">
335
+ <span className="ticket-drawer-hint">
336
+ {doneTickets.length > 0
337
+ ? `${activeTickets.length} open · ${doneTickets.length} done`
338
+ : `${tickets.length} ticket${tickets.length === 1 ? '' : 's'}`}
339
+ </span>
340
+ </div>
341
+ )}
245
342
  </>
246
343
  ) : (
247
344
  <>