@phenx-inc/ctlsurf 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/out/headless/index.mjs +29 -19
  2. package/out/headless/index.mjs.map +2 -2
  3. package/out/main/index.js +35 -19
  4. package/out/renderer/assets/{cssMode-R8fTFLae.js → cssMode-KhYyY-BM.js} +3 -3
  5. package/out/renderer/assets/{freemarker2-CyjWzWoP.js → freemarker2-BFsSgcWk.js} +1 -1
  6. package/out/renderer/assets/{handlebars-CrhCGIRM.js → handlebars-Be6_aBsm.js} +1 -1
  7. package/out/renderer/assets/{html-Cc3dPNZr.js → html-G8mvsyfc.js} +1 -1
  8. package/out/renderer/assets/{htmlMode-BWtG6ywk.js → htmlMode-Cfd5VCjw.js} +3 -3
  9. package/out/renderer/assets/{index-BL9njXIL.css → index-Bi_MjBqs.css} +233 -8
  10. package/out/renderer/assets/{index-kkG56aKv.js → index-MqgBcJzy.js} +392 -120
  11. package/out/renderer/assets/{javascript-C8T_SdLC.js → javascript-XyDWi6dC.js} +2 -2
  12. package/out/renderer/assets/{jsonMode-D1zR4VVU.js → jsonMode-DZT5gBVW.js} +3 -3
  13. package/out/renderer/assets/{liquid-COSMe9Uf.js → liquid-BuajCNJ9.js} +1 -1
  14. package/out/renderer/assets/{lspLanguageFeatures-CXGfrtzl.js → lspLanguageFeatures-BYkg2Yc-.js} +1 -1
  15. package/out/renderer/assets/{mdx-RfS44T-L.js → mdx-D0Qx2GMS.js} +1 -1
  16. package/out/renderer/assets/{python-DFSWKHUu.js → python-ChncNy6S.js} +1 -1
  17. package/out/renderer/assets/{razor-DtH2_uvo.js → razor-C6MUtfaS.js} +1 -1
  18. package/out/renderer/assets/{tsMode-BYIQ-S9Z.js → tsMode-C2aMdzdU.js} +1 -1
  19. package/out/renderer/assets/{typescript-HhQUr1Rb.js → typescript-CB71AtAT.js} +1 -1
  20. package/out/renderer/assets/{xml-KuhgdKfD.js → xml-tHS1Ec9w.js} +1 -1
  21. package/out/renderer/assets/{yaml-W_kzCFH7.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 +13 -1
  25. package/src/main/orchestrator.ts +8 -1
  26. package/src/main/ticketStore.ts +26 -20
  27. package/src/renderer/App.tsx +19 -3
  28. package/src/renderer/components/CtlsurfPanel.tsx +169 -35
  29. package/src/renderer/components/SpeakControls.tsx +8 -2
  30. package/src/renderer/components/TerminalPanel.tsx +141 -44
  31. package/src/renderer/components/TicketPanel.tsx +126 -29
  32. package/src/renderer/styles.css +233 -8
@@ -1,5 +1,5 @@
1
- import { conf as conf$1, language as language$1 } from "./typescript-HhQUr1Rb.js";
2
- import "./index-kkG56aKv.js";
1
+ import { conf as conf$1, language as language$1 } from "./typescript-CB71AtAT.js";
2
+ import "./index-MqgBcJzy.js";
3
3
  const conf = conf$1;
4
4
  const language = {
5
5
  // Set defaultToken to invalid to see what you do not tokenize yet
@@ -1,6 +1,6 @@
1
- import { c as createWebWorker, l as languages, e as editor } from "./index-kkG56aKv.js";
2
- import { f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter, H as HoverAdapter, b as DocumentSymbolAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, S as SelectionRangeAdapter, e as DiagnosticsAdapter } from "./lspLanguageFeatures-CXGfrtzl.js";
3
- import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-CXGfrtzl.js";
1
+ import { c as createWebWorker, l as languages, e as editor } from "./index-MqgBcJzy.js";
2
+ import { f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter, H as HoverAdapter, b as DocumentSymbolAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, S as SelectionRangeAdapter, e as DiagnosticsAdapter } from "./lspLanguageFeatures-BYkg2Yc-.js";
3
+ import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-BYkg2Yc-.js";
4
4
  const STOP_WHEN_IDLE_FOR = 2 * 60 * 1e3;
5
5
  class WorkerManager {
6
6
  constructor(defaults) {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-kkG56aKv.js";
1
+ import { l as languages } from "./index-MqgBcJzy.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "area",
4
4
  "base",
@@ -1,4 +1,4 @@
1
- import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-kkG56aKv.js";
1
+ import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-MqgBcJzy.js";
2
2
  var DocumentUri;
3
3
  (function(DocumentUri2) {
4
4
  function is(value) {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-kkG56aKv.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-kkG56aKv.js";
1
+ import { l as languages } from "./index-MqgBcJzy.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#",
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-kkG56aKv.js";
1
+ import { l as languages } from "./index-MqgBcJzy.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "area",
4
4
  "base",
@@ -1,4 +1,4 @@
1
- import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-kkG56aKv.js";
1
+ import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-MqgBcJzy.js";
2
2
  class WorkerManager {
3
3
  constructor(_modeId, _defaults) {
4
4
  this._modeId = _modeId;
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-kkG56aKv.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-kkG56aKv.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-kkG56aKv.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-kkG56aKv.js"></script>
53
- <link rel="stylesheet" crossorigin href="./assets/index-BL9njXIL.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.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
@@ -220,7 +220,10 @@ const orchestrator = new Orchestrator(
220
220
  },
221
221
  onCwdChanged: () => {
222
222
  mainWindow?.webContents.send('app:cwdChanged')
223
- updateProjectBadge(orchestrator.cwd)
223
+ // Reflect the active tab's bound project (remote SSH sessions bind to a
224
+ // chosen ctlsurf project whose root_path is meaningful), falling back to
225
+ // the local launch cwd for unbound/local tabs.
226
+ updateProjectBadge(orchestrator.getActiveTabProjectPath() || orchestrator.cwd)
224
227
  },
225
228
  }
226
229
  )
@@ -251,6 +254,15 @@ function createWindow(): void {
251
254
  callback(true)
252
255
  })
253
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
+
254
266
  if (process.env.ELECTRON_RENDERER_URL) {
255
267
  mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
256
268
  } else {
@@ -627,12 +627,19 @@ export class Orchestrator {
627
627
  }
628
628
 
629
629
  setActiveTab(tabId: string): void {
630
+ const prevProjectPath = this.getActiveTabProjectPath()
630
631
  this.activeTabId = tabId
631
632
  const tab = this.tabs.get(tabId)
632
633
  if (tab) {
633
634
  this.currentAgent = tab.agent
634
635
  this.currentCwd = tab.cwd
635
636
  }
637
+ // Switching tabs changes which project the dock badge / window title and
638
+ // the renderer's ticket/ctlsurf panels should reflect. Only signal when the
639
+ // effective project path actually changed to avoid needless renderer churn.
640
+ if (this.getActiveTabProjectPath() !== prevProjectPath) {
641
+ this.events.onCwdChanged()
642
+ }
636
643
  }
637
644
 
638
645
  getTabIds(): string[] {
@@ -701,7 +708,7 @@ export class Orchestrator {
701
708
  return this.ticketStore.updateTicket(cwd, rowId, input)
702
709
  }
703
710
 
704
- 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 }> {
705
712
  const cwd = this.getActiveTabProjectPath()
706
713
  if (!cwd) return { ok: false, tickets: [], error: 'No active terminal tab' }
707
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) => {
@@ -438,6 +450,10 @@ export default function App() {
438
450
  return () => window.removeEventListener('ctlsurf-open-url', onOpen)
439
451
  }, [layout, togglePane])
440
452
 
453
+ // The active tab's agent — remote SSH sessions (id `remote-…`) can't have
454
+ // their replies spoken, so the speaker toggle is disabled for them.
455
+ const activeAgentIsRemote = !!tabs.find(t => t.id === activeTabId)?.agent?.id?.startsWith('remote-')
456
+
441
457
  // Build pane contents (always rendered, layout controls visibility)
442
458
  const panes: PaneContent[] = [
443
459
  { id: 'editor', label: 'Editor', content: <EditorPanel cwd={cwd} /> },
@@ -506,7 +522,7 @@ export default function App() {
506
522
  ),
507
523
  },
508
524
  { id: 'ctlsurf', label: 'ctlsurf', content: <CtlsurfPanel navigate={ctlsurfNav} /> },
509
- { id: 'tickets', label: 'Tickets', content: <TicketPane /> },
525
+ { id: 'tickets', label: 'Tickets', content: <TicketPane onSendToTerminal={handleSendToTerminal} /> },
510
526
  ]
511
527
 
512
528
  return (
@@ -562,7 +578,7 @@ export default function App() {
562
578
  <line x1="8" y1="23" x2="16" y2="23" />
563
579
  </svg>
564
580
  </button>
565
- <SpeakControls />
581
+ <SpeakControls remote={activeAgentIsRemote} />
566
582
  <span className="titlebar-separator" />
567
583
  {agents.map(a => {
568
584
  const activeTab = tabs.find(t => t.id === activeTabId)
@@ -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
  }
@@ -18,7 +18,10 @@ import {
18
18
 
19
19
  const SAMPLE = 'This is how spoken agent replies will sound.'
20
20
 
21
- export function SpeakControls() {
21
+ // `remote` is true while the active tab is a remote SSH session, where spoken
22
+ // replies can't work: the agent's transcript is written on the remote machine,
23
+ // so there's nothing local to read aloud. The toggle is disabled in that case.
24
+ export function SpeakControls({ remote = false }: { remote?: boolean }) {
22
25
  const [enabled, setEnabled] = useState(false)
23
26
  const [engine, setEngineState] = useState<TtsEngineId>(getEngine)
24
27
  const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([])
@@ -124,7 +127,9 @@ export function SpeakControls() {
124
127
  speech.enqueue(SAMPLE)
125
128
  }, [])
126
129
 
127
- const title = enabled ? 'Spoken replies on — click to mute' : 'Speak agent replies (off)'
130
+ const title = remote
131
+ ? "Spoken replies aren't available for remote SSH sessions — the agent's transcript lives on the remote machine"
132
+ : enabled ? 'Spoken replies on — click to mute' : 'Speak agent replies (off)'
128
133
 
129
134
  return (
130
135
  <div className="speak-controls" ref={wrapRef}>
@@ -133,6 +138,7 @@ export function SpeakControls() {
133
138
  className={`titlebar-btn titlebar-icon-btn speak-btn ${enabled ? 'active' : ''}`}
134
139
  onClick={toggle}
135
140
  title={title}
141
+ disabled={remote}
136
142
  aria-label="Toggle spoken agent replies"
137
143
  aria-pressed={enabled}
138
144
  >