Skip to content
Closed
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
9 changes: 8 additions & 1 deletion apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import type { MenuItemConstructorOptions } from "electron";
import * as Effect from "effect/Effect";
import type {
DesktopMenuAction,
DesktopTheme,
DesktopUpdateActionResult,
DesktopUpdateState,
Expand Down Expand Up @@ -487,7 +488,7 @@ function registerDesktopProtocol(): void {
desktopProtocolRegistered = true;
}

function dispatchMenuAction(action: string): void {
function dispatchMenuAction(action: DesktopMenuAction): void {
const existingWindow =
BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0];
const targetWindow = existingWindow ?? createWindow();
Expand Down Expand Up @@ -619,6 +620,12 @@ function configureApplicationMenu(): void {
{ role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false },
{ role: "zoomOut" },
{ type: "separator" },
{
label: "Toggle Notes",
accelerator: "CmdOrCtrl+Shift+E",
click: () => dispatchMenuAction("toggle-notes"),
},
{ type: "separator" },
{ role: "togglefullscreen" },
],
},
Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer } from "electron";
import type { DesktopBridge } from "@t3tools/contracts";
import type { DesktopBridge, DesktopMenuAction } from "@t3tools/contracts";

const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
const CONFIRM_CHANNEL = "desktop:confirm";
Expand All @@ -13,6 +13,10 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;

function isDesktopMenuAction(action: unknown): action is DesktopMenuAction {
return action === "open-settings" || action === "toggle-notes";
}

contextBridge.exposeInMainWorld("desktopBridge", {
getWsUrl: () => wsUrl,
pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),
Expand All @@ -22,7 +26,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url),
onMenuAction: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
if (typeof action !== "string") return;
if (!isDesktopMenuAction(action)) return;
listener(action);
};

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
{ key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
{ key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
{ key: "mod+o", command: "editor.openFavorite" },
{ key: "mod+shift+e", command: "notes.toggle" },
];

function normalizeKeyToken(token: string): string {
Expand Down
73 changes: 73 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1911,4 +1911,77 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
]);
}),
);

it.effect("projects persist notes from project.meta.update", () =>
Effect.gen(function* () {
const engine = yield* OrchestrationEngineService;
const sql = yield* SqlClient.SqlClient;
const createdAt = new Date().toISOString();

yield* engine.dispatch({
type: "project.create",
commandId: CommandId.makeUnsafe("cmd-notes-project-create"),
projectId: ProjectId.makeUnsafe("project-notes"),
title: "Notes Project",
workspaceRoot: "/tmp/project-notes",
defaultModel: "gpt-5-codex",
createdAt,
});

yield* engine.dispatch({
type: "project.meta.update",
commandId: CommandId.makeUnsafe("cmd-notes-project-update"),
projectId: ProjectId.makeUnsafe("project-notes"),
notes: "Remember to follow up on the migration.",
});

const projectRows = yield* sql<{ readonly notes: string | null }>`
SELECT
notes
FROM projection_projects
WHERE project_id = 'project-notes'
`;
assert.deepEqual(projectRows, [{ notes: "Remember to follow up on the migration." }]);
}),
);

