@phenx-inc/ctlsurf 0.15.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/out/headless/index.mjs +89 -71
  2. package/out/headless/index.mjs.map +3 -3
  3. package/out/main/index.js +90 -72
  4. package/out/preload/index.js +1 -1
  5. package/out/renderer/assets/{cssMode-CJR4iD-G.js → cssMode-CPfLk04b.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-BRI9ISq9.js → freemarker2-CDHpuFqv.js} +1 -1
  7. package/out/renderer/assets/{handlebars-3hL6XIZI.js → handlebars-CgX9Jz36.js} +1 -1
  8. package/out/renderer/assets/{html-Dd5_vBoP.js → html-DdgFccDG.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-DgNtlKcG.js → htmlMode-CRHIwtvP.js} +3 -3
  10. package/out/renderer/assets/{index-5azC4yNn.js → index-DVdWgw7p.js} +50 -38
  11. package/out/renderer/assets/{javascript-D0vPLEJg.js → javascript-CHhxL0s7.js} +2 -2
  12. package/out/renderer/assets/{jsonMode-DBvR0zMu.js → jsonMode-Cy7-eWai.js} +3 -3
  13. package/out/renderer/assets/{liquid-CLoc6Tx7.js → liquid-vMkF9pfk.js} +1 -1
  14. package/out/renderer/assets/{lspLanguageFeatures-CiaMaQoY.js → lspLanguageFeatures-nAygNzuG.js} +1 -1
  15. package/out/renderer/assets/{mdx-DTsq6tlv.js → mdx-C4UVz5dW.js} +1 -1
  16. package/out/renderer/assets/{python-D04DpZDr.js → python-BHemzN9g.js} +1 -1
  17. package/out/renderer/assets/{razor-Di6CJ6s8.js → razor-DH6NPzbo.js} +1 -1
  18. package/out/renderer/assets/{tsMode-mfv6mHOv.js → tsMode-DxBdlGZB.js} +1 -1
  19. package/out/renderer/assets/{typescript-CDA0Hhef.js → typescript-BRCOaBQ5.js} +1 -1
  20. package/out/renderer/assets/{xml-BU0voRuq.js → xml-DVv9gDP1.js} +1 -1
  21. package/out/renderer/assets/{yaml-qzFWnaZJ.js → yaml-jB-C3CdS.js} +1 -1
  22. package/out/renderer/index.html +1 -1
  23. package/package.json +1 -1
  24. package/src/main/index.ts +2 -2
  25. package/src/main/orchestrator.ts +2 -2
  26. package/src/main/ticketStore.ts +100 -66
  27. package/src/preload/index.ts +3 -3
  28. package/src/renderer/App.tsx +2 -2
  29. package/src/renderer/components/FloatingMic.tsx +15 -11
  30. package/src/renderer/components/TerminalPanel.tsx +11 -2
  31. package/src/renderer/components/TicketPanel.tsx +16 -5
