From a4b6e85787ee2522e87d5da2a5fff9c4c3e79350 Mon Sep 17 00:00:00 2001 From: BW Date: Tue, 29 Sep 2026 16:51:54 -0600 Subject: [PATCH] feat(super-productivity): sync task notes to Super Productivity --- electron/ipc/channel-manifest.json | 1 + electron/preload.cjs | 1 + electron/shared/super-productivity.test.ts | 63 ++++++++++++ electron/shared/super-productivity.ts | 49 ++++++++++ electron/super-productivity/client.test.ts | 28 ++++++ electron/super-productivity/client.ts | 9 ++ electron/super-productivity/register.ts | 19 ++++ src/components/NewTaskPanel.tsx | 5 +- src/store/persistence.ts | 8 +- src/store/superProductivity.client.test.tsx | 92 +++++++++++++++++- src/store/superProductivity.ts | 95 ++++++++++++++++++- .../superProductivityOpen.client.test.tsx | 16 ++++ src/store/superProductivityOpen.ts | 13 ++- src/store/tasks.ts | 14 ++- src/store/types.ts | 3 + 15 files changed, 402 insertions(+), 14 deletions(-) diff --git a/electron/ipc/channel-manifest.json b/electron/ipc/channel-manifest.json index 2ebfca44..74f7b83d 100644 --- a/electron/ipc/channel-manifest.json +++ b/electron/ipc/channel-manifest.json @@ -207,6 +207,7 @@ "SuperProductivityGetTasks": "super_productivity_get_tasks", "SuperProductivityRenameTask": "super_productivity_rename_task", "SuperProductivityCompleteTask": "super_productivity_complete_task", + "SuperProductivityUpdateTaskNotes": "super_productivity_update_task_notes", "SuperProductivityConsumePendingOpen": "super_productivity_consume_pending_open", "SuperProductivityOpenTaskRequested": "super_productivity_open_task_requested" } diff --git a/electron/preload.cjs b/electron/preload.cjs index 1278a0f7..6aaae3ce 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -211,6 +211,7 @@ const ALLOWED_CHANNELS = new Set([ 'super_productivity_get_tasks', 'super_productivity_rename_task', 'super_productivity_complete_task', + 'super_productivity_update_task_notes', 'super_productivity_consume_pending_open', 'super_productivity_open_task_requested', ]); diff --git a/electron/shared/super-productivity.test.ts b/electron/shared/super-productivity.test.ts index 60a7485c..2bc89129 100644 --- a/electron/shared/super-productivity.test.ts +++ b/electron/shared/super-productivity.test.ts @@ -5,8 +5,11 @@ import { buildSpDoneNote, decideSpFocusAction, parseParallelCodeUrl, + resolveSpNotesSync, resolveSpTitleSync, + SP_MAX_NOTES_LENGTH, SP_MAX_TITLE_LENGTH, + toSpNotes, toSpTitle, type SpTaskSummary, } from './super-productivity.js'; @@ -139,6 +142,66 @@ describe('resolveSpTitleSync', () => { }); }); +describe('toSpNotes', () => { + it('leaves notes within limits untouched', () => { + expect(toSpNotes('')).toBe(''); + expect(toSpNotes('line 1\nline 2\n\n# Header')).toBe('line 1\nline 2\n\n# Header'); + }); + + it('caps notes longer than SP_MAX_NOTES_LENGTH', () => { + const long = 'a'.repeat(SP_MAX_NOTES_LENGTH + 50); + const cut = toSpNotes(long); + expect(cut.length).toBe(SP_MAX_NOTES_LENGTH - 1); + }); + + it('does not split surrogate pairs at the cut boundary', () => { + const prefix = 'a'.repeat(SP_MAX_NOTES_LENGTH - 2); + const withEmoji = `${prefix}🔥xx`; + const cut = toSpNotes(withEmoji); + expect(cut).toBe(prefix); + }); +}); + +describe('resolveSpNotesSync', () => { + it('does nothing when local, remote and base agree', () => { + expect(resolveSpNotesSync('notes', 'notes', 'notes')).toEqual({ kind: 'none' }); + }); + + it('pulls changes made in Super Productivity', () => { + expect(resolveSpNotesSync('a', 'a', 'b')).toEqual({ kind: 'pull', notes: 'b' }); + }); + + it('pushes changes made in Parallel Code', () => { + expect(resolveSpNotesSync('a', 'b', 'a')).toEqual({ kind: 'push', notes: 'b' }); + }); + + it('lets Parallel Code win when both changed', () => { + expect(resolveSpNotesSync('a', 'b', 'c')).toEqual({ kind: 'push', notes: 'b' }); + }); + + it('rebases when both made identical edits', () => { + expect(resolveSpNotesSync('a', 'b', 'b')).toEqual({ kind: 'rebase', notes: 'b' }); + }); + + it('pulls remote notes when base is undefined and local is empty', () => { + expect(resolveSpNotesSync(undefined, '', 'remote notes')).toEqual({ + kind: 'pull', + notes: 'remote notes', + }); + }); + + it('pushes local notes when base is undefined and local has content', () => { + expect(resolveSpNotesSync(undefined, 'local notes', '')).toEqual({ + kind: 'push', + notes: 'local notes', + }); + expect(resolveSpNotesSync(undefined, 'local notes', 'remote notes')).toEqual({ + kind: 'push', + notes: 'local notes', + }); + }); +}); + describe('buildSpDoneNote / appendSpNote', () => { it('describes a merge', () => { expect( diff --git a/electron/shared/super-productivity.ts b/electron/shared/super-productivity.ts index 3f8bf3b2..044f2579 100644 --- a/electron/shared/super-productivity.ts +++ b/electron/shared/super-productivity.ts @@ -62,6 +62,17 @@ export function toSpTitle(name: string): string { return `${cut.trimEnd()}…`; } +/** Longest notes sent to Super Productivity; longer notes are shortened. */ +export const SP_MAX_NOTES_LENGTH = 100_000; + +/** The notes Parallel Code sends to Super Productivity. */ +export function toSpNotes(notes: string): string { + if (notes.length <= SP_MAX_NOTES_LENGTH) return notes; + let cut = notes.slice(0, SP_MAX_NOTES_LENGTH - 1); + if (/[\uD800-\uDBFF]$/.test(cut)) cut = cut.slice(0, -1); + return cut; +} + export type SpResult = | { ok: true; value: T } | { ok: false; reason: SpFailureReason; message?: string }; @@ -140,6 +151,44 @@ export function resolveSpTitleSync(base: string, local: string, remote: string): return { kind: 'push', title: local }; } +// --------------------------------------------------------------------------- +// Notes sync + +export type SpNotesSyncAction = + | { kind: 'none' } + /** Both sides agree on notes; only the remembered base moves. */ + | { kind: 'rebase'; notes: string } + /** Super Productivity's notes changed: copy them into Parallel Code. */ + | { kind: 'pull'; notes: string } + /** Parallel Code's notes changed (or both did): send them to Super Productivity. */ + | { kind: 'push'; notes: string }; + +/** + * Three-way notes merge against the last notes both apps agreed on. + * If base is not set (e.g. freshly linked task): + * - If local is empty and remote has notes, pull remote into local. + * - Otherwise local wins (push to remote). + * If base is set: + * - Whichever side moved away from base wins; when both moved, Parallel Code wins. + */ +export function resolveSpNotesSync( + base: string | undefined, + local: string, + remote: string, +): SpNotesSyncAction { + const normLocal = toSpNotes(local); + const normRemote = toSpNotes(remote); + if (normLocal === normRemote) { + return base === normLocal ? { kind: 'none' } : { kind: 'rebase', notes: normLocal }; + } + if (base === undefined) { + if (!normLocal.trim() && normRemote.trim()) return { kind: 'pull', notes: normRemote }; + return { kind: 'push', notes: normLocal }; + } + if (normLocal === base) return { kind: 'pull', notes: normRemote }; + return { kind: 'push', notes: normLocal }; +} + // --------------------------------------------------------------------------- // Done notes diff --git a/electron/super-productivity/client.test.ts b/electron/super-productivity/client.test.ts index 39dd91bd..2337877b 100644 --- a/electron/super-productivity/client.test.ts +++ b/electron/super-productivity/client.test.ts @@ -158,6 +158,34 @@ describe('createSpClient', () => { }); }); + it('creates a task with notes when provided', async () => { + const fetchImpl = fakeFetch((_m, _p, body) => ({ + status: 201, + json: { ok: true, data: { id: 'n', ...(body as object) } }, + })); + const client = createSpClient({ getToken: () => 'tok', fetchImpl }); + await client.createTask({ title: 'Task with notes', notes: 'Initial notes content' }); + expect(JSON.parse(fetchImpl.mock.calls[0][1].body as string)).toEqual({ + title: 'Task with notes', + notes: 'Initial notes content', + isIgnoreShortSyntax: true, + }); + }); + + it('updates task notes via PATCH', async () => { + const fetchImpl = fakeFetch((_m, _p, _b) => ok({})); + const client = createSpClient({ getToken: () => 'tok', fetchImpl }); + const res = await client.updateTaskNotes('t1', 'Updated notes'); + expect(res).toEqual({ ok: true, value: null }); + expect(fetchImpl).toHaveBeenCalledWith( + expect.stringContaining('/tasks/t1'), + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ notes: 'Updated notes' }), + }), + ); + }); + it('completes a task by appending to its notes', async () => { const fetchImpl = fakeFetch((method) => method === 'GET' ? ok({ id: 't1', title: 'Fix', notes: 'Old' }) : ok({}), diff --git a/electron/super-productivity/client.ts b/electron/super-productivity/client.ts index 911b448e..5e1591f2 100644 --- a/electron/super-productivity/client.ts +++ b/electron/super-productivity/client.ts @@ -218,6 +218,7 @@ export function createSpClient(deps: SpClientDeps) { async createTask(input: { title: string; + notes?: string; projectId?: string; parentId?: string; }): Promise> { @@ -228,6 +229,7 @@ export function createSpClient(deps: SpClientDeps) { title: input.title, isIgnoreShortSyntax: true, }; + if (typeof input.notes === 'string') body.notes = input.notes; if (input.parentId) body.parentId = input.parentId; else if (input.projectId) body.projectId = input.projectId; const res = await request('POST', '/tasks', body); @@ -271,6 +273,13 @@ export function createSpClient(deps: SpClientDeps) { return res.ok ? { ok: true, value: null } : res; }, + async updateTaskNotes(taskId: string, notes: string): Promise> { + const res = await request('PATCH', `/tasks/${encodeURIComponent(taskId)}`, { + notes, + }); + return res.ok ? { ok: true, value: null } : res; + }, + /** Mark done and append one line to the task's notes. */ async completeTask(taskId: string, note: string): Promise> { const current = await getTask(taskId); diff --git a/electron/super-productivity/register.ts b/electron/super-productivity/register.ts index bc27c492..a2827942 100644 --- a/electron/super-productivity/register.ts +++ b/electron/super-productivity/register.ts @@ -10,6 +10,7 @@ import { getUserDataDir } from '../user-data-dir.js'; import { isValidSpId, SP_MAX_BATCH_IDS, + SP_MAX_NOTES_LENGTH, SP_MAX_TITLE_LENGTH, } from '../shared/super-productivity.js'; import { createSpClient } from './client.js'; @@ -38,6 +39,19 @@ function boundedText(value: unknown, label: string, max: number): string { return text; } +function boundedNotes(value: unknown, label: string): string { + assertString(value, label); + if (value.length > SP_MAX_NOTES_LENGTH) throw new Error(`${label} is too long`); + return value; +} + +function optionalBoundedNotes(value: unknown, label: string): string | undefined { + assertOptionalString(value, label); + if (value === undefined) return undefined; + if (value.length > SP_MAX_NOTES_LENGTH) throw new Error(`${label} is too long`); + return value; +} + export function registerSuperProductivityHandlers(): void { const userDataDir = getUserDataDir(); let token = readSpToken(userDataDir); @@ -72,6 +86,7 @@ export function registerSuperProductivityHandlers(): void { ipcMain.handle(IPC.SuperProductivityCreateTask, (_e, args: IpcArgs) => client.createTask({ title: boundedText(args?.title, 'title', SP_MAX_TITLE_LENGTH), + notes: optionalBoundedNotes(args?.notes, 'notes'), projectId: optionalSpId(args?.projectId, 'projectId'), parentId: optionalSpId(args?.parentId, 'parentId'), }), @@ -97,6 +112,10 @@ export function registerSuperProductivityHandlers(): void { ), ); + ipcMain.handle(IPC.SuperProductivityUpdateTaskNotes, (_e, args: IpcArgs) => + client.updateTaskNotes(spId(args?.taskId, 'taskId'), boundedNotes(args?.notes, 'notes')), + ); + ipcMain.handle(IPC.SuperProductivityCompleteTask, (_e, args: IpcArgs) => client.completeTask( spId(args?.taskId, 'taskId'), diff --git a/src/components/NewTaskPanel.tsx b/src/components/NewTaskPanel.tsx index 3bbd21f2..92ddf331 100644 --- a/src/components/NewTaskPanel.tsx +++ b/src/components/NewTaskPanel.tsx @@ -382,6 +382,7 @@ function AgentAutomationOptions(props: { export function NewTaskPanel(props: NewTaskPanelProps) { const [canvasPrefill, setCanvasPrefill] = createSignal(null); const [spSource, setSpSource] = createSignal(null); + const [spNotes, setSpNotes] = createSignal(undefined); const [prompt, setPrompt] = createSignal(''); const [advancedOpen, setAdvancedOpen] = createSignal(false); // Prompt/name values right after open/prefill — closing is only guarded when @@ -569,6 +570,7 @@ export function NewTaskPanel(props: NewTaskPanelProps) { // Pre-fill from an arena comparison or canvas branch. const prefill = store.newTaskPrefillPrompt; setSpSource(prefill?.superProductivity ?? null); + setSpNotes(prefill?.notes); if (prefill) { setPrompt(prefill.prompt); setName(prefill.name ?? 'Compare arena results'); @@ -998,6 +1000,7 @@ export function NewTaskPanel(props: NewTaskPanelProps) { const taskId = await create({ name: n, nameIsAutoGenerated: !manualName, + notes: spNotes(), agentDef: agent, projectId, gitIsolation: gitIsolation(), @@ -1034,7 +1037,7 @@ export function NewTaskPanel(props: NewTaskPanelProps) { setPrefillPrompt(taskId, p); } const source = spSource(); - if (source) linkNewTaskToSp(taskId, source, projectId); + if (source) linkNewTaskToSp(taskId, source, projectId, spNotes()); toggleNewTaskPanel(false); } catch (err) { setError(String(err)); diff --git a/src/store/persistence.ts b/src/store/persistence.ts index e354a748..4312e55f 100644 --- a/src/store/persistence.ts +++ b/src/store/persistence.ts @@ -235,9 +235,13 @@ function restoredCanvas(pt: PersistedTask): Pick; + const { taskId, syncedTitle, syncedNotes } = value as Record; return isValidSpId(taskId) && typeof syncedTitle === 'string' - ? { taskId, syncedTitle } + ? { + taskId, + syncedTitle, + ...(typeof syncedNotes === 'string' ? { syncedNotes } : {}), + } : undefined; } diff --git a/src/store/superProductivity.client.test.tsx b/src/store/superProductivity.client.test.tsx index d0c86ab9..b90a891b 100644 --- a/src/store/superProductivity.client.test.tsx +++ b/src/store/superProductivity.client.test.tsx @@ -8,12 +8,18 @@ import { produce } from 'solid-js/store'; import { IPC } from '../../electron/ipc/channels'; import { store, setStore } from './core'; import type { Task } from './types'; -import { SP_MAX_TITLE_LENGTH, toSpTitle } from '../../electron/shared/super-productivity'; +import { + appendSpNote, + SP_MAX_TITLE_LENGTH, + toSpTitle, +} from '../../electron/shared/super-productivity'; import { armSpCompletion, disarmSpCompletion, fireSpCompletion, onTaskRenamed, + onTaskNotesChanged, + NOTES_SYNC_DEBOUNCE_MS, TITLE_REFRESH_MIN_MS, refreshSpConnection, spBanner, @@ -72,7 +78,7 @@ function fakeSp(channel: string, args: Record = {}): unknown { isDone: false, projectId: (args.projectId as string | undefined) ?? null, parentId: (args.parentId as string | undefined) ?? null, - notes: '', + notes: (args.notes as string | undefined) ?? '', }; sp.tasks.set(task.id, task); return ok(task); @@ -87,11 +93,17 @@ function fakeSp(channel: string, args: Record = {}): unknown { task.title = args.title as string; return ok(null); } + case IPC.SuperProductivityUpdateTaskNotes: { + const task = sp.tasks.get(id); + if (!task) return missing; + task.notes = args.notes as string; + return ok(null); + } case IPC.SuperProductivityCompleteTask: { const task = sp.tasks.get(id); if (!task) return missing; task.isDone = true; - task.notes = args.note as string; + task.notes = appendSpNote(task.notes, args.note as string); return ok(null); } default: @@ -602,4 +614,78 @@ describe('Super Productivity sync', () => { await vi.advanceTimersByTimeAsync(0); expect(sp.tasks.get('sp-a')?.isDone).toBe(false); }); + + it('creates the task with notes in Super Productivity when task has notes', async () => { + addTask('a', { notes: 'Existing notes on task' }); + setStore('activeTaskId', 'a'); + await settle(1_500); + + expect(sp.creates).toEqual([ + { title: 'Task a', notes: 'Existing notes on task', projectId: 'sp-proj' }, + ]); + expect(store.tasks.a.superProductivity).toEqual({ + taskId: 'sp-1', + syncedTitle: 'Task a', + syncedNotes: 'Existing notes on task', + }); + expect(sp.tasks.get('sp-1')?.notes).toBe('Existing notes on task'); + }); + + it('pushes notes changes made in Parallel Code after debounce', async () => { + addTask('a', { + notes: 'Initial notes', + superProductivity: { taskId: 'sp-a', syncedTitle: 'Task a', syncedNotes: 'Initial notes' }, + }); + spTask('sp-a', { notes: 'Initial notes' }); + + setStore('tasks', 'a', 'notes', 'Edited in Parallel Code'); + onTaskNotesChanged('a'); + + // Before debounce fires + await vi.advanceTimersByTimeAsync(NOTES_SYNC_DEBOUNCE_MS - 100); + expect(sp.tasks.get('sp-a')?.notes).toBe('Initial notes'); + + // After debounce fires + await vi.advanceTimersByTimeAsync(150); + expect(sp.tasks.get('sp-a')?.notes).toBe('Edited in Parallel Code'); + expect(store.tasks.a.superProductivity?.syncedNotes).toBe('Edited in Parallel Code'); + }); + + it('flushes pending notes push when completing a task', async () => { + addTask('a', { + notes: 'Initial notes', + superProductivity: { taskId: 'sp-a', syncedTitle: 'Task a', syncedNotes: 'Initial notes' }, + }); + spTask('sp-a', { notes: 'Initial notes' }); + + setStore('tasks', 'a', 'notes', 'Final note before close'); + onTaskNotesChanged('a'); + + armSpCompletion('a', { kind: 'closed' }); + // Should have flushed immediately without waiting for debounce + await vi.advanceTimersByTimeAsync(0); + expect(sp.tasks.get('sp-a')?.notes).toBe('Final note before close'); + + fireSpCompletion('a'); + await vi.advanceTimersByTimeAsync(0); + expect(sp.tasks.get('sp-a')?.isDone).toBe(true); + expect(sp.tasks.get('sp-a')?.notes).toContain('Final note before close'); + expect(sp.tasks.get('sp-a')?.notes).toContain('Closed in Parallel Code'); + }); + + it('pulls notes changed in Super Productivity on focus evaluation', async () => { + addTask('a', { + notes: 'Old notes', + superProductivity: { taskId: 'sp-a', syncedTitle: 'Task a', syncedNotes: 'Old notes' }, + }); + spTask('sp-a', { notes: 'Updated remotely in Super Productivity' }); + + setStore('activeTaskId', 'a'); + await settle(1_500); + + expect(store.tasks.a.notes).toBe('Updated remotely in Super Productivity'); + expect(store.tasks.a.superProductivity?.syncedNotes).toBe( + 'Updated remotely in Super Productivity', + ); + }); }); diff --git a/src/store/superProductivity.ts b/src/store/superProductivity.ts index 313aa3e1..f3d4f43d 100644 --- a/src/store/superProductivity.ts +++ b/src/store/superProductivity.ts @@ -18,8 +18,10 @@ import { showNotification } from './notification'; import { buildSpDoneNote, decideSpFocusAction, + resolveSpNotesSync, resolveSpTitleSync, SP_MAX_BATCH_IDS, + toSpNotes, toSpTitle, type SpBannerReason, type SpConnectionState, @@ -125,6 +127,8 @@ export async function connectSuperProductivity(token: string): Promise { connectionGen++; + for (const timer of pendingNotesDebounce.values()) clearTimeout(timer); + pendingNotesDebounce.clear(); await invoke(IPC.SuperProductivityClearToken); autoMapPending = false; setSpConnection('not_configured'); @@ -171,9 +175,18 @@ function linkedSpTaskIds(): Set { return ids; } -export function setSpLink(taskId: string, spTaskId: string, syncedTitle: string): void { +export function setSpLink( + taskId: string, + spTaskId: string, + syncedTitle: string, + syncedNotes?: string, +): void { if (!store.tasks[taskId]) return; - setStore('tasks', taskId, 'superProductivity', { taskId: spTaskId, syncedTitle }); + setStore('tasks', taskId, 'superProductivity', { + taskId: spTaskId, + syncedTitle, + ...(syncedNotes !== undefined ? { syncedNotes } : {}), + }); } function clearSpLink(taskId: string): void { @@ -197,14 +210,16 @@ async function createSpTaskFor(taskId: string): Promise { const task = store.tasks[taskId]; if (!task) return null; const title = toSpTitle(task.name) || 'Parallel Code task'; + const notes = task.notes ? toSpNotes(task.notes) : undefined; const create = async (placement: Record) => { const res = await callSp(IPC.SuperProductivityCreateTask, { title, + ...(notes ? { notes } : {}), ...placement, }); // Linked if the task is still here; the id is returned either way, so a // completion armed while this was in flight can still mark it done. - if (res.ok) setSpLink(taskId, res.value.id, res.value.title); + if (res.ok) setSpLink(taskId, res.value.id, res.value.title, notes); return res; }; // Fall back when the parent or project no longer exists over there (or the @@ -306,6 +321,76 @@ async function refreshLinkedTitles(): Promise { } } +// --------------------------------------------------------------------------- +// Notes + +export const NOTES_SYNC_DEBOUNCE_MS = 500; +const pendingNotesDebounce = new Map>(); + +function applyNotesSync(taskId: string, remoteNotes: string): void { + const task = store.tasks[taskId]; + const link = task?.superProductivity; + if (!task || !link) return; + const action = resolveSpNotesSync(link.syncedNotes, task.notes, remoteNotes); + switch (action.kind) { + case 'none': + return; + case 'rebase': + setStore('tasks', taskId, 'superProductivity', 'syncedNotes', action.notes); + return; + case 'pull': + setStore('tasks', taskId, 'notes', action.notes); + setStore('tasks', taskId, 'superProductivity', 'syncedNotes', action.notes); + return; + case 'push': + void pushNotes(taskId); + } +} + +export async function pushNotes(taskId: string): Promise { + const timer = pendingNotesDebounce.get(taskId); + if (timer) { + clearTimeout(timer); + pendingNotesDebounce.delete(taskId); + } + const task = store.tasks[taskId]; + const spTaskId = task?.superProductivity?.taskId; + if (!spTaskId || !task) return; + const notes = task.notes ?? ''; + if (task.superProductivity?.syncedNotes === notes) return; + const res = await callSp(IPC.SuperProductivityUpdateTaskNotes, { + taskId: spTaskId, + notes: toSpNotes(notes), + }); + if (res.ok && store.tasks[taskId]?.superProductivity?.taskId === spTaskId) { + setStore('tasks', taskId, 'superProductivity', 'syncedNotes', notes); + } +} + +/** Called after the user edits notes for a task in Parallel Code. */ +export function onTaskNotesChanged(taskId: string): void { + const task = store.tasks[taskId]; + if (!isEnabled() || !task?.superProductivity) return; + if (task.superProductivity.syncedNotes === task.notes) return; + const existing = pendingNotesDebounce.get(taskId); + if (existing) clearTimeout(existing); + const timer = setTimeout(() => { + pendingNotesDebounce.delete(taskId); + void pushNotes(taskId); + }, NOTES_SYNC_DEBOUNCE_MS); + pendingNotesDebounce.set(taskId, timer); +} + +export function flushPendingNotes(taskId?: string): void { + if (taskId) { + if (pendingNotesDebounce.has(taskId)) void pushNotes(taskId); + } else { + for (const tid of [...pendingNotesDebounce.keys()]) { + void pushNotes(tid); + } + } +} + // --------------------------------------------------------------------------- // Tracking @@ -342,6 +427,7 @@ async function evaluateFocus(taskId: string): Promise { if (res.ok) { own = res.value; applyTitleSync(taskId, res.value.title); + applyNotesSync(taskId, res.value.notes); } else if (res.reason === 'not_found') { // Archived ("finish day") or deleted over there. Keep the link and ask: // the banner's button links a fresh task (trackTaskInSp). @@ -448,6 +534,7 @@ export function armSpCompletion( taskId: string, input: { kind: 'merged' | 'closed'; linesAdded?: number; linesRemoved?: number }, ): void { + flushPendingNotes(taskId); const task = store.tasks[taskId]; const link = task?.superProductivity; const creating = link ? undefined : creatingLinks.get(taskId); @@ -562,6 +649,8 @@ export function startSuperProductivitySync(windowFocused: Accessor): () const stop = () => { if (settleTimer) clearTimeout(settleTimer); + for (const timer of pendingNotesDebounce.values()) clearTimeout(timer); + pendingNotesDebounce.clear(); focusSeq++; // drop evaluations still in flight dispose(); if (stopActiveSync === stop) stopActiveSync = null; diff --git a/src/store/superProductivityOpen.client.test.tsx b/src/store/superProductivityOpen.client.test.tsx index 1d6be74b..5a4ccb64 100644 --- a/src/store/superProductivityOpen.client.test.tsx +++ b/src/store/superProductivityOpen.client.test.tsx @@ -84,6 +84,7 @@ describe('open a Super Productivity task in Parallel Code', () => { expect(store.newTaskPrefillPrompt).toEqual({ prompt: 'Fix login\n\nSteps to reproduce\n\nhttps://github.com/o/r/issues/7', name: 'Fix login', + notes: 'Steps to reproduce', projectId: 'proj', superProductivity: { taskId: 'sp-1', title: 'Fix login', projectId: 'sp-proj' }, }); @@ -143,6 +144,21 @@ describe('open a Super Productivity task in Parallel Code', () => { ); }); + it('links the created task with initial notes', () => { + setStore('tasks', 'b', { id: 'b', name: 'Fix login', notes: 'Initial notes' } as Task); + linkNewTaskToSp( + 'b', + { taskId: 'sp-9', title: 'Fix login', projectId: 'sp-other' }, + 'other', + 'Initial notes', + ); + expect(store.tasks.b.superProductivity).toEqual({ + taskId: 'sp-9', + syncedTitle: 'Fix login', + syncedNotes: 'Initial notes', + }); + }); + it('opens nothing without a project, so no pre-fill is left behind', async () => { setStore('projects', []); stop = startSpOpenListener(); diff --git a/src/store/superProductivityOpen.ts b/src/store/superProductivityOpen.ts index e102bde9..5c88583f 100644 --- a/src/store/superProductivityOpen.ts +++ b/src/store/superProductivityOpen.ts @@ -101,6 +101,7 @@ async function openNewTaskFromSp(spTaskId: string): Promise { setStore('newTaskPrefillPrompt', { prompt: buildPromptFromSpTask(spTask), name: source.title, + notes: spTask.notes || undefined, projectId, superProductivity: source, }); @@ -111,7 +112,12 @@ async function openNewTaskFromSp(spTaskId: string): Promise { * Link a task created from a Super Productivity task, and remember the * project pairing the user just made if the project had none. */ -export function linkNewTaskToSp(taskId: string, source: SpNewTaskSource, projectId: string): void { +export function linkNewTaskToSp( + taskId: string, + source: SpNewTaskSource, + projectId: string, + notes?: string, +): void { const task = store.tasks[taskId]; if (!task) return; const project = store.projects.find((p) => p.id === projectId); @@ -123,15 +129,16 @@ export function linkNewTaskToSp(taskId: string, source: SpNewTaskSource, project ) { setProjectSpMapping(project.id, source.projectId); } + const initialNotes = notes ?? task.notes; if (task.nameIsAutoGenerated) { // The name field was cleared, so the form made one up. Record it as the // agreed title: the next sync then sees only Super Productivity's title // as changed and copies it here, instead of sending the made-up name. - setSpLink(taskId, source.taskId, toSpTitle(task.name)); + setSpLink(taskId, source.taskId, toSpTitle(task.name), initialNotes); return; } const title = toSpTitle(source.title); - setSpLink(taskId, source.taskId, title); + setSpLink(taskId, source.taskId, title, initialNotes); // A name edited in the form wins, like any other rename. if (toSpTitle(task.name) !== title) onTaskRenamed(taskId); } diff --git a/src/store/tasks.ts b/src/store/tasks.ts index 970ed267..10fa6f0c 100644 --- a/src/store/tasks.ts +++ b/src/store/tasks.ts @@ -52,7 +52,12 @@ import { import { computeSidebarDraggableTaskOrder, getCoordinatorChildren } from './sidebar-order'; import { isLandedTaskState } from './landing'; import { forgetAgentPublication } from './reasoning-activity'; -import { armSpCompletion, fireSpCompletion, onTaskRenamed } from './superProductivity'; +import { + armSpCompletion, + fireSpCompletion, + onTaskRenamed, + onTaskNotesChanged, +} from './superProductivity'; export function createAgentRecord(args: { id: string; @@ -81,6 +86,7 @@ function createBaseTaskRecord(args: { id: string; name: string; nameIsAutoGenerated?: boolean; + notes?: string; projectId: string; gitIsolation: GitIsolationMode; branchName: string; @@ -100,7 +106,7 @@ function createBaseTaskRecord(args: { agentIds: [args.agentId], selectedAgentId: args.agentId, shellAgentIds: [], - notes: '', + notes: args.notes ?? '', lastPrompt: '', }; } @@ -247,6 +253,7 @@ const STEPS_INSTRUCTION = export interface CreateTaskOptions { name: string; nameIsAutoGenerated?: boolean; + notes?: string; agentDef: AgentDef; projectId: string; gitIsolation: GitIsolationMode; @@ -337,6 +344,7 @@ export async function createTask(opts: CreateTaskOptions): Promise { id: taskId, name, nameIsAutoGenerated, + notes: opts.notes, projectId, gitIsolation, baseBranch: baseBranch || undefined, @@ -678,7 +686,9 @@ export function updateTaskName(taskId: string, name: string): void { } export function updateTaskNotes(taskId: string, notes: string): void { + if (store.tasks[taskId]?.notes === notes) return; setStore('tasks', taskId, 'notes', notes); + onTaskNotesChanged(taskId); } /** Canvas guidance goes out once per agent session; later mentions would only repeat it. */ diff --git a/src/store/types.ts b/src/store/types.ts index bda1861c..f4108a99 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -130,6 +130,8 @@ export interface SuperProductivityLink { taskId: string; /** Last title both apps agreed on — the base for the three-way title sync. */ syncedTitle: string; + /** Last notes pushed to or pulled from Super Productivity. */ + syncedNotes?: string; } export interface DocumentModelChoice { @@ -594,6 +596,7 @@ export interface AppStore { prompt: string; projectId: string | null; name?: string; + notes?: string; baseBranch?: string; canvasSource?: CanvasTaskSource; /** Set when the form was opened from a Super Productivity task. */