@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.
Files changed (31) hide show
  1. package/out/headless/index.mjs +25 -19
  2. package/out/headless/index.mjs.map +2 -2
  3. package/out/main/index.js +30 -18
  4. package/out/renderer/assets/{cssMode-D7r66wvO.js → cssMode-KhYyY-BM.js} +3 -3
  5. package/out/renderer/assets/{freemarker2-jSWJIjPG.js → freemarker2-BFsSgcWk.js} +1 -1
  6. package/out/renderer/assets/{handlebars-BnFyz5RH.js → handlebars-Be6_aBsm.js} +1 -1
  7. package/out/renderer/assets/{html-Bl5UFavp.js → html-G8mvsyfc.js} +1 -1
  8. package/out/renderer/assets/{htmlMode-D4VByBT6.js → htmlMode-Cfd5VCjw.js} +3 -3
  9. package/out/renderer/assets/{index-Cc_TqOCL.css → index-Bi_MjBqs.css} +116 -8
  10. package/out/renderer/assets/{index-DxhW1emk.js → index-MqgBcJzy.js} +259 -87
  11. package/out/renderer/assets/{javascript-CMN1OmwG.js → javascript-XyDWi6dC.js} +2 -2
  12. package/out/renderer/assets/{jsonMode-CJ5AYSPV.js → jsonMode-DZT5gBVW.js} +3 -3
  13. package/out/renderer/assets/{liquid-BGMIDTEm.js → liquid-BuajCNJ9.js} +1 -1
  14. package/out/renderer/assets/{lspLanguageFeatures-DGifVYB5.js → lspLanguageFeatures-BYkg2Yc-.js} +1 -1
  15. package/out/renderer/assets/{mdx-B2Z7R10Q.js → mdx-D0Qx2GMS.js} +1 -1
  16. package/out/renderer/assets/{python-LMDMSSdn.js → python-ChncNy6S.js} +1 -1
  17. package/out/renderer/assets/{razor-BZSrziPu.js → razor-C6MUtfaS.js} +1 -1
  18. package/out/renderer/assets/{tsMode-DF9m6_3n.js → tsMode-C2aMdzdU.js} +1 -1
  19. package/out/renderer/assets/{typescript-rZeCMf0a.js → typescript-CB71AtAT.js} +1 -1
  20. package/out/renderer/assets/{xml-fNSZ-7so.js → xml-tHS1Ec9w.js} +1 -1
  21. package/out/renderer/assets/{yaml-1wxIuvzh.js → yaml-CZcp5ueF.js} +1 -1
  22. package/out/renderer/index.html +2 -2
  23. package/package.json +1 -1
  24. package/src/main/index.ts +9 -0
  25. package/src/main/orchestrator.ts +1 -1
  26. package/src/main/ticketStore.ts +26 -20
  27. package/src/renderer/App.tsx +14 -2
  28. package/src/renderer/components/CtlsurfPanel.tsx +169 -35
  29. package/src/renderer/components/TerminalPanel.tsx +7 -21
  30. package/src/renderer/components/TicketPanel.tsx +80 -24
  31. package/src/renderer/styles.css +116 -8
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-DxhW1emk.js";
1
+ import { l as languages } from "./index-MqgBcJzy.js";
2
2
  const conf = {
3
3
  wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\#\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g,
4
4
  comments: {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-DxhW1emk.js";
1
+ import { l as languages } from "./index-MqgBcJzy.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["<!--", "-->"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-DxhW1emk.js";
1
+ import { l as languages } from "./index-MqgBcJzy.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#"
@@ -49,8 +49,8 @@
49
49
  color: #565f89;
50
50
  }
51
51
  </style>
52
- <script type="module" crossorigin src="./assets/index-DxhW1emk.js"></script>
53
- <link rel="stylesheet" crossorigin href="./assets/index-Cc_TqOCL.css">
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.12.1",
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 {
@@ -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()) {
@@ -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
- private blockCache = new Map<string, string>()
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 blockId = await this.ensureDatastore(cwd, true)
84
- if (!blockId) {
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 blockId = await this.ensureDatastore(cwd, false)
108
- if (!blockId) return { ok: false, error: 'No Tickets datastore for this project' }
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 blockId = await this.ensureDatastore(cwd, false)
128
- if (!blockId) return { ok: true, tickets: [] }
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.blockCache.get(cwd)
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(agentPage.id)
165
+ const blockId = await this.findOrAdoptBlock(pageId)
162
166
  if (blockId) {
163
167
  await this.ensureColumns(blockId)
164
- this.blockCache.set(cwd, blockId)
165
- return blockId
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(agentPage.id, {
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
- this.blockCache.set(cwd, created.id)
184
- return created.id
188
+ const ds = { blockId: created.id, pageId }
189
+ this.dsCache.set(cwd, ds)
190
+ return ds
185
191
  }
186
192
  return null
187
193
  }
@@ -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
- // An authenticated ctlsurf URL to load (e.g. from a terminal link click).
5
- // `n` bumps per request so repeat navigations to the same URL still apply.
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<HTMLWebViewElement>(null)
11
- const [url, setUrl] = useState<string | null>(null)
12
- const [key, setKey] = useState(0) // force remount on cwd change / navigation
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
- const loadUrl = async () => {
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
- setUrl(info.pageUrl || info.frontendUrl)
75
+ load(info.pageUrl || info.frontendUrl)
18
76
  } catch (err) {
19
77
  console.error('[ctlsurf] Failed to get webview info:', err)
20
- setUrl('https://app.ctlsurf.com')
78
+ load('https://app.ctlsurf.com')
21
79
  }
22
- }
80
+ }, [load])
23
81
 
24
- // Default-load the project page on mount, unless a navigation is already
25
- // queued (e.g. the pane was just revealed to show a clicked link).
26
- useEffect(() => { if (!navigate?.url) loadUrl() }, [])
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 authenticated URL when requested.
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 webview when cwd changes
91
+ // Reload (remount) when the cwd changes — new project context.
37
92
  useEffect(() => {
38
93
  const unsub = window.worker.onCwdChanged(() => {
39
- setUrl(null)
94
+ hasSrcRef.current = false
95
+ readyRef.current = false
96
+ pendingRef.current = null
97
+ setSrc(null)
40
98
  setKey(k => k + 1)
41
- loadUrl()
99
+ loadProject()
42
100
  })
43
101
  return unsub
44
- }, [])
102
+ }, [loadProject])
45
103
 
