@phenx-inc/ctlsurf 0.12.0 → 0.12.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 (30) hide show
  1. package/out/headless/index.mjs +5 -1
  2. package/out/headless/index.mjs.map +2 -2
  3. package/out/main/index.js +5 -1
  4. package/out/renderer/assets/{cssMode-R8fTFLae.js → cssMode-D7r66wvO.js} +3 -3
  5. package/out/renderer/assets/{freemarker2-CyjWzWoP.js → freemarker2-jSWJIjPG.js} +1 -1
  6. package/out/renderer/assets/{handlebars-CrhCGIRM.js → handlebars-BnFyz5RH.js} +1 -1
  7. package/out/renderer/assets/{html-Cc3dPNZr.js → html-Bl5UFavp.js} +1 -1
  8. package/out/renderer/assets/{htmlMode-BWtG6ywk.js → htmlMode-D4VByBT6.js} +3 -3
  9. package/out/renderer/assets/{index-BL9njXIL.css → index-Cc_TqOCL.css} +117 -0
  10. package/out/renderer/assets/{index-kkG56aKv.js → index-DxhW1emk.js} +173 -73
  11. package/out/renderer/assets/{javascript-C8T_SdLC.js → javascript-CMN1OmwG.js} +2 -2
  12. package/out/renderer/assets/{jsonMode-D1zR4VVU.js → jsonMode-CJ5AYSPV.js} +3 -3
  13. package/out/renderer/assets/{liquid-COSMe9Uf.js → liquid-BGMIDTEm.js} +1 -1
  14. package/out/renderer/assets/{lspLanguageFeatures-CXGfrtzl.js → lspLanguageFeatures-DGifVYB5.js} +1 -1
  15. package/out/renderer/assets/{mdx-RfS44T-L.js → mdx-B2Z7R10Q.js} +1 -1
  16. package/out/renderer/assets/{python-DFSWKHUu.js → python-LMDMSSdn.js} +1 -1
  17. package/out/renderer/assets/{razor-DtH2_uvo.js → razor-BZSrziPu.js} +1 -1
  18. package/out/renderer/assets/{tsMode-BYIQ-S9Z.js → tsMode-DF9m6_3n.js} +1 -1
  19. package/out/renderer/assets/{typescript-HhQUr1Rb.js → typescript-rZeCMf0a.js} +1 -1
  20. package/out/renderer/assets/{xml-KuhgdKfD.js → xml-fNSZ-7so.js} +1 -1
  21. package/out/renderer/assets/{yaml-W_kzCFH7.js → yaml-1wxIuvzh.js} +1 -1
  22. package/out/renderer/index.html +2 -2
  23. package/package.json +1 -1
  24. package/src/main/index.ts +4 -1
  25. package/src/main/orchestrator.ts +7 -0
  26. package/src/renderer/App.tsx +5 -1
  27. package/src/renderer/components/SpeakControls.tsx +8 -2
  28. package/src/renderer/components/TerminalPanel.tsx +134 -23
  29. package/src/renderer/components/TicketPanel.tsx +67 -26
  30. package/src/renderer/styles.css +117 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phenx-inc/ctlsurf",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "Agent-agnostic terminal and desktop app for ctlsurf — run Claude Code, Codex, or any coding agent with live session logging and remote control",
5
5
  "main": "out/main/index.js",
6
6
  "bin": {
package/src/main/index.ts CHANGED
@@ -220,7 +220,10 @@ const orchestrator = new Orchestrator(
220
220
  },
221
221
  onCwdChanged: () => {
222
222
  mainWindow?.webContents.send('app:cwdChanged')
223
- updateProjectBadge(orchestrator.cwd)
223
+ // Reflect the active tab's bound project (remote SSH sessions bind to a
224
+ // chosen ctlsurf project whose root_path is meaningful), falling back to
225
+ // the local launch cwd for unbound/local tabs.
226
+ updateProjectBadge(orchestrator.getActiveTabProjectPath() || orchestrator.cwd)
224
227
  },
225
228
  }
226
229
  )
@@ -627,12 +627,19 @@ export class Orchestrator {
627
627
  }
628
628
 
