@phenx-inc/ctlsurf 0.14.2 → 0.15.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 (40) hide show
  1. package/THIRD-PARTY-NOTICES.md +82 -0
  2. package/out/headless/index.mjs +91 -72
  3. package/out/headless/index.mjs.map +3 -3
  4. package/out/main/index.js +117 -72
  5. package/out/preload/index.js +6 -1
  6. package/out/renderer/assets/{cssMode-bQYoCKra.js → cssMode-B77M9HlN.js} +3 -3
  7. package/out/renderer/assets/{freemarker2-BS1ERQl_.js → freemarker2-oUT2zvyQ.js} +1 -1
  8. package/out/renderer/assets/{handlebars-B-ZEHRLr.js → handlebars-jCckoi5j.js} +1 -1
  9. package/out/renderer/assets/{html-lk9e1Lkl.js → html-kAgrAaKW.js} +1 -1
  10. package/out/renderer/assets/{htmlMode-Ds1eMYgM.js → htmlMode-DIWkIawy.js} +3 -3
  11. package/out/renderer/assets/{index-BpCORklm.css → index-3sFJEz7i.css} +787 -399
  12. package/out/renderer/assets/{index-BJNR4hRK.js → index-CN3UN7Tm.js} +431 -104
  13. package/out/renderer/assets/{javascript-D6OeziKX.js → javascript-B9GYUE-I.js} +2 -2
  14. package/out/renderer/assets/{jsonMode-t5SESE75.js → jsonMode-BzFKTZjJ.js} +3 -3
  15. package/out/renderer/assets/{liquid-B5Wjh0p_.js → liquid-Ccw-S_Y6.js} +1 -1
  16. package/out/renderer/assets/{lspLanguageFeatures-BEgiDCIW.js → lspLanguageFeatures-BE0g7ubH.js} +1 -1
  17. package/out/renderer/assets/{mdx-ByNAXh8t.js → mdx-oXeAA0qU.js} +1 -1
  18. package/out/renderer/assets/{python-CIFO0XQW.js → python-DlSX3TvA.js} +1 -1
  19. package/out/renderer/assets/{razor-LXHhGcNO.js → razor-ButO9S7r.js} +1 -1
  20. package/out/renderer/assets/{tsMode-WiO8CDaI.js → tsMode-CTCpE-67.js} +1 -1
  21. package/out/renderer/assets/{typescript-ZBQlq256.js → typescript-9o62WOnS.js} +1 -1
  22. package/out/renderer/assets/{xml-DBaLJlQU.js → xml-C4nOH2_D.js} +1 -1
  23. package/out/renderer/assets/{yaml-CId9skah.js → yaml-mXYgKoWW.js} +1 -1
  24. package/out/renderer/index.html +60 -3
  25. package/package.json +5 -4
  26. package/src/main/index.ts +54 -3
  27. package/src/main/orchestrator.ts +10 -2
  28. package/src/main/ticketStore.ts +100 -66
  29. package/src/preload/index.ts +13 -3
  30. package/src/renderer/App.tsx +4 -2
  31. package/src/renderer/components/CtlsurfPanel.tsx +1 -1
  32. package/src/renderer/components/EditorPanel.tsx +29 -1
  33. package/src/renderer/components/ObserverPanel.tsx +13 -6
  34. package/src/renderer/components/SettingsDialog.tsx +82 -0
  35. package/src/renderer/components/TerminalPanel.tsx +12 -22
  36. package/src/renderer/components/TicketPanel.tsx +33 -15
  37. package/src/renderer/index.html +58 -1
  38. package/src/renderer/lib/theme.ts +173 -0
  39. package/src/renderer/lib/themes.ts +126 -0
  40. package/src/renderer/styles.css +787 -399