@@ -1,5 +1,5 @@
1
- import { conf as conf$1, language as language$1 } from "./typescript-CDA0Hhef.js";
2
- import "./index-5azC4yNn.js";
1
+ import { conf as conf$1, language as language$1 } from "./typescript-BRCOaBQ5.js";
2
+ import "./index-DVdWgw7p.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-5azC4yNn.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-CiaMaQoY.js";
3
- import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-CiaMaQoY.js";
1
+ import { c as createWebWorker, l as languages, e as editor } from "./index-DVdWgw7p.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-nAygNzuG.js";
3
+ import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-nAygNzuG.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-5azC4yNn.js";
1
+ import { l as languages } from "./index-DVdWgw7p.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-5azC4yNn.js";
1
+ import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-DVdWgw7p.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-5azC4yNn.js";
1
+ import { l as languages } from "./index-DVdWgw7p.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["{/*", "*/}"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-5azC4yNn.js";
1
+ import { l as languages } from "./index-DVdWgw7p.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#",
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-5azC4yNn.js";
1
+ import { l as languages } from "./index-DVdWgw7p.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-5azC4yNn.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-DVdWgw7p.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-5azC4yNn.js";
1
+ import { l as languages } from "./index-DVdWgw7p.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-5azC4yNn.js";
1
+ import { l as languages } from "./index-DVdWgw7p.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["<!--", "-->"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-5azC4yNn.js";
1
+ import { l as languages } from "./index-DVdWgw7p.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#"
@@ -106,7 +106,7 @@
106
106
  :root[data-theme='catppuccin-latte'] .boot-splash-title { color: #2f3145; }
107
107
  :root[data-theme='catppuccin-latte'] .boot-splash-label { color: #7c7f93; }
108
108
  </style>
109
- <script type="module" crossorigin src="./assets/index-5azC4yNn.js"></script>
109
+ <script type="module" crossorigin src="./assets/index-DVdWgw7p.js"></script>
110
110
  <link rel="stylesheet" crossorigin href="./assets/index-3sFJEz7i.css">
111
111
  </head>
112
112
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phenx-inc/ctlsurf",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "description": "Agent-agnostic terminal and desktop app for ctlsurf — run Claude Code, Codex, or any coding agent with live session logging and remote control",
5
5
  "main": "out/main/index.js",
6
6
  "bin": {
package/src/main/index.ts CHANGED
@@ -710,8 +710,8 @@ ipcMain.handle('tickets:add', async (_event, input: {
710
710
  })
711
711
  ipcMain.handle('tickets:update', async (_event, rowId: string, input: {
712
712
  title: string; description?: string; status?: string; priority?: string
713
- }) => {
714
- return orchestrator.updateTicketForActiveTab(rowId, input)
713
+ }, blockId?: string) => {
714
+ return orchestrator.updateTicketForActiveTab(rowId, input, blockId)
715
715
  })
716
716
  ipcMain.handle('tickets:list', async () => {
717
717
  return orchestrator.listTicketsForActiveTab()
@@ -780,13 +780,13 @@ export class Orchestrator {
780
780
  return this.ticketStore.addTicket(cwd, input)
781
781
  }
782
782
 
783
- 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 }> {
784
784
  const cwd = this.getActiveTabProjectPath()
785
785
  if (!cwd) return { ok: false, error: 'No active terminal tab' }
786
786
  if (!this.ctlsurfApi.getApiKey()) {
787
787
  return { ok: false, error: 'ctlsurf API key not configured' }
788
788
  }
789
- return this.ticketStore.updateTicket(cwd, rowId, input)
789
+ return this.ticketStore.updateTicket(cwd, rowId, input, blockId)
790
790
  }
791
791
 
792
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> {
@@ -138,9 +138,9 @@ const api = {
138
138
  ipcRenderer.invoke('tickets:project'),
139
139
  addTicket: (input: { title: string; description?: string; status?: string; priority?: string }): Promise<{ ok: boolean; error?: string }> =>
140
140
  ipcRenderer.invoke('tickets:add', input),
141
- updateTicket: (rowId: string, input: { title: string; description?: string; status?: string; priority?: string }): Promise<{ ok: boolean; error?: string }> =>
142
- ipcRenderer.invoke('tickets:update', rowId, input),
143
- 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 }> =>
144
144
  ipcRenderer.invoke('tickets:list'),
145
145
 
146
146
  // Chat logging (global)
@@ -62,8 +62,8 @@ declare global {
62
62
  deleteProfile: (profileId: string) => Promise<{ ok: boolean }>
63
63
  getTicketProject: () => Promise<{ cwd: string | null; name: string | null }>
64
64
  addTicket: (input: { title: string; description?: string; status?: string; priority?: string }) => Promise<{ ok: boolean; error?: string }>
65
- updateTicket: (rowId: string, input: { title: string; description?: string; status?: string; priority?: 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; 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 }>
67
67
  getTracking: () => Promise<{ active: boolean }>
68
68
  setTracking: (enabled: boolean) => Promise<{ active: boolean }>
69
69
  getLogChat: () => Promise<{ enabled: boolean }>
@@ -15,10 +15,11 @@ interface FloatingMicProps {
15
15
  onHide: () => void
16
16
  }
17
17
 
18
- // Keep the button clear of the 38px titlebar and 24px status bar.
18
+ // Keep the toolbar clear of the 38px titlebar, 24px status bar, and the
19
+ // terminal's bottom input line.
19
20
  const EDGE = 20
20
21
  const TOP_MIN = 46
21
- const BOTTOM_GAP = 36
22
+ const BOTTOM_GAP = 72
22
23
 
23
24
  function loadPos(): Pos | null {
24
25
  try {
@@ -71,17 +72,20 @@ export function FloatingMic({ onTranscript, onHide }: FloatingMicProps) {
71
72
  }
72
73
  }, [])
73
74
 
74
- // First mount with no saved position: default to bottom-right.
75
+ // Default to bottom-right, and clamp saved positions on first mount too so
76
+ // increased chrome/input clearance applies to existing installations.
75
77
  useEffect(() => {
76
- if (pos) return
77
- const el = elRef.current
78
- const w = el?.offsetWidth ?? 64
79
- const h = el?.offsetHeight ?? 90
80
- setPos({
81
- x: window.innerWidth - w - EDGE,
82
- y: window.innerHeight - h - BOTTOM_GAP,
78
+ setPos((current) => {
79
+ if (current) return clamp(current.x, current.y)
80
+ const el = elRef.current
81
+ const w = el?.offsetWidth ?? 64
82
+ const h = el?.offsetHeight ?? 90
83
+ return clamp(
84
+ window.innerWidth - w - EDGE,
85
+ window.innerHeight - h - BOTTOM_GAP,
86
+ )
83
87
  })
84
- }, [pos])
88
+ }, [clamp])
85
89
 
86
90
  // Keep it reachable if the window shrinks.
87
91
  useEffect(() => {
@@ -197,6 +197,11 @@ function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): {
197
197
 
198
198
  const terminal = new Terminal({
199
199
  cursorBlink: true,
200
+ // Codex and other modern CLIs often render links as OSC 8 hyperlinks
201
+ // instead of plain URL text. OSC 8 links are handled by xterm core (not
202
+ // WebLinksAddon), whose default activation calls window.open(). Route them
203
+ // through the same embedded-panel handler as detected plain-text URLs.
204
+ linkHandler: { activate: handleTerminalLink },
200
205
  // Off by default on macOS; without this a right-click would reselect the
201
206
  // word under the cursor and clobber a multi-line table selection.
202
207
  rightClickSelectsWord: false,
@@ -469,8 +474,12 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
469
474
 
470
475
  // Wheel over the terminal is an explicit user scroll — stamp it so onScroll
471
476
  // treats the resulting move as intentional (not an involuntary redraw jump).
477
+ // Capture is important: xterm handles wheel events on a descendant and
478
+ // emits onScroll synchronously. A bubbling listener runs too late, causing
479
+ // streamed Codex output to mistake the wheel scroll for a redraw and snap
480
+ // the viewport straight back to the bottom.
472
481
  const onWheel = () => markGesture(tabId)
473
- terminal.element?.addEventListener('wheel', onWheel, { passive: true })
482
+ terminal.element?.addEventListener('wheel', onWheel, { capture: true, passive: true })
474
483
 
475
484
  // Resize handling — skip when the container is hidden (display:none → 0x0),
476
485
  // otherwise fit() reports a tiny size and the PTY gets stuck at ~4 cols
@@ -499,7 +508,7 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
499
508
  clearTimeout(resizeTimeout)
500
509
  window.removeEventListener('resize', handleResize)
501
510
  observer.disconnect()
502
- terminal.element?.removeEventListener('wheel', onWheel)
511
+ terminal.element?.removeEventListener('wheel', onWheel, true)
503
512
  // Do NOT dispose terminal or unsub PTY listeners — they persist across remounts
504
513
  }
505
514
  }, [tabId, onExit])
@@ -31,6 +31,10 @@ interface Ticket {
31
31
  status: string
32
32
  priority: string
33
33
  created: string | null
34
+ // Owning datastore — tickets can live in more than one store per project,
35
+ // so edits and dispatch prompts must reference the row's own store.
36
+ blockId?: string
37
+ pageId?: string
34
38
  }
35
39
 
36
40
  type SaveState = { kind: 'idle' } | { kind: 'saving' } | { kind: 'error'; message: string }
@@ -82,10 +86,13 @@ function ticketPrompt(t: Ticket, refs: TicketRefs | null): string {
82
86
  'ctlsurf references (use the ctlsurf MCP tools to read details or update status):',
83
87
  `- Ticket row ID: ${t.id}`,
84
88
  )
85
- if (refs) {
89
+ // Prefer the ticket's own store — it may not be the project's primary one.
90
+ const blockId = t.blockId ?? refs?.blockId
91
+ const pageId = t.pageId ?? refs?.pageId
92
+ if (blockId && pageId) {
86
93
  lines.push(
87
- `- Tickets datastore block ID: ${refs.blockId}`,
88
- `- Agent Datastore page ID: ${refs.pageId}`,
94
+ `- Tickets datastore block ID: ${blockId}`,
95
+ `- Agent Datastore page ID: ${pageId}`,
89
96
  )
90
97
  }
91
98
  return lines.join('\n')
@@ -177,6 +184,8 @@ export function TicketPane({ onSendToTerminal }: { onSendToTerminal: (text: stri
177
184
 
178
185
  // form state
179
186
  const [editingId, setEditingId] = useState<string | null>(null)
187
+ // Store the edited ticket came from, so the save lands in the right one.
188
+ const [editingBlockId, setEditingBlockId] = useState<string | null>(null)
180
189
  const [title, setTitle] = useState('')
181
190
  const [description, setDescription] = useState('')
182
191
  const [status, setStatus] = useState(STATUS_OPTIONS[0].value)
@@ -216,6 +225,7 @@ export function TicketPane({ onSendToTerminal }: { onSendToTerminal: (text: stri
216
225
 
217
226
  const openNewForm = useCallback(() => {
218
227
  setEditingId(null)
228
+ setEditingBlockId(null)
219
229
  setTitle('')
220
230
  setDescription('')
221
231
  setStatus(STATUS_OPTIONS[0].value)
@@ -226,6 +236,7 @@ export function TicketPane({ onSendToTerminal }: { onSendToTerminal: (text: stri
226
236
 
227
237
  const openEditForm = useCallback((t: Ticket) => {
228
238
  setEditingId(t.id)
239
+ setEditingBlockId(t.blockId ?? null)
229
240
  setTitle(t.title)
230
241
  setDescription(t.description)
231
242
  setStatus(STATUS_OPTIONS.some(o => o.value === t.status) ? t.status : STATUS_OPTIONS[0].value)
@@ -240,7 +251,7 @@ export function TicketPane({ onSendToTerminal }: { onSendToTerminal: (text: stri
240
251
  const payload = { title: title.trim(), description: description.trim(), status, priority }
241
252
  try {
242
253
  const r = editingId
243
- ? await window.worker.updateTicket(editingId, payload)
254
+ ? await window.worker.updateTicket(editingId, payload, editingBlockId ?? undefined)
244
255
  : await window.worker.addTicket(payload)
245
256
  if (r?.ok) {
246
257
  setView('list')
@@ -251,7 +262,7 @@ export function TicketPane({ onSendToTerminal }: { onSendToTerminal: (text: stri
251
262
  } catch (err: any) {
252
263
  setSave({ kind: 'error', message: err?.message || 'Failed to save ticket' })
253
264
  }
254
- }, [title, description, status, priority, editingId, save.kind, refreshList])
265
+ }, [title, description, status, priority, editingId, editingBlockId, save.kind, refreshList])
255
266
 
256
267
  // Only capture keys while editing a ticket, so list-view typing in the terminal
257
268
  // isn't hijacked. Esc returns to the list; Cmd/Ctrl+Enter saves.