@phenx-inc/ctlsurf 0.10.0 → 0.12.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 +111 -5
- package/out/headless/index.mjs.map +3 -3
- package/out/main/index.js +357 -28
- package/out/preload/index.js +9 -0
- package/out/renderer/assets/{cssMode-BbCVOTaJ.js → cssMode-R8fTFLae.js} +3 -3
- package/out/renderer/assets/{freemarker2-DryLywQv.js → freemarker2-CyjWzWoP.js} +1 -1
- package/out/renderer/assets/{handlebars-CdUMtSdr.js → handlebars-CrhCGIRM.js} +1 -1
- package/out/renderer/assets/{html-Cwkf2GMH.js → html-Cc3dPNZr.js} +1 -1
- package/out/renderer/assets/{htmlMode-C7PzhXxH.js → htmlMode-BWtG6ywk.js} +3 -3
- package/out/renderer/assets/{index-BIceGcz3.css → index-BL9njXIL.css} +206 -46
- package/out/renderer/assets/{index-Dg1vMspf.js → index-kkG56aKv.js} +529 -156
- package/out/renderer/assets/{javascript-Cy7ROXJl.js → javascript-C8T_SdLC.js} +2 -2
- package/out/renderer/assets/{jsonMode-BtZtt3QW.js → jsonMode-D1zR4VVU.js} +3 -3
- package/out/renderer/assets/{liquid-xReAQeYs.js → liquid-COSMe9Uf.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-Dmx0L0Bw.js → lspLanguageFeatures-CXGfrtzl.js} +1 -1
- package/out/renderer/assets/{mdx-YMNFO1Zz.js → mdx-RfS44T-L.js} +1 -1
- package/out/renderer/assets/{python-BExaDVxv.js → python-DFSWKHUu.js} +1 -1
- package/out/renderer/assets/{razor-HL8ANmQ4.js → razor-DtH2_uvo.js} +1 -1
- package/out/renderer/assets/{tsMode-Dqd8vdLE.js → tsMode-BYIQ-S9Z.js} +1 -1
- package/out/renderer/assets/{typescript-Dysz1PTX.js → typescript-HhQUr1Rb.js} +1 -1
- package/out/renderer/assets/{xml-BHp5Z9nc.js → xml-KuhgdKfD.js} +1 -1
- package/out/renderer/assets/{yaml-CPDHxVup.js → yaml-W_kzCFH7.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/agents.ts +9 -1
- package/src/main/ctlsurfApi.ts +7 -0
- package/src/main/index.ts +118 -34
- package/src/main/orchestrator.ts +105 -4
- package/src/main/ssh.ts +348 -0
- package/src/preload/index.ts +26 -0
- package/src/renderer/App.tsx +29 -7
- package/src/renderer/components/AgentPicker.tsx +18 -0
- package/src/renderer/components/ProjectPicker.tsx +121 -0
- package/src/renderer/components/RemoteConnect.tsx +315 -0
- package/src/renderer/components/TicketPanel.tsx +20 -25
- package/src/renderer/styles.css +206 -46
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
interface AgentConfig {
|
|
4
|
+
id: string
|
|
5
|
+
name: string
|
|
6
|
+
command: string
|
|
7
|
+
args: string[]
|
|
8
|
+
description: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface SshHost {
|
|
12
|
+
alias: string
|
|
13
|
+
hostName?: string
|
|
14
|
+
user?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ProbeResult {
|
|
18
|
+
reachable: boolean
|
|
19
|
+
authRequired: boolean
|
|
20
|
+
error?: string
|
|
21
|
+
tmuxInstalled: boolean
|
|
22
|
+
sessions: string[]
|
|
23
|
+
ctlsurfSessions: string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface RemoteConnectProps {
|
|
27
|
+
/** Called with the built ssh-backed agent once an action is chosen. */
|
|
28
|
+
onConnect: (agent: AgentConfig) => void
|
|
29
|
+
/** Return to the normal agent list. */
|
|
30
|
+
onBack: () => void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Step = 'hosts' | 'probing' | 'action' | 'error'
|
|
34
|
+
|
|
35
|
+
const TMUX_PREFIX = 'ctlsurf_'
|
|
36
|
+
|
|
37
|
+
// Lowest free ctlsurf_N given the sessions already on the host (mirrors
|
|
38
|
+
// nextSessionName in src/main/ssh.ts so the label is right before we connect).
|
|
39
|
+
function nextSessionName(sessions: string[]): string {
|
|
40
|
+
const used = new Set(
|
|
41
|
+
sessions
|
|
42
|
+
.filter((s) => s.startsWith(TMUX_PREFIX))
|
|
43
|
+
.map((s) => parseInt(s.slice(TMUX_PREFIX.length), 10))
|
|
44
|
+
.filter((n) => Number.isInteger(n) && n > 0),
|
|
45
|
+
)
|
|
46
|
+
let n = 1
|
|
47
|
+
while (used.has(n)) n++
|
|
48
|
+
return `${TMUX_PREFIX}${n}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function RemoteConnect({ onConnect, onBack }: RemoteConnectProps) {
|
|
52
|
+
const [step, setStep] = useState<Step>('hosts')
|
|
53
|
+
const [configHosts, setConfigHosts] = useState<SshHost[]>([])
|
|
54
|
+
const [recents, setRecents] = useState<string[]>([])
|
|
55
|
+
const [manual, setManual] = useState('')
|
|
56
|
+
const [agentCmd, setAgentCmd] = useState<'claude' | ''>('claude') // '' = remote shell
|
|
57
|
+
const [target, setTarget] = useState('')
|
|
58
|
+
const [probe, setProbe] = useState<ProbeResult | null>(null)
|
|
59
|
+
const [errorMsg, setErrorMsg] = useState('')
|
|
60
|
+
const [password, setPassword] = useState('')
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
let cancelled = false
|
|
64
|
+
window.worker.sshListHosts().then((r) => {
|
|
65
|
+
if (cancelled) return
|
|
66
|
+
setConfigHosts(r.configHosts || [])
|
|
67
|
+
setRecents(r.recents || [])
|
|
68
|
+
}).catch(() => { /* no hosts */ })
|
|
69
|
+
return () => { cancelled = true }
|
|
70
|
+
}, [])
|
|
71
|
+
|
|
72
|
+
const startProbe = async (t: string) => {
|
|
73
|
+
const dest = t.trim()
|
|
74
|
+
if (!dest) return
|
|
75
|
+
setTarget(dest)
|
|
76
|
+
setStep('probing')
|
|
77
|
+
try {
|
|
78
|
+
const result = await window.worker.sshProbe(dest)
|
|
79
|
+
setProbe(result)
|
|
80
|
+
if (result.reachable || result.authRequired) {
|
|
81
|
+
setStep('action')
|
|
82
|
+
} else {
|
|
83
|
+
setErrorMsg(result.error || 'Could not connect')
|
|
84
|
+
setStep('error')
|
|
85
|
+
}
|
|
86
|
+
} catch (err: any) {
|
|
87
|
+
setErrorMsg(err?.message || 'Probe failed')
|
|
88
|
+
setStep('error')
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const connect = async (action: unknown) => {
|
|
93
|
+
try {
|
|
94
|
+
const agent = await window.worker.sshBuildAgent(target, action, agentCmd)
|
|
95
|
+
// Hand the typed password (if any) to the main process for one-shot
|
|
96
|
+
// injection into ssh's prompt, then wipe it from component state. Blank =
|
|
97
|
+
// let ssh prompt in the terminal instead.
|
|
98
|
+
if (password) {
|
|
99
|
+
await window.worker.sshSetPassword(target, password).catch(() => { /* falls back to terminal prompt */ })
|
|
100
|
+
}
|
|
101
|
+
setPassword('')
|
|
102
|
+
window.worker.sshAddRecent(target).catch(() => { /* convenience only */ })
|
|
103
|
+
onConnect(agent)
|
|
104
|
+
} catch (err: any) {
|
|
105
|
+
setErrorMsg(err?.message || 'Failed to build connection')
|
|
106
|
+
setStep('error')
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const passwordField = (
|
|
111
|
+
<div className="remote-pw">
|
|
112
|
+
<input
|
|
113
|
+
className="remote-input"
|
|
114
|
+
type="password"
|
|
115
|
+
placeholder="Password (optional — blank prompts in terminal)"
|
|
116
|
+
value={password}
|
|
117
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
118
|
+
autoCapitalize="off"
|
|
119
|
+
autoCorrect="off"
|
|
120
|
+
spellCheck={false}
|
|
121
|
+
/>
|
|
122
|
+
<div className="remote-pw-hint">Used once for this connection — never stored.</div>
|
|
123
|
+
</div>
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
const agentLabel = agentCmd === 'claude' ? 'Claude Code' : 'shell'
|
|
127
|
+
|
|
128
|
+
// ─── Agent toggle (Claude Code vs shell), shared across steps ───
|
|
129
|
+
const agentToggle = (
|
|
130
|
+
<div className="remote-toggle">
|
|
131
|
+
<span className="remote-toggle-label">Run:</span>
|
|
132
|
+
<button
|
|
133
|
+
className={`remote-toggle-btn${agentCmd === 'claude' ? ' is-active' : ''}`}
|
|
134
|
+
onClick={() => setAgentCmd('claude')}
|
|
135
|
+
>Claude Code</button>
|
|
136
|
+
<button
|
|
137
|
+
className={`remote-toggle-btn${agentCmd === '' ? ' is-active' : ''}`}
|
|
138
|
+
onClick={() => setAgentCmd('')}
|
|
139
|
+
>Shell</button>
|
|
140
|
+
</div>
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<div className="agent-picker-overlay">
|
|
145
|
+
<div className="agent-picker-modal">
|
|
146
|
+
<div className="agent-picker-brand">ctlsurf</div>
|
|
147
|
+
<div className="agent-picker-title">CONNECT TO REMOTE</div>
|
|
148
|
+
|
|
149
|
+
{step === 'hosts' && (
|
|
150
|
+
<>
|
|
151
|
+
{agentToggle}
|
|
152
|
+
|
|
153
|
+
{recents.length > 0 && (
|
|
154
|
+
<>
|
|
155
|
+
<div className="remote-section-label">RECENT</div>
|
|
156
|
+
<div className="agent-picker-list">
|
|
157
|
+
{recents.map((r) => (
|
|
158
|
+
<button key={r} className="agent-picker-item" onClick={() => startProbe(r)}>
|
|
159
|
+
<span className="agent-picker-name">{r}</span>
|
|
160
|
+
<span className="agent-picker-desc">reconnect</span>
|
|
161
|
+
</button>
|
|
162
|
+
))}
|
|
163
|
+
</div>
|
|
164
|
+
</>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{configHosts.length > 0 && (
|
|
168
|
+
<>
|
|
169
|
+
<div className="remote-section-label">~/.ssh/config</div>
|
|
170
|
+
<div className="agent-picker-list remote-host-scroll">
|
|
171
|
+
{configHosts.map((h) => (
|
|
172
|
+
<button key={h.alias} className="agent-picker-item" onClick={() => startProbe(h.alias)}>
|
|
173
|
+
<span className="agent-picker-name">{h.alias}</span>
|
|
174
|
+
<span className="agent-picker-desc">
|
|
175
|
+
{h.user ? `${h.user}@` : ''}{h.hostName || ''}
|
|
176
|
+
</span>
|
|
177
|
+
</button>
|
|
178
|
+
))}
|
|
179
|
+
</div>
|
|
180
|
+
</>
|
|
181
|
+
)}
|
|
182
|
+
|
|
183
|
+
<div className="remote-section-label">OR TYPE A HOST</div>
|
|
184
|
+
<form
|
|
185
|
+
className="remote-manual"
|
|
186
|
+
onSubmit={(e) => { e.preventDefault(); startProbe(manual) }}
|
|
187
|
+
>
|
|
188
|
+
<input
|
|
189
|
+
className="remote-input"
|
|
190
|
+
type="text"
|
|
191
|
+
placeholder="user@host"
|
|
192
|
+
value={manual}
|
|
193
|
+
onChange={(e) => setManual(e.target.value)}
|
|
194
|
+
autoCapitalize="off"
|
|
195
|
+
autoCorrect="off"
|
|
196
|
+
spellCheck={false}
|
|
197
|
+
/>
|
|
198
|
+
<button className="remote-btn" type="submit" disabled={!manual.trim()}>Connect</button>
|
|
199
|
+
</form>
|
|
200
|
+
|
|
201
|
+
<button className="remote-link" onClick={onBack}>← back to agents</button>
|
|
202
|
+
</>
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
{step === 'probing' && (
|
|
206
|
+
<div className="remote-status">
|
|
207
|
+
<div className="remote-spinner" />
|
|
208
|
+
<div>Connecting to <b>{target}</b>…</div>
|
|
209
|
+
<div className="remote-status-sub">Checking for tmux sessions</div>
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
{step === 'action' && probe && (
|
|
214
|
+
<>
|
|
215
|
+
<div className="remote-target-line">
|
|
216
|
+
<b>{target}</b> · run {agentLabel}
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
{probe.authRequired && (
|
|
220
|
+
<>
|
|
221
|
+
<div className="remote-note">
|
|
222
|
+
This host needs a password. Enter it below, or leave blank to
|
|
223
|
+
type it at ssh's prompt in the terminal. Either way it is never stored.
|
|
224
|
+
</div>
|
|
225
|
+
{passwordField}
|
|
226
|
+
</>
|
|
227
|
+
)}
|
|
228
|
+
|
|
229
|
+
{/* Reattach existing sessions (only when we could enumerate them). */}
|
|
230
|
+
{probe.reachable && probe.sessions.length > 0 && (
|
|
231
|
+
<>
|
|
232
|
+
<div className="remote-section-label">REATTACH</div>
|
|
233
|
+
<div className="agent-picker-list">
|
|
234
|
+
{[...probe.sessions]
|
|
235
|
+
.sort((a, b) => (a.startsWith(TMUX_PREFIX) === b.startsWith(TMUX_PREFIX) ? 0 : a.startsWith(TMUX_PREFIX) ? -1 : 1))
|
|
236
|
+
.map((s) => (
|
|
237
|
+
<button
|
|
238
|
+
key={s}
|
|
239
|
+
className="agent-picker-item"
|
|
240
|
+
onClick={() => connect({ kind: 'attach', session: s })}
|
|
241
|
+
>
|
|
242
|
+
<span className="agent-picker-name">{s}</span>
|
|
243
|
+
<span className="agent-picker-desc">attach running tmux session</span>
|
|
244
|
+
</button>
|
|
245
|
+
))}
|
|
246
|
+
</div>
|
|
247
|
+
</>
|
|
248
|
+
)}
|
|
249
|
+
|
|
250
|
+
<div className="remote-section-label">START</div>
|
|
251
|
+
<div className="agent-picker-list">
|
|
252
|
+
{/* New tmux session — when reachable+tmux, or password host (auto). */}
|
|
253
|
+
{((probe.reachable && probe.tmuxInstalled) || probe.authRequired) && (
|
|
254
|
+
<button
|
|
255
|
+
className="agent-picker-item"
|
|
256
|
+
onClick={() => {
|
|
257
|
+
const session = probe.reachable ? nextSessionName(probe.sessions) : `${TMUX_PREFIX}1`
|
|
258
|
+
connect(probe.reachable
|
|
259
|
+
? { kind: 'new-tmux', session }
|
|
260
|
+
: { kind: 'auto', session })
|
|
261
|
+
}}
|
|
262
|
+
>
|
|
263
|
+
<span className="agent-picker-name">
|
|
264
|
+
New tmux session ({probe.reachable ? nextSessionName(probe.sessions) : `${TMUX_PREFIX}1`})
|
|
265
|
+
</span>
|
|
266
|
+
<span className="agent-picker-desc">persists across disconnects (recommended)</span>
|
|
267
|
+
</button>
|
|
268
|
+
)}
|
|
269
|
+
|
|
270
|
+
{probe.reachable && !probe.tmuxInstalled && (
|
|
271
|
+
<div className="remote-note">tmux isn't installed on this host — running without it.</div>
|
|
272
|
+
)}
|
|
273
|
+
|
|
274
|
+
<button
|
|
275
|
+
className="agent-picker-item"
|
|
276
|
+
onClick={() => connect({ kind: 'no-tmux' })}
|
|
277
|
+
>
|
|
278
|
+
<span className="agent-picker-name">Run without tmux</span>
|
|
279
|
+
<span className="agent-picker-desc">{agentLabel} directly — won't survive a dropped connection</span>
|
|
280
|
+
</button>
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
<button className="remote-link" onClick={() => setStep('hosts')}>← choose another host</button>
|
|
284
|
+
</>
|
|
285
|
+
)}
|
|
286
|
+
|
|
287
|
+
{step === 'error' && (
|
|
288
|
+
<>
|
|
289
|
+
<div className="remote-error">Couldn't connect to <b>{target}</b></div>
|
|
290
|
+
<div className="remote-status-sub">{errorMsg}</div>
|
|
291
|
+
{passwordField}
|
|
292
|
+
<div className="remote-section-label">TRY ANYWAY (INTERACTIVE)</div>
|
|
293
|
+
<div className="agent-picker-list">
|
|
294
|
+
<button
|
|
295
|
+
className="agent-picker-item"
|
|
296
|
+
onClick={() => connect({ kind: 'auto', session: `${TMUX_PREFIX}1` })}
|
|
297
|
+
>
|
|
298
|
+
<span className="agent-picker-name">Connect with tmux</span>
|
|
299
|
+
<span className="agent-picker-desc">ssh will prompt if a password / key passphrase is needed</span>
|
|
300
|
+
</button>
|
|
301
|
+
<button
|
|
302
|
+
className="agent-picker-item"
|
|
303
|
+
onClick={() => connect({ kind: 'no-tmux' })}
|
|
304
|
+
>
|
|
305
|
+
<span className="agent-picker-name">Connect without tmux</span>
|
|
306
|
+
<span className="agent-picker-desc">run {agentLabel} directly</span>
|
|
307
|
+
</button>
|
|
308
|
+
</div>
|
|
309
|
+
<button className="remote-link" onClick={() => setStep('hosts')}>← choose another host</button>
|
|
310
|
+
</>
|
|
311
|
+
)}
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
)
|
|
315
|
+
}
|
|
@@ -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
|
}
|