@phenx-inc/ctlsurf 0.9.0 → 0.11.0
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 +196 -2
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +326 -24
- package/out/preload/index.js +7 -0
- package/out/renderer/assets/{cssMode-BdYp0fp9.js → cssMode-Bmzy9hKX.js} +3 -3
- package/out/renderer/assets/{freemarker2-Cya-9zr-.js → freemarker2-CDAsExTa.js} +1 -1
- package/out/renderer/assets/{handlebars-EJs9W5Ub.js → handlebars-CvpBYG1L.js} +1 -1
- package/out/renderer/assets/{html-Ex4qy2lh.js → html-CNhEQqr8.js} +1 -1
- package/out/renderer/assets/{htmlMode-CCm0aJ6C.js → htmlMode-DD7YPgga.js} +3 -3
- package/out/renderer/assets/{index-Cj7K9-Ba.js → index-BDlCPzCG.js} +153 -146
- package/out/renderer/assets/{index-nvsNYP5c.css → index-CGHeDwZa.css} +23 -46
- package/out/renderer/assets/{javascript-B2niCouI.js → javascript-CsGLsswK.js} +2 -2
- package/out/renderer/assets/{jsonMode-BgCUHaFo.js → jsonMode-CTuGgRvC.js} +3 -3
- package/out/renderer/assets/{liquid-DOQmSo4G.js → liquid-BrrN4uul.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-8SIcRAMh.js → lspLanguageFeatures-Bhdn2Eqy.js} +1 -1
- package/out/renderer/assets/{mdx-DuKdGNKE.js → mdx-DJo-gax4.js} +1 -1
- package/out/renderer/assets/{python-BUa4XFG-.js → python-DYHeg5b2.js} +1 -1
- package/out/renderer/assets/{razor-BUw8jho0.js → razor-Cqcks14K.js} +1 -1
- package/out/renderer/assets/{tsMode-8FLRnobw.js → tsMode-YTLgCayh.js} +1 -1
- package/out/renderer/assets/{typescript-rdwAjCi4.js → typescript-oxBn_Vcw.js} +1 -1
- package/out/renderer/assets/{xml-HOzT59Qg.js → xml-BEqGMCTc.js} +1 -1
- package/out/renderer/assets/{yaml-B81JfLsW.js → yaml-BGSrk5HC.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/agentState.ts +329 -0
- package/src/main/index.ts +135 -20
- package/src/main/orchestrator.ts +21 -1
- package/src/preload/index.ts +8 -0
- package/src/renderer/App.tsx +32 -9
- package/src/renderer/components/TicketPanel.tsx +20 -25
- package/src/renderer/styles.css +23 -46
package/src/main/orchestrator.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { AgentConfig, isCodingAgent } from './agents'
|
|
|
7
7
|
import { CtlsurfApi } from './ctlsurfApi'
|
|
8
8
|
import { ConversationBridge } from './bridge'
|
|
9
9
|
import { TranscriptTailer, supportsTranscriptLogging, readLatestAssistantReply } from './transcripts'
|
|
10
|
+
import { AgentStateMonitor, type AgentState } from './agentState'
|
|
10
11
|
import { WorkerWsClient, type WorkerWsStatus, type IncomingMessage } from './workerWs'
|
|
11
12
|
import { TimeTracker } from './timeTracker'
|
|
12
13
|
import { TicketStore, type TicketInput } from './ticketStore'
|
|
@@ -51,6 +52,10 @@ export interface OrchestratorEvents {
|
|
|
51
52
|
// text-to-speech. Optional and wired only by the Electron entry — the
|
|
52
53
|
// headless build leaves it unset, so replies are never spoken in a terminal.
|
|
53
54
|
onAgentMessage?: (text: string) => void
|
|
55
|
+
// Real-time agent activity state (busy vs awaiting the user). `anyAwaiting`
|
|
56
|
+
// is true when any tab is waiting on the user, and drives the dock/taskbar
|
|
57
|
+
// attention indicator. Optional — only the Electron entry wires it.
|
|
58
|
+
onAgentStateChange?: (tabId: string, state: AgentState, anyAwaiting: boolean) => void
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
interface TabState {
|
|
@@ -102,11 +107,17 @@ export class Orchestrator {
|
|
|
102
107
|
private noProjectPollCwd: string | null = null
|
|
103
108
|
private currentProjectName: string | null = null
|
|
104
109
|
private transcriptTailer: TranscriptTailer | null = null
|
|
110
|
+
private readonly stateMonitor: AgentStateMonitor
|
|
105
111
|
|
|
106
112
|
constructor(settingsDir: string, events: OrchestratorEvents) {
|
|
107
113
|
this.settingsDir = settingsDir
|
|
108
114
|
this.events = events
|
|
109
115
|
|
|
116
|
+
this.stateMonitor = new AgentStateMonitor(({ tabId, state, anyAwaiting }) => {
|
|
117
|
+
this.events.onAgentStateChange?.(tabId, state, anyAwaiting)
|
|
118
|
+
})
|
|
119
|
+
this.stateMonitor.start()
|
|
120
|
+
|
|
110
121
|
this.workerWs = new WorkerWsClient({
|
|
111
122
|
onStatusChange: (status: WorkerWsStatus) => {
|
|
112
123
|
log(`[worker-ws] Status: ${status}`)
|
|
@@ -119,9 +130,10 @@ export class Orchestrator {
|
|
|
119
130
|
|
|
120
131
|
if (message.type === 'prompt' || message.type === 'task_dispatch') {
|
|
121
132
|
const activeTab = this.activeTabId ? this.tabs.get(this.activeTabId) : null
|
|
122
|
-
if (activeTab) {
|
|
133
|
+
if (activeTab && this.activeTabId) {
|
|
123
134
|
activeTab.ptyManager.write(message.content + '\r')
|
|
124
135
|
this.bridge.feedInput(message.content + '\r')
|
|
136
|
+
this.stateMonitor.noteInput(this.activeTabId, message.content + '\r')
|
|
125
137
|
}
|
|
126
138
|
}
|
|
127
139
|
},
|
|
@@ -463,10 +475,14 @@ export class Orchestrator {
|
|
|
463
475
|
const ptyManager = new PtyManager(agent, cwd)
|
|
464
476
|
const tab: TabState = { ptyManager, agent, cwd, termStreamBuffer: '', termStreamTimer: null }
|
|
465
477
|
this.tabs.set(tabId, tab)
|
|
478
|
+
// Track all tabs (not just the active one): a background agent finishing is
|
|
479
|
+
// exactly when the dock indicator is most useful.
|
|
480
|
+
this.stateMonitor.trackTab(tabId, agent.id, cwd)
|
|
466
481
|
|
|
467
482
|
ptyManager.onData((data: string) => {
|
|
468
483
|
this.events.onPtyData(tabId, data)
|
|
469
484
|
this.timeTracker.recordActivity(tabId)
|
|
485
|
+
this.stateMonitor.feed(tabId, data)
|
|
470
486
|
if (tabId === this.activeTabId) {
|
|
471
487
|
this.bridge.feedOutput(data)
|
|
472
488
|
this.streamTerminalData(tabId, data)
|
|
@@ -485,6 +501,7 @@ export class Orchestrator {
|
|
|
485
501
|
// Clean up tab state
|
|
486
502
|
const t = this.tabs.get(tabId)
|
|
487
503
|
if (t?.termStreamTimer) clearTimeout(t.termStreamTimer)
|
|
504
|
+
this.stateMonitor.removeTab(tabId)
|
|
488
505
|
})
|
|
489
506
|
|
|
490
507
|
this.startChatLogging(agent, cwd)
|
|
@@ -511,6 +528,7 @@ export class Orchestrator {
|
|
|
511
528
|
|
|
512
529
|
writePty(tabId: string, data: string): void {
|
|
513
530
|
this.tabs.get(tabId)?.ptyManager.write(data)
|
|
531
|
+
this.stateMonitor.noteInput(tabId, data)
|
|
514
532
|
if (tabId === this.activeTabId) {
|
|
515
533
|
this.bridge.feedInput(data)
|
|
516
534
|
}
|
|
@@ -531,6 +549,7 @@ export class Orchestrator {
|
|
|
531
549
|
await this.timeTracker.endSession(tabId)
|
|
532
550
|
tab.ptyManager.kill()
|
|
533
551
|
this.tabs.delete(tabId)
|
|
552
|
+
this.stateMonitor.removeTab(tabId)
|
|
534
553
|
if (tabId === this.activeTabId) {
|
|
535
554
|
this.stopChatLogging()
|
|
536
555
|
if (isCodingAgent(tab.agent)) {
|
|
@@ -702,6 +721,7 @@ export class Orchestrator {
|
|
|
702
721
|
async shutdown(): Promise<void> {
|
|
703
722
|
this.stopNoProjectPolling()
|
|
704
723
|
this.stopChatLogging()
|
|
724
|
+
this.stateMonitor.stop()
|
|
705
725
|
await this.timeTracker.endAll()
|
|
706
726
|
for (const [, tab] of this.tabs) {
|
|
707
727
|
if (tab.termStreamTimer) clearTimeout(tab.termStreamTimer)
|
package/src/preload/index.ts
CHANGED
|
@@ -119,6 +119,14 @@ const api = {
|
|
|
119
119
|
ipcRenderer.on('agent:message', listener)
|
|
120
120
|
return () => ipcRenderer.removeListener('agent:message', listener)
|
|
121
121
|
},
|
|
122
|
+
// Real-time agent activity state for an in-app indicator dot.
|
|
123
|
+
// state: 'idle' | 'busy' | 'awaiting_input' | 'awaiting_decision'
|
|
124
|
+
onAgentState: (callback: (tabId: string, state: string, anyAwaiting: boolean) => void) => {
|
|
125
|
+
const listener = (_event: Electron.IpcRendererEvent, tabId: string, state: string, anyAwaiting: boolean) =>
|
|
126
|
+
callback(tabId, state, anyAwaiting)
|
|
127
|
+
ipcRenderer.on('agent:state', listener)
|
|
128
|
+
return () => ipcRenderer.removeListener('agent:state', listener)
|
|
129
|
+
},
|
|
122
130
|
// Latest assistant reply from the active tab's transcript (null if none).
|
|
123
131
|
getLastReply: (): Promise<string | null> =>
|
|
124
132
|
ipcRenderer.invoke('agent:getLastReply'),
|
package/src/renderer/App.tsx
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from './components/PaneLayout'
|
|
15
15
|
import { StatusBar } from './components/StatusBar'
|
|
16
16
|
import { SettingsDialog } from './components/SettingsDialog'
|
|
17
|
-
import {
|
|
17
|
+
import { TicketPane } from './components/TicketPanel'
|
|
18
18
|
|
|
19
19
|
interface AgentConfig {
|
|
20
20
|
id: string
|
|
@@ -60,6 +60,7 @@ declare global {
|
|
|
60
60
|
getSpeakReplies: () => Promise<{ enabled: boolean }>
|
|
61
61
|
setSpeakReplies: (enabled: boolean) => Promise<{ enabled: boolean }>
|
|
62
62
|
onAgentMessage: (callback: (text: string) => void) => () => void
|
|
63
|
+
onAgentState: (callback: (tabId: string, state: string, anyAwaiting: boolean) => void) => () => void
|
|
63
64
|
getLastReply: () => Promise<string | null>
|
|
64
65
|
createProject: () => Promise<{ ok: boolean; folder_id?: string; error?: string }>
|
|
65
66
|
getWebviewInfo: () => Promise<{
|
|
@@ -95,11 +96,13 @@ const DEFAULT_LAYOUT: LayoutNode = {
|
|
|
95
96
|
direction: 'horizontal',
|
|
96
97
|
children: [
|
|
97
98
|
{ type: 'leaf', paneId: 'terminal' },
|
|
98
|
-
{ type: 'leaf', paneId: '
|
|
99
|
+
{ type: 'leaf', paneId: 'tickets' },
|
|
99
100
|
],
|
|
100
101
|
sizes: [50, 50],
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
// Panes shown as plain toggle buttons in the titlebar. `tickets` has its own
|
|
105
|
+
// dedicated icon button, so it's not listed here.
|
|
103
106
|
const ALL_PANE_IDS = ['editor', 'terminal', 'ctlsurf']
|
|
104
107
|
|
|
105
108
|
interface TabInfo {
|
|
@@ -137,7 +140,9 @@ export default function App() {
|
|
|
137
140
|
})
|
|
138
141
|
const [activeTabId, setActiveTabId] = useState<string>(tabs[0].id)
|
|
139
142
|
const [trackingActive, setTrackingActive] = useState(false)
|
|
140
|
-
|
|
143
|
+
// Per-tab agent activity ('busy' | 'awaiting_input' | 'awaiting_decision' | 'idle'),
|
|
144
|
+
// driven by the main process. Powers the awaiting-input dot on each tab.
|
|
145
|
+
const [agentStates, setAgentStates] = useState<Record<string, string>>({})
|
|
141
146
|
// Draggable on-canvas push-to-talk mic; visibility persists across launches.
|
|
142
147
|
const [showFloatingMic, setShowFloatingMic] = useState<boolean>(() => {
|
|
143
148
|
try { return localStorage.getItem('ctlsurf.floatingMicVisible') !== 'false' } catch { return true }
|
|
@@ -202,6 +207,16 @@ export default function App() {
|
|
|
202
207
|
return unsub
|
|
203
208
|
}, [])
|
|
204
209
|
|
|
210
|
+
// Track each tab's agent activity for the awaiting-input dot. The aggregate-
|
|
211
|
+
// only signal arrives with an empty tabId, which we ignore here.
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
const unsub = window.worker.onAgentState((tabId, state) => {
|
|
214
|
+
if (!tabId) return
|
|
215
|
+
setAgentStates(prev => (prev[tabId] === state ? prev : { ...prev, [tabId]: state }))
|
|
216
|
+
})
|
|
217
|
+
return unsub
|
|
218
|
+
}, [])
|
|
219
|
+
|
|
205
220
|
// Reflect tracking state for the active tab; refresh on tab switch and poll
|
|
206
221
|
// periodically so the UI catches changes (e.g. a session ending naturally).
|
|
207
222
|
useEffect(() => {
|
|
@@ -385,6 +400,7 @@ export default function App() {
|
|
|
385
400
|
case 'E': e.preventDefault(); togglePane('editor'); break
|
|
386
401
|
case 'T': e.preventDefault(); togglePane('terminal'); break
|
|
387
402
|
case 'P': e.preventDefault(); togglePane('ctlsurf'); break
|
|
403
|
+
case 'K': e.preventDefault(); togglePane('tickets'); break
|
|
388
404
|
}
|
|
389
405
|
}
|
|
390
406
|
}
|
|
@@ -424,8 +440,15 @@ export default function App() {
|
|
|
424
440
|
>
|
|
425
441
|
<span className="terminal-tab-label">
|
|
426
442
|
{tab.agent ? tab.agent.name : tab.label}
|
|
427
|
-
{tab.agentStatus === '
|
|
428
|
-
|
|
443
|
+
{tab.agentStatus === 'exited' ? (
|
|
444
|
+
<span className="terminal-tab-dot exited" />
|
|
445
|
+
) : agentStates[tab.id] === 'awaiting_decision' ? (
|
|
446
|
+
<span className="terminal-tab-dot decision" title="Awaiting your approval" />
|
|
447
|
+
) : agentStates[tab.id] === 'awaiting_input' ? (
|
|
448
|
+
<span className="terminal-tab-dot awaiting" title="Awaiting your input" />
|
|
449
|
+
) : tab.agentStatus === 'active' ? (
|
|
450
|
+
<span className="terminal-tab-dot active" />
|
|
451
|
+
) : null}
|
|
429
452
|
</span>
|
|
430
453
|
{tabs.length > 1 && (
|
|
431
454
|
<button
|
|
@@ -467,6 +490,7 @@ export default function App() {
|
|
|
467
490
|
),
|
|
468
491
|
},
|
|
469
492
|
{ id: 'ctlsurf', label: 'ctlsurf', content: <CtlsurfPanel navigate={ctlsurfNav} /> },
|
|
493
|
+
{ id: 'tickets', label: 'Tickets', content: <TicketPane /> },
|
|
470
494
|
]
|
|
471
495
|
|
|
472
496
|
return (
|
|
@@ -495,9 +519,9 @@ export default function App() {
|
|
|
495
519
|
<span className={`tracking-dot ${trackingActive ? 'on' : 'off'}`} />
|
|
496
520
|
</button>
|
|
497
521
|
<button
|
|
498
|
-
className={`titlebar-btn titlebar-icon-btn ${
|
|
499
|
-
onClick={() =>
|
|
500
|
-
title="
|
|
522
|
+
className={`titlebar-btn titlebar-icon-btn ${visiblePaneIds.includes('tickets') ? 'active' : ''}`}
|
|
523
|
+
onClick={() => togglePane('tickets')}
|
|
524
|
+
title="Toggle tickets pane for the active project"
|
|
501
525
|
aria-label="Tickets"
|
|
502
526
|
>
|
|
503
527
|
<svg className="ticket-tag-icon" viewBox="0 0 24 24" width="13" height="13"
|
|
@@ -568,7 +592,6 @@ export default function App() {
|
|
|
568
592
|
|
|
569
593
|
<StatusBar wsStatus={wsStatus} cwd={cwd} onChangeCwd={handleChangeCwd} updateInfo={updateInfo} />
|
|
570
594
|
<SettingsDialog open={showSettings} onClose={() => setShowSettings(false)} />
|
|
571
|
-
<TicketPanel open={showTicketPanel} onClose={() => setShowTicketPanel(false)} />
|
|
572
595
|
|
|
573
596
|
{showAgentPicker && agents.length > 0 && (
|
|
574
597
|
<AgentPicker
|
|
@@ -26,11 +26,6 @@ interface Ticket {
|
|
|
26
26
|
created: string | null
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
interface TicketPanelProps {
|
|
30
|
-
open: boolean
|
|
31
|
-
onClose: () => void
|
|
32
|
-
}
|
|
33
|
-
|
|
34
29
|
type SaveState = { kind: 'idle' } | { kind: 'saving' } | { kind: 'error'; message: string }
|
|
35
30
|
type View = 'list' | 'form'
|
|
36
31
|
|
|
@@ -79,7 +74,10 @@ function Segmented({ options, value, onChange }: SegmentedProps) {
|
|
|
79
74
|
)
|
|
80
75
|
}
|
|
81
76
|
|
|
82
|
-
|
|
77
|
+
// Docked side-pane variant of the ticket UI: header + list/form body + footer.
|
|
78
|
+
// It only mounts when its pane is visible, so it loads on mount and refreshes
|
|
79
|
+
// when the connected project (cwd) changes. Closed via the pane's own header.
|
|
80
|
+
export function TicketPane() {
|
|
83
81
|
const [project, setProject] = useState<string | null>(null)
|
|
84
82
|
const [view, setView] = useState<View>('list')
|
|
85
83
|
|
|
@@ -111,17 +109,18 @@ export function TicketPanel({ open, onClose }: TicketPanelProps) {
|
|
|
111
109
|
}
|
|
112
110
|
}, [])
|
|
113
111
|
|
|
114
|
-
//
|
|
112
|
+
// Load project + tickets on mount, and refresh whenever the connected project
|
|
113
|
+
// (cwd) changes so the list always tracks the active project.
|
|
115
114
|
useEffect(() => {
|
|
116
|
-
if (!open) return
|
|
117
|
-
setView('list')
|
|
118
115
|
let cancelled = false
|
|
119
|
-
window.worker.getTicketProject()
|
|
116
|
+
const loadProject = () => window.worker.getTicketProject()
|
|
120
117
|
.then(r => { if (!cancelled) setProject(r?.name ?? null) })
|
|
121
118
|
.catch(() => { if (!cancelled) setProject(null) })
|
|
119
|
+
loadProject()
|
|
122
120
|
void refreshList()
|
|
123
|
-
|
|
124
|
-
|
|
121
|
+
const unsub = window.worker.onCwdChanged(() => { loadProject(); void refreshList() })
|
|
122
|
+
return () => { cancelled = true; unsub() }
|
|
123
|
+
}, [refreshList])
|
|
125
124
|
|
|
126
125
|
const openNewForm = useCallback(() => {
|
|
127
126
|
setEditingId(null)
|
|
@@ -162,28 +161,26 @@ export function TicketPanel({ open, onClose }: TicketPanelProps) {
|
|
|
162
161
|
}
|
|
163
162
|
}, [title, description, status, priority, editingId, save.kind, refreshList])
|
|
164
163
|
|
|
165
|
-
//
|
|
164
|
+
// Only capture keys while editing a ticket, so list-view typing in the terminal
|
|
165
|
+
// isn't hijacked. Esc returns to the list; Cmd/Ctrl+Enter saves.
|
|
166
166
|
useEffect(() => {
|
|
167
|
-
if (
|
|
167
|
+
if (view !== 'form') return
|
|
168
168
|
const onKey = (e: KeyboardEvent) => {
|
|
169
169
|
if (e.key === 'Escape') {
|
|
170
170
|
e.preventDefault()
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && view === 'form') {
|
|
171
|
+
setView('list')
|
|
172
|
+
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
174
173
|
e.preventDefault()
|
|
175
174
|
void handleSave()
|
|
176
175
|
}
|
|
177
176
|
}
|
|
178
177
|
window.addEventListener('keydown', onKey)
|
|
179
178
|
return () => window.removeEventListener('keydown', onKey)
|
|
180
|
-
}, [
|
|
179
|
+
}, [view, handleSave])
|
|
181
180
|
|
|
182
181
|
return (
|
|
183
|
-
|
|
184
|
-
<div className=
|
|
185
|
-
<div className={`ticket-drawer ${open ? 'open' : ''}`} role="dialog" aria-label="Tickets">
|
|
186
|
-
<div className="ticket-drawer-header">
|
|
182
|
+
<div className="ticket-pane" role="region" aria-label="Tickets">
|
|
183
|
+
<div className="ticket-drawer-header">
|
|
187
184
|
<div className="ticket-drawer-heading">
|
|
188
185
|
{view === 'form' ? (
|
|
189
186
|
<button className="ticket-drawer-back" onClick={() => setView('list')} title="Back (Esc)">‹</button>
|
|
@@ -201,7 +198,6 @@ export function TicketPanel({ open, onClose }: TicketPanelProps) {
|
|
|
201
198
|
</span>
|
|
202
199
|
</div>
|
|
203
200
|
</div>
|
|
204
|
-
<button className="ticket-drawer-close" onClick={onClose} title="Close">×</button>
|
|
205
201
|
</div>
|
|
206
202
|
|
|
207
203
|
{view === 'list' ? (
|
|
@@ -302,7 +298,6 @@ export function TicketPanel({ open, onClose }: TicketPanelProps) {
|
|
|
302
298
|
</div>
|
|
303
299
|
</>
|
|
304
300
|
)}
|
|
305
|
-
|
|
306
|
-
</>
|
|
301
|
+
</div>
|
|
307
302
|
)
|
|
308
303
|
}
|
package/src/renderer/styles.css
CHANGED
|
@@ -202,6 +202,24 @@ html, body, #root {
|
|
|
202
202
|
background: #565f89;
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
/* Agent is waiting on the user — pulse to draw the eye across tabs. */
|
|
206
|
+
.terminal-tab-dot.awaiting {
|
|
207
|
+
background: #e0af68; /* amber: waiting for next prompt */
|
|
208
|
+
color: #e0af68; /* drives the box-shadow glow via currentColor */
|
|
209
|
+
animation: tab-dot-pulse 1.1s ease-in-out infinite;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.terminal-tab-dot.decision {
|
|
213
|
+
background: #f7768e; /* red: blocked on an approval/permission prompt */
|
|
214
|
+
color: #f7768e;
|
|
215
|
+
animation: tab-dot-pulse 1.1s ease-in-out infinite;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
@keyframes tab-dot-pulse {
|
|
219
|
+
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 currentColor; }
|
|
220
|
+
50% { opacity: 0.35; box-shadow: 0 0 4px 1px currentColor; }
|
|
221
|
+
}
|
|
222
|
+
|
|
205
223
|
.terminal-tab-close {
|
|
206
224
|
background: none;
|
|
207
225
|
border: none;
|
|
@@ -1440,38 +1458,13 @@ html, body, #root {
|
|
|
1440
1458
|
|
|
1441
1459
|
/* ─── Ticket drawer ──────────────────────────────── */
|
|
1442
1460
|
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
inset: 0;
|
|
1446
|
-
background: rgba(13, 14, 22, 0.55);
|
|
1447
|
-
backdrop-filter: blur(2px);
|
|
1448
|
-
opacity: 0;
|
|
1449
|
-
pointer-events: none;
|
|
1450
|
-
transition: opacity 0.2s ease;
|
|
1451
|
-
z-index: 90;
|
|
1452
|
-
}
|
|
1453
|
-
.ticket-scrim.open {
|
|
1454
|
-
opacity: 1;
|
|
1455
|
-
pointer-events: auto;
|
|
1456
|
-
}
|
|
1457
|
-
|
|
1458
|
-
.ticket-drawer {
|
|
1459
|
-
position: fixed;
|
|
1460
|
-
top: 0;
|
|
1461
|
-
right: 0;
|
|
1462
|
-
bottom: 0;
|
|
1463
|
-
width: 380px;
|
|
1464
|
-
background: linear-gradient(180deg, #20243a 0%, #1b1e2e 100%);
|
|
1465
|
-
border-left: 1px solid #343860;
|
|
1466
|
-
box-shadow: -16px 0 48px rgba(0, 0, 0, 0.5);
|
|
1461
|
+
/* Docked side-pane variant: fills its pane (header/body/footer flex column). */
|
|
1462
|
+
.ticket-pane {
|
|
1467
1463
|
display: flex;
|
|
1468
1464
|
flex-direction: column;
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
}
|
|
1473
|
-
.ticket-drawer.open {
|
|
1474
|
-
transform: translateX(0);
|
|
1465
|
+
height: 100%;
|
|
1466
|
+
min-height: 0;
|
|
1467
|
+
background: linear-gradient(180deg, #20243a 0%, #1b1e2e 100%);
|
|
1475
1468
|
}
|
|
1476
1469
|
|
|
1477
1470
|
/* ── header ── */
|
|
@@ -1521,22 +1514,6 @@ html, body, #root {
|
|
|
1521
1514
|
.ticket-drawer-warn {
|
|
1522
1515
|
color: #e0af68;
|
|
1523
1516
|
}
|
|
1524
|
-
.ticket-drawer-close {
|
|
1525
|
-
background: none;
|
|
1526
|
-
border: none;
|
|
1527
|
-
color: #565f89;
|
|
1528
|
-
font-size: 20px;
|
|
1529
|
-
line-height: 1;
|
|
1530
|
-
cursor: pointer;
|
|
1531
|
-
padding: 2px 6px;
|
|
1532
|
-
border-radius: 6px;
|
|
1533
|
-
transition: background 0.12s ease, color 0.12s ease;
|
|
1534
|
-
}
|
|
1535
|
-
.ticket-drawer-close:hover {
|
|
1536
|
-
color: #d5dbf5;
|
|
1537
|
-
background: rgba(255, 255, 255, 0.06);
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
1517
|
/* ── body ── */
|
|
1541
1518
|
.ticket-drawer-body {
|
|
1542
1519
|
flex: 1;
|