@phenx-inc/ctlsurf 0.12.1 → 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 +25 -19
- package/out/headless/index.mjs.map +2 -2
- package/out/main/index.js +30 -18
- package/out/renderer/assets/{cssMode-D7r66wvO.js → cssMode-KhYyY-BM.js} +3 -3
- package/out/renderer/assets/{freemarker2-jSWJIjPG.js → freemarker2-BFsSgcWk.js} +1 -1
- package/out/renderer/assets/{handlebars-BnFyz5RH.js → handlebars-Be6_aBsm.js} +1 -1
- package/out/renderer/assets/{html-Bl5UFavp.js → html-G8mvsyfc.js} +1 -1
- package/out/renderer/assets/{htmlMode-D4VByBT6.js → htmlMode-Cfd5VCjw.js} +3 -3
- package/out/renderer/assets/{index-Cc_TqOCL.css → index-Bi_MjBqs.css} +116 -8
- package/out/renderer/assets/{index-DxhW1emk.js → index-MqgBcJzy.js} +259 -87
- package/out/renderer/assets/{javascript-CMN1OmwG.js → javascript-XyDWi6dC.js} +2 -2
- package/out/renderer/assets/{jsonMode-CJ5AYSPV.js → jsonMode-DZT5gBVW.js} +3 -3
- package/out/renderer/assets/{liquid-BGMIDTEm.js → liquid-BuajCNJ9.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-DGifVYB5.js → lspLanguageFeatures-BYkg2Yc-.js} +1 -1
- package/out/renderer/assets/{mdx-B2Z7R10Q.js → mdx-D0Qx2GMS.js} +1 -1
- package/out/renderer/assets/{python-LMDMSSdn.js → python-ChncNy6S.js} +1 -1
- package/out/renderer/assets/{razor-BZSrziPu.js → razor-C6MUtfaS.js} +1 -1
- package/out/renderer/assets/{tsMode-DF9m6_3n.js → tsMode-C2aMdzdU.js} +1 -1
- package/out/renderer/assets/{typescript-rZeCMf0a.js → typescript-CB71AtAT.js} +1 -1
- package/out/renderer/assets/{xml-fNSZ-7so.js → xml-tHS1Ec9w.js} +1 -1
- package/out/renderer/assets/{yaml-1wxIuvzh.js → yaml-CZcp5ueF.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/index.ts +9 -0
- package/src/main/orchestrator.ts +1 -1
- package/src/main/ticketStore.ts +26 -20
- package/src/renderer/App.tsx +14 -2
- package/src/renderer/components/CtlsurfPanel.tsx +169 -35
- package/src/renderer/components/TerminalPanel.tsx +7 -21
- package/src/renderer/components/TicketPanel.tsx +80 -24
- package/src/renderer/styles.css +116 -8
package/out/renderer/index.html
CHANGED
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
color: #565f89;
|
|
50
50
|
}
|
|
51
51
|
</style>
|
|
52
|
-
<script type="module" crossorigin src="./assets/index-
|
|
53
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
52
|
+
<script type="module" crossorigin src="./assets/index-MqgBcJzy.js"></script>
|
|
53
|
+
<link rel="stylesheet" crossorigin href="./assets/index-Bi_MjBqs.css">
|
|
54
54
|
</head>
|
|
55
55
|
<body>
|
|
56
56
|
<div id="boot-splash">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phenx-inc/ctlsurf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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
|
@@ -254,6 +254,15 @@ function createWindow(): void {
|
|
|
254
254
|
callback(true)
|
|
255
255
|
})
|
|
256
256
|
|
|
257
|
+
// Keep links opened from inside the embedded ctlsurf webview (target=_blank,
|
|
258
|
+
// window.open) in the panel instead of spawning a detached popup window.
|
|
259
|
+
mainWindow.webContents.on('did-attach-webview', (_e, contents) => {
|
|
260
|
+
contents.setWindowOpenHandler(({ url }) => {
|
|
261
|
+
setImmediate(() => contents.loadURL(url))
|
|
262
|
+
return { action: 'deny' }
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
257
266
|
if (process.env.ELECTRON_RENDERER_URL) {
|
|
258
267
|
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
|
259
268
|
} else {
|
package/src/main/orchestrator.ts
CHANGED
|
@@ -708,7 +708,7 @@ export class Orchestrator {
|
|
|
708
708
|
return this.ticketStore.updateTicket(cwd, rowId, input)
|
|
709
709
|
}
|
|
710
710
|
|
|
711
|
-
async listTicketsForActiveTab(): Promise<{ ok: boolean; tickets: import('./ticketStore').Ticket[]; error?: string }> {
|
|
711
|
+
async listTicketsForActiveTab(): Promise<{ ok: boolean; tickets: import('./ticketStore').Ticket[]; blockId?: string; pageId?: string; error?: string }> {
|
|
712
712
|
const cwd = this.getActiveTabProjectPath()
|
|
713
713
|
if (!cwd) return { ok: false, tickets: [], error: 'No active terminal tab' }
|
|
714
714
|
if (!this.ctlsurfApi.getApiKey()) {
|
package/src/main/ticketStore.ts
CHANGED
|
@@ -69,7 +69,10 @@ function findPageByTitle(pages: any[], title: string): any | null {
|
|
|
69
69
|
*/
|
|
70
70
|
export class TicketStore {
|
|
71
71
|
private api: CtlsurfApi
|
|
72
|
-
|
|
72
|
+
// Per-cwd cache of the resolved Tickets datastore: the block (the datastore
|
|
73
|
+
// element) and the Agent Datastore page it lives on. Both ids are handed to
|
|
74
|
+
// the coding agent so it can read/update tickets via the ctlsurf MCP tools.
|
|
75
|
+
private dsCache = new Map<string, { blockId: string; pageId: string }>()
|
|
73
76
|
|
|
74
77
|
constructor(api: CtlsurfApi) {
|
|
75
78
|
this.api = api
|
|
@@ -80,11 +83,11 @@ export class TicketStore {
|
|
|
80
83
|
const title = input.title?.trim()
|
|
81
84
|
if (!title) return { ok: false, error: 'Title is required' }
|
|
82
85
|
try {
|
|
83
|
-
const
|
|
84
|
-
if (!
|
|
86
|
+
const ds = await this.ensureDatastore(cwd, true)
|
|
87
|
+
if (!ds) {
|
|
85
88
|
return { ok: false, error: 'No ctlsurf project found for this folder' }
|
|
86
89
|
}
|
|
87
|
-
await this.api.addRow(blockId, {
|
|
90
|
+
await this.api.addRow(ds.blockId, {
|
|
88
91
|
Title: title,
|
|
89
92
|
Description: input.description?.trim() || '',
|
|
90
93
|
Status: input.status || 'Open',
|
|
@@ -104,9 +107,9 @@ export class TicketStore {
|
|
|
104
107
|
const title = input.title?.trim()
|
|
105
108
|
if (!title) return { ok: false, error: 'Title is required' }
|
|
106
109
|
try {
|
|
107
|
-
const
|
|
108
|
-
if (!
|
|
109
|
-
await this.api.updateRow(blockId, rowId, {
|
|
110
|
+
const ds = await this.ensureDatastore(cwd, false)
|
|
111
|
+
if (!ds) return { ok: false, error: 'No Tickets datastore for this project' }
|
|
112
|
+
await this.api.updateRow(ds.blockId, rowId, {
|
|
110
113
|
Title: title,
|
|
111
114
|
Description: input.description?.trim() || '',
|
|
112
115
|
Status: input.status || 'Open',
|
|
@@ -122,11 +125,11 @@ export class TicketStore {
|
|
|
122
125
|
|
|
123
126
|
/** Lists existing tickets for the project, newest first. Does not create the
|
|
124
127
|
* datastore — an unconfigured project simply has no tickets yet. */
|
|
125
|
-
async listTickets(cwd: string): Promise<{ ok: boolean; tickets: Ticket[]; error?: string }> {
|
|
128
|
+
async listTickets(cwd: string): Promise<{ ok: boolean; tickets: Ticket[]; blockId?: string; pageId?: string; error?: string }> {
|
|
126
129
|
try {
|
|
127
|
-
const
|
|
128
|
-
if (!
|
|
129
|
-
const res = await this.api.queryRows(blockId, { orderBy: 'Created', order: 'desc', limit: 200 })
|
|
130
|
+
const ds = await this.ensureDatastore(cwd, false)
|
|
131
|
+
if (!ds) return { ok: true, tickets: [] }
|
|
132
|
+
const res = await this.api.queryRows(ds.blockId, { orderBy: 'Created', order: 'desc', limit: 200 })
|
|
130
133
|
const tickets: Ticket[] = (res?.rows || []).map(r => ({
|
|
131
134
|
id: r.id,
|
|
132
135
|
title: String(r.data?.Title ?? ''),
|
|
@@ -135,15 +138,15 @@ export class TicketStore {
|
|
|
135
138
|
priority: String(r.data?.Priority ?? 'Med'),
|
|
136
139
|
created: (r.data?.Created as string) ?? r.created_at ?? null,
|
|
137
140
|
}))
|
|
138
|
-
return { ok: true, tickets }
|
|
141
|
+
return { ok: true, tickets, blockId: ds.blockId, pageId: ds.pageId }
|
|
139
142
|
} catch (err: any) {
|
|
140
143
|
log(`listTickets failed for ${cwd}: ${err?.message || err}`)
|
|
141
144
|
return { ok: false, tickets: [], error: err?.message || String(err) }
|
|
142
145
|
}
|
|
143
146
|
}
|
|
144
147
|
|
|
145
|
-
private async ensureDatastore(cwd: string, create: boolean): Promise<string | null> {
|
|
146
|
-
const cached = this.
|
|
148
|
+
private async ensureDatastore(cwd: string, create: boolean): Promise<{ blockId: string; pageId: string } | null> {
|
|
149
|
+
const cached = this.dsCache.get(cwd)
|
|
147
150
|
if (cached) return cached
|
|
148
151
|
|
|
149
152
|
let folder: any = null
|
|
@@ -157,12 +160,14 @@ export class TicketStore {
|
|
|
157
160
|
const folderDetail = await this.api.getFolder(folder.id)
|
|
158
161
|
const agentPage = findPageByTitle(folderDetail?.pages || [], AGENT_DATASTORE_PAGE_TITLE)
|
|
159
162
|
if (!agentPage?.id) return null
|
|
163
|
+
const pageId: string = agentPage.id
|
|
160
164
|
|
|
161
|
-
const blockId = await this.findOrAdoptBlock(
|
|
165
|
+
const blockId = await this.findOrAdoptBlock(pageId)
|
|
162
166
|
if (blockId) {
|
|
163
167
|
await this.ensureColumns(blockId)
|
|
164
|
-
|
|
165
|
-
|
|
168
|
+
const ds = { blockId, pageId }
|
|
169
|
+
this.dsCache.set(cwd, ds)
|
|
170
|
+
return ds
|
|
166
171
|
}
|
|
167
172
|
|
|
168
173
|
if (!create) return null
|
|
@@ -173,15 +178,16 @@ export class TicketStore {
|
|
|
173
178
|
type: c.type,
|
|
174
179
|
...(c.options ? { options: c.options } : {}),
|
|
175
180
|
}))
|
|
176
|
-
const created = await this.api.createBlock(
|
|
181
|
+
const created = await this.api.createBlock(pageId, {
|
|
177
182
|
type: 'datastore',
|
|
178
183
|
title: DATASTORE_TITLE,
|
|
179
184
|
props: { columns, system_key: SYSTEM_KEY },
|
|
180
185
|
})
|
|
181
186
|
if (created?.id) {
|
|
182
187
|
log(`Created "${DATASTORE_TITLE}" datastore on Agent Datastore page for ${cwd}`)
|
|
183
|
-
|
|
184
|
-
|
|
188
|
+
const ds = { blockId: created.id, pageId }
|
|
189
|
+
this.dsCache.set(cwd, ds)
|
|
190
|
+
return ds
|
|
185
191
|
}
|
|
186
192
|
return null
|
|
187
193
|
}
|
package/src/renderer/App.tsx
CHANGED
|
@@ -60,7 +60,7 @@ declare global {
|
|
|
60
60
|
getTicketProject: () => Promise<{ cwd: string | null; name: string | null }>
|
|
61
61
|
addTicket: (input: { title: string; description?: string; status?: string; priority?: string }) => Promise<{ ok: boolean; error?: string }>
|
|
62
62
|
updateTicket: (rowId: string, input: { title: string; description?: string; status?: string; priority?: string }) => Promise<{ ok: boolean; error?: string }>
|
|
63
|
-
listTickets: () => Promise<{ ok: boolean; tickets: Array<{ id: string; title: string; description: string; status: string; priority: string; created: string | null }>; error?: string }>
|
|
63
|
+
listTickets: () => Promise<{ ok: boolean; tickets: Array<{ id: string; title: string; description: string; status: string; priority: string; created: string | null }>; blockId?: string; pageId?: string; error?: string }>
|
|
64
64
|
getTracking: () => Promise<{ active: boolean }>
|
|
65
65
|
setTracking: (enabled: boolean) => Promise<{ active: boolean }>
|
|
66
66
|
getLogChat: () => Promise<{ enabled: boolean }>
|
|
@@ -269,6 +269,18 @@ export default function App() {
|
|
|
269
269
|
setTimeout(() => window.worker.writePty(activeTabId, '\r'), 80)
|
|
270
270
|
}, [activeTabId])
|
|
271
271
|
|
|
272
|
+
// Send a prepared prompt (e.g. a ticket) into the active terminal and submit
|
|
273
|
+
// it, so the coding agent can start working immediately. Same paste-then-
|
|
274
|
+
// delayed-\r dance as voice typing — a slightly longer delay since ticket
|
|
275
|
+
// prompts are multi-line and take a touch longer to drain before the Enter.
|
|
276
|
+
const handleSendToTerminal = useCallback((text: string) => {
|
|
277
|
+
const trimmed = text.trim()
|
|
278
|
+
if (!trimmed) return
|
|
279
|
+
window.worker.writePty(activeTabId, trimmed)
|
|
280
|
+
focusTerminal(activeTabId)
|
|
281
|
+
setTimeout(() => window.worker.writePty(activeTabId, '\r'), 120)
|
|
282
|
+
}, [activeTabId])
|
|
283
|
+
|
|
272
284
|
const cwdRef = useRef<string | null>(null)
|
|
273
285
|
|
|
274
286
|
const handleSpawn = useCallback(async (tabId: string, agent: AgentConfig) => {
|
|
@@ -510,7 +522,7 @@ export default function App() {
|
|
|
510
522
|
),
|
|
511
523
|
},
|
|
512
524
|
{ id: 'ctlsurf', label: 'ctlsurf', content: <CtlsurfPanel navigate={ctlsurfNav} /> },
|
|
513
|
-
{ id: 'tickets', label: 'Tickets', content: <TicketPane /> },
|
|
525
|
+
{ id: 'tickets', label: 'Tickets', content: <TicketPane onSendToTerminal={handleSendToTerminal} /> },
|
|
514
526
|
]
|
|
515
527
|
|
|
516
528
|
return (
|
|
@@ -1,65 +1,199 @@
|
|
|
1
|
-
import { useRef, useEffect, useState } from 'react'
|
|
1
|
+
import { useRef, useEffect, useState, useCallback } from 'react'
|
|
2
2
|
|
|
3
3
|
interface CtlsurfPanelProps {
|
|
4
|
-
//
|
|
5
|
-
//
|
|
4
|
+
// A URL to load (e.g. from a terminal link click). `n` bumps per request so
|
|
5
|
+
// repeat navigations to the same URL still apply.
|
|
6
6
|
navigate?: { url: string; n: number } | null
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
// The <webview> element exposes navigation methods at runtime that aren't in
|
|
10
|
+
// React's typings — narrow the ref to the bits we use.
|
|
11
|
+
type WebviewEl = HTMLElement & {
|
|
12
|
+
loadURL: (url: string) => void
|
|
13
|
+
goBack: () => void
|
|
14
|
+
goForward: () => void
|
|
15
|
+
reload: () => void
|
|
16
|
+
stop: () => void
|
|
17
|
+
canGoBack: () => boolean
|
|
18
|
+
canGoForward: () => boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Strip the noisy auth/embed params so the address bar shows a clean URL.
|
|
22
|
+
function displayUrl(raw: string): string {
|
|
23
|
+
try {
|
|
24
|
+
const u = new URL(raw)
|
|
25
|
+
u.searchParams.delete('_code')
|
|
26
|
+
u.searchParams.delete('embed')
|
|
27
|
+
return u.toString()
|
|
28
|
+
} catch {
|
|
29
|
+
return raw
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
9
33
|
export function CtlsurfPanel({ navigate }: CtlsurfPanelProps) {
|
|
10
|
-
const webviewRef = useRef<
|
|
11
|
-
const [
|
|
12
|
-
const [
|
|
34
|
+
const webviewRef = useRef<WebviewEl | null>(null)
|
|
35
|
+
const [src, setSrc] = useState<string | null>(null) // initial webview src
|
|
36
|
+
const [address, setAddress] = useState('') // editable address-bar text
|
|
37
|
+
const [loading, setLoading] = useState(false)
|
|
38
|
+
const [nav, setNav] = useState({ back: false, forward: false })
|
|
39
|
+
const [key, setKey] = useState(0) // remount on cwd change
|
|
40
|
+
|
|
41
|
+
const hasSrcRef = useRef(false) // has the webview been seeded?
|
|
42
|
+
const readyRef = useRef(false) // is the webview dom-ready?
|
|
43
|
+
const pendingRef = useRef<string | null>(null) // url queued before dom-ready
|
|
44
|
+
|
|
45
|
+
// Resolve a raw URL to an authenticated ctlsurf embed URL when it points at
|
|
46
|
+
// ctlsurf, else return it unchanged.
|
|
47
|
+
const resolve = useCallback(async (raw: string): Promise<string> => {
|
|
48
|
+
try {
|
|
49
|
+
return (await window.worker.getEmbedUrl(raw)) || raw
|
|
50
|
+
} catch {
|
|
51
|
+
return raw
|
|
52
|
+
}
|
|
53
|
+
}, [])
|
|
54
|
+
|
|
55
|
+
// Load a URL into the webview. The first load seeds the `src` attribute (the
|
|
56
|
+
// element doesn't exist yet); later loads call loadURL so in-webview history
|
|
57
|
+
// stays intact for the back/forward buttons.
|
|
58
|
+
const load = useCallback((url: string) => {
|
|
59
|
+
if (!url) return
|
|
60
|
+
setAddress(displayUrl(url))
|
|
61
|
+
if (!hasSrcRef.current) {
|
|
62
|
+
hasSrcRef.current = true
|
|
63
|
+
setSrc(url)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
const wv = webviewRef.current
|
|
67
|
+
if (wv && readyRef.current) wv.loadURL(url)
|
|
68
|
+
else pendingRef.current = url
|
|
69
|
+
}, [])
|
|
13
70
|
|
|
14
|
-
|
|
71
|
+
// Default-load the project page, preferring the authenticated page URL.
|
|
72
|
+
const loadProject = useCallback(async () => {
|
|
15
73
|
try {
|
|
16
74
|
const info = await window.worker.getWebviewInfo()
|
|
17
|
-
|
|
75
|
+
load(info.pageUrl || info.frontendUrl)
|
|
18
76
|
} catch (err) {
|
|
19
77
|
console.error('[ctlsurf] Failed to get webview info:', err)
|
|
20
|
-
|
|
78
|
+
load('https://app.ctlsurf.com')
|
|
21
79
|
}
|
|
22
|
-
}
|
|
80
|
+
}, [load])
|
|
23
81
|
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
useEffect(() => { if (!navigate?.url)
|
|
82
|
+
// On mount, load the project page unless a navigation is already queued (e.g.
|
|
83
|
+
// the pane was just revealed to show a clicked link).
|
|
84
|
+
useEffect(() => { if (!navigate?.url) loadProject() }, [])
|
|
27
85
|
|
|
28
|
-
// Navigate to a specific
|
|
86
|
+
// Navigate to a specific URL when requested (terminal / external link click).
|
|
29
87
|
useEffect(() => {
|
|
30
|
-
if (navigate?.url)
|
|
31
|
-
setUrl(navigate.url)
|
|
32
|
-
setKey(k => k + 1)
|
|
33
|
-
}
|
|
88
|
+
if (navigate?.url) resolve(navigate.url).then(load)
|
|
34
89
|
}, [navigate?.n])
|
|
35
90
|
|
|
36
|
-
// Reload
|
|
91
|
+
// Reload (remount) when the cwd changes — new project context.
|
|
37
92
|
useEffect(() => {
|
|
38
93
|
const unsub = window.worker.onCwdChanged(() => {
|
|
39
|
-
|
|
94
|
+
hasSrcRef.current = false
|
|
95
|
+
readyRef.current = false
|
|
96
|
+
pendingRef.current = null
|
|
97
|
+
setSrc(null)
|
|
40
98
|
setKey(k => k + 1)
|
|
41
|
-
|
|
99
|
+
loadProject()
|
|
42
100
|
})
|
|
43
101
|
return unsub
|
|
44
|
-
}, [])
|
|
102
|
+
}, [loadProject])
|
|
45
103
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
104
|
+
// Wire webview lifecycle + navigation events to the address bar.
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const wv = webviewRef.current
|
|
107
|
+
if (!wv) return
|
|
108
|
+
const syncNav = () =>
|
|
109
|
+
setNav({ back: wv.canGoBack?.() ?? false, forward: wv.canGoForward?.() ?? false })
|
|
110
|
+
const onReady = () => {
|
|
111
|
+
readyRef.current = true
|
|
112
|
+
if (pendingRef.current) {
|
|
113
|
+
wv.loadURL(pendingRef.current)
|
|
114
|
+
pendingRef.current = null
|
|
115
|
+
}
|
|
116
|
+
syncNav()
|
|
117
|
+
}
|
|
118
|
+
const onNavigate = (e: Event) => {
|
|
119
|
+
const url = (e as Event & { url?: string }).url
|
|
120
|
+
if (url) setAddress(displayUrl(url))
|
|
121
|
+
syncNav()
|
|
122
|
+
}
|
|
123
|
+
const onStart = () => setLoading(true)
|
|
124
|
+
const onStop = () => { setLoading(false); syncNav() }
|
|
125
|
+
wv.addEventListener('dom-ready', onReady)
|
|
126
|
+
wv.addEventListener('did-navigate', onNavigate)
|
|
127
|
+
wv.addEventListener('did-navigate-in-page', onNavigate)
|
|
128
|
+
wv.addEventListener('did-start-loading', onStart)
|
|
129
|
+
wv.addEventListener('did-stop-loading', onStop)
|
|
130
|
+
return () => {
|
|
131
|
+
wv.removeEventListener('dom-ready', onReady)
|
|
132
|
+
wv.removeEventListener('did-navigate', onNavigate)
|
|
133
|
+
wv.removeEventListener('did-navigate-in-page', onNavigate)
|
|
134
|
+
wv.removeEventListener('did-start-loading', onStart)
|
|
135
|
+
wv.removeEventListener('did-stop-loading', onStop)
|
|
136
|
+
}
|
|
137
|
+
}, [src, key])
|
|
138
|
+
|
|
139
|
+
const onSubmit = (e: React.FormEvent) => {
|
|
140
|
+
e.preventDefault()
|
|
141
|
+
let target = address.trim()
|
|
142
|
+
if (!target) return
|
|
143
|
+
// Bare hostnames (no scheme) default to https.
|
|
144
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(target)) target = 'https://' + target
|
|
145
|
+
resolve(target).then(load)
|
|
52
146
|
}
|
|
53
147
|
|
|
54
148
|
return (
|
|
55
149
|
<div className="ctlsurf-panel">
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
150
|
+
<div className="ctlsurf-addressbar">
|
|
151
|
+
<button
|
|
152
|
+
type="button"
|
|
153
|
+
className="ctlsurf-nav-btn"
|
|
154
|
+
title="Back"
|
|
155
|
+
disabled={!nav.back}
|
|
156
|
+
onClick={() => webviewRef.current?.goBack?.()}
|
|
157
|
+
>‹</button>
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
className="ctlsurf-nav-btn"
|
|
161
|
+
title="Forward"
|
|
162
|
+
disabled={!nav.forward}
|
|
163
|
+
onClick={() => webviewRef.current?.goForward?.()}
|
|
164
|
+
>›</button>
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
className="ctlsurf-nav-btn"
|
|
168
|
+
title={loading ? 'Stop' : 'Reload'}
|
|
169
|
+
onClick={() => (loading ? webviewRef.current?.stop?.() : webviewRef.current?.reload?.())}
|
|
170
|
+
>{loading ? '×' : '⟳'}</button>
|
|
171
|
+
<form className="ctlsurf-address-form" onSubmit={onSubmit}>
|
|
172
|
+
<input
|
|
173
|
+
className="ctlsurf-address-input"
|
|
174
|
+
value={address}
|
|
175
|
+
spellCheck={false}
|
|
176
|
+
placeholder="Enter a URL"
|
|
177
|
+
onChange={e => setAddress(e.target.value)}
|
|
178
|
+
onFocus={e => e.currentTarget.select()}
|
|
179
|
+
/>
|
|
180
|
+
</form>
|
|
181
|
+
</div>
|
|
182
|
+
<div className="ctlsurf-webview-host">
|
|
183
|
+
{src ? (
|
|
184
|
+
<webview
|
|
185
|
+
key={key}
|
|
186
|
+
ref={webviewRef as any}
|
|
187
|
+
src={src}
|
|
188
|
+
style={{ width: '100%', height: '100%' }}
|
|
189
|
+
allowpopups={'true' as any}
|
|
190
|
+
/>
|
|
191
|
+
) : (
|
|
192
|
+
<div className="ctlsurf-webview-loading">
|
|
193
|
+
<span style={{ color: '#565f89', fontSize: '13px' }}>Loading ctlsurf...</span>
|
|
194
|
+
</div>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
63
197
|
</div>
|
|
64
198
|
)
|
|
65
199
|
}
|
|
@@ -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 {
|
|
@@ -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
|
|
@@ -75,40 +113,55 @@ function Segmented({ options, value, onChange }: SegmentedProps) {
|
|
|
75
113
|
}
|
|
76
114
|
|
|
77
115
|
// A single ticket row. Shared between the open list and the (collapsible)
|
|
78
|
-
// Done section so both render identically.
|
|
79
|
-
|
|
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 }) {
|
|
80
119
|
return (
|
|
81
|
-
<
|
|
82
|
-
<
|
|
83
|
-
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
<
|
|
87
|
-
<span className="ticket-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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>
|
|
91
132
|
</div>
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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"
|
|
96
146
|
>
|
|
97
|
-
|
|
98
|
-
</
|
|
99
|
-
</
|
|
147
|
+
<SendIcon />
|
|
148
|
+
</button>
|
|
149
|
+
</div>
|
|
100
150
|
)
|
|
101
151
|
}
|
|
102
152
|
|
|
103
153
|
// Docked side-pane variant of the ticket UI: header + list/form body + footer.
|
|
104
154
|
// It only mounts when its pane is visible, so it loads on mount and refreshes
|
|
105
155
|
// when the connected project (cwd) changes. Closed via the pane's own header.
|
|
106
|
-
export function TicketPane() {
|
|
156
|
+
export function TicketPane({ onSendToTerminal }: { onSendToTerminal: (text: string) => void }) {
|
|
107
157
|
const [project, setProject] = useState<string | null>(null)
|
|
108
158
|
const [view, setView] = useState<View>('list')
|
|
109
159
|
|
|
110
160
|
// list state
|
|
111
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)
|
|
112
165
|
const [listLoading, setListLoading] = useState(false)
|
|
113
166
|
const [listError, setListError] = useState<string | null>(null)
|
|
114
167
|
// Done tickets are tucked into a collapsed section so the list stays focused
|
|
@@ -128,10 +181,13 @@ export function TicketPane() {
|
|
|
128
181
|
setListError(null)
|
|
129
182
|
try {
|
|
130
183
|
const r = await window.worker.listTickets()
|
|
131
|
-
if (r?.ok)
|
|
132
|
-
|
|
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') }
|
|
133
188
|
} catch (err: any) {
|
|
134
189
|
setTickets([])
|
|
190
|
+
setRefs(null)
|
|
135
191
|
setListError(err?.message || 'Failed to load tickets')
|
|
136
192
|
} finally {
|
|
137
193
|
setListLoading(false)
|
|
@@ -251,7 +307,7 @@ export function TicketPane() {
|
|
|
251
307
|
</div>
|
|
252
308
|
)}
|
|
253
309
|
{!listLoading && activeTickets.map(t => (
|
|
254
|
-
<TicketCard key={t.id} ticket={t} onClick={() => openEditForm(t)} />
|
|
310
|
+
<TicketCard key={t.id} ticket={t} onClick={() => openEditForm(t)} onSend={() => onSendToTerminal(ticketPrompt(t, refs))} />
|
|
255
311
|
))}
|
|
256
312
|
{!listLoading && activeTickets.length === 0 && doneTickets.length > 0 && (
|
|
257
313
|
<div className="ticket-list-msg">Nothing open — all caught up.</div>
|
|
@@ -269,7 +325,7 @@ export function TicketPane() {
|
|
|
269
325
|
<span className="ticket-done-count">{doneTickets.length}</span>
|
|
270
326
|
</button>
|
|
271
327
|
{showDone && doneTickets.map(t => (
|
|
272
|
-
<TicketCard key={t.id} ticket={t} onClick={() => openEditForm(t)} />
|
|
328
|
+
<TicketCard key={t.id} ticket={t} onClick={() => openEditForm(t)} onSend={() => onSendToTerminal(ticketPrompt(t, refs))} />
|
|
273
329
|
))}
|
|
274
330
|
</>
|
|
275
331
|
)}
|