diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 062c79fa69..f563caab73 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -18,6 +18,7 @@ import { import type { MenuItemConstructorOptions } from "electron"; import * as Effect from "effect/Effect"; import type { + DesktopMenuAction, DesktopTheme, DesktopUpdateActionResult, DesktopUpdateState, @@ -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(); @@ -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" }, ], }, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 1e1bb3bd8e..16f79eb7d6 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -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"; @@ -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), @@ -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); }; 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..d87a866b76 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -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 }]); + }), + ); }); 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..59a5731dbb 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -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); 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.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 48c627747d..39cea3eb03 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2,6 +2,9 @@ import "../index.css"; import { + type DesktopBridge, + type DesktopMenuAction, + type DesktopUpdateState, ORCHESTRATION_WS_METHODS, type MessageId, type OrchestrationReadModel, @@ -273,6 +276,69 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture { }; } +interface DesktopBridgeHarness { + bridge: DesktopBridge; + emitMenuAction: (action: DesktopMenuAction) => void; + getMenuListenerCount: () => number; +} + +function buildDesktopUpdateState(): DesktopUpdateState { + return { + enabled: false, + status: "disabled", + currentVersion: "1.0.0", + hostArch: "arm64", + appArch: "arm64", + runningUnderArm64Translation: false, + availableVersion: null, + downloadedVersion: null, + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, + }; +} + +function createDesktopBridgeHarness(): DesktopBridgeHarness { + const menuListeners = new Set<(action: DesktopMenuAction) => void>(); + + return { + bridge: { + getWsUrl: () => null, + pickFolder: async () => null, + confirm: async () => true, + setTheme: async () => {}, + showContextMenu: async () => null, + openExternal: async () => true, + onMenuAction: (listener) => { + menuListeners.add(listener); + return () => { + menuListeners.delete(listener); + }; + }, + getUpdateState: async () => buildDesktopUpdateState(), + downloadUpdate: async () => ({ + accepted: false, + completed: false, + state: buildDesktopUpdateState(), + }), + installUpdate: async () => ({ + accepted: false, + completed: false, + state: buildDesktopUpdateState(), + }), + onUpdateState: () => () => {}, + }, + emitMenuAction: (action) => { + for (const listener of menuListeners) { + listener(action); + } + }, + getMenuListenerCount: () => menuListeners.size, + }; +} + function addThreadToSnapshot( snapshot: OrchestrationReadModel, threadId: ThreadId, @@ -764,6 +830,7 @@ describe("ChatView timeline estimator parity (full app)", () => { afterEach(() => { document.body.innerHTML = ""; + Reflect.deleteProperty(window, "desktopBridge"); }); it.each(TEXT_VIEWPORT_MATRIX)( @@ -1527,6 +1594,51 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("toggles the notes sidebar from a desktop menu action", async () => { + const desktopBridge = createDesktopBridgeHarness(); + Object.defineProperty(window, "desktopBridge", { + configurable: true, + writable: true, + value: desktopBridge.bridge, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-desktop-notes-toggle-test" as MessageId, + targetText: "desktop notes toggle test", + }), + }); + + const notesSelector = + 'textarea[placeholder="Jot down ideas, todos, or notes for this project..."]'; + + try { + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + await vi.waitFor(() => { + expect(desktopBridge.getMenuListenerCount()).toBeGreaterThan(0); + }); + + expect(document.querySelector(notesSelector)).toBeNull(); + + desktopBridge.emitMenuAction("toggle-notes"); + + await waitForElement( + () => document.querySelector(notesSelector), + "Unable to find project notes sidebar after desktop menu action.", + ); + + desktopBridge.emitMenuAction("toggle-notes"); + + await vi.waitFor(() => { + expect(document.querySelector(notesSelector)).toBeNull(); + }); + } finally { + await mounted.cleanup(); + } + }); + it("creates a fresh draft after the previous draft thread is promoted", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e628f6ea6a..6c1b76ee79 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"; @@ -178,6 +180,7 @@ import { SendPhase, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { NOTES_SIDEBAR_DEFAULT_WIDTH_PX, clampNotesSidebarWidth } from "./notesSidebarLayout"; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -335,6 +338,8 @@ export default function ChatView({ threadId }: ChatViewProps) { useState>({}); const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); + const [notesSidebarOpen, setNotesSidebarOpen] = useState(false); + const [notesSidebarWidth, setNotesSidebarWidth] = useState(NOTES_SIDEBAR_DEFAULT_WIDTH_PX); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); @@ -1138,6 +1143,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 +1590,65 @@ 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; + }); + }, []); + useEffect(() => { + const onMenuAction = window.desktopBridge?.onMenuAction; + if (typeof onMenuAction !== "function") { + return; + } + + const unsubscribe = onMenuAction((action) => { + if (action !== "toggle-notes" || !activeProject) return; + toggleNotesSidebar(); + }); + + return () => { + unsubscribe?.(); + }; + }, [activeProject, toggleNotesSidebar]); + useEffect(() => { + const handleWindowResize = () => { + setNotesSidebarWidth((currentWidth) => { + const nextWidth = clampNotesSidebarWidth(currentWidth, window.innerWidth); + return nextWidth === currentWidth ? currentWidth : nextWidth; + }); + }; + + handleWindowResize(); + window.addEventListener("resize", handleWindowResize); + return () => { + window.removeEventListener("resize", handleWindowResize); + }; + }, []); + const handleNotesChange = useCallback( + (notes: string) => { + if (!activeProject) return; + const nextNotes = notes === "" ? null : notes; + if ((activeProject.notes ?? null) === nextNotes) return; + const api = readNativeApi(); + if (!api) return; + void api.orchestration.dispatchCommand({ + type: "project.meta.update", + commandId: newCommandId(), + projectId: activeProject.id, + notes: nextNotes, + }); + }, + [activeProject], + ); const persistThreadSettingsForNextTurn = useCallback( async (input: { @@ -2165,6 +2229,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 +2258,7 @@ export default function ChatView({ threadId }: ChatViewProps) { splitTerminal, keybindings, onToggleDiff, + toggleNotesSidebar, toggleTerminalVisibility, ]); @@ -3763,7 +3835,7 @@ export default function ChatView({ threadId }: ChatViewProps) { "flex min-w-0 flex-1 items-center", isComposerFooterCompact ? "gap-1 overflow-hidden" - : "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible", + : "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-0", )} > {/* Provider/model picker */} @@ -3789,10 +3861,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 +3959,37 @@ export default function ChatView({ threadId }: ChatViewProps) { ) : null} + + {activeProject ? ( + <> + + + + ) : null} )} @@ -4103,6 +4208,17 @@ export default function ChatView({ threadId }: ChatViewProps) { }} /> ) : null} + {notesSidebarOpen && activeProject ? ( + setNotesSidebarOpen(false)} + /> + ) : null} {/* end horizontal flex container */} diff --git a/apps/web/src/components/ProjectNotesSidebar.browser.tsx b/apps/web/src/components/ProjectNotesSidebar.browser.tsx new file mode 100644 index 0000000000..3f5677c2db --- /dev/null +++ b/apps/web/src/components/ProjectNotesSidebar.browser.tsx @@ -0,0 +1,58 @@ +import "../index.css"; + +import { ProjectId } from "@t3tools/contracts"; +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import ProjectNotesSidebar from "./ProjectNotesSidebar"; + +const PROJECT_ID = ProjectId.makeUnsafe("project-notes-sidebar"); + +describe("ProjectNotesSidebar", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("does not flush pending notes when it rerenders with a new change callback", async () => { + const host = document.createElement("div"); + document.body.append(host); + const firstOnNotesChange = vi.fn(); + const screen = await render( + , + { container: host }, + ); + + try { + const textarea = page.getByPlaceholder("Jot down ideas, todos, or notes for this project..."); + await textarea.fill("pending notes"); + + const secondOnNotesChange = vi.fn(); + await screen.rerender( + , + ); + + expect(firstOnNotesChange).not.toHaveBeenCalled(); + expect(secondOnNotesChange).not.toHaveBeenCalled(); + } finally { + await screen.unmount(); + host.remove(); + } + }); +}); diff --git a/apps/web/src/components/ProjectNotesSidebar.tsx b/apps/web/src/components/ProjectNotesSidebar.tsx new file mode 100644 index 0000000000..cf19710815 --- /dev/null +++ b/apps/web/src/components/ProjectNotesSidebar.tsx @@ -0,0 +1,199 @@ +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"; +import { resizeNotesSidebarWidth } from "./notesSidebarLayout"; +import { shouldSyncProjectNotesInput } from "./projectNotesSidebarState"; + +interface ProjectNotesSidebarProps { + projectId: ProjectId; + projectName: string; + notes: string; + width: number; + onNotesChange: (notes: string) => void; + onWidthChange: (width: number) => void; + onClose: () => void; +} + +const DEBOUNCE_MS = 500; + +const ProjectNotesSidebar = memo(function ProjectNotesSidebar({ + projectId, + projectName, + notes, + width, + onNotesChange, + onWidthChange, + onClose, +}: ProjectNotesSidebarProps) { + const [localNotes, setLocalNotes] = useState(notes); + const debounceTimerRef = useRef | null>(null); + const pendingValueRef = useRef(null); + const resizeStateRef = useRef<{ startClientX: number; startWidth: number } | null>(null); + const previousProjectIdRef = useRef(projectId); + const textareaRef = useRef(null); + const onNotesChangeRef = useRef(onNotesChange); + + useEffect(() => { + onNotesChangeRef.current = onNotesChange; + }, [onNotesChange]); + + useEffect(() => { + const projectChanged = previousProjectIdRef.current !== projectId; + previousProjectIdRef.current = projectId; + const textarea = textareaRef.current; + const isTextareaFocused = textarea !== null && document.activeElement === textarea; + const hasPendingLocalChange = + pendingValueRef.current !== null || debounceTimerRef.current !== null; + if ( + !shouldSyncProjectNotesInput({ + projectChanged, + isTextareaFocused, + hasPendingLocalChange, + }) + ) { + return; + } + 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; + onNotesChangeRef.current(nextValue); + }, []); + + useEffect(() => { + return () => { + flushPendingChange(); + }; + }, [projectId, flushPendingChange]); + + const stopResizing = useCallback(() => { + resizeStateRef.current = null; + document.body.style.removeProperty("cursor"); + document.body.style.removeProperty("user-select"); + }, []); + + const handleWindowPointerMove = useCallback( + (event: PointerEvent) => { + const resizeState = resizeStateRef.current; + if (!resizeState) return; + onWidthChange( + resizeNotesSidebarWidth({ + startWidth: resizeState.startWidth, + startClientX: resizeState.startClientX, + currentClientX: event.clientX, + viewportWidth: window.innerWidth, + }), + ); + }, + [onWidthChange], + ); + + const handleWindowPointerEnd = useCallback(() => { + window.removeEventListener("pointermove", handleWindowPointerMove); + window.removeEventListener("pointerup", handleWindowPointerEnd); + window.removeEventListener("pointercancel", handleWindowPointerEnd); + stopResizing(); + }, [handleWindowPointerMove, stopResizing]); + + useEffect(() => { + return () => { + handleWindowPointerEnd(); + }; + }, [handleWindowPointerEnd]); + + 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], + ); + + const handleResizePointerDown = useCallback( + (event: React.PointerEvent) => { + event.preventDefault(); + resizeStateRef.current = { + startClientX: event.clientX, + startWidth: width, + }; + document.body.style.setProperty("cursor", "col-resize"); + document.body.style.setProperty("user-select", "none"); + window.addEventListener("pointermove", handleWindowPointerMove); + window.addEventListener("pointerup", handleWindowPointerEnd); + window.addEventListener("pointercancel", handleWindowPointerEnd); + }, + [handleWindowPointerEnd, handleWindowPointerMove, width], + ); + + return ( +
+
+
+
+
+
+
+ + Notes + + + {projectName} + +
+ +
+
+