@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.
- package/out/headless/index.mjs +5 -1
- package/out/headless/index.mjs.map +2 -2
- package/out/main/index.js +5 -1
- package/out/renderer/assets/{cssMode-R8fTFLae.js → cssMode-D7r66wvO.js} +3 -3
- package/out/renderer/assets/{freemarker2-CyjWzWoP.js → freemarker2-jSWJIjPG.js} +1 -1
- package/out/renderer/assets/{handlebars-CrhCGIRM.js → handlebars-BnFyz5RH.js} +1 -1
- package/out/renderer/assets/{html-Cc3dPNZr.js → html-Bl5UFavp.js} +1 -1
- package/out/renderer/assets/{htmlMode-BWtG6ywk.js → htmlMode-D4VByBT6.js} +3 -3
- package/out/renderer/assets/{index-BL9njXIL.css → index-Cc_TqOCL.css} +117 -0
- package/out/renderer/assets/{index-kkG56aKv.js → index-DxhW1emk.js} +173 -73
- package/out/renderer/assets/{javascript-C8T_SdLC.js → javascript-CMN1OmwG.js} +2 -2
- package/out/renderer/assets/{jsonMode-D1zR4VVU.js → jsonMode-CJ5AYSPV.js} +3 -3
- package/out/renderer/assets/{liquid-COSMe9Uf.js → liquid-BGMIDTEm.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-CXGfrtzl.js → lspLanguageFeatures-DGifVYB5.js} +1 -1
- package/out/renderer/assets/{mdx-RfS44T-L.js → mdx-B2Z7R10Q.js} +1 -1
- package/out/renderer/assets/{python-DFSWKHUu.js → python-LMDMSSdn.js} +1 -1
- package/out/renderer/assets/{razor-DtH2_uvo.js → razor-BZSrziPu.js} +1 -1
- package/out/renderer/assets/{tsMode-BYIQ-S9Z.js → tsMode-DF9m6_3n.js} +1 -1
- package/out/renderer/assets/{typescript-HhQUr1Rb.js → typescript-rZeCMf0a.js} +1 -1
- package/out/renderer/assets/{xml-KuhgdKfD.js → xml-fNSZ-7so.js} +1 -1
- package/out/renderer/assets/{yaml-W_kzCFH7.js → yaml-1wxIuvzh.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/index.ts +4 -1
- package/src/main/orchestrator.ts +7 -0
- package/src/renderer/App.tsx +5 -1
- package/src/renderer/components/SpeakControls.tsx +8 -2
- package/src/renderer/components/TerminalPanel.tsx +134 -23
- package/src/renderer/components/TicketPanel.tsx +67 -26
- 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.
|
|
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
|
-
|
|
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
|
)
|
package/src/main/orchestrator.ts
CHANGED
|
@@ -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[] {
|
package/src/renderer/App.tsx
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
99
|
+
return buf.baseY - buf.viewportY <= 2
|
|
82
100
|
}
|
|
83
101
|
|
|
84
|
-
function
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
151
|
-
unsubExit: null
|
|
152
|
-
spawnedAgentId: 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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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-
|
|
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 &&
|
|
218
|
-
<
|
|
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
|
-
|
|
240
|
-
<
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
<>
|
package/src/renderer/styles.css
CHANGED
|
@@ -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;
|