it.effect("projects clear notes when project.meta.update sets notes to null", () =>
Effect.gen(function* () {
const engine = yield* OrchestrationEngineService;
const sql = yield* SqlClient.SqlClient;
const createdAt = new Date().toISOString();

yield* engine.dispatch({
type: "project.create",
commandId: CommandId.makeUnsafe("cmd-clear-notes-project-create"),
projectId: ProjectId.makeUnsafe("project-clear-notes"),
title: "Clear Notes Project",
workspaceRoot: "/tmp/project-clear-notes",
defaultModel: "gpt-5-codex",
createdAt,
});

yield* engine.dispatch({
type: "project.meta.update",
commandId: CommandId.makeUnsafe("cmd-clear-notes-project-seed"),
projectId: ProjectId.makeUnsafe("project-clear-notes"),
notes: "Temporary note",
});

yield* engine.dispatch({
type: "project.meta.update",
commandId: CommandId.makeUnsafe("cmd-clear-notes-project-update"),
projectId: ProjectId.makeUnsafe("project-clear-notes"),
notes: null,
});

const projectRows = yield* sql<{ readonly notes: string | null }>`
SELECT
notes
FROM projection_projects
WHERE project_id = 'project-clear-notes'
`;
assert.deepEqual(projectRows, [{ notes: null }]);
}),
);
});
2 changes: 2 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
workspaceRoot: event.payload.workspaceRoot,
defaultModel: event.payload.defaultModel,
scripts: event.payload.scripts,
notes: null,
createdAt: event.payload.createdAt,
updatedAt: event.payload.updatedAt,
deletedAt: null,
Expand All @@ -387,6 +388,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
? { defaultModel: event.payload.defaultModel }
: {}),
...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}),
...(event.payload.notes !== undefined ? { notes: event.payload.notes } : {}),
updatedAt: event.payload.updatedAt,
});
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
runOnWorktreeCreate: false,
},
],
notes: null,
createdAt: "2026-02-24T00:00:00.000Z",
updatedAt: "2026-02-24T00:00:01.000Z",
deletedAt: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
workspace_root AS "workspaceRoot",
default_model AS "defaultModel",
scripts_json AS "scripts",
notes,
created_at AS "createdAt",
updated_at AS "updatedAt",
deleted_at AS "deletedAt"
Expand Down Expand Up @@ -537,6 +538,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
workspaceRoot: row.workspaceRoot,
defaultModel: row.defaultModel,
scripts: row.scripts,
notes: row.notes ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
deletedAt: row.deletedAt,
Expand Down
90 changes: 90 additions & 0 deletions apps/server/src/orchestration/decider.projectScripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,96 @@ describe("decider project scripts", () => {
expect((event.payload as { scripts?: unknown[] }).scripts).toEqual(scripts);
});

it("propagates notes in project.meta.update payload", async () => {
const now = new Date().toISOString();
const initial = createEmptyReadModel(now);
const readModel = await Effect.runPromise(
projectEvent(initial, {
sequence: 1,
eventId: asEventId("evt-project-create-notes"),
aggregateKind: "project",
aggregateId: asProjectId("project-notes"),
type: "project.created",
occurredAt: now,
commandId: CommandId.makeUnsafe("cmd-project-create-notes"),
causationEventId: null,
correlationId: CommandId.makeUnsafe("cmd-project-create-notes"),
metadata: {},
payload: {
projectId: asProjectId("project-notes"),
title: "Notes",
workspaceRoot: "/tmp/notes",
defaultModel: null,
scripts: [],
createdAt: now,
updatedAt: now,
},
}),
);

const result = await Effect.runPromise(
decideOrchestrationCommand({
command: {
type: "project.meta.update",
commandId: CommandId.makeUnsafe("cmd-project-update-notes"),
projectId: asProjectId("project-notes"),
notes: "Remember to update the release docs.",
},
readModel,
}),
);

const event = Array.isArray(result) ? result[0] : result;
expect(event.type).toBe("project.meta-updated");
expect((event.payload as { notes?: string }).notes).toBe(
"Remember to update the release docs.",
);
});

it("propagates null notes in project.meta.update payload", async () => {
const now = new Date().toISOString();
const initial = createEmptyReadModel(now);
const readModel = await Effect.runPromise(
projectEvent(initial, {
sequence: 1,
eventId: asEventId("evt-project-create-null-notes"),
aggregateKind: "project",
aggregateId: asProjectId("project-null-notes"),
type: "project.created",
occurredAt: now,
commandId: CommandId.makeUnsafe("cmd-project-create-null-notes"),
causationEventId: null,
correlationId: CommandId.makeUnsafe("cmd-project-create-null-notes"),
metadata: {},
payload: {
projectId: asProjectId("project-null-notes"),
title: "Notes",
workspaceRoot: "/tmp/notes",
defaultModel: null,
scripts: [],
createdAt: now,
updatedAt: now,
},
}),
);

const result = await Effect.runPromise(
decideOrchestrationCommand({
command: {
type: "project.meta.update",
commandId: CommandId.makeUnsafe("cmd-project-clear-notes"),
projectId: asProjectId("project-null-notes"),
notes: null,
},
readModel,
}),
);

const event = Array.isArray(result) ? result[0] : result;
expect(event.type).toBe("project.meta-updated");
expect((event.payload as { notes?: string | null }).notes).toBeNull();
});

