@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.
- package/bin/ctlsurf-worker.js +38 -22
- package/out/headless/index.mjs +295 -3
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +351 -48
- package/out/preload/index.js +11 -0
- package/out/renderer/assets/{cssMode-CYoo4t9f.js → cssMode-D5dPwEy5.js} +3 -3
- package/out/renderer/assets/{freemarker2--UQnPZsn.js → freemarker2-c5jJjQ9s.js} +1 -1
- package/out/renderer/assets/{handlebars-DVDrmX0C.js → handlebars-BTbmOxx9.js} +1 -1
- package/out/renderer/assets/{html-D1-cXoLy.js → html-3cIIQcxO.js} +1 -1
- package/out/renderer/assets/{htmlMode-f5nBuprq.js → htmlMode-DYbpW1yY.js} +3 -3
- package/out/renderer/assets/{index-65hyKM_8.css → index-6KvOnYL1.css} +404 -0
- package/out/renderer/assets/{index-D23nru43.js → index-D2MUZin7.js} +332 -23
- package/out/renderer/assets/{javascript-CcarFzBL.js → javascript-CDuCMm-6.js} +2 -2
- package/out/renderer/assets/{jsonMode-BvF-xK9U.js → jsonMode-COLqbq0s.js} +3 -3
- package/out/renderer/assets/{liquid-CHLtUKl2.js → liquid-BFcqZizB.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-B9aNeatS.js → lspLanguageFeatures-CbkEcL-z.js} +1 -1
- package/out/renderer/assets/{mdx-HGDrkifZ.js → mdx-DyK93oEE.js} +1 -1
- package/out/renderer/assets/{python-B_dPzjJ6.js → python-D4lCwSVr.js} +1 -1
- package/out/renderer/assets/{razor-CHheM4ot.js → razor-DdkE9XVt.js} +1 -1
- package/out/renderer/assets/{tsMode-CdC3i1gG.js → tsMode-BrQ4Fsc-.js} +1 -1
- package/out/renderer/assets/{typescript-BX6guVRK.js → typescript-BakbYMnC.js} +1 -1
- package/out/renderer/assets/{xml-CpS-pOPE.js → xml-DHDW9Xhp.js} +1 -1
- package/out/renderer/assets/{yaml-Du0AjOHW.js → yaml-1Ayv_J3q.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/agents.ts +36 -1
- package/src/main/ctlsurfApi.ts +11 -0
- package/src/main/headless.ts +5 -3
- package/src/main/index.ts +24 -2
- package/src/main/orchestrator.ts +66 -0
- package/src/main/ticketStore.ts +252 -0
- package/src/preload/index.ts +17 -0
- package/src/renderer/App.tsx +40 -1
- package/src/renderer/components/TicketPanel.tsx +308 -0
- 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.
|
|
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": {
|
package/src/main/agents.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/src/main/ctlsurfApi.ts
CHANGED
|
@@ -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
|
}
|
package/src/main/headless.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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,
|
|
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', () =>
|
|
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) => {
|
package/src/main/orchestrator.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/preload/index.ts
CHANGED
|
@@ -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'),
|
package/src/renderer/App.tsx
CHANGED
|
@@ -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">
|
|
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
|