@@ -1,5 +1,5 @@
1
- import { conf as conf$1, language as language$1 } from "./typescript-ZBQlq256.js";
2
- import "./index-BJNR4hRK.js";
1
+ import { conf as conf$1, language as language$1 } from "./typescript-9o62WOnS.js";
2
+ import "./index-CN3UN7Tm.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-BJNR4hRK.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-BEgiDCIW.js";
3
- import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-BEgiDCIW.js";
1
+ import { c as createWebWorker, l as languages, e as editor } from "./index-CN3UN7Tm.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-BE0g7ubH.js";
3
+ import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-BE0g7ubH.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-BJNR4hRK.js";
1
+ import { l as languages } from "./index-CN3UN7Tm.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-BJNR4hRK.js";
1
+ import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-CN3UN7Tm.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-BJNR4hRK.js";
1
+ import { l as languages } from "./index-CN3UN7Tm.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["{/*", "*/}"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-BJNR4hRK.js";
1
+ import { l as languages } from "./index-CN3UN7Tm.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#",
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-BJNR4hRK.js";
1
+ import { l as languages } from "./index-CN3UN7Tm.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-BJNR4hRK.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-CN3UN7Tm.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-BJNR4hRK.js";
1
+ import { l as languages } from "./index-CN3UN7Tm.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-BJNR4hRK.js";
1
+ import { l as languages } from "./index-CN3UN7Tm.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["<!--", "-->"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-BJNR4hRK.js";
1
+ import { l as languages } from "./index-CN3UN7Tm.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#"
@@ -4,10 +4,42 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>ctlsurf-worker</title>
7
+ <!-- Stamp the theme before the first paint. This has to be an inline,
8
+ synchronous script in <head>: reading the pref over IPC (or waiting for
9
+ the bundle) would let one frame of the wrong theme through, which reads
10
+ as a flash every launch. lib/theme.ts owns the same key afterwards. -->
11
+ <script>
12
+ (function () {
13
+ var DARK = 'tokyo-night', LIGHT = 'tokyo-day'
14
+ var a = null
15
+ try {
16
+ var raw = localStorage.getItem('ctlsurf.appearance')
17
+ if (raw) a = JSON.parse(raw)
18
+ else {
19
+ // Migrate the original system/light/dark preference. lib/theme.ts
20
+ // performs the same mapping; both must agree or the first frame
21
+ // disagrees with the second.
22
+ var legacy = localStorage.getItem('ctlsurf.theme')
23
+ if (legacy === 'light') a = { sync: false, theme: LIGHT }
24
+ else if (legacy === 'dark') a = { sync: false, theme: DARK }
25
+ }
26
+ } catch (e) { /* private mode, or corrupt JSON */ }
27
+ var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches
28
+ var id
29
+ if (a && a.sync === false) id = a.theme
30
+ else if (a) id = prefersLight ? a.light : a.dark
31
+ else id = prefersLight ? LIGHT : DARK
32
+ document.documentElement.setAttribute('data-theme', id || (prefersLight ? LIGHT : DARK))
33
+ })()
34
+ </script>
7
35
  <!-- Boot splash styles are inlined here so they paint on the first frame,
8
36
  before the JS bundle and styles.css have loaded. Without this the window
9
- shows an empty dark pane while React mounts and the startup IPC runs. -->
37
+ shows an empty dark pane while React mounts and the startup IPC runs.
38
+ They cannot use the tokens in styles.css for the same reason, so the two
39
+ palettes are repeated here — keep them in step with :root there. -->
10
40
  <style>
41
+ :root { color-scheme: dark }
42
+ :root[data-theme='tokyo-day'], :root[data-theme='catppuccin-latte'] { color-scheme: light }
11
43
  #boot-splash {
12
44
  position: fixed;
13
45
  inset: 0;
@@ -48,9 +80,34 @@
48
80
  letter-spacing: 0.04em;
49
81
  color: #565f89;
50
82
  }