it("emits user message and turn-start-requested events for thread.turn.start", async () => {
const now = new Date().toISOString();
const initial = createEmptyReadModel(now);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
...(command.workspaceRoot !== undefined ? { workspaceRoot: command.workspaceRoot } : {}),
...(command.defaultModel !== undefined ? { defaultModel: command.defaultModel } : {}),
...(command.scripts !== undefined ? { scripts: command.scripts } : {}),
...(command.notes !== undefined ? { notes: command.notes } : {}),
updatedAt: occurredAt,
},
};
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/orchestration/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ export function projectEvent(
workspaceRoot: payload.workspaceRoot,
defaultModel: payload.defaultModel,
scripts: payload.scripts,
notes: null,
createdAt: payload.createdAt,
updatedAt: payload.updatedAt,
deletedAt: null,
Expand Down Expand Up @@ -215,6 +216,7 @@ export function projectEvent(
? { defaultModel: payload.defaultModel }
: {}),
...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}),
...(payload.notes !== undefined ? { notes: payload.notes } : {}),
updatedAt: payload.updatedAt,
}
: project,
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/persistence/Layers/ProjectionProjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
workspace_root,
default_model,
scripts_json,
notes,
created_at,
updated_at,
deleted_at
Expand All @@ -48,6 +49,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
${row.workspaceRoot},
${row.defaultModel},
${row.scripts},
${row.notes ?? null},
${row.createdAt},
${row.updatedAt},
${row.deletedAt}
Expand All @@ -58,6 +60,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
workspace_root = excluded.workspace_root,
default_model = excluded.default_model,
scripts_json = excluded.scripts_json,
notes = excluded.notes,
created_at = excluded.created_at,
updated_at = excluded.updated_at,
deleted_at = excluded.deleted_at
Expand All @@ -75,6 +78,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
workspace_root AS "workspaceRoot",
default_model AS "defaultModel",
scripts_json AS "scripts",
notes,
created_at AS "createdAt",
updated_at AS "updatedAt",
deleted_at AS "deletedAt"
Expand All @@ -94,6 +98,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
workspace_root AS "workspaceRoot",
default_model AS "defaultModel",
scripts_json AS "scripts",
notes,
created_at AS "createdAt",
updated_at AS "updatedAt",
deleted_at AS "deletedAt"
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts"
import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts";
import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplementation.ts";
import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts";
import Migration0016 from "./Migrations/016_ProjectionProjectNotes.ts";
import { Effect } from "effect";

/**
Expand Down Expand Up @@ -55,6 +56,7 @@ const loader = Migrator.fromRecord({
"13_ProjectionThreadProposedPlans": Migration0013,
"14_ProjectionThreadProposedPlanImplementation": Migration0014,
"15_ProjectionTurnsSourceProposedPlan": Migration0015,
"16_ProjectionProjectNotes": Migration0016,
});

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as Effect from "effect/Effect";
import * as SqlClient from "effect/unstable/sql/SqlClient";

export default Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

yield* sql`ALTER TABLE projection_projects ADD COLUMN notes TEXT DEFAULT NULL`;
});
1 change: 1 addition & 0 deletions apps/server/src/persistence/Services/ProjectionProjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const ProjectionProject = Schema.Struct({
workspaceRoot: Schema.String,
defaultModel: Schema.NullOr(Schema.String),
scripts: Schema.Array(ProjectScript),
notes: Schema.optional(Schema.NullOr(Schema.String)),
createdAt: IsoDateTime,
updatedAt: IsoDateTime,
deletedAt: Schema.NullOr(IsoDateTime),
Expand Down
Loading