Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions electron/ipc/channel-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
1 change: 1 addition & 0 deletions electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
Expand Down
63 changes: 63 additions & 0 deletions electron/shared/super-productivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down
49 changes: 49 additions & 0 deletions electron/shared/super-productivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> =
| { ok: true; value: T }
| { ok: false; reason: SpFailureReason; message?: string };
Expand Down Expand Up @@ -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

Expand Down
28 changes: 28 additions & 0 deletions electron/super-productivity/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({}),
Expand Down
9 changes: 9 additions & 0 deletions electron/super-productivity/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ export function createSpClient(deps: SpClientDeps) {

async createTask(input: {
title: string;
notes?: string;
projectId?: string;
parentId?: string;
}): Promise<SpResult<SpTaskSummary>> {
Expand All @@ -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);
Expand Down Expand Up @@ -271,6 +273,13 @@ export function createSpClient(deps: SpClientDeps) {
return res.ok ? { ok: true, value: null } : res;
},

async updateTaskNotes(taskId: string, notes: string): Promise<SpResult<null>> {
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<SpResult<null>> {
const current = await getTask(taskId);
Expand Down
19 changes: 19 additions & 0 deletions electron/super-productivity/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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'),
}),
Expand All @@ -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'),
Expand Down
5 changes: 4 additions & 1 deletion src/components/NewTaskPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ function AgentAutomationOptions(props: {
export function NewTaskPanel(props: NewTaskPanelProps) {
const [canvasPrefill, setCanvasPrefill] = createSignal<AppStore['newTaskPrefillPrompt']>(null);
const [spSource, setSpSource] = createSignal<SpNewTaskSource | null>(null);
const [spNotes, setSpNotes] = createSignal<string | undefined>(undefined);
const [prompt, setPrompt] = createSignal('');
const [advancedOpen, setAdvancedOpen] = createSignal(false);
// Prompt/name values right after open/prefill — closing is only guarded when
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -998,6 +1000,7 @@ export function NewTaskPanel(props: NewTaskPanelProps) {
const taskId = await create({
name: n,
nameIsAutoGenerated: !manualName,
notes: spNotes(),
agentDef: agent,
projectId,
gitIsolation: gitIsolation(),
Expand Down Expand Up @@ -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));
Expand Down
8 changes: 6 additions & 2 deletions src/store/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,9 +235,13 @@ function restoredCanvas(pt: PersistedTask): Pick<Task, 'canvasTabs' | 'canvasAct

function restoreSuperProductivityLink(value: unknown): SuperProductivityLink | undefined {
if (typeof value !== 'object' || value === null) return undefined;
const { taskId, syncedTitle } = value as Record<string, unknown>;
const { taskId, syncedTitle, syncedNotes } = value as Record<string, unknown>;
return isValidSpId(taskId) && typeof syncedTitle === 'string'
? { taskId, syncedTitle }
? {
taskId,
syncedTitle,
...(typeof syncedNotes === 'string' ? { syncedNotes } : {}),
}
: undefined;
}

Expand Down
Loading