46
- if (!url) {
47
- return (
48
- <div className="ctlsurf-panel" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
49
- <span style={{ color: '#565f89', fontSize: '13px' }}>Loading ctlsurf...</span>
50
- </div>
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
- <webview
57
- key={key}
58
- ref={webviewRef as any}
59
- src={url}
60
- style={{ width: '100%', height: '100%' }}
61
- allowpopups={'true' as any}
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
- // Open a URL in the system browser, mirroring WebLinksAddon's safe default
26
- // (clear the opener so the new page can't reach back into this window).
27
- function openExternal(uri: string): void {
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
- if (embedUrl) {
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
- function TicketCard({ ticket, onClick }: { ticket: Ticket; onClick: () => void }) {
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
- <button className="ticket-card" onClick={onClick}>
82
- <span className="ticket-card-bar" style={{ background: colorOf(STATUS_OPTIONS, ticket.status) }} />
83
- <div className="ticket-card-main">
84
- <span className="ticket-card-title">{ticket.title || 'Untitled'}</span>
85
- <div className="ticket-card-meta">
86
- <span className="ticket-card-status" style={{ color: colorOf(STATUS_OPTIONS, ticket.status) }}>
87
- <span className="ticket-seg-dot" style={{ background: colorOf(STATUS_OPTIONS, ticket.status) }} />
88
- {ticket.status}
89
- </span>
90
- {ticket.created && <span className="ticket-card-date">{formatDate(ticket.created)}</span>}
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
- </div>
93
- <span
94
- className="ticket-card-pri"
95
- style={{ color: colorOf(PRIORITY_OPTIONS, ticket.priority), borderColor: `${colorOf(PRIORITY_OPTIONS, ticket.priority)}66` }}
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
- {ticket.priority}
98
- </span>
99
- </button>
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) setTickets(r.tickets || [])
132
- else { setTickets([]); setListError(r?.error || 'Failed to load tickets') }
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
  )}