629
629
  setActiveTab(tabId: string): void {
630
+ const prevProjectPath = this.getActiveTabProjectPath()
630
631
  this.activeTabId = tabId
631
632
  const tab = this.tabs.get(tabId)
632
633
  if (tab) {
633
634
  this.currentAgent = tab.agent
634
635
  this.currentCwd = tab.cwd
635
636
  }
637
+ // Switching tabs changes which project the dock badge / window title and
638
+ // the renderer's ticket/ctlsurf panels should reflect. Only signal when the
639
+ // effective project path actually changed to avoid needless renderer churn.
640
+ if (this.getActiveTabProjectPath() !== prevProjectPath) {
641
+ this.events.onCwdChanged()
642
+ }
636
643
  }
637
644
 
638
645
  getTabIds(): string[] {
@@ -438,6 +438,10 @@ export default function App() {
438
438
  return () => window.removeEventListener('ctlsurf-open-url', onOpen)
439
439
  }, [layout, togglePane])
440
440
 
441
+ // The active tab's agent — remote SSH sessions (id `remote-…`) can't have
442
+ // their replies spoken, so the speaker toggle is disabled for them.
443
+ const activeAgentIsRemote = !!tabs.find(t => t.id === activeTabId)?.agent?.id?.startsWith('remote-')
444
+
441
445
  // Build pane contents (always rendered, layout controls visibility)