83
+ /* One rule per theme: the splash paints before styles.css exists, so it
84
+ cannot use the tokens. Keep in step with the :root blocks there. */
85
+ :root[data-theme='tokyo-day'] #boot-splash { background: #e9eaef; }
86
+ :root[data-theme='tokyo-day'] .boot-spinner {
87
+ border-color: rgba(46, 125, 233, 0.22);
88
+ border-top-color: #2e7de9;
89
+ }
90
+ :root[data-theme='tokyo-day'] .boot-splash-title { color: #1b1e33; }
91
+ :root[data-theme='tokyo-day'] .boot-splash-label { color: #6b7190; }
92
+
93
+ :root[data-theme='catppuccin-mocha'] #boot-splash { background: #1e1e2e; }
94
+ :root[data-theme='catppuccin-mocha'] .boot-spinner {
95
+ border-color: rgba(137, 180, 250, 0.22);
96
+ border-top-color: #89b4fa;
97
+ }
98
+ :root[data-theme='catppuccin-mocha'] .boot-splash-title { color: #cdd6f4; }
99
+ :root[data-theme='catppuccin-mocha'] .boot-splash-label { color: #7f849c; }
100
+
101
+ :root[data-theme='catppuccin-latte'] #boot-splash { background: #eff1f5; }
102
+ :root[data-theme='catppuccin-latte'] .boot-spinner {
103
+ border-color: rgba(30, 102, 245, 0.22);
104
+ border-top-color: #1e66f5;
105
+ }
106
+ :root[data-theme='catppuccin-latte'] .boot-splash-title { color: #2f3145; }
107
+ :root[data-theme='catppuccin-latte'] .boot-splash-label { color: #7c7f93; }
51
108
  </style>
52
- <script type="module" crossorigin src="./assets/index-BJNR4hRK.js"></script>
53
- <link rel="stylesheet" crossorigin href="./assets/index-BpCORklm.css">
109
+ <script type="module" crossorigin src="./assets/index-CN3UN7Tm.js"></script>
110
+ <link rel="stylesheet" crossorigin href="./assets/index-3sFJEz7i.css">
54
111
  </head>
55
112
  <body>
56
113
  <div id="boot-splash">
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@phenx-inc/ctlsurf",
3
- "version": "0.14.2",
4
- "description": "Agent-agnostic terminal and desktop app for ctlsurf \u2014 run Claude Code, Codex, or any coding agent with live session logging and remote control",
3
+ "version": "0.15.1",
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": {
7
7
  "ctlsurf": "./bin/ctlsurf-worker.js",
@@ -15,7 +15,8 @@
15
15
  "src/",
16
16
  "package.json",
17
17
  "electron-vite.config.ts",
18
- "tsconfig*.json"
18
+ "tsconfig*.json",
19
+ "THIRD-PARTY-NOTICES.md"
19
20
  ],
20
21
  "scripts": {
21
22
  "dev": "electron-vite dev",
@@ -84,4 +85,4 @@
84
85
  "bugs": {
85
86
  "email": "phenx@phenx.io"
86
87
  }
87
- }
88
+ }
package/src/main/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { app, BrowserWindow, ipcMain, dialog, nativeImage, clipboard } from 'electron'
1
+ import { app, BrowserWindow, ipcMain, dialog, nativeImage, clipboard, nativeTheme } from 'electron'
2
2
  import path from 'path'
3
3
  import fs from 'fs'
4
4
 
@@ -233,12 +233,24 @@ const orchestrator = new Orchestrator(
233
233
  // ─── Window ───────────────────────────────────────
234
234
 
235
235
  function createWindow(): void {
236
+ // Replay last session's appearance choice before the window exists, so the
237
+ // frame Electron paints before the renderer loads is already the right
238
+ // colour. The renderer re-asserts this over app:setAppearance once booted.
239
+ const saved = orchestrator.settingsData
240
+ if (saved.themeSource === 'light' || saved.themeSource === 'dark' || saved.themeSource === 'system') {
241
+ nativeTheme.themeSource = saved.themeSource
242
+ }
243
+ const savedBg = typeof saved.themeBackground === 'string' && /^#[0-9a-fA-F]{6}$/.test(saved.themeBackground)
244
+ ? saved.themeBackground
245
+ : null
246
+
236
247
  mainWindow = new BrowserWindow({
237
248
  width: 1400,
238
249
  height: 900,
239
250
  minWidth: 600,
240
251
  minHeight: 400,
241
252
  title: 'ctlsurf-worker',
253
+ backgroundColor: savedBg ?? (nativeTheme.shouldUseDarkColors ? '#1a1b26' : '#e9eaef'),
242
254
  titleBarStyle: 'hiddenInset',
243
255
  icon: path.join(__dirname, '../../resources/icon.png'),
244
256
  webPreferences: {
@@ -388,6 +400,45 @@ ipcMain.handle('app:homePath', () => app.getPath('home'))
388
400
  ipcMain.handle('app:cwd', () => process.env.CTLSURF_WORKER_CWD || process.cwd())
389
401
  ipcMain.handle('app:projectName', () => orchestrator.projectName)
390
402
 
403
+ // The renderer owns the theme preference (see lib/theme.ts) and mirrors it here
404
+ // so the parts Chromium paints for us — native form controls, scrollbars, and
405
+ // the window background between loads — match the app chrome. Also remembered
406
+ // so the next launch can paint the right backgroundColor before the renderer
407
+ // has booted.
408
+ // The renderer owns the appearance preference (see renderer/lib/theme.ts) and
409
+ // mirrors it here for two reasons: so Chromium paints native form controls,
410
+ // scrollbars and the pre-load window background to match, and so createWindow
411
+ // can pick the right backgroundColor on the *next* launch, before the renderer
412
+ // has booted. It is also the recovery source if localStorage is ever wiped.
413
+ ipcMain.handle('app:getAppearance', () => ({
414
+ pref: orchestrator.settingsData.appearance ?? null,
415
+ }))
416
+
417
+ ipcMain.handle('app:setAppearance', (_e, data: {
418
+ pref?: unknown
419
+ source?: 'system' | 'light' | 'dark'
420
+ background?: string
421
+ }) => {
422
+ const source = data?.source
423
+ if (source !== 'system' && source !== 'light' && source !== 'dark') return { ok: false }
424
+ nativeTheme.themeSource = source
425
+
426
+ const settings = orchestrator.settingsData
427
+ if (data.pref && typeof data.pref === 'object') {
428
+ settings.appearance = data.pref as Record<string, unknown>
429
+ }
430
+ settings.themeSource = source
431
+ // Only trust a plain hex colour — this value is handed straight to Electron.
432
+ if (typeof data.background === 'string' && /^#[0-9a-fA-F]{6}$/.test(data.background)) {
433
+ settings.themeBackground = data.background
434
+ if (mainWindow && !mainWindow.isDestroyed()) {
435
+ mainWindow.setBackgroundColor(data.background)
436
+ }
437
+ }
438
+ orchestrator.saveSettings()
439
+ return { ok: true }
440
+ })
441
+
391
442
  ipcMain.handle('app:browseCwd', async () => {
392
443
  if (!mainWindow) return null
393
444
  const result = await dialog.showOpenDialog(mainWindow, {
@@ -659,8 +710,8 @@ ipcMain.handle('tickets:add', async (_event, input: {
659
710
  })
660
711
  ipcMain.handle('tickets:update', async (_event, rowId: string, input: {
661
712
  title: string; description?: string; status?: string; priority?: string
662
- }) => {
663
- return orchestrator.updateTicketForActiveTab(rowId, input)
713
+ }, blockId?: string) => {
714
+ return orchestrator.updateTicketForActiveTab(rowId, input, blockId)
664
715
  })
665
716
  ipcMain.handle('tickets:list', async () => {
666
717
  return orchestrator.listTicketsForActiveTab()
@@ -43,6 +43,14 @@ export interface SettingsData {
43
43
  // src/main/observer/. Requires an Anthropic API key for Tiers 1–2;
44
44
  // without one it still records the ledger, Tier-0 tags, and spans.
45
45
  observer?: ObserverSettings
46
+ // Mirror of the renderer's appearance preference (localStorage owns the
47
+ // authoritative copy — index.html has to read it before first paint). Kept
48
+ // here so createWindow can pick the right backgroundColor on the next launch
49
+ // before the renderer boots, and so the choice survives a wiped localStorage.
50
+ // Shape is owned by renderer/lib/theme.ts; the main process only round-trips it.
51
+ appearance?: Record<string, unknown>
52
+ themeSource?: 'system' | 'light' | 'dark'
53
+ themeBackground?: string
46
54
  }
47
55
 
48
56
  export interface OrchestratorEvents {
@@ -772,13 +780,13 @@ export class Orchestrator {
772
780
  return this.ticketStore.addTicket(cwd, input)
773
781
  }
774
782
 
775
- async updateTicketForActiveTab(rowId: string, input: TicketInput): Promise<{ ok: boolean; error?: string }> {
783
+ async updateTicketForActiveTab(rowId: string, input: TicketInput, blockId?: string): Promise<{ ok: boolean; error?: string }> {
776
784
  const cwd = this.getActiveTabProjectPath()
777
785
  if (!cwd) return { ok: false, error: 'No active terminal tab' }
778
786
  if (!this.ctlsurfApi.getApiKey()) {
779
787
  return { ok: false, error: 'ctlsurf API key not configured' }
780
788
  }
781
- return this.ticketStore.updateTicket(cwd, rowId, input)
789
+ return this.ticketStore.updateTicket(cwd, rowId, input, blockId)
782
790
  }
783
791
 
784
792
  async listTicketsForActiveTab(): Promise<{ ok: boolean; tickets: import('./ticketStore').Ticket[]; blockId?: string; pageId?: string; error?: string }> {
@@ -45,23 +45,31 @@ export interface Ticket {
45
45
  status: string
46
46
  priority: string
47
47
  created: string | null
48
+ // Owning datastore, so edits and agent dispatches target the store this row
49
+ // actually lives in — a folder can accumulate more than one Tickets store.
50
+ blockId: string
51
+ pageId: string
48
52
  }
49
53
 
50
54
  function log(...args: unknown[]): void {
51
55
  baseLog('[ticket-store]', ...args)
52
56
  }
53
57
 
54
- function findPageByTitle(pages: any[], title: string): any | null {
58
+ /** Collects every page with the given title, in tree order. A folder can end
59
+ * up with duplicate "Agent Datastore" pages (e.g. an agent creating its own
60
+ * under an unrelated page), with tickets split across them. */
61
+ function findPagesByTitle(pages: any[], title: string): any[] {
62
+ const found: any[] = []
55
63
  for (const p of pages) {
56
- if (p?.title === title) return p
57
- if (p?.children?.length) {
58
- const c = findPageByTitle(p.children, title)
59
- if (c) return c
60
- }
64
+ if (p?.title === title) found.push(p)
65
+ if (p?.children?.length) found.push(...findPagesByTitle(p.children, title))
61
66
  }
62
- return null
67
+ return found
63
68
  }
64
69
 
70
+ interface DsRef { blockId: string; pageId: string }
71
+ interface ResolvedStores { primary: DsRef; all: DsRef[] }
72
+
65
73
  /**
66
74
  * Per-project `Tickets` datastore manager. Mirrors TimeTracker's datastore
67
75
  * resolution (find folder → Agent Datastore page → find/create block by
@@ -70,10 +78,10 @@ function findPageByTitle(pages: any[], title: string): any | null {
70
78
  */
71
79
  export class TicketStore {
72
80
  private api: CtlsurfApi
73
- // Per-cwd cache of the resolved Tickets datastore: the block (the datastore
74
- // element) and the Agent Datastore page it lives on. Both ids are handed to
75
- // the coding agent so it can read/update tickets via the ctlsurf MCP tools.
76
- private dsCache = new Map<string, { blockId: string; pageId: string }>()
81
+ // Per-cwd cache of every resolved Tickets datastore. `primary` is where new
82
+ // tickets are written and what gets handed to the coding agent; `all` covers
83
+ // duplicate Agent Datastore pages so listing misses nothing.
84
+ private dsCache = new Map<string, ResolvedStores>()
77
85
 
78
86
  constructor(api: CtlsurfApi) {
79
87
  this.api = api
@@ -84,11 +92,11 @@ export class TicketStore {
84
92
  const title = input.title?.trim()
85
93
  if (!title) return { ok: false, error: 'Title is required' }
86
94
  try {
87
- const ds = await this.ensureDatastore(cwd, true)
88
- if (!ds) {
95
+ const stores = await this.resolveStores(cwd, true)
96
+ if (!stores) {
89
97
  return { ok: false, error: 'No ctlsurf project found for this folder' }
90
98
  }
91
- await this.api.addRow(ds.blockId, {
99
+ await this.api.addRow(stores.primary.blockId, {
92
100
  Title: title,
93
101
  Description: input.description?.trim() || '',
94
102
  Status: input.status || 'Open',
@@ -103,14 +111,15 @@ export class TicketStore {
103
111
  }
104
112
  }
105
113
 
106
- /** Updates an existing ticket row in the project's Tickets datastore. */
107
- async updateTicket(cwd: string, rowId: string, input: TicketInput): Promise<{ ok: boolean; error?: string }> {
114
+ /** Updates an existing ticket row. `blockId` names the store the row lives
115
+ * in (from listTickets); without it the primary store is assumed. */
116
+ async updateTicket(cwd: string, rowId: string, input: TicketInput, blockId?: string): Promise<{ ok: boolean; error?: string }> {
108
117
  const title = input.title?.trim()
109
118
  if (!title) return { ok: false, error: 'Title is required' }
110
119
  try {
111
- const ds = await this.ensureDatastore(cwd, false)
112
- if (!ds) return { ok: false, error: 'No Tickets datastore for this project' }
113
- await this.api.updateRow(ds.blockId, rowId, {
120
+ const stores = await this.resolveStores(cwd, false)
121
+ if (!stores) return { ok: false, error: 'No Tickets datastore for this project' }
122
+ await this.api.updateRow(blockId || stores.primary.blockId, rowId, {
114
123
  Title: title,
115
124
  Description: input.description?.trim() || '',
116
125
  Status: input.status || 'Open',
@@ -124,29 +133,42 @@ export class TicketStore {
124
133
  }
125
134
  }
126
135
 
127
- /** Lists existing tickets for the project, newest first. Does not create the
128
- * datastore — an unconfigured project simply has no tickets yet. */
136
+ /** Lists existing tickets across every Tickets store in the project's
137
+ * folder, newest first. Does not create the datastore — an unconfigured
138
+ * project simply has no tickets yet. */
129
139
  async listTickets(cwd: string): Promise<{ ok: boolean; tickets: Ticket[]; blockId?: string; pageId?: string; error?: string }> {
130
140
  try {
131
- const ds = await this.ensureDatastore(cwd, false)
132
- if (!ds) return { ok: true, tickets: [] }
133
- const res = await this.api.queryRows(ds.blockId, { orderBy: 'Created', order: 'desc', limit: 200 })
134
- const tickets: Ticket[] = (res?.rows || []).map(r => ({
135
- id: r.id,
136
- title: String(r.data?.Title ?? ''),
137
- description: String(r.data?.Description ?? ''),
138
- status: String(r.data?.Status ?? 'Open'),
139
- priority: String(r.data?.Priority ?? 'Med'),
140
- created: (r.data?.Created as string) ?? r.created_at ?? null,
141
- }))
142
- return { ok: true, tickets, blockId: ds.blockId, pageId: ds.pageId }
141
+ const stores = await this.resolveStores(cwd, false)
142
+ if (!stores) return { ok: true, tickets: [] }
143
+ const tickets: Ticket[] = []
144
+ for (const ds of stores.all) {
145
+ const res = await this.api.queryRows(ds.blockId, { orderBy: 'Created', order: 'desc', limit: 200 })
146
+ for (const r of res?.rows || []) {
147
+ tickets.push({
148
+ id: r.id,
149
+ title: String(r.data?.Title ?? ''),
150
+ description: String(r.data?.Description ?? ''),
151
+ status: String(r.data?.Status ?? 'Open'),
152
+ priority: String(r.data?.Priority ?? 'Med'),
153
+ created: (r.data?.Created as string) ?? r.created_at ?? null,
154
+ blockId: ds.blockId,
155
+ pageId: ds.pageId,
156
+ })
157
+ }
158
+ }
159
+ // Each store is queried newest-first; re-sort the merged list so rows
160
+ // from different stores interleave correctly (ISO strings compare fine).
161
+ if (stores.all.length > 1) {
162
+ tickets.sort((a, b) => (b.created || '').localeCompare(a.created || ''))
163
+ }
164
+ return { ok: true, tickets, blockId: stores.primary.blockId, pageId: stores.primary.pageId }
143
165
  } catch (err: any) {
144
166
  log(`listTickets failed for ${cwd}: ${err?.message || err}`)
145
167
  return { ok: false, tickets: [], error: err?.message || String(err) }
146
168
  }
147
169
  }
148
170
 
149
- private async ensureDatastore(cwd: string, create: boolean): Promise<{ blockId: string; pageId: string } | null> {
171
+ private async resolveStores(cwd: string, create: boolean): Promise<ResolvedStores | null> {
150
172
  const cached = this.dsCache.get(cwd)
151
173
  if (cached) return cached
152
174
 
@@ -159,20 +181,34 @@ export class TicketStore {
159
181
  if (!folder?.id) return null
160
182
 
161
183
  const folderDetail = await this.api.getFolder(folder.id)
162
- const agentPage = findPageByTitle(folderDetail?.pages || [], AGENT_DATASTORE_PAGE_TITLE)
163
- if (!agentPage?.id) return null
164
- const pageId: string = agentPage.id
184
+ const agentPages = findPagesByTitle(folderDetail?.pages || [], AGENT_DATASTORE_PAGE_TITLE)
185
+ if (agentPages.length === 0) return null
165
186
 
166
- const blockId = await this.findOrAdoptBlock(pageId)
167
- if (blockId) {
168
- await this.ensureColumns(blockId)
169
- const ds = { blockId, pageId }
170
- this.dsCache.set(cwd, ds)
171
- return ds
187
+ // The page bound to the codebase root is the canonical one; prefer it as
188
+ // the write target. Stable sort keeps tree order among the rest.
189
+ const rootPath = folder.root_path || cwd
190
+ agentPages.sort((a, b) => Number(b?.root_path === rootPath) - Number(a?.root_path === rootPath))
191
+ if (agentPages.length > 1) {
192
+ log(`Found ${agentPages.length} "Agent Datastore" pages for ${cwd}; aggregating tickets across all of them`)
193
+ }
194
+
195
+ const all: DsRef[] = []
196
+ for (const page of agentPages) {
197
+ for (const blockId of await this.findTicketBlocks(page.id)) {
198
+ all.push({ blockId, pageId: page.id })
199
+ }
200
+ }
201
+
202
+ if (all.length > 0) {
203
+ for (const ds of all) await this.ensureColumns(ds.blockId)
204
+ const stores = { primary: all[0], all }
205
+ this.dsCache.set(cwd, stores)
206
+ return stores
172
207
  }
173
208
 
174
209
  if (!create) return null
175
210
 
211
+ const pageId: string = agentPages[0].id
176
212
  const columns = COLUMNS.map((c, i) => ({
177
213
  id: `col_${i}`,
178
214
  name: c.name,
@@ -187,50 +223,48 @@ export class TicketStore {
187
223
  if (created?.id) {
188
224
  log(`Created "${DATASTORE_TITLE}" datastore on Agent Datastore page for ${cwd}`)
189
225
  const ds = { blockId: created.id, pageId }
190
- this.dsCache.set(cwd, ds)
191
- return ds
226
+ const stores = { primary: ds, all: [ds] }
227
+ this.dsCache.set(cwd, stores)
228
+ return stores
192
229
  }
193
230
  return null
194
231
  }
195
232
 
196
- /** Finds an existing Tickets block on the page. Prefers a system_key match
197
- * (which survives title renames). Falls back to title match for legacy blocks
198
- * and backfills system_key on the way out. */
199
- private async findOrAdoptBlock(pageId: string): Promise<string | null> {
233
+ /** Finds every Tickets block on the page: system_key matches first, then
234
+ * title matches for legacy blocks. When no system_key block exists, the
235
+ * first legacy block is adopted (system_key backfilled so it survives a
236
+ * later rename); extra title matches are still listed but left unstamped so
237
+ * the primary can't flip between sessions. */
238
+ private async findTicketBlocks(pageId: string): Promise<string[]> {
200
239
  const summaries = (await this.api.getPageBlockSummaries(pageId)) || []
201
240
  const datastoreSummaries = summaries.filter((b: any) => b?.type === 'datastore' && b?.id)
202
- if (datastoreSummaries.length === 0) return null
241
+ if (datastoreSummaries.length === 0) return []
203
242
 
204
- let titleFallbackId: string | null = null
205
- let titleFallbackProps: Record<string, unknown> | null = null
243
+ const keyed: string[] = []
244
+ const legacy: Array<{ id: string; props: Record<string, unknown> }> = []
206
245
  for (const s of datastoreSummaries) {
207
246
  try {
208
247
  const block = await this.api.getBlock(s.id)
209
248
  const props = (block?.props || {}) as Record<string, unknown>
210
- if (props.system_key === SYSTEM_KEY) {
211
- return s.id
212
- }
213
- if (s.title === DATASTORE_TITLE && titleFallbackId === null) {
214
- titleFallbackId = s.id
215
- titleFallbackProps = props
216
- }
249
+ if (props.system_key === SYSTEM_KEY) keyed.push(s.id)
250
+ else if (s.title === DATASTORE_TITLE) legacy.push({ id: s.id, props })
217
251
  } catch (err: any) {
218
252
  log(`getBlock(${s.id}) failed during lookup: ${err?.message || err}`)
219
253
  }
220
254
  }
221
255
 
222
- if (titleFallbackId) {
256
+ if (keyed.length === 0 && legacy.length > 0) {
257
+ const adopt = legacy[0]
223
258
  try {
224
- await this.api.updateBlock(titleFallbackId, {
225
- props: { ...(titleFallbackProps || {}), system_key: SYSTEM_KEY },
259
+ await this.api.updateBlock(adopt.id, {
260
+ props: { ...adopt.props, system_key: SYSTEM_KEY },
226
261
  })
227
- log(`Backfilled system_key on legacy Tickets block ${titleFallbackId}`)
262
+ log(`Backfilled system_key on legacy Tickets block ${adopt.id}`)
228
263
  } catch (err: any) {
229
- log(`backfill system_key failed on ${titleFallbackId}: ${err?.message || err}`)
264
+ log(`backfill system_key failed on ${adopt.id}: ${err?.message || err}`)
230
265
  }
231
- return titleFallbackId
232
266
  }
233
- return null
267
+ return [...keyed, ...legacy.map(l => l.id)]
234
268
  }
235
269
 
236
270
  private async ensureColumns(blockId: string): Promise<void> {
@@ -79,6 +79,16 @@ const api = {
79
79
  },
80
80
  browseCwd: (): Promise<string | null> =>
81
81
  ipcRenderer.invoke('app:browseCwd'),
82
+ // Mirror the renderer's appearance preference onto Chromium's nativeTheme and
83
+ // persist it. localStorage stays the source of truth (see renderer/lib/theme.ts);
84
+ // this copy backs the pre-load window background and localStorage recovery.
85
+ setAppearance: (data: {
86
+ pref: unknown
87
+ source: 'system' | 'light' | 'dark'
88
+ background: string
89
+ }): Promise<{ ok: boolean }> => ipcRenderer.invoke('app:setAppearance', data),
90
+ getAppearance: (): Promise<{ pref: unknown }> =>
91
+ ipcRenderer.invoke('app:getAppearance'),
82
92
 
83
93
  // Clipboard — write a rich-HTML table (+ plain-text fallback) for email paste.
84
94
  // `ok` reflects a read-back verification in the main process, not just that the
@@ -128,9 +138,9 @@ const api = {
128
138
  ipcRenderer.invoke('tickets:project'),
129
139
  addTicket: (input: { title: string; description?: string; status?: string; priority?: string }): Promise<{ ok: boolean; error?: string }> =>
130
140
  ipcRenderer.invoke('tickets:add', input),
131
- updateTicket: (rowId: string, input: { title: string; description?: string; status?: string; priority?: string }): Promise<{ ok: boolean; error?: string }> =>
132
- ipcRenderer.invoke('tickets:update', rowId, input),
133
- listTickets: (): Promise<{ ok: boolean; tickets: Array<{ id: string; title: string; description: string; status: string; priority: string; created: string | null }>; error?: string }> =>
141
+ updateTicket: (rowId: string, input: { title: string; description?: string; status?: string; priority?: string }, blockId?: string): Promise<{ ok: boolean; error?: string }> =>
142
+ ipcRenderer.invoke('tickets:update', rowId, input, blockId),
143
+ listTickets: (): Promise<{ ok: boolean; tickets: Array<{ id: string; title: string; description: string; status: string; priority: string; created: string | null; blockId?: string; pageId?: string }>; blockId?: string; pageId?: string; error?: string }> =>
134
144
  ipcRenderer.invoke('tickets:list'),
135
145
 
136
146
  // Chat logging (global)
@@ -50,6 +50,8 @@ declare global {
50
50
  getProjectName: () => Promise<string | null>
51
51
  onProjectChanged: (callback: (name: string | null) => void) => () => void
52
52
  browseCwd: () => Promise<string | null>
53
+ setAppearance?: (data: { pref: unknown; source: 'system' | 'light' | 'dark'; background: string }) => Promise<{ ok: boolean }>
54
+ getAppearance?: () => Promise<{ pref: unknown }>
53
55
  getSetting: (key: string) => Promise<string | null>
54
56
  setSetting: (key: string, value: string) => Promise<{ ok: boolean }>
55
57
  getAllSettings: () => Promise<Record<string, string | null>>
@@ -60,8 +62,8 @@ declare global {
60
62
  deleteProfile: (profileId: string) => Promise<{ ok: boolean }>
61
63
  getTicketProject: () => Promise<{ cwd: string | null; name: string | null }>
62
64
  addTicket: (input: { title: string; description?: string; status?: string; priority?: string }) => Promise<{ ok: boolean; error?: string }>
63
- updateTicket: (rowId: string, input: { title: string; description?: string; status?: string; priority?: string }) => Promise<{ ok: boolean; error?: string }>
64
- 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 }>
65
+ updateTicket: (rowId: string, input: { title: string; description?: string; status?: string; priority?: string }, blockId?: string) => Promise<{ ok: boolean; error?: string }>
66
+ listTickets: () => Promise<{ ok: boolean; tickets: Array<{ id: string; title: string; description: string; status: string; priority: string; created: string | null; blockId?: string; pageId?: string }>; blockId?: string; pageId?: string; error?: string }>
65
67
  getTracking: () => Promise<{ active: boolean }>
66
68
  setTracking: (enabled: boolean) => Promise<{ active: boolean }>
67
69
  getLogChat: () => Promise<{ enabled: boolean }>
@@ -190,7 +190,7 @@ export function CtlsurfPanel({ navigate }: CtlsurfPanelProps) {
190
190
  />
191
191
  ) : (
192
192
  <div className="ctlsurf-webview-loading">
193
- <span style={{ color: '#565f89', fontSize: '13px' }}>Loading ctlsurf...</span>
193
+ <span style={{ color: 'var(--text-muted)', fontSize: '13px' }}>Loading ctlsurf...</span>
194
194
  </div>
195
195
  )}
196
196
  </div>