@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.
- package/out/headless/index.mjs +29 -19
- package/out/headless/index.mjs.map +2 -2
- package/out/main/index.js +35 -19
- package/out/renderer/assets/{cssMode-R8fTFLae.js → cssMode-KhYyY-BM.js} +3 -3
- package/out/renderer/assets/{freemarker2-CyjWzWoP.js → freemarker2-BFsSgcWk.js} +1 -1
- package/out/renderer/assets/{handlebars-CrhCGIRM.js → handlebars-Be6_aBsm.js} +1 -1
- package/out/renderer/assets/{html-Cc3dPNZr.js → html-G8mvsyfc.js} +1 -1
- package/out/renderer/assets/{htmlMode-BWtG6ywk.js → htmlMode-Cfd5VCjw.js} +3 -3
- package/out/renderer/assets/{index-BL9njXIL.css → index-Bi_MjBqs.css} +233 -8
- package/out/renderer/assets/{index-kkG56aKv.js → index-MqgBcJzy.js} +392 -120
- package/out/renderer/assets/{javascript-C8T_SdLC.js → javascript-XyDWi6dC.js} +2 -2
- package/out/renderer/assets/{jsonMode-D1zR4VVU.js → jsonMode-DZT5gBVW.js} +3 -3
- package/out/renderer/assets/{liquid-COSMe9Uf.js → liquid-BuajCNJ9.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-CXGfrtzl.js → lspLanguageFeatures-BYkg2Yc-.js} +1 -1
- package/out/renderer/assets/{mdx-RfS44T-L.js → mdx-D0Qx2GMS.js} +1 -1
- package/out/renderer/assets/{python-DFSWKHUu.js → python-ChncNy6S.js} +1 -1
- package/out/renderer/assets/{razor-DtH2_uvo.js → razor-C6MUtfaS.js} +1 -1
- package/out/renderer/assets/{tsMode-BYIQ-S9Z.js → tsMode-C2aMdzdU.js} +1 -1
- package/out/renderer/assets/{typescript-HhQUr1Rb.js → typescript-CB71AtAT.js} +1 -1
- package/out/renderer/assets/{xml-KuhgdKfD.js → xml-tHS1Ec9w.js} +1 -1
- package/out/renderer/assets/{yaml-W_kzCFH7.js → yaml-CZcp5ueF.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/index.ts +13 -1
- package/src/main/orchestrator.ts +8 -1
- package/src/main/ticketStore.ts +26 -20
- package/src/renderer/App.tsx +19 -3
- package/src/renderer/components/CtlsurfPanel.tsx +169 -35
- package/src/renderer/components/SpeakControls.tsx +8 -2
- package/src/renderer/components/TerminalPanel.tsx +141 -44
- package/src/renderer/components/TicketPanel.tsx +126 -29
- package/src/renderer/styles.css +233 -8
|
@@ -22,29 +22,15 @@ function copySelectionAsEmailTable(terminal: Terminal): void {
|
|
|
22
22
|
)
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
85
|
+
return buf.baseY - buf.viewportY <= 2
|
|
82
86
|
}
|
|
83
87
|
|
|
84
|
-
function
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
151
|
-
unsubExit: null
|
|
152
|
-
spawnedAgentId: 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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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-
|
|
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)
|
|
103
|
-
|
|
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 &&
|
|
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>
|
|
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
|
-
|
|
240
|
-
<
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
<>
|