@phenx-inc/ctlsurf 0.3.13 → 0.3.15

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 (35) hide show
  1. package/bin/ctlsurf-worker.js +38 -22
  2. package/out/headless/index.mjs +295 -3
  3. package/out/headless/index.mjs.map +4 -4
  4. package/out/main/index.js +351 -48
  5. package/out/preload/index.js +11 -0
  6. package/out/renderer/assets/{cssMode-CYoo4t9f.js → cssMode-D5dPwEy5.js} +3 -3
  7. package/out/renderer/assets/{freemarker2--UQnPZsn.js → freemarker2-c5jJjQ9s.js} +1 -1
  8. package/out/renderer/assets/{handlebars-DVDrmX0C.js → handlebars-BTbmOxx9.js} +1 -1
  9. package/out/renderer/assets/{html-D1-cXoLy.js → html-3cIIQcxO.js} +1 -1
  10. package/out/renderer/assets/{htmlMode-f5nBuprq.js → htmlMode-DYbpW1yY.js} +3 -3
  11. package/out/renderer/assets/{index-65hyKM_8.css → index-6KvOnYL1.css} +404 -0
  12. package/out/renderer/assets/{index-D23nru43.js → index-D2MUZin7.js} +332 -23
  13. package/out/renderer/assets/{javascript-CcarFzBL.js → javascript-CDuCMm-6.js} +2 -2
  14. package/out/renderer/assets/{jsonMode-BvF-xK9U.js → jsonMode-COLqbq0s.js} +3 -3
  15. package/out/renderer/assets/{liquid-CHLtUKl2.js → liquid-BFcqZizB.js} +1 -1
  16. package/out/renderer/assets/{lspLanguageFeatures-B9aNeatS.js → lspLanguageFeatures-CbkEcL-z.js} +1 -1
  17. package/out/renderer/assets/{mdx-HGDrkifZ.js → mdx-DyK93oEE.js} +1 -1
  18. package/out/renderer/assets/{python-B_dPzjJ6.js → python-D4lCwSVr.js} +1 -1
  19. package/out/renderer/assets/{razor-CHheM4ot.js → razor-DdkE9XVt.js} +1 -1
  20. package/out/renderer/assets/{tsMode-CdC3i1gG.js → tsMode-BrQ4Fsc-.js} +1 -1
  21. package/out/renderer/assets/{typescript-BX6guVRK.js → typescript-BakbYMnC.js} +1 -1
  22. package/out/renderer/assets/{xml-CpS-pOPE.js → xml-DHDW9Xhp.js} +1 -1
  23. package/out/renderer/assets/{yaml-Du0AjOHW.js → yaml-1Ayv_J3q.js} +1 -1
  24. package/out/renderer/index.html +2 -2
  25. package/package.json +1 -1
  26. package/src/main/agents.ts +36 -1
  27. package/src/main/ctlsurfApi.ts +11 -0
  28. package/src/main/headless.ts +5 -3
  29. package/src/main/index.ts +24 -2
  30. package/src/main/orchestrator.ts +66 -0
  31. package/src/main/ticketStore.ts +252 -0
  32. package/src/preload/index.ts +17 -0
  33. package/src/renderer/App.tsx +40 -1
  34. package/src/renderer/components/TicketPanel.tsx +308 -0
  35. package/src/renderer/styles.css +404 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phenx-inc/ctlsurf",