442
446
  const panes: PaneContent[] = [
443
447
  { id: 'editor', label: 'Editor', content: <EditorPanel cwd={cwd} /> },
@@ -562,7 +566,7 @@ export default function App() {
562
566
  <line x1="8" y1="23" x2="16" y2="23" />
563
567
  </svg>
564
568
  </button>
565
- <SpeakControls />
569
+ <SpeakControls remote={activeAgentIsRemote} />
566
570
  <span className="titlebar-separator" />
567
571
  {agents.map(a => {
568
572
  const activeTab = tabs.find(t => t.id === activeTabId)
@@ -18,7 +18,10 @@ import {
18
18
 
19
19
  const SAMPLE = 'This is how spoken agent replies will sound.'
20
20
 
21
- export function SpeakControls() {
21
+ // `remote` is true while the active tab is a remote SSH session, where spoken
22
+ // replies can't work: the agent's transcript is written on the remote machine,
23
+ // so there's nothing local to read aloud. The toggle is disabled in that case.
24
+ export function SpeakControls({ remote = false }: { remote?: boolean }) {
22
25
  const [enabled, setEnabled] = useState(false)
23
26
  const [engine, setEngineState] = useState<TtsEngineId>(getEngine)
24
27
  const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([])
@@ -124,7 +127,9 @@ export function SpeakControls() {
124
127
  speech.enqueue(SAMPLE)
125
128
  }, [])
126
129
 
127
- const title = enabled ? 'Spoken replies on — click to mute' : 'Speak agent replies (off)'
130
+ const title = remote
131
+ ? "Spoken replies aren't available for remote SSH sessions — the agent's transcript lives on the remote machine"
132
+ : enabled ? 'Spoken replies on — click to mute' : 'Speak agent replies (off)'
128
133
 
129
134
  return (
130
135
  <div className="speak-controls" ref={wrapRef}>
@@ -133,6 +138,7 @@ export function SpeakControls() {
133
138
  className={`titlebar-btn titlebar-icon-btn speak-btn ${enabled ? 'active' : ''}`}
134
139
  onClick={toggle}
135
140
  title={title}
141
+ disabled={remote}
136
142
  aria-label="Toggle spoken agent replies"
137
143
  aria-pressed={enabled}
138
144
  >
@@ -67,27 +67,72 @@ interface TerminalPanelProps {
67
67
  // Keyed by tabId so each tab has its own xterm + pty listeners.
68
68
  // Survives React re-renders but cleaned up when tab is closed.
69
69
 
70
- const _terminals = new Map<string, {
70
+ type TermState = {
71
71
  terminal: Terminal
72
72
  fitAddon: FitAddon
73
73
  unsubData: (() => void) | null
74
74
  unsubExit: (() => void) | null
75
75
  spawnedAgentId: string | null
76
+ // Follow mode: while true, new output keeps the view pinned to the bottom.
76
77
  pinnedToBottom: boolean
77
- }>()
78
+ // When NOT following, the absolute buffer line held at the top of the
79
+ // viewport — restored after each write so redraws can't drag the view
80
+ // (neither down to the bottom, nor up to the top — the old bug).
81
+ anchorLine: number | null
82
+ // One-shot flag set by a real user scroll gesture (wheel / scroll keys) and
83
+ // consumed by the very next onScroll, so a redraw firing onScroll right after
84
+ // a gesture isn't mistaken for one. Spinner redraws (~10x/s while "thinking")
85
+ // would otherwise be misread as user intent and re-pin to the bottom.
86
+ userScrollPending: boolean
87
+ // Guard so our own programmatic scrolls don't feed back through onScroll.
88
+ holding: boolean
89
+ // Subscribers (the jump-to-latest button) notified when follow mode flips.
90
+ pinListeners: Set<(pinned: boolean) => void>
91
+ }
92
+
93
+ const _terminals = new Map<string, TermState>()
78
94
 
95
+ // Within a couple of lines of the bottom still counts as "at bottom", so a
96
+ // small redraw delta doesn't strand the view just shy of the end.
79
97
  function isAtBottom(terminal: Terminal): boolean {
80
98
  const buf = terminal.buffer.active
81
- return buf.viewportY >= buf.baseY
99
+ return buf.baseY - buf.viewportY <= 2
82
100
  }
83
101
 
84
- function scrollIfPinned(tabId: string, terminal: Terminal) {
85
- const state = _terminals.get(tabId)
86
- if (state?.pinnedToBottom) {
87
- terminal.scrollToBottom()
102
+ function notifyPin(state: TermState): void {
103
+ state.pinListeners.forEach((fn) => fn(state.pinnedToBottom))
104
+ }
105
+
106
+ function setPinned(state: TermState, pinned: boolean): void {
107
+ if (pinned) state.anchorLine = null
108
+ if (state.pinnedToBottom === pinned) return
109
+ state.pinnedToBottom = pinned
110
+ notifyPin(state)
111
+ }
112
+
113
+ // Re-assert the intended scroll position: follow the bottom while pinned,
114
+ // otherwise hold the user's anchored line. Guarded so the resulting onScroll
115
+ // is recognised as ours and ignored. This both keeps output pinned AND stops
116
+ // involuntary redraw jumps (the old "scrolls to the top" bug) from sticking.
117
+ function reassert(state: TermState): void {
118
+ const { terminal } = state
119
+ state.holding = true
120
+ try {
121
+ if (state.pinnedToBottom) {
122
+ terminal.scrollToBottom()
123
+ } else if (state.anchorLine != null && terminal.buffer.active.viewportY !== state.anchorLine) {
124
+ terminal.scrollToLine(state.anchorLine)
125
+ }
126
+ } finally {
127
+ state.holding = false
88
128
  }
89
129
  }
90
130
 
131
+ function markGesture(tabId: string): void {
132
+ const s = _terminals.get(tabId)
133
+ if (s) s.userScrollPending = true
134
+ }
135
+
91
136
  function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): { terminal: Terminal; fitAddon: FitAddon } {
92
137
  const existing = _terminals.get(tabId)
93
138
  if (existing) {
@@ -137,6 +182,10 @@ function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): {
137
182
  // is running, where right-click is forwarded to the program instead.
138
183
  terminal.attachCustomKeyEventHandler((e) => {
139
184
  if (e.type !== 'keydown') return true
185
+ // Buffer-scroll keys count as a user gesture (see onScroll).
186
+ if (e.key === 'PageUp' || e.key === 'PageDown' || e.key === 'Home' || e.key === 'End') {
187
+ markGesture(tabId)
188
+ }
140
189
  const isCombo = (e.metaKey || e.ctrlKey) && e.shiftKey && e.code === 'KeyE'
141
190
  if (!isCombo) return true
142
191
  e.preventDefault()
@@ -144,32 +193,53 @@ function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): {
144
193
  return false // consume — don't forward to the pty
145
194
  })
146
195
 
147
- const state = {
196
+ const state: TermState = {
148
197
  terminal,
149
198
  fitAddon,
150
- unsubData: null as (() => void) | null,
151
- unsubExit: null as (() => void) | null,
152
- spawnedAgentId: null as string | null,
199
+ unsubData: null,
200
+ unsubExit: null,
201
+ spawnedAgentId: null,
153
202
  pinnedToBottom: true,
203
+ anchorLine: null,
204
+ userScrollPending: false,
205
+ holding: false,
206
+ pinListeners: new Set(),
154
207
  }
155
208
 
156
- // Track scroll position
209
+ // Track scroll position. Distinguish a real user scroll (recent wheel/key
210
+ // gesture) from the involuntary viewport jumps TUI redraws cause: trust the
211
+ // former to set follow/anchor; for the latter, re-assert our target so the
212
+ // view neither snaps to the bottom nor jumps to the top.
157
213
  terminal.onScroll(() => {
158
- state.pinnedToBottom = isAtBottom(terminal)
214
+ if (state.holding) return
215
+ if (!state.userScrollPending) {
216
+ // Not tied to a user gesture → an involuntary scroll from a redraw.
217
+ // Hold our target instead of trusting it (this is the spinner case:
218
+ // every screen update would otherwise drag the view back to the bottom).
219
+ reassert(state)
220
+ return
221
+ }
222
+ state.userScrollPending = false
223
+ if (isAtBottom(terminal)) {
224
+ setPinned(state, true)
225
+ } else {
226
+ state.anchorLine = terminal.buffer.active.viewportY
227
+ setPinned(state, false)
228
+ }
159
229
  })
160
230
 
161
- // Send keystrokes to pty
231
+ // Send keystrokes to pty — typing always returns to (and follows) the bottom.
162
232
  terminal.onData((data) => {
163
- state.pinnedToBottom = true
233
+ setPinned(state, true)
164
234
  terminal.scrollToBottom()
165
235
  window.worker.writePty(tabId, data)
166
236
  })
167
237
 
168
- // Receive pty output for this tab
238
+ // Receive pty output for this tab. Re-assert AFTER the write is parsed (the
239
+ // write callback) so we read settled buffer coordinates.
169
240
  state.unsubData = window.worker.onPtyData((eventTabId, data) => {
170
241
  if (eventTabId === tabId) {
171
- terminal.write(data)
172
- scrollIfPinned(tabId, terminal)
242
+ terminal.write(data, () => reassert(state))
173
243
  }
174
244
  })
175
245
 
@@ -206,6 +276,17 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
206
276
  const containerRef = useRef<HTMLDivElement>(null)
207
277
  const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
208
278
  const [toast, setToast] = useState<string | null>(null)
279
+ // Mirrors the terminal's follow-mode; drives the "jump to latest" overlay,
280
+ // which only shows while the user has scrolled up.
281
+ const [followBottom, setFollowBottom] = useState(true)
282
+
283
+ const jumpToLatest = () => {
284
+ const s = _terminals.get(tabId)
285
+ if (!s) return
286
+ setPinned(s, true)
287
+ s.terminal.scrollToBottom()
288
+ s.terminal.focus()
289
+ }
209
290
 
210
291
  // Right-click → offer "Copy as email table", but only when the current
211
292
  // selection actually parses as a table; otherwise fall through to default.
@@ -270,7 +351,13 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
270
351
  }
271
352
 
272
353
  fitAddon.fit()
273
- scrollIfPinned(tabId, terminal)
354
+ const attached = _terminals.get(tabId)
355
+ if (attached) reassert(attached)
356
+
357
+ // Wheel over the terminal is an explicit user scroll — stamp it so onScroll
358
+ // treats the resulting move as intentional (not an involuntary redraw jump).
359
+ const onWheel = () => markGesture(tabId)
360
+ terminal.element?.addEventListener('wheel', onWheel, { passive: true })
274
361
 
275
362
  // Resize handling — skip when the container is hidden (display:none → 0x0),
276
363
  // otherwise fit() reports a tiny size and the PTY gets stuck at ~4 cols
@@ -286,7 +373,7 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
286
373
  state.fitAddon.fit()
287
374
  const { cols, rows } = state.terminal
288
375
  if (cols < 10 || rows < 5) return
289
- scrollIfPinned(tabId, state.terminal)
376
+ reassert(state)
290
377
  window.worker.resizePty(tabId, cols, rows)
291
378
  }, 150)
292
379
  }
@@ -299,6 +386,7 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
299
386
  clearTimeout(resizeTimeout)
300
387
  window.removeEventListener('resize', handleResize)
301
388
  observer.disconnect()
389
+ terminal.element?.removeEventListener('wheel', onWheel)
302
390
  // Do NOT dispose terminal or unsub PTY listeners — they persist across remounts
303
391
  }
304
392
  }, [tabId, onExit])
@@ -317,7 +405,7 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
317
405
  const s = _terminals.get(tabId)
318
406
  if (s) {
319
407
  s.fitAddon.fit()
320
- s.pinnedToBottom = true
408
+ setPinned(s, true)
321
409
  s.terminal.scrollToBottom()
322
410
  const { cols, rows } = s.terminal
323
411
  window.worker.resizePty(tabId, cols, rows)
@@ -337,7 +425,7 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
337
425
  if (state) {
338
426
  state.fitAddon.fit()
339
427
  state.terminal.focus()
340
- scrollIfPinned(tabId, state.terminal)
428
+ reassert(state)
341
429
  const { cols, rows } = state.terminal
342
430
  window.worker.resizePty(tabId, cols, rows)
343
431
  }
@@ -346,9 +434,32 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
346
434
  }
347
435
  }, [isActive, tabId])
348
436
 
437
+ // Subscribe to follow-mode flips so the jump button shows/hides. Runs after
438
+ // the attach effect has created the terminal for this tab.
439
+ useEffect(() => {
440
+ const s = _terminals.get(tabId)
441
+ if (!s) return
442
+ setFollowBottom(s.pinnedToBottom)
443
+ const fn = (p: boolean) => setFollowBottom(p)
444
+ s.pinListeners.add(fn)
445
+ return () => { s.pinListeners.delete(fn) }
446
+ }, [tabId])
447
+
349
448
  return (
350
449
  <>
351
- <div className="terminal-container" ref={containerRef} onContextMenu={handleContextMenu} />
450
+ <div className="terminal-wrap">
451
+ <div className="terminal-container" ref={containerRef} onContextMenu={handleContextMenu} />
452
+ {!followBottom && (
453
+ <button
454
+ type="button"
455
+ className="term-jump-latest"
456
+ onMouseDown={(e) => { e.preventDefault(); jumpToLatest() }}
457
+ title="Jump to latest output"
458
+ >
459
+ ↓ Latest
460
+ </button>
461
+ )}
462
+ </div>
352
463
  {menu && (
353
464
  <div className="term-context-menu" style={{ position: 'fixed', left: menu.x, top: menu.y }}>
354
465
  <button type="button" onMouseDown={(e) => { e.preventDefault(); runConvert() }}>
@@ -74,6 +74,32 @@ function Segmented({ options, value, onChange }: SegmentedProps) {
74
74
  )
75
75
  }
76
76
 
77
+ // A single ticket row. Shared between the open list and the (collapsible)
78
+ // Done section so both render identically.
79
+ function TicketCard({ ticket, onClick }: { ticket: Ticket; onClick: () => void }) {
80
+ return (
81
+ <button className="ticket-card" onClick={onClick}>
82
+ <span className="ticket-card-bar" style={{ background: colorOf(STATUS_OPTIONS, ticket.status) }} />
83
+ <div className="ticket-card-main">
84
+ <span className="ticket-card-title">{ticket.title || 'Untitled'}</span>
85
+ <div className="ticket-card-meta">
86
+ <span className="ticket-card-status" style={{ color: colorOf(STATUS_OPTIONS, ticket.status) }}>
87
+ <span className="ticket-seg-dot" style={{ background: colorOf(STATUS_OPTIONS, ticket.status) }} />
88
+ {ticket.status}
89
+ </span>
90
+ {ticket.created && <span className="ticket-card-date">{formatDate(ticket.created)}</span>}
91
+ </div>
92
+ </div>
93
+ <span
94
+ className="ticket-card-pri"
95
+ style={{ color: colorOf(PRIORITY_OPTIONS, ticket.priority), borderColor: `${colorOf(PRIORITY_OPTIONS, ticket.priority)}66` }}
96
+ >
97
+ {ticket.priority}
98
+ </span>
99
+ </button>
100
+ )
101
+ }
102
+
77
103
  // Docked side-pane variant of the ticket UI: header + list/form body + footer.
78
104
  // It only mounts when its pane is visible, so it loads on mount and refreshes
79
105
  // when the connected project (cwd) changes. Closed via the pane's own header.
@@ -85,6 +111,9 @@ export function TicketPane() {
85
111
  const [tickets, setTickets] = useState<Ticket[]>([])
86
112
  const [listLoading, setListLoading] = useState(false)
87
113
  const [listError, setListError] = useState<string | null>(null)
114
+ // Done tickets are tucked into a collapsed section so the list stays focused
115
+ // on what's still open; the user can expand to review completed work.
116
+ const [showDone, setShowDone] = useState(false)
88
117
 
89
118
  // form state
90
119
  const [editingId, setEditingId] = useState<string | null>(null)
@@ -178,6 +207,10 @@ export function TicketPane() {
178
207
  return () => window.removeEventListener('keydown', onKey)
179
208
  }, [view, handleSave])
180
209
 
210
+ // Split out Done tickets so they can live in their own collapsed section.
211
+ const activeTickets = tickets.filter(t => t.status !== 'Done')
212
+ const doneTickets = tickets.filter(t => t.status === 'Done')
213
+
181
214
  return (
182
215
  <div className="ticket-pane" role="region" aria-label="Tickets">
183
216
  <div className="ticket-drawer-header">
@@ -198,6 +231,9 @@ export function TicketPane() {
198
231
  </span>
199
232
  </div>
200
233
  </div>
234
+ {view === 'list' && (
235
+ <button className="ticket-drawer-new" onClick={openNewForm} title="New ticket">+ New</button>
236
+ )}
201
237
  </div>
202
238
 
203
239
  {view === 'list' ? (
@@ -214,34 +250,39 @@ export function TicketPane() {
214
250
  <span className="ticket-empty-hint">Log the first one for this project.</span>
215
251
  </div>
216
252
  )}
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>
253
+ {!listLoading && activeTickets.map(t => (
254
+ <TicketCard key={t.id} ticket={t} onClick={() => openEditForm(t)} />
237
255
  ))}
256
+ {!listLoading && activeTickets.length === 0 && doneTickets.length > 0 && (
257
+ <div className="ticket-list-msg">Nothing open — all caught up.</div>
258
+ )}
259
+ {!listLoading && doneTickets.length > 0 && (
260
+ <>
261
+ <button
262
+ type="button"
263
+ className="ticket-done-toggle"
264
+ onClick={() => setShowDone(v => !v)}
265
+ aria-expanded={showDone}
266
+ >
267
+ <span className={`ticket-done-caret ${showDone ? 'open' : ''}`} aria-hidden="true">▸</span>
268
+ <span className="ticket-done-label">Done</span>
269
+ <span className="ticket-done-count">{doneTickets.length}</span>
270
+ </button>
271
+ {showDone && doneTickets.map(t => (
272
+ <TicketCard key={t.id} ticket={t} onClick={() => openEditForm(t)} />
273
+ ))}
274
+ </>
275
+ )}
238
276
  </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>
277
+ {tickets.length > 0 && (
278
+ <div className="ticket-drawer-footer">
279
+ <span className="ticket-drawer-hint">
280
+ {doneTickets.length > 0
281
+ ? `${activeTickets.length} open · ${doneTickets.length} done`
282
+ : `${tickets.length} ticket${tickets.length === 1 ? '' : 's'}`}
283
+ </span>
284
+ </div>
285
+ )}
245
286
  </>
246
287
  ) : (
247
288
  <>
@@ -100,6 +100,17 @@ html, body, #root {
100
100
  background: #1f2335;
101
101
  }
102
102
 
103
+ .titlebar-btn:disabled {
104
+ opacity: 0.4;
105
+ cursor: not-allowed;
106
+ }
107
+
108
+ /* Keep the greyed-out look stable on hover for disabled buttons. */
109
+ .titlebar-btn:disabled:hover {
110
+ color: #565f89;
111
+ border-color: #3b3d57;
112
+ }
113
+
103
114
  .titlebar-separator {
104
115
  width: 1px;
105
116
  height: 16px;
@@ -266,6 +277,15 @@ html, body, #root {
266
277
  }
267
278
 
268
279
  /* Terminal container */
280
+ .terminal-wrap {
281
+ position: relative;
282
+ flex: 1;
283
+ min-width: 0;
284
+ min-height: 0;
285
+ width: 100%;
286
+ height: 100%;
287
+ }
288
+
269
289
  .terminal-container {
270
290
  width: 100%;
271
291
  height: 100%;
@@ -277,6 +297,34 @@ html, body, #root {
277
297
  height: 100%;
278
298
  }
279
299
 
300
+ /* Floating "jump to latest" pill — shown only while scrolled up. */
301
+ .term-jump-latest {
302
+ position: absolute;
303
+ bottom: 14px;
304
+ right: 20px;
305
+ z-index: 5;
306
+ display: inline-flex;
307
+ align-items: center;
308
+ gap: 5px;
309
+ padding: 5px 12px;
310
+ border-radius: 14px;
311
+ background: #2a2b3d;
312
+ border: 1px solid #7aa2f7;
313
+ color: #c0caf5;
314
+ font-family: inherit;
315
+ font-size: 11.5px;
316
+ font-weight: 600;
317
+ cursor: pointer;
318
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45);
319
+ transition: background 0.13s ease, transform 0.06s ease;
320
+ }
321
+ .term-jump-latest:hover {
322
+ background: #343650;
323
+ }
324
+ .term-jump-latest:active {
325
+ transform: translateY(1px);
326
+ }
327
+
280
328
  .terminal-fullscreen {
281
329
  width: 100%;
282
330
  height: 100%;
@@ -1712,6 +1760,32 @@ html, body, #root {
1712
1760
  color: #7aa2f7;
1713
1761
  font-weight: 600;
1714
1762
  }
1763
+ /* "+ New" action in the header (replaces the old footer button). */
1764
+ .ticket-drawer-new {
1765
+ flex-shrink: 0;
1766
+ align-self: center;
1767
+ display: inline-flex;
1768
+ align-items: center;
1769
+ gap: 3px;
1770
+ padding: 5px 11px;
1771
+ border-radius: 7px;
1772
+ background: rgba(122, 162, 247, 0.14);
1773
+ border: 1px solid rgba(122, 162, 247, 0.3);
1774
+ color: #9db4f5;
1775
+ font-family: inherit;
1776
+ font-size: 12px;
1777
+ font-weight: 600;
1778
+ cursor: pointer;
1779
+ transition: background 0.13s ease, border-color 0.13s ease, color 0.13s ease, transform 0.06s ease;
1780
+ }
1781
+ .ticket-drawer-new:hover {
1782
+ background: rgba(122, 162, 247, 0.22);
1783
+ border-color: rgba(122, 162, 247, 0.5);
1784
+ color: #c0d0fb;
1785
+ }
1786
+ .ticket-drawer-new:active {
1787
+ transform: translateY(1px);
1788
+ }
1715
1789
  .ticket-drawer-warn {
1716
1790
  color: #e0af68;
1717
1791
  }
@@ -1909,6 +1983,49 @@ html, body, #root {
1909
1983
  text-align: center;
1910
1984
  }
1911
1985
 
1986
+ /* ── collapsible "Done" section header ── */
1987
+ .ticket-done-toggle {
1988
+ display: flex;
1989
+ align-items: center;
1990
+ gap: 7px;
1991
+ width: 100%;
1992
+ margin-top: 3px;
1993
+ padding: 7px 4px;
1994
+ background: none;
1995
+ border: none;
1996
+ border-top: 1px solid #262a3f;
1997
+ color: #6b739b;
1998
+ font-family: inherit;
1999
+ font-size: 11.5px;
2000
+ font-weight: 600;
2001
+ letter-spacing: 0.03em;
2002
+ text-transform: uppercase;
2003
+ cursor: pointer;
2004
+ transition: color 0.13s ease;
2005
+ }
2006
+ .ticket-done-toggle:hover {
2007
+ color: #a9b1d6;
2008
+ }
2009
+ .ticket-done-caret {
2010
+ display: inline-block;
2011
+ font-size: 10px;
2012
+ transition: transform 0.13s ease;
2013
+ }
2014
+ .ticket-done-caret.open {
2015
+ transform: rotate(90deg);
2016
+ }
2017
+ .ticket-done-count {
2018
+ margin-left: auto;
2019
+ min-width: 18px;
2020
+ padding: 1px 6px;
2021
+ border-radius: 9px;
2022
+ background: #262a3f;
2023
+ color: #8088ac;
2024
+ font-size: 11px;
2025
+ font-weight: 600;
2026
+ text-align: center;
2027
+ }
2028
+
1912
2029
  .ticket-empty {
1913
2030
  display: flex;
1914
2031
  flex-direction: column;