From a197bd7bfed057148ea82907663c6c350a9ccc69 Mon Sep 17 00:00:00 2001 From: Maxim IJzelendoorn Date: Sat, 21 Mar 2026 10:53:48 +0100 Subject: [PATCH 1/3] feat: add persisted project notes sidebar --- apps/server/src/keybindings.ts | 1 + .../Layers/ProjectionPipeline.test.ts | 33 ++++++ .../Layers/ProjectionPipeline.ts | 2 + .../Layers/ProjectionSnapshotQuery.test.ts | 1 + .../Layers/ProjectionSnapshotQuery.ts | 2 + .../decider.projectScripts.test.ts | 46 ++++++++ apps/server/src/orchestration/decider.ts | 1 + apps/server/src/orchestration/projector.ts | 2 + .../persistence/Layers/ProjectionProjects.ts | 5 + apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/016_ProjectionProjectNotes.ts | 8 ++ .../Services/ProjectionProjects.ts | 1 + apps/web/src/components/ChatView.tsx | 82 ++++++++++++++ .../src/components/ProjectNotesSidebar.tsx | 106 ++++++++++++++++++ .../CompactComposerControlsMenu.browser.tsx | 2 + .../chat/CompactComposerControlsMenu.tsx | 9 +- apps/web/src/store.test.ts | 21 ++++ apps/web/src/store.ts | 1 + apps/web/src/types.ts | 1 + packages/contracts/src/keybindings.ts | 1 + packages/contracts/src/orchestration.ts | 3 + 21 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/persistence/Migrations/016_ProjectionProjectNotes.ts create mode 100644 apps/web/src/components/ProjectNotesSidebar.tsx diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index bf58467825..1f3cc80e04 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -74,6 +74,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { 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 { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 83ee080fbe..5debf556bb 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1911,4 +1911,37 @@ 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." }]); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 0651dab646..53575e8114 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -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, @@ -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; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index b5b73fd6e0..ee58177e11 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -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, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 849d2fa3b6..c6b52a3615 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -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" @@ -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, diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 516d8b2a28..e934e6e419 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -94,6 +94,52 @@ 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("emits user message and turn-start-requested events for thread.turn.start", async () => { const now = new Date().toISOString(); const initial = createEmptyReadModel(now); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 6ea4c51759..7f531323b0 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -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, }, }; diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 015f82a677..f28b637933 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -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, @@ -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, diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index 5dbc8c2d1f..45fd8016d5 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -38,6 +38,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root, default_model, scripts_json, + notes, created_at, updated_at, deleted_at @@ -48,6 +49,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.workspaceRoot}, ${row.defaultModel}, ${row.scripts}, + ${row.notes ?? null}, ${row.createdAt}, ${row.updatedAt}, ${row.deletedAt} @@ -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 @@ -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" @@ -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" diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ea1821014a..f70c8ce150 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -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"; /** @@ -55,6 +56,7 @@ const loader = Migrator.fromRecord({ "13_ProjectionThreadProposedPlans": Migration0013, "14_ProjectionThreadProposedPlanImplementation": Migration0014, "15_ProjectionTurnsSourceProposedPlan": Migration0015, + "16_ProjectionProjectNotes": Migration0016, }); /** diff --git a/apps/server/src/persistence/Migrations/016_ProjectionProjectNotes.ts b/apps/server/src/persistence/Migrations/016_ProjectionProjectNotes.ts new file mode 100644 index 0000000000..14ed93b9a1 --- /dev/null +++ b/apps/server/src/persistence/Migrations/016_ProjectionProjectNotes.ts @@ -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`; +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 1380a9609a..f822874d8e 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -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), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e628f6ea6a..789785de96 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -89,6 +89,7 @@ import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import BranchToolbar from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; +import ProjectNotesSidebar from "./ProjectNotesSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { BotIcon, @@ -99,6 +100,7 @@ import { ListTodoIcon, LockIcon, LockOpenIcon, + StickyNoteIcon, XIcon, } from "lucide-react"; import { Button } from "./ui/button"; @@ -335,6 +337,7 @@ export default function ChatView({ threadId }: ChatViewProps) { useState>({}); const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); + const [notesSidebarOpen, setNotesSidebarOpen] = useState(false); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); @@ -1138,6 +1141,10 @@ export default function ChatView({ threadId }: ChatViewProps) { () => shortcutLabelForCommand(keybindings, "diff.toggle"), [keybindings], ); + const notesToggleShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "notes.toggle"), + [keybindings], + ); const onToggleDiff = useCallback(() => { void navigate({ to: "/$threadId", @@ -1581,10 +1588,35 @@ export default function ChatView({ threadId }: ChatViewProps) { } } else { planSidebarDismissedForTurnRef.current = null; + setNotesSidebarOpen(false); } return !open; }); }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); + const toggleNotesSidebar = useCallback(() => { + setNotesSidebarOpen((open) => { + if (!open) { + setPlanSidebarOpen(false); + planSidebarDismissedForTurnRef.current = null; + } + return !open; + }); + }, []); + const handleNotesChange = useCallback( + (notes: string) => { + if (!activeProject) return; + if ((activeProject.notes ?? "") === notes) return; + const api = readNativeApi(); + if (!api) return; + void api.orchestration.dispatchCommand({ + type: "project.meta.update", + commandId: newCommandId(), + projectId: activeProject.id, + notes, + }); + }, + [activeProject], + ); const persistThreadSettingsForNextTurn = useCallback( async (input: { @@ -2165,6 +2197,13 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } + if (command === "notes.toggle") { + event.preventDefault(); + event.stopPropagation(); + toggleNotesSidebar(); + return; + } + const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; const script = activeProject.scripts.find((entry) => entry.id === scriptId); @@ -2187,6 +2226,7 @@ export default function ChatView({ threadId }: ChatViewProps) { splitTerminal, keybindings, onToggleDiff, + toggleNotesSidebar, toggleTerminalVisibility, ]); @@ -3789,10 +3829,12 @@ export default function ChatView({ threadId }: ChatViewProps) { )} interactionMode={interactionMode} planSidebarOpen={planSidebarOpen} + notesSidebarOpen={notesSidebarOpen} runtimeMode={runtimeMode} traitsMenuContent={providerTraitsMenuContent} onToggleInteractionMode={toggleInteractionMode} onTogglePlanSidebar={togglePlanSidebar} + onToggleNotesSidebar={toggleNotesSidebar} onToggleRuntimeMode={toggleRuntimeMode} /> ) : ( @@ -3885,6 +3927,37 @@ export default function ChatView({ threadId }: ChatViewProps) { ) : null} + + {activeProject ? ( + <> + + + + ) : null} )} @@ -4103,6 +4176,15 @@ export default function ChatView({ threadId }: ChatViewProps) { }} /> ) : null} + {notesSidebarOpen && activeProject ? ( + setNotesSidebarOpen(false)} + /> + ) : null} {/* end horizontal flex container */} diff --git a/apps/web/src/components/ProjectNotesSidebar.tsx b/apps/web/src/components/ProjectNotesSidebar.tsx new file mode 100644 index 0000000000..37c73625f5 --- /dev/null +++ b/apps/web/src/components/ProjectNotesSidebar.tsx @@ -0,0 +1,106 @@ +import { memo, useCallback, useEffect, useRef, useState } from "react"; +import type { ProjectId } from "@t3tools/contracts"; +import { PanelRightCloseIcon } from "lucide-react"; + +import { Badge } from "./ui/badge"; +import { Button } from "./ui/button"; + +interface ProjectNotesSidebarProps { + projectId: ProjectId; + projectName: string; + notes: string; + onNotesChange: (notes: string) => void; + onClose: () => void; +} + +const DEBOUNCE_MS = 500; + +const ProjectNotesSidebar = memo(function ProjectNotesSidebar({ + projectId, + projectName, + notes, + onNotesChange, + onClose, +}: ProjectNotesSidebarProps) { + const [localNotes, setLocalNotes] = useState(notes); + const debounceTimerRef = useRef | null>(null); + const pendingValueRef = useRef(null); + + useEffect(() => { + setLocalNotes(notes); + }, [projectId, notes]); + + const flushPendingChange = useCallback(() => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + if (pendingValueRef.current === null) { + return; + } + const nextValue = pendingValueRef.current; + pendingValueRef.current = null; + onNotesChange(nextValue); + }, [onNotesChange]); + + useEffect(() => { + return () => { + flushPendingChange(); + }; + }, [projectId, flushPendingChange]); + + const handleChange = useCallback( + (event: React.ChangeEvent) => { + const value = event.target.value; + setLocalNotes(value); + pendingValueRef.current = value; + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + debounceTimerRef.current = setTimeout(() => { + flushPendingChange(); + }, DEBOUNCE_MS); + }, + [flushPendingChange], + ); + + return ( +
+
+
+ + Notes + + + {projectName} + +
+ +
+
+