3
- "version": "0.3.13",
3
+ "version": "0.3.15",
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": {
@@ -1,3 +1,6 @@
1
+ import { accessSync, constants } from 'fs'
2
+ import { join, delimiter } from 'path'
3
+
1
4
  export interface AgentConfig {
2
5
  id: string
3
6
  name: string
@@ -11,6 +14,27 @@ function getShellCommand(): string {
11
14
  return process.env.SHELL || '/bin/zsh'
12
15
  }
13
16
 
17
+ // Resolve a bare command name against PATH (like `which`/`where`), so we only
18
+ // offer coding agents whose CLI is actually installed. Both launch modes
19
+ // inherit the user's shell PATH (the `ctlsurf` launcher runs under their
20
+ // shell), so process.env.PATH is the right thing to scan.
21
+ function isCommandAvailable(command: string): boolean {
22
+ const dirs = (process.env.PATH || '').split(delimiter).filter(Boolean)
23
+ const isWin = process.platform === 'win32'
24
+ const exts = isWin
25
+ ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean)
26
+ : ['']
27
+ for (const dir of dirs) {
28
+ for (const ext of exts) {
29
+ try {
30
+ accessSync(join(dir, command + ext), isWin ? constants.F_OK : constants.X_OK)
31
+ return true
32
+ } catch { /* not in this dir */ }
33
+ }
34
+ }
35
+ return false
36
+ }
37
+
14
38
  export function getBuiltinAgents(): AgentConfig[] {
15
39
  return [
16
40
  {
@@ -37,8 +61,19 @@ export function getBuiltinAgents(): AgentConfig[] {
37
61
  ]
38
62
  }
39
63
 
64
+ // Builtin agents filtered to those actually usable: coding agents only when
65
+ // their CLI is installed (on PATH), with the shell always offered last. If no
66
+ // coding agent is installed the list collapses to just the shell.
67
+ export function getAvailableAgents(): AgentConfig[] {
68
+ const all = getBuiltinAgents()
69
+ const coding = all.filter(a => isCodingAgent(a) && isCommandAvailable(a.command))
70
+ const shell = all.filter(a => !isCodingAgent(a))
71
+ return [...coding, ...shell]
72
+ }
73
+
40
74
  export function getDefaultAgent(): AgentConfig {
41
- return getBuiltinAgents()[0]
75
+ // First available coding agent, or the shell when none are installed.
76
+ return getAvailableAgents()[0]
42
77
  }
43
78
 
44
79
  export function isCodingAgent(agent: AgentConfig): boolean {
@@ -123,6 +123,17 @@ export class CtlsurfApi {
123
123
  return this.request('PUT', `/datastore/${blockId}/rows/${rowId}`, { data })
124
124
  }
125
125
 
126
+ async queryRows(blockId: string, opts?: {
127
+ orderBy?: string; order?: 'asc' | 'desc'; limit?: number
128
+ }): Promise<{ rows: Array<{ id: string; data: Record<string, unknown>; created_at: string | null; updated_at: string | null }>; total_count: number }> {
129
+ const params = new URLSearchParams()
130
+ if (opts?.orderBy) params.set('order_by', opts.orderBy)
131
+ if (opts?.order) params.set('order', opts.order)
132
+ params.set('limit', String(opts?.limit ?? 200))
133
+ const qs = params.toString()
134
+ return this.request('GET', `/datastore/${blockId}/rows${qs ? `?${qs}` : ''}`)
135
+ }
136
+
126
137
  async getDatastoreSchema(blockId: string): Promise<{ block_id: string; columns: Array<{ id: string; name: string; type: string }> }> {
127
138
  return this.request('GET', `/datastore/${blockId}/schema`)
128
139
  }
@@ -29,7 +29,7 @@ setSilent(true)
29
29
 
30
30
  import { Orchestrator } from './orchestrator'
31
31
  import { getSettingsDir } from './settingsDir'
32
- import { getBuiltinAgents, isCodingAgent, type AgentConfig } from './agents'
32
+ import { getBuiltinAgents, getAvailableAgents, isCodingAgent, type AgentConfig } from './agents'
33
33
  import { Tui } from './tui'
34
34
  import { fetchLatestNpmVersion, compareSemver, NPM_PACKAGE } from './updateCheck'
35
35
 
@@ -103,7 +103,9 @@ async function main() {
103
103
  await checkVersionAndNotify()
104
104
 
105
105
  const tui = new Tui()
106
- const agents = getBuiltinAgents()
106
+ // Picker only offers installed coding agents (+ shell, last). Explicit
107
+ // --agent still resolves against the full builtin list below.
108
+ const agents = getAvailableAgents()
107
109
 
108
110
  // ─── Orchestrator (loaded early so picker can read profile defaults) ──
109
111
 
@@ -140,7 +142,7 @@ async function main() {
140
142
  let trackTimeOverride: boolean | undefined
141
143
 
142
144
  if (args.agent) {
143
- const found = agents.find(a => a.id === args.agent)
145
+ const found = getBuiltinAgents().find(a => a.id === args.agent)
144
146
  agent = found || {
145
147
  id: args.agent,
146
148
  name: args.agent,
package/src/main/index.ts CHANGED
@@ -21,7 +21,7 @@ function log(...args: unknown[]): void {
21
21
  try { console.log(...args) } catch { /* EPIPE safe */ }
22
22
  }
23
23
 
24
- import { AgentConfig, getDefaultAgent, getBuiltinAgents } from './agents'
24
+ import { AgentConfig, getDefaultAgent, getAvailableAgents } from './agents'
25
25
  import { getSettingsDir } from './settingsDir'
26
26
  import { Orchestrator } from './orchestrator'
27
27
 
@@ -105,6 +105,7 @@ const orchestrator = new Orchestrator(
105
105
  onWorkerStatus: (status) => mainWindow?.webContents.send('worker:status', status),
106
106
  onWorkerMessage: (message) => mainWindow?.webContents.send('worker:message', message),
107
107
  onWorkerRegistered: (data) => mainWindow?.webContents.send('worker:registered', data),
108
+ onProjectChanged: (name) => mainWindow?.webContents.send('app:projectChanged', name),
108
109
  onCwdChanged: () => {
109
110
  mainWindow?.webContents.send('app:cwdChanged')
110
111
  updateProjectBadge(orchestrator.cwd)
@@ -165,11 +166,12 @@ ipcMain.handle('pty:setActiveTab', (_event, tabId: string) => {
165
166
  orchestrator.setActiveTab(tabId)
166
167
  })
167
168
 
168
- ipcMain.handle('agents:list', () => getBuiltinAgents())
169
+ ipcMain.handle('agents:list', () => getAvailableAgents())
169
170
  ipcMain.handle('agents:default', () => getDefaultAgent())
170
171
 
171
172
  ipcMain.handle('app:homePath', () => app.getPath('home'))
172
173
  ipcMain.handle('app:cwd', () => process.env.CTLSURF_WORKER_CWD || process.cwd())
174
+ ipcMain.handle('app:projectName', () => orchestrator.projectName)
173
175
 
174
176
  ipcMain.handle('app:browseCwd', async () => {
175
177
  if (!mainWindow) return null
@@ -366,6 +368,26 @@ ipcMain.handle('tracking:set', async (_event, enabled: boolean) => {
366
368
  return { active: orchestrator.isActiveTabTracking() }
367
369
  })
368
370
 
371
+ // ─── Tickets IPC ──────────────────────────────────
372
+
373
+ ipcMain.handle('tickets:project', () => {
374
+ const cwd = orchestrator.getActiveTabCwd()
375
+ return { cwd, name: cwd ? cwd.split('/').filter(Boolean).pop() || cwd : null }
376
+ })
377
+ ipcMain.handle('tickets:add', async (_event, input: {
378
+ title: string; description?: string; status?: string; priority?: string
379
+ }) => {
380
+ return orchestrator.addTicketForActiveTab(input)
381
+ })
382
+ ipcMain.handle('tickets:update', async (_event, rowId: string, input: {
383
+ title: string; description?: string; status?: string; priority?: string
384
+ }) => {
385
+ return orchestrator.updateTicketForActiveTab(rowId, input)
386
+ })
387
+ ipcMain.handle('tickets:list', async () => {
388
+ return orchestrator.listTicketsForActiveTab()
389
+ })
390
+
369
391
  // ─── Legacy Settings IPC ──────────────────────────
370
392
 
371
393
  ipcMain.handle('settings:get', (_event, key: string) => {
@@ -8,6 +8,7 @@ import { CtlsurfApi } from './ctlsurfApi'
8
8
  import { ConversationBridge } from './bridge'
9
9
  import { WorkerWsClient, type WorkerWsStatus, type IncomingMessage } from './workerWs'
10
10
  import { TimeTracker } from './timeTracker'
11
+ import { TicketStore, type TicketInput } from './ticketStore'
11
12
  import { log } from './logger'
12
13
 
13
14
  // ─── Types ────────────────────────────────────────
@@ -39,6 +40,9 @@ export interface OrchestratorEvents {
39
40
  onWorkerMessage: (message: IncomingMessage) => void
40
41
  onWorkerRegistered: (data: { worker_id: string; folder_id: string | null; status: string }) => void
41
42
  onCwdChanged: () => void
43
+ // Human-readable name of the connected ctlsurf project (folder), or null
44
+ // when no project is connected. Optional — only the desktop header uses it.
45
+ onProjectChanged?: (name: string | null) => void
42
46
  }
43
47
 
44
48
  interface TabState {
@@ -74,6 +78,7 @@ export class Orchestrator {
74
78
  readonly bridge = new ConversationBridge()
75
79
  readonly workerWs: WorkerWsClient
76
80
  readonly timeTracker = new TimeTracker(this.ctlsurfApi)
81
+ readonly ticketStore = new TicketStore(this.ctlsurfApi)
77
82
 
78
83
  // State
79
84
  private tabs = new Map<string, TabState>()
@@ -87,6 +92,7 @@ export class Orchestrator {
87
92
  }
88
93
  private noProjectPollTimer: ReturnType<typeof setInterval> | null = null
89
94
  private noProjectPollCwd: string | null = null
95
+ private currentProjectName: string | null = null
90
96
 
91
97
  constructor(settingsDir: string, events: OrchestratorEvents) {
92
98
  this.settingsDir = settingsDir
@@ -114,12 +120,14 @@ export class Orchestrator {
114
120
  log(`[worker-ws] Registered: worker_id=${data.worker_id}, folder_id=${data.folder_id}, status=${data.status}`)
115
121
  events.onWorkerRegistered(data)
116
122
  if (!data.folder_id) {
123
+ this.setProjectName(null)
117
124
  events.onWorkerStatus('no_project')
118
125
  if (this.currentCwd && data.status !== 'pending_approval') {
119
126
  this.startNoProjectPolling(this.currentCwd)
120
127
  }
121
128
  } else {
122
129
  this.stopNoProjectPolling()
130
+ this.resolveProjectName(data.folder_id)
123
131
  }
124
132
  },
125
133
  onTerminalInput: (data: string) => {
@@ -163,6 +171,29 @@ export class Orchestrator {
163
171
  return this.currentCwd
164
172
  }
165
173
 
174
+ // Name of the connected ctlsurf project (folder) for the desktop header.
175
+ get projectName(): string | null {
176
+ return this.currentProjectName
177
+ }
178
+
179
+ private setProjectName(name: string | null): void {
180
+ if (this.currentProjectName === name) return
181
+ this.currentProjectName = name
182
+ this.events.onProjectChanged?.(name)
183
+ }
184
+
185
+ // Resolve the connected folder's human-readable name. Best-effort: a failed
186
+ // lookup just leaves the project name unset rather than blocking anything.
187
+ private async resolveProjectName(folderId: string): Promise<void> {
188
+ try {
189
+ const folder = await this.ctlsurfApi.getFolder(folderId)
190
+ const name = folder?.name ?? folder?.title
191
+ this.setProjectName(typeof name === 'string' && name ? name : null)
192
+ } catch (err) {
193
+ log(`[worker-ws] Failed to resolve project name for folder ${folderId}: ${err}`)
194
+ }
195
+ }
196
+
166
197
  get agent(): AgentConfig | null {
167
198
  return this.currentAgent
168
199
  }
@@ -480,6 +511,41 @@ export class Orchestrator {
480
511
  }
481
512
  }
482
513
 
514
+ // ─── Tickets (active tab) ───────────────────────
515
+
516
+ /** cwd of the focused terminal tab, or null if no tab is active. */
517
+ getActiveTabCwd(): string | null {
518
+ if (!this.activeTabId) return null
519
+ return this.tabs.get(this.activeTabId)?.cwd ?? null
520
+ }
521
+
522
+ async addTicketForActiveTab(input: TicketInput): Promise<{ ok: boolean; error?: string }> {
523
+ const cwd = this.getActiveTabCwd()
524
+ if (!cwd) return { ok: false, error: 'No active terminal tab' }
525
+ if (!this.ctlsurfApi.getApiKey()) {
526
+ return { ok: false, error: 'ctlsurf API key not configured' }
527
+ }
528
+ return this.ticketStore.addTicket(cwd, input)
529
+ }
530
+
531
+ async updateTicketForActiveTab(rowId: string, input: TicketInput): Promise<{ ok: boolean; error?: string }> {
532
+ const cwd = this.getActiveTabCwd()
533
+ if (!cwd) return { ok: false, error: 'No active terminal tab' }
534
+ if (!this.ctlsurfApi.getApiKey()) {
535
+ return { ok: false, error: 'ctlsurf API key not configured' }
536
+ }
537
+ return this.ticketStore.updateTicket(cwd, rowId, input)
538
+ }
539
+
540
+ async listTicketsForActiveTab(): Promise<{ ok: boolean; tickets: import('./ticketStore').Ticket[]; error?: string }> {
541
+ const cwd = this.getActiveTabCwd()
542
+ if (!cwd) return { ok: false, tickets: [], error: 'No active terminal tab' }
543
+ if (!this.ctlsurfApi.getApiKey()) {
544
+ return { ok: false, tickets: [], error: 'ctlsurf API key not configured' }
545
+ }
546
+ return this.ticketStore.listTickets(cwd)
547
+ }
548
+
483
549
  // ─── Worker WebSocket ───────────────────────────
484
550
 
485
551
  connectWorkerWs(agent: AgentConfig, cwd: string): void {
@@ -0,0 +1,252 @@
1
+ import type { CtlsurfApi } from './ctlsurfApi'
2
+
3
+ const DATASTORE_TITLE = 'Tickets'
4
+ const AGENT_DATASTORE_PAGE_TITLE = 'Agent Datastore'
5
+ const SYSTEM_KEY = 'tickets' // stable identifier in props.system_key; lookup survives title rename
6
+
7
+ const STATUS_OPTIONS = [
8
+ { value: 'Open', color: 'blue' },
9
+ { value: 'In Progress', color: 'yellow' },
10
+ { value: 'Blocked', color: 'red' },
11
+ { value: 'Done', color: 'green' },
12
+ ]
13
+ const PRIORITY_OPTIONS = [
14
+ { value: 'Low', color: 'gray' },
15
+ { value: 'Med', color: 'yellow' },
16
+ { value: 'High', color: 'red' },
17
+ ]
18
+
19
+ interface ColumnSpec {
20
+ name: string
21
+ type: string
22
+ options?: Array<{ value: string; color?: string }>
23
+ }
24
+
25
+ const COLUMNS: ColumnSpec[] = [
26
+ { name: 'Title', type: 'text' },
27
+ { name: 'Description', type: 'text' },
28
+ { name: 'Status', type: 'select', options: STATUS_OPTIONS },
29
+ { name: 'Priority', type: 'select', options: PRIORITY_OPTIONS },
30
+ { name: 'Created', type: 'date' },
31
+ ]
32
+
33
+ export interface TicketInput {
34
+ title: string
35
+ description?: string
36
+ status?: string
37
+ priority?: string
38
+ }
39
+
40
+ export interface Ticket {
41
+ id: string
42
+ title: string
43
+ description: string
44
+ status: string
45
+ priority: string
46
+ created: string | null
47
+ }
48
+
49
+ function log(...args: unknown[]): void {
50
+ try { console.log('[ticket-store]', ...args) } catch { /* EPIPE safe */ }
51
+ }
52
+
53
+ function findPageByTitle(pages: any[], title: string): any | null {
54
+ for (const p of pages) {
55
+ if (p?.title === title) return p
56
+ if (p?.children?.length) {
57
+ const c = findPageByTitle(p.children, title)
58
+ if (c) return c
59
+ }
60
+ }
61
+ return null
62
+ }
63
+
64
+ /**
65
+ * Per-project `Tickets` datastore manager. Mirrors TimeTracker's datastore
66
+ * resolution (find folder → Agent Datastore page → find/create block by
67
+ * system_key), minus all the session/checkpoint machinery — a ticket is a
68
+ * single manual capture, not a tracked session.
69
+ */
70
+ export class TicketStore {
71
+ private api: CtlsurfApi
72
+ private blockCache = new Map<string, string>()
73
+
74
+ constructor(api: CtlsurfApi) {
75
+ this.api = api
76
+ }
77
+
78
+ /** Resolves (or creates) the project's Tickets datastore and appends a row. */
79
+ async addTicket(cwd: string, input: TicketInput): Promise<{ ok: boolean; error?: string }> {
80
+ const title = input.title?.trim()
81
+ if (!title) return { ok: false, error: 'Title is required' }
82
+ try {
83
+ const blockId = await this.ensureDatastore(cwd, true)
84
+ if (!blockId) {
85
+ return { ok: false, error: 'No ctlsurf project found for this folder' }
86
+ }
87
+ await this.api.addRow(blockId, {
88
+ Title: title,
89
+ Description: input.description?.trim() || '',
90
+ Status: input.status || 'Open',
91
+ Priority: input.priority || 'Med',
92
+ Created: new Date().toISOString(),
93
+ })
94
+ log(`Added ticket "${title}" for ${cwd}`)
95
+ return { ok: true }
96
+ } catch (err: any) {
97
+ log(`addTicket failed for ${cwd}: ${err?.message || err}`)
98
+ return { ok: false, error: err?.message || String(err) }
99
+ }
100
+ }
101
+
102
+ /** Updates an existing ticket row in the project's Tickets datastore. */
103
+ async updateTicket(cwd: string, rowId: string, input: TicketInput): Promise<{ ok: boolean; error?: string }> {
104
+ const title = input.title?.trim()
105
+ if (!title) return { ok: false, error: 'Title is required' }
106
+ 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
+ Title: title,
111
+ Description: input.description?.trim() || '',
112
+ Status: input.status || 'Open',
113
+ Priority: input.priority || 'Med',
114
+ })
115
+ log(`Updated ticket ${rowId} for ${cwd}`)
116
+ return { ok: true }
117
+ } catch (err: any) {
118
+ log(`updateTicket failed for ${cwd}: ${err?.message || err}`)
119
+ return { ok: false, error: err?.message || String(err) }
120
+ }
121
+ }
122
+
123
+ /** Lists existing tickets for the project, newest first. Does not create the
124
+ * datastore — an unconfigured project simply has no tickets yet. */
125
+ async listTickets(cwd: string): Promise<{ ok: boolean; tickets: Ticket[]; error?: string }> {
126
+ 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 tickets: Ticket[] = (res?.rows || []).map(r => ({
131
+ id: r.id,
132
+ title: String(r.data?.Title ?? ''),
133
+ description: String(r.data?.Description ?? ''),
134
+ status: String(r.data?.Status ?? 'Open'),
135
+ priority: String(r.data?.Priority ?? 'Med'),
136
+ created: (r.data?.Created as string) ?? r.created_at ?? null,
137
+ }))
138
+ return { ok: true, tickets }
139
+ } catch (err: any) {
140
+ log(`listTickets failed for ${cwd}: ${err?.message || err}`)
141
+ return { ok: false, tickets: [], error: err?.message || String(err) }
142
+ }
143
+ }
144
+
145
+ private async ensureDatastore(cwd: string, create: boolean): Promise<string | null> {
146
+ const cached = this.blockCache.get(cwd)
147
+ if (cached) return cached
148
+
149
+ let folder: any = null
150
+ try {
151
+ folder = await this.api.findFolderByPath(cwd)
152
+ } catch {
153
+ return null
154
+ }
155
+ if (!folder?.id) return null
156
+
157
+ const folderDetail = await this.api.getFolder(folder.id)
158
+ const agentPage = findPageByTitle(folderDetail?.pages || [], AGENT_DATASTORE_PAGE_TITLE)
159
+ if (!agentPage?.id) return null
160
+
161
+ const blockId = await this.findOrAdoptBlock(agentPage.id)
162
+ if (blockId) {
163
+ await this.ensureColumns(blockId)
164
+ this.blockCache.set(cwd, blockId)
165
+ return blockId
166
+ }
167
+
168
+ if (!create) return null
169
+
170
+ const columns = COLUMNS.map((c, i) => ({
171
+ id: `col_${i}`,
172
+ name: c.name,
173
+ type: c.type,
174
+ ...(c.options ? { options: c.options } : {}),
175
+ }))
176
+ const created = await this.api.createBlock(agentPage.id, {
177
+ type: 'datastore',
178
+ title: DATASTORE_TITLE,
179
+ props: { columns, system_key: SYSTEM_KEY },
180
+ })
181
+ if (created?.id) {
182
+ log(`Created "${DATASTORE_TITLE}" datastore on Agent Datastore page for ${cwd}`)
183
+ this.blockCache.set(cwd, created.id)
184
+ return created.id
185
+ }
186
+ return null
187
+ }
188
+
189
+ /** Finds an existing Tickets block on the page. Prefers a system_key match
190
+ * (which survives title renames). Falls back to title match for legacy blocks
191
+ * and backfills system_key on the way out. */
192
+ private async findOrAdoptBlock(pageId: string): Promise<string | null> {
193
+ const summaries = (await this.api.getPageBlockSummaries(pageId)) || []
194
+ const datastoreSummaries = summaries.filter((b: any) => b?.type === 'datastore' && b?.id)
195
+ if (datastoreSummaries.length === 0) return null
196
+
197
+ let titleFallbackId: string | null = null
198
+ let titleFallbackProps: Record<string, unknown> | null = null
199
+ for (const s of datastoreSummaries) {
200
+ try {
201
+ const block = await this.api.getBlock(s.id)
202
+ const props = (block?.props || {}) as Record<string, unknown>
203
+ if (props.system_key === SYSTEM_KEY) {
204
+ return s.id
205
+ }
206
+ if (s.title === DATASTORE_TITLE && titleFallbackId === null) {
207
+ titleFallbackId = s.id
208
+ titleFallbackProps = props
209
+ }
210
+ } catch (err: any) {
211
+ log(`getBlock(${s.id}) failed during lookup: ${err?.message || err}`)
212
+ }
213
+ }
214
+
215
+ if (titleFallbackId) {
216
+ try {
217
+ await this.api.updateBlock(titleFallbackId, {
218
+ props: { ...(titleFallbackProps || {}), system_key: SYSTEM_KEY },
219
+ })
220
+ log(`Backfilled system_key on legacy Tickets block ${titleFallbackId}`)
221
+ } catch (err: any) {
222
+ log(`backfill system_key failed on ${titleFallbackId}: ${err?.message || err}`)
223
+ }
224
+ return titleFallbackId
225
+ }
226
+ return null
227
+ }
228
+
229
+ private async ensureColumns(blockId: string): Promise<void> {
230
+ try {
231
+ const schema = await this.api.getDatastoreSchema(blockId)
232
+ const existingCols = schema.columns || []
233
+ const existingNames = new Set(existingCols.map(c => c.name))
234
+ const missing = COLUMNS.filter(c => !existingNames.has(c.name))
235
+ if (missing.length === 0) return
236
+
237
+ const usedIds = new Set(existingCols.map(c => c.id))
238
+ let nextIdx = existingCols.length
239
+ const appended = missing.map(c => {
240
+ let id = `col_${nextIdx++}`
241
+ while (usedIds.has(id)) id = `col_${nextIdx++}`
242
+ usedIds.add(id)
243
+ return { id, name: c.name, type: c.type, ...(c.options ? { options: c.options } : {}) }
244
+ })
245
+ const merged = [...existingCols, ...appended]
246
+ await this.api.updateDatastoreSchema(blockId, merged)
247
+ log(`Added ${missing.length} missing column(s) to existing Tickets datastore: ${missing.map(c => c.name).join(', ')}`)
248
+ } catch (err: any) {
249
+ log(`ensureColumns failed: ${err?.message || err}`)
250
+ }
251
+ }
252
+ }
@@ -44,6 +44,13 @@ const api = {
44
44
  ipcRenderer.invoke('app:homePath'),
45
45
  getCwd: (): Promise<string> =>
46
46
  ipcRenderer.invoke('app:cwd'),
47
+ getProjectName: (): Promise<string | null> =>
48
+ ipcRenderer.invoke('app:projectName'),
49
+ onProjectChanged: (callback: (name: string | null) => void) => {
50
+ const listener = (_event: Electron.IpcRendererEvent, name: string | null) => callback(name)
51
+ ipcRenderer.on('app:projectChanged', listener)
52
+ return () => ipcRenderer.removeListener('app:projectChanged', listener)
53
+ },
47
54
  browseCwd: (): Promise<string | null> =>
48
55
  ipcRenderer.invoke('app:browseCwd'),
49
56
  getVersion: (): Promise<string> =>
@@ -82,6 +89,16 @@ const api = {
82
89
  setTracking: (enabled: boolean): Promise<{ active: boolean }> =>
83
90
  ipcRenderer.invoke('tracking:set', enabled),
84
91
 
92
+ // Tickets (active tab)
93
+ getTicketProject: (): Promise<{ cwd: string | null; name: string | null }> =>
94
+ ipcRenderer.invoke('tickets:project'),
95
+ addTicket: (input: { title: string; description?: string; status?: string; priority?: string }): Promise<{ ok: boolean; error?: string }> =>
96
+ ipcRenderer.invoke('tickets:add', input),
97
+ updateTicket: (rowId: string, input: { title: string; description?: string; status?: string; priority?: string }): Promise<{ ok: boolean; error?: string }> =>
98
+ ipcRenderer.invoke('tickets:update', rowId, input),
99
+ listTickets: (): Promise<{ ok: boolean; tickets: Array<{ id: string; title: string; description: string; status: string; priority: string; created: string | null }>; error?: string }> =>
100
+ ipcRenderer.invoke('tickets:list'),
101
+
85
102
  // Chat logging (global)
86
103
  getLogChat: (): Promise<{ enabled: boolean }> =>
87
104
  ipcRenderer.invoke('logchat:get'),
@@ -12,6 +12,7 @@ import {
12
12
  } from './components/PaneLayout'
13
13
  import { StatusBar } from './components/StatusBar'
14
14
  import { SettingsDialog } from './components/SettingsDialog'
15
+ import { TicketPanel } from './components/TicketPanel'
15
16
 
16
17
  interface AgentConfig {
17
18
  id: string
@@ -35,6 +36,8 @@ declare global {
35
36
  getDefaultAgent: () => Promise<AgentConfig>
36
37
  getHomePath: () => Promise<string>
37
38
  getCwd: () => Promise<string>
39
+ getProjectName: () => Promise<string | null>
40
+ onProjectChanged: (callback: (name: string | null) => void) => () => void
38
41
  browseCwd: () => Promise<string | null>
39
42
  getSetting: (key: string) => Promise<string | null>
40
43
  setSetting: (key: string, value: string) => Promise<{ ok: boolean }>
@@ -44,6 +47,10 @@ declare global {
44
47
  saveProfile: (profileId: string, data: { name: string; apiKey?: string; baseUrl: string; dataspacePageId: string }) => Promise<{ ok: boolean }>
45
48
  switchProfile: (profileId: string) => Promise<{ ok: boolean }>
46
49
  deleteProfile: (profileId: string) => Promise<{ ok: boolean }>
50
+ getTicketProject: () => Promise<{ cwd: string | null; name: string | null }>
51
+ addTicket: (input: { title: string; description?: string; status?: string; priority?: string }) => Promise<{ ok: boolean; error?: string }>
52
+ updateTicket: (rowId: string, input: { title: string; description?: string; status?: string; priority?: string }) => Promise<{ ok: boolean; error?: string }>
53
+ listTickets: () => Promise<{ ok: boolean; tickets: Array<{ id: string; title: string; description: string; status: string; priority: string; created: string | null }>; error?: string }>
47
54
  getTracking: () => Promise<{ active: boolean }>
48
55
  setTracking: (enabled: boolean) => Promise<{ active: boolean }>
49
56
  getLogChat: () => Promise<{ enabled: boolean }>
@@ -107,6 +114,7 @@ export default function App() {
107
114
  const [showSettings, setShowSettings] = useState(false)
108
115
  const [wsStatus, setWsStatus] = useState('disconnected')
109
116
  const [cwd, setCwd] = useState<string | null>(null)
117
+ const [projectName, setProjectName] = useState<string | null>(null)
110
118
  const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
111
119
 
112
120
  // Multi-tab state
@@ -116,6 +124,7 @@ export default function App() {
116
124
  })
117
125
  const [activeTabId, setActiveTabId] = useState<string>(tabs[0].id)
118
126
  const [trackingActive, setTrackingActive] = useState(false)
127
+ const [showTicketPanel, setShowTicketPanel] = useState(false)
119
128
 
120
129
  // Agent picker state: which tab is being configured (null = initial picker for first tab)
121
130
  const [pickerTargetTabId, setPickerTargetTabId] = useState<string | null>(tabs[0].id)
@@ -134,10 +143,17 @@ export default function App() {
134
143
  const initialCwd = await window.worker.getCwd().catch(() => window.worker.getHomePath())
135
144
  setCwd(initialCwd)
136
145
  cwdRef.current = initialCwd
146
+ // Connected ctlsurf project name for the header (null until a worker registers)
147
+ window.worker.getProjectName().then(setProjectName).catch(() => { /* ignore */ })
137
148
  }
138
149
  init()
139
150
  }, [])
140
151
 
152
+ useEffect(() => {
153
+ const unsub = window.worker.onProjectChanged((name) => setProjectName(name))
154
+ return unsub
155
+ }, [])
156
+
141
157
  useEffect(() => {
142
158
  const unsub = window.worker.onWorkerStatus((status) => setWsStatus(status))
143
159
  return unsub
@@ -398,7 +414,15 @@ export default function App() {
398
414
  return (
399
415
  <div className="app">
400
416
  <div className="titlebar">
401
- <span className="titlebar-title">ctlsurf-worker</span>
417
+ <span className="titlebar-title">
418
+ ctlsurf
419
+ {projectName && (
420
+ <>
421
+ <span className="titlebar-title-sep">·</span>
422
+ <span className="titlebar-project" title={projectName}>{projectName}</span>
423
+ </>
424
+ )}
425
+ </span>
402
426
  <div className="titlebar-controls">
403
427
  <button className="titlebar-btn" onClick={() => setShowSettings(true)} title="Settings">
404
428
  Settings
@@ -412,6 +436,20 @@ export default function App() {
412
436
  <span className="tracking-icon">⏱</span>
413
437
  <span className={`tracking-dot ${trackingActive ? 'on' : 'off'}`} />
414
438
  </button>
439
+ <button
440
+ className={`titlebar-btn titlebar-icon-btn ${showTicketPanel ? 'active' : ''}`}
441
+ onClick={() => setShowTicketPanel(v => !v)}
442
+ title="Tickets for the active project"
443
+ aria-label="Tickets"
444
+ >
445
+ <svg className="ticket-tag-icon" viewBox="0 0 24 24" width="13" height="13"
446
+ fill="none" stroke="currentColor" strokeWidth="2"
447
+ strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
448
+ <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
449
+ <line x1="7" y1="7" x2="7.01" y2="7" />
450
+ </svg>
451
+ <span>Tickets</span>
452
+ </button>
415
453
  <span className="titlebar-separator" />
416
454
  {agents.map(a => {
417
455
  const activeTab = tabs.find(t => t.id === activeTabId)
@@ -457,6 +495,7 @@ export default function App() {
457
495
 
458
496
  <StatusBar wsStatus={wsStatus} cwd={cwd} onChangeCwd={handleChangeCwd} updateInfo={updateInfo} />
459
497
  <SettingsDialog open={showSettings} onClose={() => setShowSettings(false)} />
498
+ <TicketPanel open={showTicketPanel} onClose={() => setShowTicketPanel(false)} />
460
499
 
461
500
  {showAgentPicker && agents.length > 0 && (
462
501
  <AgentPicker