From 82214e86ee5c94cee906fa17bb9ff7000aac3b35 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 15 May 2026 11:46:06 -0700 Subject: [PATCH] Revert "Add project notes (#1861)" (#1945) This reverts commit c73fd2c90b30d4b5f19f80ca895d99c420df4b68. --- src/cli/args.ts | 3 +- src/cli/dispatch.ts | 4 +- src/cli/format.ts | 34 - src/cli/handlers/note.ts | 95 --- src/cli/specs/index.ts | 4 +- src/cli/specs/note.ts | 37 - src/main/ipc/notes.ts | 40 - src/main/ipc/register-core-handlers.test.ts | 7 - src/main/ipc/register-core-handlers.ts | 2 - src/main/ipc/worktrees.test.ts | 4 +- src/main/ipc/worktrees.ts | 3 - src/main/notes/notes-markdown-store.test.ts | 76 -- src/main/notes/notes-markdown-store.ts | 586 --------------- src/main/runtime/orca-runtime.test.ts | 264 ------- src/main/runtime/orca-runtime.ts | 273 +------ src/main/runtime/rpc/errors.ts | 3 - src/main/runtime/rpc/methods/index.ts | 2 - src/main/runtime/rpc/methods/notes.test.ts | 148 ---- src/main/runtime/rpc/methods/notes.ts | 165 ----- src/preload/api-types.ts | 30 - src/preload/index.ts | 13 - src/renderer/src/components/Terminal.tsx | 20 - .../src/components/WorktreeJumpPalette.tsx | 2 +- .../src/components/editor/EditorPanel.tsx | 2 +- .../FloatingTerminalPanel.tsx | 153 +--- .../notes/ProjectNotesTabContent.tsx | 699 ------------------ .../components/right-sidebar/NotesPanel.tsx | 364 --------- .../src/components/right-sidebar/index.tsx | 10 +- .../src/components/sidebar/WorktreeList.tsx | 5 - .../sidebar/use-worktree-activity-status.ts | 5 - .../sidebar/visible-worktrees.test.ts | 33 +- .../components/sidebar/visible-worktrees.ts | 14 +- .../components/tab-bar/ProjectNotesTab.tsx | 131 ---- .../tab-bar/TabBar.context-menu.test.ts | 13 - .../src/components/tab-bar/TabBar.tsx | 188 +---- .../src/components/tab-bar/TabDragPreview.tsx | 10 +- .../src/components/tab-bar/group-tab-order.ts | 21 +- .../src/components/tab-bar/reconcile-order.ts | 7 +- .../components/tab-group/TabGroupPanel.tsx | 58 +- .../components/tab-group/useTabDragSplit.ts | 2 +- .../tab-group/useTabGroupWorkspaceModel.ts | 80 -- .../src/components/terminal/tab-type-cycle.ts | 2 +- .../src/components/ui/dropdown-menu.tsx | 8 +- src/renderer/src/hooks/ipc-tab-switch.ts | 3 - src/renderer/src/hooks/resolve-zoom-target.ts | 4 +- src/renderer/src/hooks/useIpcEvents.ts | 10 - src/renderer/src/lib/notes-events.ts | 1 - .../src/lib/open-project-notes-tab.ts | 65 -- .../lib/project-notes-close-request.test.ts | 47 -- .../src/lib/project-notes-close-request.ts | 28 - src/renderer/src/lib/worktree-status.test.ts | 24 - src/renderer/src/lib/worktree-status.ts | 4 - .../src/runtime/runtime-notes-client.test.ts | 228 ------ .../src/runtime/runtime-notes-client.ts | 229 ------ src/renderer/src/store/slices/editor.ts | 8 +- .../src/store/slices/tabs-hydration.ts | 3 +- src/renderer/src/store/slices/tabs.test.ts | 50 -- src/renderer/src/store/slices/tabs.ts | 25 +- src/renderer/src/store/slices/worktrees.ts | 8 +- src/renderer/src/web/web-preload-api.ts | 18 - src/shared/notes-types.ts | 130 ---- src/shared/types.ts | 11 +- src/shared/workspace-session-schema.ts | 14 +- 63 files changed, 69 insertions(+), 4461 deletions(-) delete mode 100644 src/cli/handlers/note.ts delete mode 100644 src/cli/specs/note.ts delete mode 100644 src/main/ipc/notes.ts delete mode 100644 src/main/notes/notes-markdown-store.test.ts delete mode 100644 src/main/notes/notes-markdown-store.ts delete mode 100644 src/main/runtime/rpc/methods/notes.test.ts delete mode 100644 src/main/runtime/rpc/methods/notes.ts delete mode 100644 src/renderer/src/components/notes/ProjectNotesTabContent.tsx delete mode 100644 src/renderer/src/components/right-sidebar/NotesPanel.tsx delete mode 100644 src/renderer/src/components/tab-bar/ProjectNotesTab.tsx delete mode 100644 src/renderer/src/lib/notes-events.ts delete mode 100644 src/renderer/src/lib/open-project-notes-tab.ts delete mode 100644 src/renderer/src/lib/project-notes-close-request.test.ts delete mode 100644 src/renderer/src/lib/project-notes-close-request.ts delete mode 100644 src/renderer/src/runtime/runtime-notes-client.test.ts delete mode 100644 src/renderer/src/runtime/runtime-notes-client.ts delete mode 100644 src/shared/notes-types.ts diff --git a/src/cli/args.ts b/src/cli/args.ts index 9af56d69065..59472dcd258 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -93,8 +93,7 @@ export function isCommandGroup(commandPath: string[]): boolean { 'storage', 'orchestration', 'computer', - 'environment', - 'note' + 'environment' ].includes(commandPath[0])) || (commandPath.length === 2 && commandPath[0] === 'storage' && diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 00896007067..a9adbadf543 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -15,7 +15,6 @@ import { BROWSER_STORAGE_HANDLERS } from './handlers/browser-storage' import { ORCHESTRATION_HANDLERS } from './handlers/orchestration' import { COMPUTER_HANDLERS } from './handlers/computer' import { ENVIRONMENT_HANDLERS } from './handlers/environment' -import { NOTE_HANDLERS } from './handlers/note' export type HandlerContext = { flags: Map @@ -43,8 +42,7 @@ function buildHandlers(): Map { BROWSER_STORAGE_HANDLERS, ORCHESTRATION_HANDLERS, COMPUTER_HANDLERS, - ENVIRONMENT_HANDLERS, - NOTE_HANDLERS + ENVIRONMENT_HANDLERS ] for (const group of groups) { for (const [key, handler] of Object.entries(group)) { diff --git a/src/cli/format.ts b/src/cli/format.ts index 0b99b8bcae4..f032bb1803f 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -34,7 +34,6 @@ import type { RuntimeWorktreeRecord } from '../shared/runtime-types' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' -import type { NoteListResult, NoteMutationResult, NoteShowResult } from '../shared/notes-types' import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client' import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client' @@ -208,39 +207,6 @@ export function formatTerminalWait(result: { wait: RuntimeTerminalWait }): strin ].join('\n') } -export function formatNoteList(result: NoteListResult): string { - if (result.notes.length === 0) { - return 'No notes.' - } - const body = result.notes - .map((note) => { - const link = note.linkKind ? ` ${note.linkKind}` : '' - const preview = note.preview ? `\n${note.preview}` : '' - return `${note.id} ${note.title}${link}\npath: ${note.relativePath}\nupdated: ${note.updatedAt}${preview}` - }) - .join('\n\n') - return result.truncated - ? `${body}\n\ntruncated: showing ${result.notes.length} of ${result.totalCount}` - : body -} - -export function formatNoteShow(result: NoteShowResult): string { - const link = result.linkKind ? `link: ${result.linkKind}` : 'link: none' - return [ - `id: ${result.note.id}`, - `path: ${result.note.relativePath}`, - `title: ${result.note.title}`, - `revision: ${result.note.revision}`, - link, - '', - result.note.bodyMarkdown - ].join('\n') -} - -export function formatNoteMutation(result: NoteMutationResult): string { - return `Saved note ${result.note.id} (${result.note.title}) revision ${result.note.revision}.` -} - export function formatWorktreePs(result: RuntimeWorktreePsResult): string { if (result.worktrees.length === 0) { return 'No worktrees found.' diff --git a/src/cli/handlers/note.ts b/src/cli/handlers/note.ts deleted file mode 100644 index 12ac16d1058..00000000000 --- a/src/cli/handlers/note.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { readFileSync } from 'fs' -import type { NoteListResult, NoteMutationResult, NoteShowResult } from '../../shared/notes-types' -import type { CommandHandler } from '../dispatch' -import { formatNoteList, formatNoteMutation, formatNoteShow, printResult } from '../format' -import { - getOptionalPositiveIntegerFlag, - getOptionalStringFlag, - getRequiredStringFlag -} from '../flags' -import { RuntimeClientError } from '../runtime-client' -import { getBrowserWorktreeSelector } from '../selectors' - -async function getNoteWorktreeSelector( - flags: Map, - cwd: string, - client: Parameters[0]['client'] -): Promise { - const worktree = await getBrowserWorktreeSelector(flags, cwd, client) - if (!worktree) { - throw new RuntimeClientError( - 'selector_not_found', - 'No Orca-managed worktree contains the current directory. Pass --worktree.' - ) - } - return worktree -} - -function readBody(flags: Map, required: boolean): string | undefined { - const body = getOptionalStringFlag(flags, 'body') - const bodyFile = getOptionalStringFlag(flags, 'body-file') - const bodyStdin = flags.get('body-stdin') === true - const specified = [body !== undefined, bodyFile !== undefined, bodyStdin].filter(Boolean).length - if (specified > 1) { - throw new RuntimeClientError( - 'invalid_argument', - 'Pass only one of --body, --body-file, or --body-stdin' - ) - } - if (body !== undefined) { - return body - } - if (bodyFile) { - return readFileSync(bodyFile, 'utf8') - } - if (bodyStdin) { - return readFileSync(0, 'utf8') - } - if (required) { - throw new RuntimeClientError('invalid_argument', 'Missing note body') - } - return undefined -} - -export const NOTE_HANDLERS: Record = { - 'note list': async ({ flags, client, cwd, json }) => { - const result = await client.call('note.list', { - worktree: await getNoteWorktreeSelector(flags, cwd, client), - limit: getOptionalPositiveIntegerFlag(flags, 'limit') - }) - printResult(result, json, formatNoteList) - }, - 'note show': async ({ flags, client, cwd, json }) => { - const result = await client.call('note.show', { - worktree: await getNoteWorktreeSelector(flags, cwd, client), - note: getRequiredStringFlag(flags, 'note') - }) - printResult(result, json, formatNoteShow) - }, - 'note create': async ({ flags, client, cwd, json }) => { - const result = await client.call('note.create', { - worktree: await getNoteWorktreeSelector(flags, cwd, client), - title: getRequiredStringFlag(flags, 'title'), - bodyMarkdown: readBody(flags, false), - makeActive: true - }) - printResult(result, json, formatNoteMutation) - }, - 'note append': async ({ flags, client, cwd, json }) => { - const result = await client.call('note.append', { - worktree: await getNoteWorktreeSelector(flags, cwd, client), - note: getRequiredStringFlag(flags, 'note'), - bodyMarkdown: readBody(flags, true), - makeActive: true - }) - printResult(result, json, formatNoteMutation) - }, - 'note search': async ({ flags, client, cwd, json }) => { - const result = await client.call('note.search', { - worktree: await getNoteWorktreeSelector(flags, cwd, client), - query: getRequiredStringFlag(flags, 'query'), - limit: getOptionalPositiveIntegerFlag(flags, 'limit') - }) - printResult(result, json, formatNoteList) - } -} diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index ebcbb022fcd..48c05a44142 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -5,7 +5,6 @@ import { CORE_COMMAND_SPECS } from './core' import { ORCHESTRATION_COMMAND_SPECS } from './orchestration' import { COMPUTER_COMMAND_SPECS } from './computer' import { ENVIRONMENT_COMMAND_SPECS } from './environment' -import { NOTE_COMMAND_SPECS } from './note' export const COMMAND_SPECS: CommandSpec[] = [ ...CORE_COMMAND_SPECS, @@ -13,6 +12,5 @@ export const COMMAND_SPECS: CommandSpec[] = [ ...BROWSER_ADVANCED_COMMAND_SPECS, ...ORCHESTRATION_COMMAND_SPECS, ...COMPUTER_COMMAND_SPECS, - ...ENVIRONMENT_COMMAND_SPECS, - ...NOTE_COMMAND_SPECS + ...ENVIRONMENT_COMMAND_SPECS ] diff --git a/src/cli/specs/note.ts b/src/cli/specs/note.ts deleted file mode 100644 index 6b4d6e4122c..00000000000 --- a/src/cli/specs/note.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { CommandSpec } from '../args' -import { GLOBAL_FLAGS } from '../args' - -export const NOTE_COMMAND_SPECS: CommandSpec[] = [ - { - path: ['note', 'list'], - summary: 'List project notes for the current Orca worktree', - usage: 'orca note list [--worktree ] [--limit ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'limit'] - }, - { - path: ['note', 'show'], - summary: 'Show a project note', - usage: 'orca note show --note [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'note', 'worktree'] - }, - { - path: ['note', 'create'], - summary: 'Create a project note', - usage: - 'orca note create --title [--body <text>|--body-file <path>|--body-stdin] [--worktree <selector>] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'title', 'body', 'body-file', 'body-stdin', 'worktree'] - }, - { - path: ['note', 'append'], - summary: 'Append Markdown to a project note', - usage: - 'orca note append --note <selector> [--body <text>|--body-file <path>|--body-stdin] [--worktree <selector>] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'note', 'body', 'body-file', 'body-stdin', 'worktree'] - }, - { - path: ['note', 'search'], - summary: 'Search project notes', - usage: 'orca note search --query <text> [--worktree <selector>] [--limit <n>] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'query', 'worktree', 'limit'] - } -] diff --git a/src/main/ipc/notes.ts b/src/main/ipc/notes.ts deleted file mode 100644 index 88829ead892..00000000000 --- a/src/main/ipc/notes.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { ipcMain } from 'electron' -import type { OrcaRuntimeService } from '../runtime/orca-runtime' -import type { - NoteAppendArgs, - NoteCreateArgs, - NoteDeleteArgs, - NoteLinkArgs, - NoteListArgs, - NoteRenameArgs, - NoteSaveArgs, - NoteSearchArgs, - NoteShowArgs, - NotesPanelStateArgs -} from '../../shared/notes-types' - -export function registerNotesHandlers(runtime: OrcaRuntimeService): void { - ipcMain.removeHandler('notes:list') - ipcMain.removeHandler('notes:show') - ipcMain.removeHandler('notes:create') - ipcMain.removeHandler('notes:save') - ipcMain.removeHandler('notes:rename') - ipcMain.removeHandler('notes:delete') - ipcMain.removeHandler('notes:append') - ipcMain.removeHandler('notes:search') - ipcMain.removeHandler('notes:link') - ipcMain.removeHandler('notes:panelState') - - ipcMain.handle('notes:list', (_event, args: NoteListArgs) => runtime.listProjectNotes(args)) - ipcMain.handle('notes:show', (_event, args: NoteShowArgs) => runtime.showProjectNote(args)) - ipcMain.handle('notes:create', (_event, args: NoteCreateArgs) => runtime.createProjectNote(args)) - ipcMain.handle('notes:save', (_event, args: NoteSaveArgs) => runtime.saveProjectNote(args)) - ipcMain.handle('notes:rename', (_event, args: NoteRenameArgs) => runtime.renameProjectNote(args)) - ipcMain.handle('notes:delete', (_event, args: NoteDeleteArgs) => runtime.deleteProjectNote(args)) - ipcMain.handle('notes:append', (_event, args: NoteAppendArgs) => runtime.appendProjectNote(args)) - ipcMain.handle('notes:search', (_event, args: NoteSearchArgs) => runtime.searchProjectNotes(args)) - ipcMain.handle('notes:link', (_event, args: NoteLinkArgs) => runtime.linkProjectNote(args)) - ipcMain.handle('notes:panelState', (_event, args: NotesPanelStateArgs) => - runtime.resolveNotesPanelOpenState(args) - ) -} diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 7ff589620fc..f81db6fddf8 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -12,7 +12,6 @@ const { registerStatsHandlersMock, registerMemoryHandlersMock, registerNotebookHandlersMock, - registerNotesHandlersMock, registerNotificationHandlersMock, registerDeveloperPermissionHandlersMock, registerComputerUsePermissionHandlersMock, @@ -54,7 +53,6 @@ const { registerStatsHandlersMock: vi.fn(), registerMemoryHandlersMock: vi.fn(), registerNotebookHandlersMock: vi.fn(), - registerNotesHandlersMock: vi.fn(), registerNotificationHandlersMock: vi.fn(), registerDeveloperPermissionHandlersMock: vi.fn(), registerComputerUsePermissionHandlersMock: vi.fn(), @@ -136,10 +134,6 @@ vi.mock('./notebook', () => ({ registerNotebookHandlers: registerNotebookHandlersMock })) -vi.mock('./notes', () => ({ - registerNotesHandlers: registerNotesHandlersMock -})) - vi.mock('./notifications', () => ({ registerNotificationHandlers: registerNotificationHandlersMock })) @@ -256,7 +250,6 @@ describe('registerCoreHandlers', () => { registerStatsHandlersMock.mockReset() registerMemoryHandlersMock.mockReset() registerNotebookHandlersMock.mockReset() - registerNotesHandlersMock.mockReset() registerNotificationHandlersMock.mockReset() registerDeveloperPermissionHandlersMock.mockReset() registerComputerUsePermissionHandlersMock.mockReset() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index b6d87da3947..f3e5bcce61a 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -22,7 +22,6 @@ import { registerMemoryHandlers } from './memory' import { registerRateLimitHandlers } from './rate-limits' import { registerRuntimeHandlers } from './runtime' import { registerRuntimeEnvironmentHandlers } from './runtime-environments' -import { registerNotesHandlers } from './notes' import { registerNotificationHandlers } from './notifications' import { registerNotebookHandlers } from './notebook' import { registerOnboardingHandlers } from './onboarding' @@ -132,7 +131,6 @@ export function registerCoreHandlers( registerFilesystemWatcherHandlers() registerRuntimeHandlers(runtime) registerRuntimeEnvironmentHandlers() - registerNotesHandlers(runtime) registerClipboardHandlers() registerUpdaterHandlers(store) registerSpeechHandlers(store) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 4a0de9b6e97..ab70a164fef 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -166,7 +166,6 @@ describe('registerWorktreeHandlers', () => { recordOptimisticReconcileToken: ReturnType<typeof vi.fn> reconcileWorktreeBaseStatus: ReturnType<typeof vi.fn> clearOptimisticReconcileToken: ReturnType<typeof vi.fn> - unlinkNotesWorktree: ReturnType<typeof vi.fn> } beforeEach(() => { @@ -302,8 +301,7 @@ describe('registerWorktreeHandlers', () => { emitWorktreeBaseStatus: vi.fn(), recordOptimisticReconcileToken: vi.fn().mockReturnValue('token-1'), reconcileWorktreeBaseStatus: vi.fn(), - clearOptimisticReconcileToken: vi.fn(), - unlinkNotesWorktree: vi.fn().mockResolvedValue(undefined) + clearOptimisticReconcileToken: vi.fn() } registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never) }) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 8bb2b01494a..70b749c56e2 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -564,7 +564,6 @@ export function registerWorktreeHandlers( if (repo.connectionId) { await provider!.removeWorktree(canonicalWorktreePath, args.force) runtime.clearOptimisticReconcileToken(args.worktreeId) - await runtime.unlinkNotesWorktree(repoId, args.worktreeId) store.removeWorktreeMeta(args.worktreeId) deleteWorktreeHistoryDir(args.worktreeId) notifyWorktreesChanged(mainWindow, repoId) @@ -610,7 +609,6 @@ export function registerWorktreeHandlers( // remains locked — other worktrees cannot check it out. await gitExecFileAsync(['worktree', 'prune'], { cwd: repo.path }).catch(() => {}) runtime.clearOptimisticReconcileToken(args.worktreeId) - await runtime.unlinkNotesWorktree(repoId, args.worktreeId) store.removeWorktreeMeta(args.worktreeId) deleteWorktreeHistoryDir(args.worktreeId) invalidateAuthorizedRootsCache() @@ -622,7 +620,6 @@ export function registerWorktreeHandlers( ) } runtime.clearOptimisticReconcileToken(args.worktreeId) - await runtime.unlinkNotesWorktree(repoId, args.worktreeId) store.removeWorktreeMeta(args.worktreeId) deleteWorktreeHistoryDir(args.worktreeId) invalidateAuthorizedRootsCache() diff --git a/src/main/notes/notes-markdown-store.test.ts b/src/main/notes/notes-markdown-store.test.ts deleted file mode 100644 index 62793f2f4c1..00000000000 --- a/src/main/notes/notes-markdown-store.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { mkdtemp, rm } from 'fs/promises' -import { tmpdir } from 'os' -import { join } from 'path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { NotesMarkdownStore } from './notes-markdown-store' - -describe('NotesMarkdownStore mutations', () => { - let rootPath: string - let store: NotesMarkdownStore - - beforeEach(async () => { - rootPath = await mkdtemp(join(tmpdir(), 'orca-notes-store-')) - store = new NotesMarkdownStore() - }) - - afterEach(async () => { - await rm(rootPath, { force: true, recursive: true }) - }) - - it('renames the markdown file and keeps the note id stable', async () => { - const created = await store.create( - { projectId: 'repo-1', rootPath }, - { - projectId: 'repo-1', - worktreeId: 'wt-1', - title: 'First note', - bodyMarkdown: 'body' - } - ) - - const renamed = await store.rename( - { projectId: 'repo-1', rootPath }, - { - projectId: 'repo-1', - worktreeId: 'wt-1', - note: created.note.id, - title: 'Renamed note' - } - ) - - expect(renamed.note.id).toBe(created.note.id) - expect(renamed.note.title).toBe('Renamed note') - expect(renamed.note.relativePath).toContain('renamed-note') - - const listed = await store.list( - { projectId: 'repo-1', rootPath }, - { projectId: 'repo-1', worktreeId: 'wt-1' } - ) - expect(listed.notes).toHaveLength(1) - expect(listed.notes[0].title).toBe('Renamed note') - expect(listed.notes[0].linkKind).toBe('active') - }) - - it('deletes the markdown file and clears worktree links', async () => { - const created = await store.create( - { projectId: 'repo-1', rootPath }, - { - projectId: 'repo-1', - worktreeId: 'wt-1', - title: 'Delete me', - bodyMarkdown: 'body' - } - ) - - await store.delete( - { projectId: 'repo-1', rootPath }, - { projectId: 'repo-1', worktreeId: 'wt-1', note: created.note.id } - ) - - const listed = await store.list( - { projectId: 'repo-1', rootPath }, - { projectId: 'repo-1', worktreeId: 'wt-1' } - ) - expect(listed.notes).toEqual([]) - }) -}) diff --git a/src/main/notes/notes-markdown-store.ts b/src/main/notes/notes-markdown-store.ts deleted file mode 100644 index 1cac4546c24..00000000000 --- a/src/main/notes/notes-markdown-store.ts +++ /dev/null @@ -1,586 +0,0 @@ -/* eslint-disable max-lines -- Why: note file serialization, index updates, and selector resolution need one persistence boundary so Markdown files stay user-owned without splitting active-link invariants across modules. */ -import { randomBytes } from 'crypto' -import { mkdir, readFile, readdir, rename, rm, writeFile } from 'fs/promises' -import { join, posix } from 'path' -import type { IFilesystemProvider } from '../providers/types' -import type { - NoteAppendArgs, - NoteCreateArgs, - NoteDeleteArgs, - NoteDeleteResult, - NoteLink, - NoteLinkArgs, - NoteLinkKind, - NoteListArgs, - NoteListResult, - NoteMutationResult, - NoteRecord, - NoteRenameArgs, - NoteSaveArgs, - NoteSearchArgs, - NoteShowArgs, - NoteShowResult, - NoteSummary, - NotesPanelOpenState, - NotesPanelStateArgs -} from '../../shared/notes-types' - -type NotesMarkdownScope = { - projectId: string - rootPath: string - connectionId?: string | null - provider?: IFilesystemProvider -} - -type NotesIndex = { - version: 1 - activeByWorktree: Record<string, string> - referencedByWorktree: Record<string, string[]> -} - -type NoteFrontMatter = { - id: string - title: string - createdAt: string - updatedAt: string - archivedAt: string | null - createdBySessionId: string | null - updatedBySessionId: string | null - revision: number -} - -const NOTES_DIR = 'notes' -const INDEX_FILE = 'index.json' -const DEFAULT_LIMIT = 50 -const MAX_LIMIT = 200 - -function nowIso(): string { - return new Date().toISOString() -} - -function generateId(): string { - return `note_${randomBytes(8).toString('hex')}` -} - -function clampLimit(limit: number | undefined): number { - if (!Number.isFinite(limit) || limit === undefined) { - return DEFAULT_LIMIT - } - return Math.max(1, Math.min(MAX_LIMIT, Math.floor(limit))) -} - -function pathJoin(scope: NotesMarkdownScope, ...parts: string[]): string { - return scope.connectionId ? posix.join(scope.rootPath, ...parts) : join(scope.rootPath, ...parts) -} - -function slugTitle(title: string): string { - const slug = title - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - return slug || 'untitled-note' -} - -function isMissingFile(error: unknown): boolean { - return ( - error instanceof Error && - 'code' in error && - ((error as NodeJS.ErrnoException).code === 'ENOENT' || - (error as NodeJS.ErrnoException).code === 'ENOTDIR') - ) -} - -function emptyIndex(): NotesIndex { - return { - version: 1, - activeByWorktree: {}, - referencedByWorktree: {} - } -} - -function notePreview(bodyMarkdown: string): string { - return bodyMarkdown.replace(/\s+/g, ' ').trim().slice(0, 180) -} - -function parseFrontMatterValue(raw: string): string | number | null { - const value = raw.trim() - if (value === 'null') { - return null - } - if (/^\d+$/.test(value)) { - return Number.parseInt(value, 10) - } - if (value.startsWith('"')) { - return JSON.parse(value) as string - } - return value -} - -function parseNoteFile( - projectId: string, - filePath: string, - relativePath: string, - raw: string -): NoteRecord { - if (!raw.startsWith('---\n')) { - throw new Error('invalid_note_file') - } - const end = raw.indexOf('\n---\n', 4) - if (end === -1) { - throw new Error('invalid_note_file') - } - const frontMatter = raw.slice(4, end) - const bodyMarkdown = raw.slice(end + 5) - const parsed: Partial<NoteFrontMatter> = {} - for (const line of frontMatter.split('\n')) { - const index = line.indexOf(':') - if (index === -1) { - continue - } - const key = line.slice(0, index).trim() as keyof NoteFrontMatter - const value = parseFrontMatterValue(line.slice(index + 1)) - ;(parsed as Record<string, unknown>)[key] = value - } - if (!parsed.id || !parsed.title || !parsed.createdAt || !parsed.updatedAt) { - throw new Error('invalid_note_file') - } - return { - id: parsed.id, - projectId, - filePath, - relativePath, - title: parsed.title, - bodyMarkdown, - revision: typeof parsed.revision === 'number' ? parsed.revision : 1, - createdAt: parsed.createdAt, - updatedAt: parsed.updatedAt, - archivedAt: parsed.archivedAt ?? null, - createdBySessionId: parsed.createdBySessionId ?? null, - updatedBySessionId: parsed.updatedBySessionId ?? null - } -} - -function serializeNote(note: NoteRecord): string { - const frontMatter: NoteFrontMatter = { - id: note.id, - title: note.title, - createdAt: note.createdAt, - updatedAt: note.updatedAt, - archivedAt: note.archivedAt, - createdBySessionId: note.createdBySessionId ?? null, - updatedBySessionId: note.updatedBySessionId ?? null, - revision: note.revision - } - const lines = Object.entries(frontMatter).map(([key, value]) => { - if (typeof value === 'string') { - return `${key}: ${JSON.stringify(value)}` - } - return `${key}: ${value === null ? 'null' : value}` - }) - return `---\n${lines.join('\n')}\n---\n${note.bodyMarkdown}` -} - -function linkKindForNote( - index: NotesIndex, - noteId: string, - worktreeId?: string | null -): NoteLinkKind | null { - if (!worktreeId) { - return null - } - if (index.activeByWorktree[worktreeId] === noteId) { - return 'active' - } - if ((index.referencedByWorktree[worktreeId] ?? []).includes(noteId)) { - return 'referenced' - } - return null -} - -function toSummary(note: NoteRecord, index: NotesIndex, worktreeId?: string | null): NoteSummary { - return { - ...note, - preview: notePreview(note.bodyMarkdown), - linkKind: linkKindForNote(index, note.id, worktreeId) - } -} - -export class NotesMarkdownStore { - async list(scope: NotesMarkdownScope, args: NoteListArgs): Promise<NoteListResult> { - const limit = clampLimit(args.limit) - const [notes, index] = await Promise.all([this.readNotes(scope), this.readIndex(scope)]) - const visible = notes - .filter((note) => note.archivedAt === null) - .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) - const sorted = visible.sort((left, right) => { - const leftLink = linkKindForNote(index, left.id, args.worktreeId) - const rightLink = linkKindForNote(index, right.id, args.worktreeId) - const rank = (kind: NoteLinkKind | null): number => - kind === 'active' ? 0 : kind === 'referenced' ? 1 : 2 - return rank(leftLink) - rank(rightLink) || right.updatedAt.localeCompare(left.updatedAt) - }) - return { - notes: sorted.slice(0, limit).map((note) => toSummary(note, index, args.worktreeId)), - totalCount: sorted.length, - truncated: sorted.length > limit - } - } - - async show(scope: NotesMarkdownScope, args: NoteShowArgs): Promise<NoteShowResult> { - const [note, index] = await Promise.all([ - this.resolveNote(scope, args.note), - this.readIndex(scope) - ]) - return { - note, - linkKind: linkKindForNote(index, note.id, args.worktreeId) - } - } - - async create(scope: NotesMarkdownScope, args: NoteCreateArgs): Promise<NoteMutationResult> { - const at = nowIso() - const id = generateId() - const title = args.title.trim() || 'Untitled note' - const note: NoteRecord = { - id, - projectId: scope.projectId, - filePath: this.notePath(scope, title, id), - relativePath: posix.join(NOTES_DIR, `${slugTitle(title)}-${id}.md`), - title, - bodyMarkdown: args.bodyMarkdown ?? '', - revision: 1, - createdAt: at, - updatedAt: at, - archivedAt: null, - createdBySessionId: args.createdBySessionId ?? null, - updatedBySessionId: args.createdBySessionId ?? null - } - await this.writeNote(scope, note) - if (args.makeActive !== false && args.worktreeId) { - await this.setLink(scope, { - projectId: args.projectId, - worktreeId: args.worktreeId, - note: note.id, - kind: 'active' - }) - } - const index = await this.readIndex(scope) - return { - note, - linkKind: linkKindForNote(index, note.id, args.worktreeId) - } - } - - async save(scope: NotesMarkdownScope, args: NoteSaveArgs): Promise<NoteMutationResult> { - const current = await this.resolveNote(scope, args.note) - if (args.revision !== undefined && args.revision !== current.revision) { - throw new Error('revision_conflict') - } - const next: NoteRecord = { - ...current, - title: args.title?.trim() || current.title, - bodyMarkdown: args.bodyMarkdown, - revision: current.revision + 1, - updatedAt: nowIso(), - updatedBySessionId: args.updatedBySessionId ?? null - } - await this.writeNote(scope, next) - if (args.makeActive === true && args.worktreeId) { - await this.setLink(scope, { - projectId: args.projectId, - worktreeId: args.worktreeId, - note: next.id, - kind: 'active' - }) - } - const index = await this.readIndex(scope) - return { - note: next, - linkKind: linkKindForNote(index, next.id, args.worktreeId) - } - } - - async rename(scope: NotesMarkdownScope, args: NoteRenameArgs): Promise<NoteMutationResult> { - const current = await this.resolveNote(scope, args.note) - const title = args.title.trim() - if (!title) { - throw new Error('invalid_note_title') - } - const nextPath = this.notePath(scope, title, current.id) - const nextRelativePath = posix.join(NOTES_DIR, `${slugTitle(title)}-${current.id}.md`) - const next: NoteRecord = { - ...current, - filePath: nextPath, - relativePath: nextRelativePath, - title, - revision: current.revision + 1, - updatedAt: nowIso(), - updatedBySessionId: args.updatedBySessionId ?? null - } - if (next.filePath !== current.filePath) { - await this.renamePath(scope, current.filePath, next.filePath) - } - await this.writeNote(scope, next) - const index = await this.readIndex(scope) - return { - note: next, - linkKind: linkKindForNote(index, next.id, args.worktreeId) - } - } - - async delete(scope: NotesMarkdownScope, args: NoteDeleteArgs): Promise<NoteDeleteResult> { - const note = await this.resolveNote(scope, args.note) - await this.deletePath(scope, note.filePath) - const index = await this.readIndex(scope) - for (const [worktreeId, noteId] of Object.entries(index.activeByWorktree)) { - if (noteId === note.id) { - delete index.activeByWorktree[worktreeId] - } - } - for (const [worktreeId, noteIds] of Object.entries(index.referencedByWorktree)) { - const next = noteIds.filter((noteId) => noteId !== note.id) - if (next.length === 0) { - delete index.referencedByWorktree[worktreeId] - } else { - index.referencedByWorktree[worktreeId] = next - } - } - await this.writeIndex(scope, index) - return { - noteId: note.id, - projectId: scope.projectId - } - } - - async append(scope: NotesMarkdownScope, args: NoteAppendArgs): Promise<NoteMutationResult> { - const current = await this.resolveNote(scope, args.note) - const separator = current.bodyMarkdown.trim().length > 0 ? '\n\n' : '' - return await this.save(scope, { - projectId: args.projectId, - worktreeId: args.worktreeId, - note: current.id, - title: current.title, - bodyMarkdown: `${current.bodyMarkdown}${separator}${args.bodyMarkdown}`, - makeActive: args.makeActive, - updatedBySessionId: args.updatedBySessionId - }) - } - - async search(scope: NotesMarkdownScope, args: NoteSearchArgs): Promise<NoteListResult> { - const limit = clampLimit(args.limit) - const query = args.query.trim().toLowerCase() - const [notes, index] = await Promise.all([this.readNotes(scope), this.readIndex(scope)]) - const matches = notes - .filter( - (note) => - note.archivedAt === null && - (note.title.toLowerCase().includes(query) || - note.bodyMarkdown.toLowerCase().includes(query)) - ) - .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) - return { - notes: matches.slice(0, limit).map((note) => toSummary(note, index, args.worktreeId)), - totalCount: matches.length, - truncated: matches.length > limit - } - } - - async setLink(scope: NotesMarkdownScope, args: NoteLinkArgs): Promise<NoteLink> { - const note = await this.resolveNote(scope, args.note) - const index = await this.readIndex(scope) - if (args.kind === 'active') { - index.activeByWorktree[args.worktreeId] = note.id - } else { - const existing = index.referencedByWorktree[args.worktreeId] ?? [] - index.referencedByWorktree[args.worktreeId] = Array.from(new Set([...existing, note.id])) - } - await this.writeIndex(scope, index) - return { - noteId: note.id, - projectId: args.projectId, - worktreeId: args.worktreeId, - kind: args.kind, - createdAt: nowIso() - } - } - - async unlinkWorktree(scope: NotesMarkdownScope, worktreeId: string): Promise<void> { - const index = await this.readIndex(scope) - delete index.activeByWorktree[worktreeId] - delete index.referencedByWorktree[worktreeId] - await this.writeIndex(scope, index) - } - - async resolvePanelOpenState( - scope: NotesMarkdownScope | null, - args: NotesPanelStateArgs - ): Promise<NotesPanelOpenState> { - if (!scope || !args.projectId) { - return { state: 'noProject' } - } - const [notes, index] = await Promise.all([this.readNotes(scope), this.readIndex(scope)]) - if (args.worktreeId) { - const activeId = index.activeByWorktree[args.worktreeId] - const active = notes.find((note) => note.id === activeId && note.archivedAt === null) - if (active) { - return { - state: 'active', - projectId: args.projectId, - worktreeId: args.worktreeId, - note: active - } - } - } - const summaries = notes - .filter((note) => note.archivedAt === null) - .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) - .map((note) => toSummary(note, index, args.worktreeId)) - if (summaries.length > 0) { - return { - state: 'pickerRequired', - projectId: args.projectId, - worktreeId: args.worktreeId ?? null, - notes: summaries - } - } - return { - state: 'emptyDraft', - projectId: args.projectId, - worktreeId: args.worktreeId ?? null - } - } - - private notesDir(scope: NotesMarkdownScope): string { - return pathJoin(scope, NOTES_DIR) - } - - private indexPath(scope: NotesMarkdownScope): string { - return pathJoin(scope, NOTES_DIR, INDEX_FILE) - } - - private notePath(scope: NotesMarkdownScope, title: string, id: string): string { - return pathJoin(scope, NOTES_DIR, `${slugTitle(title)}-${id}.md`) - } - - private async ensureNotesDir(scope: NotesMarkdownScope): Promise<void> { - if (scope.provider) { - await scope.provider.createDir(this.notesDir(scope)) - return - } - await mkdir(this.notesDir(scope), { recursive: true }) - } - - private async readText(scope: NotesMarkdownScope, filePath: string): Promise<string> { - if (scope.provider) { - return (await scope.provider.readFile(filePath)).content - } - return await readFile(filePath, 'utf8') - } - - private async writeText( - scope: NotesMarkdownScope, - filePath: string, - content: string - ): Promise<void> { - await this.ensureNotesDir(scope) - if (scope.provider) { - await scope.provider.writeFile(filePath, content) - return - } - await writeFile(filePath, content, 'utf8') - } - - private async renamePath( - scope: NotesMarkdownScope, - oldPath: string, - newPath: string - ): Promise<void> { - await this.ensureNotesDir(scope) - if (scope.provider) { - await scope.provider.rename(oldPath, newPath) - return - } - await rename(oldPath, newPath) - } - - private async deletePath(scope: NotesMarkdownScope, filePath: string): Promise<void> { - if (scope.provider) { - await scope.provider.deletePath(filePath) - return - } - await rm(filePath, { force: true }) - } - - private async readIndex(scope: NotesMarkdownScope): Promise<NotesIndex> { - try { - const raw = await this.readText(scope, this.indexPath(scope)) - const parsed = JSON.parse(raw) as Partial<NotesIndex> - return { - version: 1, - activeByWorktree: parsed.activeByWorktree ?? {}, - referencedByWorktree: parsed.referencedByWorktree ?? {} - } - } catch (error) { - if (isMissingFile(error)) { - return emptyIndex() - } - throw error - } - } - - private async writeIndex(scope: NotesMarkdownScope, index: NotesIndex): Promise<void> { - await this.writeText(scope, this.indexPath(scope), `${JSON.stringify(index, null, 2)}\n`) - } - - private async readNotes(scope: NotesMarkdownScope): Promise<NoteRecord[]> { - let names: string[] - try { - if (scope.provider) { - const entries = await scope.provider.readDir(this.notesDir(scope)) - names = entries.filter((entry) => !entry.isDirectory).map((entry) => entry.name) - } else { - names = await readdir(this.notesDir(scope)) - } - } catch (error) { - if (isMissingFile(error)) { - return [] - } - throw error - } - const files = names.filter((name) => name.endsWith('.md')) - const notes = await Promise.all( - files.map(async (name) => { - const filePath = pathJoin(scope, NOTES_DIR, name) - const relativePath = posix.join(NOTES_DIR, name) - const raw = await this.readText(scope, filePath) - return parseNoteFile(scope.projectId, filePath, relativePath, raw) - }) - ) - return notes - } - - private async writeNote(scope: NotesMarkdownScope, note: NoteRecord): Promise<void> { - await this.writeText(scope, note.filePath, serializeNote(note)) - } - - private async resolveNote(scope: NotesMarkdownScope, selector: string): Promise<NoteRecord> { - const normalized = selector.trim().toLowerCase() - const notes = await this.readNotes(scope) - const matches = notes.filter( - (note) => - note.archivedAt === null && - (note.id === selector || - note.title.toLowerCase() === normalized || - note.relativePath === selector || - note.filePath === selector) - ) - if (matches.length === 0) { - throw new Error('note_not_found') - } - if (matches.length > 1) { - throw new Error('note_ambiguous') - } - return matches[0] - } -} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index d1b3f1b2e54..d84f393531d 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -19,19 +19,6 @@ import { } from '../providers/ssh-filesystem-dispatch' import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' -vi.mock('electron', () => ({ - app: { - getPath: vi.fn(() => '/tmp/orca-user-data') - }, - BrowserWindow: { - fromId: vi.fn(() => null) - }, - ipcMain: { - on: vi.fn(), - removeListener: vi.fn() - } -})) - const { MOCK_GIT_WORKTREES, addWorktreeMock, @@ -2172,120 +2159,6 @@ describe('OrcaRuntimeService', () => { expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), result.setup) }) - it('spawns startup and setup in runtime before revealing activated worktrees', async () => { - const runtime = new OrcaRuntimeService(store) - const activateWorktree = vi.fn() - const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-visible-worktree' }) - const spawn = vi - .fn() - .mockResolvedValueOnce({ id: 'pty-visible-startup' }) - .mockResolvedValueOnce({ id: 'pty-visible-setup' }) - runtime.setPtyController({ - spawn, - write: () => true, - kill: () => true, - getForegroundProcess: async () => null - }) - runtime.setNotifier({ - worktreesChanged: vi.fn(), - reposChanged: vi.fn(), - activateWorktree, - createTerminal: vi.fn(), - revealTerminalSession, - splitTerminal: vi.fn(), - renameTerminal: vi.fn(), - focusTerminal: vi.fn(), - closeTerminal: vi.fn(), - sleepWorktree: vi.fn(), - terminalFitOverrideChanged: vi.fn(), - terminalDriverChanged: vi.fn() - }) - runtime.attachWindow(1) - - computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-startup-setup') - ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-startup-setup') - vi.mocked(getEffectiveHooks).mockReturnValue({ - scripts: { - setup: 'pnpm worktree:setup' - } - }) - vi.mocked(createSetupRunnerScript).mockReturnValue({ - runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', - envVars: { - ORCA_ROOT_PATH: '/tmp/repo', - ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup' - } - }) - vi.mocked(listWorktrees).mockResolvedValue([ - { - path: '/tmp/workspaces/runtime-startup-setup', - head: 'def', - branch: 'runtime-startup-setup', - isBare: false, - isMainWorktree: false - } - ]) - - const result = await runtime.createManagedWorktree({ - repoSelector: 'id:repo-1', - name: 'runtime-startup-setup', - runHooks: true, - startup: { - command: 'codex --prompt "setup"', - env: { - ORCA_STARTUP_SETUP: '1' - } - } - }) - - expect(spawn).toHaveBeenCalledTimes(2) - expect(spawn).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - cwd: '/tmp/workspaces/runtime-startup-setup', - command: 'codex --prompt "setup"', - env: expect.objectContaining({ - ORCA_STARTUP_SETUP: '1' - }), - worktreeId: result.worktree.id - }) - ) - expect(spawn).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - cwd: '/tmp/workspaces/runtime-startup-setup', - command: 'bash /tmp/repo/.git/orca/setup-runner.sh', - env: expect.objectContaining({ - ORCA_ROOT_PATH: '/tmp/repo', - ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup' - }), - worktreeId: result.worktree.id - }) - ) - expect(revealTerminalSession).toHaveBeenNthCalledWith( - 1, - result.worktree.id, - expect.objectContaining({ - ptyId: 'pty-visible-startup', - title: null, - activate: false - }) - ) - expect(revealTerminalSession).toHaveBeenNthCalledWith( - 2, - result.worktree.id, - expect.objectContaining({ - ptyId: 'pty-visible-setup', - title: 'Setup', - activate: false - }) - ) - expect(activateWorktree).toHaveBeenCalledWith('repo-1', result.worktree.id, undefined) - expect(revealTerminalSession.mock.invocationCallOrder[1]).toBeLessThan( - activateWorktree.mock.invocationCallOrder[0] - ) - }) - it('follows normal setup policy for CLI-created worktrees without activating them', async () => { const runtime = new OrcaRuntimeService(store) const activateWorktree = vi.fn() @@ -2469,79 +2342,6 @@ describe('OrcaRuntimeService', () => { }) }) - it('runs startup commands through background PTYs when worktree activation was not requested', async () => { - const runtime = new OrcaRuntimeService(store) - const activateWorktree = vi.fn() - const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-startup-worktree' }) - const spawn = vi.fn().mockResolvedValue({ id: 'pty-startup-worktree' }) - runtime.setPtyController({ - spawn, - write: () => true, - kill: () => true, - getForegroundProcess: async () => null - }) - runtime.setNotifier({ - worktreesChanged: vi.fn(), - reposChanged: vi.fn(), - activateWorktree, - createTerminal: vi.fn(), - revealTerminalSession, - splitTerminal: vi.fn(), - renameTerminal: vi.fn(), - focusTerminal: vi.fn(), - closeTerminal: vi.fn(), - sleepWorktree: vi.fn(), - terminalFitOverrideChanged: vi.fn(), - terminalDriverChanged: vi.fn() - }) - runtime.attachWindow(1) - - computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-startup-terminal') - ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-startup-terminal') - vi.mocked(getEffectiveHooks).mockReturnValue(null) - vi.mocked(listWorktrees).mockResolvedValue([ - { - path: '/tmp/workspaces/runtime-startup-terminal', - head: 'def', - branch: 'runtime-startup-terminal', - isBare: false, - isMainWorktree: false - } - ]) - - const result = await runtime.createManagedWorktree({ - repoSelector: 'id:repo-1', - name: 'runtime-startup-terminal', - startup: { - command: 'codex --prompt "summarize"', - env: { - ORCA_TEST_MODE: '1' - } - } - }) - - expect(activateWorktree).not.toHaveBeenCalled() - expect(spawn).toHaveBeenCalledWith( - expect.objectContaining({ - cwd: '/tmp/workspaces/runtime-startup-terminal', - command: 'codex --prompt "summarize"', - env: expect.objectContaining({ - ORCA_TEST_MODE: '1' - }), - worktreeId: result.worktree.id, - preAllocatedHandle: expect.stringMatching(/^term_/) - }) - ) - expect(revealTerminalSession).toHaveBeenCalledWith( - result.worktree.id, - expect.objectContaining({ - ptyId: 'pty-startup-worktree', - title: null, - activate: false - }) - ) - }) - it('keeps CLI-created worktrees successful when initial terminal creation fails', async () => { const runtime = new OrcaRuntimeService(store) const spawn = vi.fn().mockRejectedValue(new Error('pty unavailable')) @@ -2642,70 +2442,6 @@ describe('OrcaRuntimeService', () => { expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), undefined) }) - it('spawns startup commands in runtime before revealing explicitly activated worktrees', async () => { - const runtime = new OrcaRuntimeService(store) - const activateWorktree = vi.fn() - const spawn = vi.fn().mockResolvedValue({ id: 'pty-visible-startup' }) - runtime.setPtyController({ - spawn, - write: () => true, - kill: () => true, - getForegroundProcess: async () => null - }) - runtime.setNotifier({ - worktreesChanged: vi.fn(), - reposChanged: vi.fn(), - activateWorktree, - createTerminal: vi.fn(), - revealTerminalSession: vi.fn(), - splitTerminal: vi.fn(), - renameTerminal: vi.fn(), - focusTerminal: vi.fn(), - closeTerminal: vi.fn(), - sleepWorktree: vi.fn(), - terminalFitOverrideChanged: vi.fn(), - terminalDriverChanged: vi.fn() - }) - runtime.attachWindow(1) - - computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-visible-startup') - ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-visible-startup') - vi.mocked(getEffectiveHooks).mockReturnValue(null) - vi.mocked(listWorktrees).mockResolvedValue([ - { - path: '/tmp/workspaces/runtime-visible-startup', - head: 'def', - branch: 'runtime-visible-startup', - isBare: false, - isMainWorktree: false - } - ]) - - const result = await runtime.createManagedWorktree({ - repoSelector: 'id:repo-1', - name: 'runtime-visible-startup', - activate: true, - startup: { - command: 'claude --dangerously-skip-permissions', - env: { - ORCA_VISIBLE_STARTUP: '1' - } - } - }) - - expect(spawn).toHaveBeenCalledWith( - expect.objectContaining({ - cwd: '/tmp/workspaces/runtime-visible-startup', - command: 'claude --dangerously-skip-permissions', - env: expect.objectContaining({ - ORCA_VISIBLE_STARTUP: '1' - }), - worktreeId: result.worktree.id - }) - ) - expect(activateWorktree).toHaveBeenCalledWith('repo-1', result.worktree.id, undefined) - }) - it('stamps createdAt alongside lastActivityAt so CLI-created worktrees get the Recent-sort grace window', async () => { // Why: parity with createLocalWorktree / createRemoteWorktree. Without // createdAt, ambient PTY bumps in OTHER worktrees during the few seconds diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 53a8e2ce6ef..8e6d46134de 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9,7 +9,7 @@ import { import type { AgentStatus } from '../../shared/agent-detection' import { gitExecFileAsync, wslAwareSpawn } from '../git/runner' import { isWslPath, parseWslPath, getWslHome } from '../wsl' -import { createHash, randomUUID } from 'crypto' +import { randomUUID } from 'crypto' import { basename, isAbsolute, join } from 'path' import { mkdir, readdir, rm, stat } from 'fs/promises' import { OrchestrationDb } from './orchestration/db' @@ -80,7 +80,7 @@ import { RuntimeBrowserCommands } from './orca-runtime-browser' import { RuntimeFileCommands } from './orca-runtime-files' import { RuntimeGitCommands } from './orca-runtime-git' import { joinWorktreeRelativePath } from './runtime-relative-paths' -import { app, BrowserWindow, ipcMain } from 'electron' +import { BrowserWindow, ipcMain } from 'electron' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import { getPRForBranch, @@ -239,26 +239,6 @@ import type { CodexAccountService } from '../codex-accounts/service' import type { RateLimitService } from '../rate-limits/service' import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types' import type { RateLimitState } from '../../shared/rate-limit-types' -import { NotesMarkdownStore } from '../notes/notes-markdown-store' -import type { - NoteAppendArgs, - NoteCreateArgs, - NoteDeleteArgs, - NoteDeleteResult, - NoteLinkArgs, - NoteLink, - NoteLinkKind, - NoteListArgs, - NoteListResult, - NoteMutationResult, - NoteRenameArgs, - NoteSaveArgs, - NoteSearchArgs, - NoteShowArgs, - NoteShowResult, - NotesPanelOpenState, - NotesPanelStateArgs -} from '../../shared/notes-types' import type { VoiceSettings } from '../../shared/speech-types' import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service' @@ -783,7 +763,6 @@ export class OrcaRuntimeService { private optimisticReconcileTokens = new Map<string, string>() private readonly getLocalProviderFn: (() => IPtyProvider) | null private accountServices: RuntimeAccountServices | null = null - private _notesStore: NotesMarkdownStore | null = null private mobileDictation: { id: string owner: string @@ -838,17 +817,6 @@ export class OrcaRuntimeService { this._orchestrationDb = db } - getNotesStore(): NotesMarkdownStore { - if (!this._notesStore) { - this._notesStore = new NotesMarkdownStore() - } - return this._notesStore - } - - setNotesStore(store: NotesMarkdownStore): void { - this._notesStore = store - } - getRuntimeId(): string { return this.runtimeId } @@ -4912,205 +4880,6 @@ export class OrcaRuntimeService { } } - async listNotes(args: { worktreeSelector: string; limit?: number }): Promise<NoteListResult> { - const scope = await this.resolveNotesScope(args.worktreeSelector) - return await this.getNotesStore().list(this.getNotesScope(scope.projectId), { - projectId: scope.projectId, - worktreeId: scope.worktreeId, - limit: args.limit - }) - } - - async showNote(args: { worktreeSelector: string; note: string }): Promise<NoteShowResult> { - const scope = await this.resolveNotesScope(args.worktreeSelector) - return await this.getNotesStore().show(this.getNotesScope(scope.projectId), { - projectId: scope.projectId, - worktreeId: scope.worktreeId, - note: args.note - }) - } - - async createNote(args: { - worktreeSelector: string - title: string - bodyMarkdown?: string - makeActive?: boolean - createdBySessionId?: string | null - }): Promise<NoteMutationResult> { - const scope = await this.resolveNotesScope(args.worktreeSelector) - return await this.getNotesStore().create(this.getNotesScope(scope.projectId), { - projectId: scope.projectId, - worktreeId: scope.worktreeId, - title: args.title, - bodyMarkdown: args.bodyMarkdown, - makeActive: args.makeActive, - createdBySessionId: args.createdBySessionId - }) - } - - async saveNote(args: { - worktreeSelector: string - note: string - title?: string - bodyMarkdown: string - revision?: number - makeActive?: boolean - updatedBySessionId?: string | null - }): Promise<NoteMutationResult> { - const scope = await this.resolveNotesScope(args.worktreeSelector) - return await this.getNotesStore().save(this.getNotesScope(scope.projectId), { - projectId: scope.projectId, - worktreeId: scope.worktreeId, - note: args.note, - title: args.title, - bodyMarkdown: args.bodyMarkdown, - revision: args.revision, - makeActive: args.makeActive, - updatedBySessionId: args.updatedBySessionId - }) - } - - async renameNote(args: { - worktreeSelector: string - note: string - title: string - updatedBySessionId?: string | null - }): Promise<NoteMutationResult> { - const scope = await this.resolveNotesScope(args.worktreeSelector) - return await this.getNotesStore().rename(this.getNotesScope(scope.projectId), { - projectId: scope.projectId, - worktreeId: scope.worktreeId, - note: args.note, - title: args.title, - updatedBySessionId: args.updatedBySessionId - }) - } - - async deleteNote(args: { worktreeSelector: string; note: string }): Promise<NoteDeleteResult> { - const scope = await this.resolveNotesScope(args.worktreeSelector) - return await this.getNotesStore().delete(this.getNotesScope(scope.projectId), { - projectId: scope.projectId, - worktreeId: scope.worktreeId, - note: args.note - }) - } - - async appendNote(args: { - worktreeSelector: string - note: string - bodyMarkdown: string - makeActive?: boolean - updatedBySessionId?: string | null - }): Promise<NoteMutationResult> { - const scope = await this.resolveNotesScope(args.worktreeSelector) - return await this.getNotesStore().append(this.getNotesScope(scope.projectId), { - projectId: scope.projectId, - worktreeId: scope.worktreeId, - note: args.note, - bodyMarkdown: args.bodyMarkdown, - makeActive: args.makeActive, - updatedBySessionId: args.updatedBySessionId - }) - } - - async searchNotes(args: { - worktreeSelector: string - query: string - limit?: number - }): Promise<NoteListResult> { - const scope = await this.resolveNotesScope(args.worktreeSelector) - return await this.getNotesStore().search(this.getNotesScope(scope.projectId), { - projectId: scope.projectId, - worktreeId: scope.worktreeId, - query: args.query, - limit: args.limit - }) - } - - async linkNote(args: { - worktreeSelector: string - note: string - kind: NoteLinkKind - }): Promise<NoteLink> { - const scope = await this.resolveNotesScope(args.worktreeSelector) - return await this.getNotesStore().setLink(this.getNotesScope(scope.projectId), { - projectId: scope.projectId, - worktreeId: scope.worktreeId, - note: args.note, - kind: args.kind - }) - } - - async resolveNotesPanelOpenStateForWorktree(args: { - worktreeSelector: string - }): Promise<NotesPanelOpenState> { - const scope = await this.resolveNotesScope(args.worktreeSelector) - return await this.getNotesStore().resolvePanelOpenState(this.getNotesScope(scope.projectId), { - projectId: scope.projectId, - worktreeId: scope.worktreeId - }) - } - - async listProjectNotes(args: NoteListArgs): Promise<NoteListResult> { - this.assertKnownNotesProject(args.projectId) - return await this.getNotesStore().list(this.getNotesScope(args.projectId), args) - } - - async showProjectNote(args: NoteShowArgs): Promise<NoteShowResult> { - this.assertKnownNotesProject(args.projectId) - return await this.getNotesStore().show(this.getNotesScope(args.projectId), args) - } - - async createProjectNote(args: NoteCreateArgs): Promise<NoteMutationResult> { - this.assertKnownNotesProject(args.projectId) - return await this.getNotesStore().create(this.getNotesScope(args.projectId), args) - } - - async saveProjectNote(args: NoteSaveArgs): Promise<NoteMutationResult> { - this.assertKnownNotesProject(args.projectId) - return await this.getNotesStore().save(this.getNotesScope(args.projectId), args) - } - - async renameProjectNote(args: NoteRenameArgs): Promise<NoteMutationResult> { - this.assertKnownNotesProject(args.projectId) - return await this.getNotesStore().rename(this.getNotesScope(args.projectId), args) - } - - async deleteProjectNote(args: NoteDeleteArgs): Promise<NoteDeleteResult> { - this.assertKnownNotesProject(args.projectId) - return await this.getNotesStore().delete(this.getNotesScope(args.projectId), args) - } - - async appendProjectNote(args: NoteAppendArgs): Promise<NoteMutationResult> { - this.assertKnownNotesProject(args.projectId) - return await this.getNotesStore().append(this.getNotesScope(args.projectId), args) - } - - async searchProjectNotes(args: NoteSearchArgs): Promise<NoteListResult> { - this.assertKnownNotesProject(args.projectId) - return await this.getNotesStore().search(this.getNotesScope(args.projectId), args) - } - - async linkProjectNote(args: NoteLinkArgs) { - this.assertKnownNotesProject(args.projectId) - return await this.getNotesStore().setLink(this.getNotesScope(args.projectId), args) - } - - async unlinkNotesWorktree(projectId: string, worktreeId: string): Promise<void> { - this.assertKnownNotesProject(projectId) - await this.getNotesStore().unlinkWorktree(this.getNotesScope(projectId), worktreeId) - } - - async resolveNotesPanelOpenState(args: NotesPanelStateArgs): Promise<NotesPanelOpenState> { - if (args.projectId) { - this.assertKnownNotesProject(args.projectId) - } - return await this.getNotesStore().resolvePanelOpenState( - args.projectId ? this.getNotesScope(args.projectId) : null, - args - ) - } - async listManagedWorktrees( repoSelector?: string, limit = DEFAULT_WORKTREE_LIST_LIMIT @@ -6030,7 +5799,6 @@ export class OrcaRuntimeService { // remains locked — other worktrees cannot check it out. await gitExecFileAsync(['worktree', 'prune'], { cwd: repo.path }).catch(() => {}) this.clearOptimisticReconcileToken(worktree.id) - await this.getNotesStore().unlinkWorktree(this.getNotesScope(repo.id), worktree.id) this.store.removeWorktreeMeta(worktree.id) this.invalidateResolvedWorktreeCache() invalidateAuthorizedRootsCache() @@ -6043,7 +5811,6 @@ export class OrcaRuntimeService { } this.clearOptimisticReconcileToken(worktree.id) - await this.getNotesStore().unlinkWorktree(this.getNotesScope(repo.id), worktree.id) this.store.removeWorktreeMeta(worktree.id) this.invalidateResolvedWorktreeCache() invalidateAuthorizedRootsCache() @@ -6726,42 +6493,6 @@ export class OrcaRuntimeService { throw new Error('selector_not_found') } - private async resolveNotesScope(worktreeSelector: string): Promise<{ - projectId: string - worktreeId: string - }> { - const worktree = await this.resolveWorktreeSelector(worktreeSelector) - return { - projectId: worktree.repoId, - worktreeId: worktree.id - } - } - - private assertKnownNotesProject(projectId: string): void { - if (!this.store?.getRepo(projectId)) { - throw new Error('repo_not_found') - } - } - - private getNotesScope(projectId: string): { - projectId: string - rootPath: string - } { - const repo = this.store?.getRepo(projectId) - if (!repo) { - throw new Error('repo_not_found') - } - const identity = `${repo.connectionId ?? 'local'}:${repo.path}` - const notesRoot = createHash('sha256').update(identity).digest('hex').slice(0, 24) - return { - projectId, - // Why: notes are Orca workspace memory, not repo source files. Keeping - // them in userData prevents accidental git commits while still sharing - // one notes folder across every Orca worktree for the same repo. - rootPath: join(app.getPath('userData'), 'project-notes', notesRoot) - } - } - private async resolveRepoSelector(selector: string): Promise<Repo> { if (!this.store) { throw new Error('repo_not_found') diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index efb2044118d..da19f1cba1a 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -43,9 +43,6 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([ 'terminal_gone', 'no_active_terminal', 'repo_not_found', - 'note_not_found', - 'note_ambiguous', - 'revision_conflict', 'timeout', 'invalid_limit' ]) diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 868a3b503aa..7f46810a99a 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -17,7 +17,6 @@ import { GIT_METHODS } from './git' import { GITHUB_METHODS } from './github' import { HOSTED_REVIEW_METHODS } from './hosted-review' import { LINEAR_METHODS } from './linear' -import { NOTE_METHODS } from './notes' import { SPEECH_METHODS } from './speech' // Why: a flat manifest keeps registration order explicit and provides one @@ -42,6 +41,5 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...GITHUB_METHODS, ...HOSTED_REVIEW_METHODS, ...LINEAR_METHODS, - ...NOTE_METHODS, ...SPEECH_METHODS ] diff --git a/src/main/runtime/rpc/methods/notes.test.ts b/src/main/runtime/rpc/methods/notes.test.ts deleted file mode 100644 index 35c56129f5e..00000000000 --- a/src/main/runtime/rpc/methods/notes.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { OrcaRuntimeService } from '../../orca-runtime' -import type { RpcRequest } from '../core' -import { RpcDispatcher } from '../dispatcher' -import { NOTE_METHODS } from './notes' - -function makeRequest(method: string, params?: unknown): RpcRequest { - return { id: 'req-1', authToken: 'tok', method, params } -} - -describe('notes RPC methods', () => { - it('routes note reads through the selected worktree', async () => { - const runtime = { - getRuntimeId: () => 'test-runtime', - listNotes: vi.fn().mockResolvedValue({ notes: [], totalCount: 0, truncated: false }), - showNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: null }) - } as unknown as OrcaRuntimeService - const dispatcher = new RpcDispatcher({ runtime, methods: NOTE_METHODS }) - - await dispatcher.dispatch(makeRequest('note.list', { worktree: 'id:wt-1', limit: 50 })) - await dispatcher.dispatch(makeRequest('note.show', { worktree: 'id:wt-1', note: 'note-1' })) - - expect(runtime.listNotes).toHaveBeenCalledWith({ worktreeSelector: 'id:wt-1', limit: 50 }) - expect(runtime.showNote).toHaveBeenCalledWith({ - worktreeSelector: 'id:wt-1', - note: 'note-1' - }) - }) - - it('routes note mutations through the selected worktree', async () => { - const runtime = { - getRuntimeId: () => 'test-runtime', - createNote: vi.fn().mockResolvedValue({ note: { id: 'created' }, linkKind: 'active' }), - saveNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: 'active' }), - renameNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: null }), - deleteNote: vi.fn().mockResolvedValue({ noteId: 'note-1', projectId: 'repo-1' }), - appendNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: null }), - linkNote: vi.fn().mockResolvedValue({ - noteId: 'note-1', - projectId: 'repo-1', - worktreeId: 'wt-1', - kind: 'active', - createdAt: 'now' - }) - } as unknown as OrcaRuntimeService - const dispatcher = new RpcDispatcher({ runtime, methods: NOTE_METHODS }) - - await dispatcher.dispatch( - makeRequest('note.create', { - worktree: 'id:wt-1', - title: 'Plan', - bodyMarkdown: 'body', - makeActive: true - }) - ) - await dispatcher.dispatch( - makeRequest('note.save', { - worktree: 'id:wt-1', - note: 'note-1', - title: 'Plan v2', - bodyMarkdown: 'updated', - revision: 2, - makeActive: true - }) - ) - await dispatcher.dispatch( - makeRequest('note.rename', { worktree: 'id:wt-1', note: 'note-1', title: 'Renamed' }) - ) - await dispatcher.dispatch(makeRequest('note.delete', { worktree: 'id:wt-1', note: 'note-1' })) - await dispatcher.dispatch( - makeRequest('note.append', { - worktree: 'id:wt-1', - note: 'note-1', - bodyMarkdown: 'more', - makeActive: true - }) - ) - await dispatcher.dispatch( - makeRequest('note.link', { worktree: 'id:wt-1', note: 'note-1', kind: 'active' }) - ) - - expect(runtime.createNote).toHaveBeenCalledWith( - expect.objectContaining({ - worktreeSelector: 'id:wt-1', - title: 'Plan', - bodyMarkdown: 'body', - makeActive: true - }) - ) - expect(runtime.saveNote).toHaveBeenCalledWith( - expect.objectContaining({ - worktreeSelector: 'id:wt-1', - note: 'note-1', - title: 'Plan v2', - bodyMarkdown: 'updated', - revision: 2, - makeActive: true - }) - ) - expect(runtime.renameNote).toHaveBeenCalledWith( - expect.objectContaining({ - worktreeSelector: 'id:wt-1', - note: 'note-1', - title: 'Renamed' - }) - ) - expect(runtime.deleteNote).toHaveBeenCalledWith({ - worktreeSelector: 'id:wt-1', - note: 'note-1' - }) - expect(runtime.appendNote).toHaveBeenCalledWith( - expect.objectContaining({ - worktreeSelector: 'id:wt-1', - note: 'note-1', - bodyMarkdown: 'more', - makeActive: true - }) - ) - expect(runtime.linkNote).toHaveBeenCalledWith({ - worktreeSelector: 'id:wt-1', - note: 'note-1', - kind: 'active' - }) - }) - - it('routes panel state and search through the selected worktree', async () => { - const runtime = { - getRuntimeId: () => 'test-runtime', - searchNotes: vi.fn().mockResolvedValue({ notes: [], totalCount: 0, truncated: false }), - resolveNotesPanelOpenStateForWorktree: vi.fn().mockResolvedValue({ state: 'emptyDraft' }) - } as unknown as OrcaRuntimeService - const dispatcher = new RpcDispatcher({ runtime, methods: NOTE_METHODS }) - - await dispatcher.dispatch( - makeRequest('note.search', { worktree: 'id:wt-1', query: 'todo', limit: 20 }) - ) - await dispatcher.dispatch(makeRequest('note.panelState', { worktree: 'id:wt-1' })) - - expect(runtime.searchNotes).toHaveBeenCalledWith({ - worktreeSelector: 'id:wt-1', - query: 'todo', - limit: 20 - }) - expect(runtime.resolveNotesPanelOpenStateForWorktree).toHaveBeenCalledWith({ - worktreeSelector: 'id:wt-1' - }) - }) -}) diff --git a/src/main/runtime/rpc/methods/notes.ts b/src/main/runtime/rpc/methods/notes.ts deleted file mode 100644 index fc0ea173871..00000000000 --- a/src/main/runtime/rpc/methods/notes.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { z } from 'zod' -import { defineMethod, type RpcAnyMethod } from '../core' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' - -const NoteScopedParams = z.object({ - worktree: requiredString('Missing worktree selector') -}) - -const NoteListParams = NoteScopedParams.extend({ - limit: OptionalFiniteNumber -}) - -const NoteShowParams = NoteScopedParams.extend({ - note: requiredString('Missing note selector') -}) - -const NoteCreateParams = NoteScopedParams.extend({ - title: requiredString('Missing note title'), - bodyMarkdown: OptionalString, - makeActive: z.boolean().optional(), - createdBySessionId: z.string().nullable().optional() -}) - -const NoteSaveParams = NoteScopedParams.extend({ - note: requiredString('Missing note selector'), - title: OptionalString, - bodyMarkdown: requiredString('Missing note body'), - revision: OptionalFiniteNumber, - makeActive: z.boolean().optional(), - updatedBySessionId: z.string().nullable().optional() -}) - -const NoteRenameParams = NoteScopedParams.extend({ - note: requiredString('Missing note selector'), - title: requiredString('Missing note title'), - updatedBySessionId: z.string().nullable().optional() -}) - -const NoteDeleteParams = NoteScopedParams.extend({ - note: requiredString('Missing note selector') -}) - -const NoteAppendParams = NoteScopedParams.extend({ - note: requiredString('Missing note selector'), - bodyMarkdown: requiredString('Missing note body'), - makeActive: z.boolean().optional(), - updatedBySessionId: z.string().nullable().optional() -}) - -const NoteSearchParams = NoteScopedParams.extend({ - query: requiredString('Missing search query'), - limit: OptionalFiniteNumber -}) - -const NoteLinkParams = NoteScopedParams.extend({ - note: requiredString('Missing note selector'), - kind: z.enum(['active', 'referenced']) -}) - -export const NOTE_METHODS: readonly RpcAnyMethod[] = [ - defineMethod({ - name: 'note.list', - params: NoteListParams, - handler: async (params, { runtime }) => - await runtime.listNotes({ - worktreeSelector: params.worktree, - limit: params.limit - }) - }), - defineMethod({ - name: 'note.show', - params: NoteShowParams, - handler: async (params, { runtime }) => - await runtime.showNote({ - worktreeSelector: params.worktree, - note: params.note - }) - }), - defineMethod({ - name: 'note.create', - params: NoteCreateParams, - handler: async (params, { runtime }) => - await runtime.createNote({ - worktreeSelector: params.worktree, - title: params.title, - bodyMarkdown: params.bodyMarkdown, - makeActive: params.makeActive, - createdBySessionId: params.createdBySessionId - }) - }), - defineMethod({ - name: 'note.save', - params: NoteSaveParams, - handler: async (params, { runtime }) => - await runtime.saveNote({ - worktreeSelector: params.worktree, - note: params.note, - title: params.title, - bodyMarkdown: params.bodyMarkdown, - revision: params.revision, - makeActive: params.makeActive, - updatedBySessionId: params.updatedBySessionId - }) - }), - defineMethod({ - name: 'note.rename', - params: NoteRenameParams, - handler: async (params, { runtime }) => - await runtime.renameNote({ - worktreeSelector: params.worktree, - note: params.note, - title: params.title, - updatedBySessionId: params.updatedBySessionId - }) - }), - defineMethod({ - name: 'note.delete', - params: NoteDeleteParams, - handler: async (params, { runtime }) => - await runtime.deleteNote({ - worktreeSelector: params.worktree, - note: params.note - }) - }), - defineMethod({ - name: 'note.append', - params: NoteAppendParams, - handler: async (params, { runtime }) => - await runtime.appendNote({ - worktreeSelector: params.worktree, - note: params.note, - bodyMarkdown: params.bodyMarkdown, - makeActive: params.makeActive, - updatedBySessionId: params.updatedBySessionId - }) - }), - defineMethod({ - name: 'note.search', - params: NoteSearchParams, - handler: async (params, { runtime }) => - await runtime.searchNotes({ - worktreeSelector: params.worktree, - query: params.query, - limit: params.limit - }) - }), - defineMethod({ - name: 'note.link', - params: NoteLinkParams, - handler: async (params, { runtime }) => - await runtime.linkNote({ - worktreeSelector: params.worktree, - note: params.note, - kind: params.kind - }) - }), - defineMethod({ - name: 'note.panelState', - params: NoteScopedParams, - handler: async (params, { runtime }) => - await runtime.resolveNotesPanelOpenStateForWorktree({ - worktreeSelector: params.worktree - }) - }) -] diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 5e21c47a89f..666a8f3e412 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -207,24 +207,6 @@ import type { AutomationRun, AutomationUpdateInput } from '../shared/automations-types' -import type { - NoteAppendArgs, - NoteCreateArgs, - NoteDeleteArgs, - NoteDeleteResult, - NoteLinkArgs, - NoteLink, - NoteListArgs, - NoteListResult, - NoteMutationResult, - NoteRenameArgs, - NoteSaveArgs, - NoteSearchArgs, - NoteShowArgs, - NoteShowResult, - NotesPanelOpenState, - NotesPanelStateArgs -} from '../shared/notes-types' export type BrowserApi = { registerGuest: (args: { @@ -1281,18 +1263,6 @@ export type PreloadApi = { connectionId?: string }) => Promise<string | null> } - notes: { - list: (args: NoteListArgs) => Promise<NoteListResult> - show: (args: NoteShowArgs) => Promise<NoteShowResult> - create: (args: NoteCreateArgs) => Promise<NoteMutationResult> - save: (args: NoteSaveArgs) => Promise<NoteMutationResult> - rename: (args: NoteRenameArgs) => Promise<NoteMutationResult> - delete: (args: NoteDeleteArgs) => Promise<NoteDeleteResult> - append: (args: NoteAppendArgs) => Promise<NoteMutationResult> - search: (args: NoteSearchArgs) => Promise<NoteListResult> - link: (args: NoteLinkArgs) => Promise<NoteLink> - panelState: (args: NotesPanelStateArgs) => Promise<NotesPanelOpenState> - } ui: { get: () => Promise<PersistedUIState> set: (args: Partial<PersistedUIState>) => Promise<void> diff --git a/src/preload/index.ts b/src/preload/index.ts index 764714f28cb..0ae09605a4b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -338,19 +338,6 @@ const api = { isAvailable: (): Promise<boolean> => ipcRenderer.invoke('pwsh:isAvailable') }, - notes: { - list: (args: unknown): Promise<unknown> => ipcRenderer.invoke('notes:list', args), - show: (args: unknown): Promise<unknown> => ipcRenderer.invoke('notes:show', args), - create: (args: unknown): Promise<unknown> => ipcRenderer.invoke('notes:create', args), - save: (args: unknown): Promise<unknown> => ipcRenderer.invoke('notes:save', args), - rename: (args: unknown): Promise<unknown> => ipcRenderer.invoke('notes:rename', args), - delete: (args: unknown): Promise<unknown> => ipcRenderer.invoke('notes:delete', args), - append: (args: unknown): Promise<unknown> => ipcRenderer.invoke('notes:append', args), - search: (args: unknown): Promise<unknown> => ipcRenderer.invoke('notes:search', args), - link: (args: unknown): Promise<unknown> => ipcRenderer.invoke('notes:link', args), - panelState: (args: unknown): Promise<unknown> => ipcRenderer.invoke('notes:panelState', args) - }, - repos: { list: (): Promise<unknown[]> => ipcRenderer.invoke('repos:list'), diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index d1b172000dd..1169ea6dacc 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -29,7 +29,6 @@ import { type EditorRequestFileCloseDetail, requestEditorSaveQuiesce } from './editor/editor-autosave' -import { requestProjectNotesTabClose } from '@/lib/project-notes-close-request' import { isUpdaterQuitAndInstallInProgress } from '@/lib/updater-beforeunload' import EditorAutosaveController from './editor/EditorAutosaveController' import type { TabGroupLayoutNode } from '../../../shared/types' @@ -50,7 +49,6 @@ import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout' import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal' import { shouldRepairActiveTerminalTab } from './terminal/active-terminal-repair' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' -import { openProjectNotesTab } from '@/lib/open-project-notes-tab' import { getEffectiveLayoutForWorktree as getEffectiveLayout, anyMountedWorktreeHasLayout as computeAnyMountedWorktreeHasLayout @@ -656,16 +654,6 @@ function Terminal(): React.JSX.Element | null { } }, [activeWorktreeId, openFile]) - const handleNewNotesTab = useCallback( - (noteId?: string) => { - if (!activeWorktreeId) { - return - } - void openProjectNotesTab(activeWorktreeId, noteId) - }, - [activeWorktreeId] - ) - const handleCloseTab = useCallback( (tabId: string) => { const state = useAppStore.getState() @@ -986,13 +974,6 @@ function Terminal(): React.JSX.Element | null { handleCloseFile(state.activeFileId) } else if (state.activeTabType === 'browser' && state.activeBrowserTabId) { closeBrowserTab(state.activeBrowserTabId) - } else if (state.activeTabType === 'notes') { - const activeTab = activeWorktreeId ? state.getActiveTab(activeWorktreeId) : null - if (activeTab?.contentType === 'notes') { - requestProjectNotesTabClose(activeTab.id, () => { - state.closeUnifiedTab(activeTab.id) - }) - } } return } @@ -1211,7 +1192,6 @@ function Terminal(): React.JSX.Element | null { onNewTerminalWithShell={handleNewTab} onNewBrowserTab={handleNewBrowserTab} onNewFileTab={handleNewFile} - onNewNotesTab={handleNewNotesTab} wslAvailable={wslAvailable} onSetCustomTitle={setTabCustomTitle} onSetTabColor={setTabColor} diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 7584be239bd..e21eb0ab923 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -228,7 +228,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const deferredQuery = useDeferredValue(query) const [selectedItemId, setSelectedItemId] = useState('') const previousWorktreeIdRef = useRef<string | null>(null) - const previousActiveTabTypeRef = useRef<'browser' | 'editor' | 'terminal' | 'notes'>('terminal') + const previousActiveTabTypeRef = useRef<'browser' | 'editor' | 'terminal'>('terminal') const previousBrowserPageIdRef = useRef<string | null>(null) const previousBrowserFocusTargetRef = useRef<'webview' | 'address-bar'>('webview') const wasVisibleRef = useRef(false) diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index 86ad99428c2..c0835db1e23 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -257,7 +257,7 @@ function EditorPanelInner({ settings, renameDialogFile?.runtimeEnvironmentId )?.activeRuntimeEnvironmentId?.trim() || - (renameDialogFile ? getConnectionId(renameDialogFile.worktreeId) : null) + (renameDialogFile ? getConnectionId(renameDialogFile.worktreeId) : null) ) return ( diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index 94c89362086..0d0ffe18c89 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: the floating surface owns both terminal chrome and local notes tabs until the shared floating shell is extracted. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import TabBar from '@/components/tab-bar/TabBar' import TerminalPane from '@/components/terminal-pane/TerminalPane' @@ -11,15 +10,10 @@ import { isOrchestrationSetupDismissed, notifyOrchestrationSetupStateChanged } from '@/lib/orchestration-setup-state' -import { notifyProjectNotesSelectionChanged } from '@/lib/open-project-notes-tab' -import { requestProjectNotesTabClose } from '@/lib/project-notes-close-request' -import { linkRuntimeProjectNote, showRuntimeProjectNote } from '@/runtime/runtime-notes-client' import { useAppStore } from '@/store' -import { createBrowserUuid } from '@/lib/browser-uuid' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { TerminalTab } from '../../../../shared/types' import { FloatingTerminalOrchestrationDialog } from './FloatingTerminalOrchestrationDialog' -import ProjectNotesTabContent from '@/components/notes/ProjectNotesTabContent' import { FloatingTerminalResizeHandles } from './FloatingTerminalResizeHandles' import { FloatingTerminalWindowControls } from './FloatingTerminalWindowControls' export { FloatingTerminalToggleButton } from './FloatingTerminalToggleButton' @@ -30,7 +24,6 @@ import { type FloatingTerminalPanelBounds } from './floating-terminal-panel-bounds' const EMPTY_TERMINAL_TABS: TerminalTab[] = [] -type FloatingNotesTab = { id: string; label: string; noteId: string | null; isDirty: boolean } type FloatingTerminalPanelProps = { open: boolean @@ -53,7 +46,6 @@ export function FloatingTerminalPanel({ const setTabPaneExpanded = useAppStore((s) => s.setTabPaneExpanded) const tabBarOrder = useAppStore((s) => s.tabBarOrderByWorktree[FLOATING_TERMINAL_WORKTREE_ID]) const floatingTerminalCwd = useAppStore((s) => s.settings?.floatingTerminalCwd ?? '~') - const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const [cwd, setCwd] = useState<string | null>(null) const [bounds, setBounds] = useState(() => getDefaultFloatingTerminalBounds()) @@ -62,9 +54,6 @@ export function FloatingTerminalPanel({ const [showOrchestrationSetup, setShowOrchestrationSetup] = useState( () => !hasOrchestrationSetupMarker() && !isOrchestrationSetupDismissed() ) - const [notesTabs, setNotesTabs] = useState<FloatingNotesTab[]>([]) - const [activeNotesTabId, setActiveNotesTabId] = useState<string | null>(null) - const [activeSurface, setActiveSurface] = useState<'terminal' | 'notes'>('terminal') const restoreBoundsRef = useRef<FloatingTerminalPanelBounds | null>(null) const normalizedInitialBoundsRef = useRef(false) const panelRef = useRef<HTMLDivElement>(null) @@ -155,7 +144,6 @@ export function FloatingTerminalPanel({ const createFloatingTab = useCallback(() => { const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID, undefined, undefined, { activate: false }) setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tab.id) - setActiveSurface('terminal') const state = useAppStore.getState() const currentTabs = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] const stored = state.tabBarOrderByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] @@ -174,7 +162,6 @@ export function FloatingTerminalPanel({ const closeFloatingTab = useCallback( (tabId: string) => { closeTab(tabId) - setActiveSurface('terminal') }, [closeTab] ) @@ -203,73 +190,6 @@ export function FloatingTerminalPanel({ [closeTab, tabs] ) - const openFloatingNotesTab = useCallback( - async (noteId?: string) => { - if (!activeWorktreeId) { - return - } - const state = useAppStore.getState() - const worktree = Object.values(state.worktreesByRepo) - .flat() - .find((candidate) => candidate.id === activeWorktreeId) - const projectId = - state.repos.find((candidate) => candidate.id === worktree?.repoId)?.id ?? worktree?.repoId - const settings = state.settings - let label = 'Project Notes' - if (noteId && projectId) { - try { - const result = await showRuntimeProjectNote(settings, { - projectId, - worktreeId: activeWorktreeId, - note: noteId - }) - label = result.note.title - } catch { - label = 'Project Notes' - } - if (projectId) { - await linkRuntimeProjectNote(settings, { - projectId, - worktreeId: activeWorktreeId, - note: noteId, - kind: 'active' - }) - notifyProjectNotesSelectionChanged() - } - } - const id = `floating-project-notes:${createBrowserUuid()}` - setNotesTabs((current) => [...current, { id, label, noteId: noteId ?? null, isDirty: false }]) - setActiveNotesTabId(id) - setActiveSurface('notes') - }, - [activeWorktreeId] - ) - - const closeFloatingNotesTab = useCallback((tabId: string) => { - requestProjectNotesTabClose(tabId, () => { - setNotesTabs((current) => current.filter((tab) => tab.id !== tabId)) - setActiveNotesTabId((current) => (current === tabId ? null : current)) - setActiveSurface('terminal') - }) - }, []) - - useEffect(() => { - if (!open || activeSurface !== 'notes' || !activeNotesTabId) { - return - } - const isMac = navigator.userAgent.includes('Mac') - const onKeyDown = (event: KeyboardEvent): void => { - const mod = isMac ? event.metaKey : event.ctrlKey - if (!mod || event.shiftKey || event.repeat || event.key.toLowerCase() !== 'w') { - return - } - event.preventDefault() - closeFloatingNotesTab(activeNotesTabId) - } - window.addEventListener('keydown', onKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) - }, [activeNotesTabId, activeSurface, closeFloatingNotesTab, open]) - const toggleMaximized = useCallback(() => { setMaximized((current) => { if (current) { @@ -372,31 +292,19 @@ export function FloatingTerminalPanel({ activeTabId={activeTab?.id ?? null} worktreeId={FLOATING_TERMINAL_WORKTREE_ID} expandedPaneByTabId={expandedPaneByTabId} - onActivate={(tabId) => { - setActiveSurface('terminal') - setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tabId) - }} + onActivate={(tabId) => setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tabId)} onClose={closeFloatingTab} onCloseOthers={closeOthers} onCloseToRight={closeToRight} onNewTerminalTab={createFloatingTab} onNewBrowserTab={() => {}} - onNewNotesTab={activeWorktreeId ? openFloatingNotesTab : undefined} - notesWorktreeId={activeWorktreeId} terminalOnly onSetCustomTitle={setTabCustomTitle} onSetTabColor={setTabColor} onTogglePaneExpand={(tabId) => setTabPaneExpanded(tabId, expandedPaneByTabId[tabId] !== true) } - notesTabs={activeWorktreeId ? notesTabs : []} - activeNotesTabId={activeSurface === 'notes' ? activeNotesTabId : null} - onActivateNotesTab={(tabId) => { - setActiveNotesTabId(tabId) - setActiveSurface('notes') - }} - onCloseNotesTab={closeFloatingNotesTab} - activeTabType={activeSurface} + activeTabType="terminal" tabBarOrder={tabBarOrder} /> </div> @@ -408,42 +316,27 @@ export function FloatingTerminalPanel({ </div> <div className="relative min-h-0 flex-1 overflow-hidden bg-background"> - {activeSurface === 'notes' && activeWorktreeId && activeNotesTabId ? ( - <ProjectNotesTabContent - key={activeNotesTabId} - worktreeId={activeWorktreeId} - tabId={activeNotesTabId} - noteId={notesTabs.find((tab) => tab.id === activeNotesTabId)?.noteId ?? null} - forceNew={notesTabs.find((tab) => tab.id === activeNotesTabId)?.noteId === null} - onDirtyChange={(dirty) => { - setNotesTabs((current) => - current.map((tab) => - tab.id === activeNotesTabId ? { ...tab, isDirty: dirty } : tab - ) - ) - }} - /> - ) : cwd ? ( - tabs.map((tab) => ( - <div - key={`${tab.id}-${tab.generation ?? 0}`} - className={ - tab.id === activeTab?.id ? 'absolute inset-0' : 'absolute inset-0 hidden' - } - aria-hidden={tab.id !== activeTab?.id} - > - <TerminalPane - tabId={tab.id} - worktreeId={FLOATING_TERMINAL_WORKTREE_ID} - cwd={cwd} - isActive={tab.id === activeTab?.id} - isVisible={tab.id === activeTab?.id} - onPtyExit={() => closeTab(tab.id)} - onCloseTab={() => closeTab(tab.id)} - /> - </div> - )) - ) : null} + {cwd + ? tabs.map((tab) => ( + <div + key={`${tab.id}-${tab.generation ?? 0}`} + className={ + tab.id === activeTab?.id ? 'absolute inset-0' : 'absolute inset-0 hidden' + } + aria-hidden={tab.id !== activeTab?.id} + > + <TerminalPane + tabId={tab.id} + worktreeId={FLOATING_TERMINAL_WORKTREE_ID} + cwd={cwd} + isActive={tab.id === activeTab?.id} + isVisible={tab.id === activeTab?.id} + onPtyExit={() => closeTab(tab.id)} + onCloseTab={() => closeTab(tab.id)} + /> + </div> + )) + : null} </div> </div> {showOrchestrationSetup ? ( diff --git a/src/renderer/src/components/notes/ProjectNotesTabContent.tsx b/src/renderer/src/components/notes/ProjectNotesTabContent.tsx deleted file mode 100644 index 99a81458b1f..00000000000 --- a/src/renderer/src/components/notes/ProjectNotesTabContent.tsx +++ /dev/null @@ -1,699 +0,0 @@ -/* eslint-disable max-lines -- Why: project notes keep picker, save-as, and - rich/source/preview markdown modes together so unsaved note creation and - active-note switching cannot drift across separate surfaces. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Copy, FileText, MoreHorizontal, Plus, Save } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import RichMarkdownEditor from '@/components/editor/RichMarkdownEditor' -import MarkdownPreview from '@/components/editor/MarkdownPreview' -import EditorViewToggle from '@/components/editor/EditorViewToggle' -import { useAppStore } from '@/store' -import { useRepoById, useWorktreeById } from '@/store/selectors' -import { NOTES_ACTIVE_CHANGED_EVENT } from '@/lib/notes-events' -import { - getProjectNotesEntityId, - notifyProjectNotesSelectionChanged -} from '@/lib/open-project-notes-tab' -import { - createRuntimeProjectNote, - listRuntimeProjectNotes, - linkRuntimeProjectNote, - resolveRuntimeNotesPanelState, - saveRuntimeProjectNote, - showRuntimeProjectNote -} from '@/runtime/runtime-notes-client' -import { - ORCA_PROJECT_NOTES_REQUEST_CLOSE_EVENT, - type ProjectNotesCloseRequestDetail -} from '@/lib/project-notes-close-request' -import { toast } from 'sonner' -import type { MarkdownDocument } from '../../../../shared/types' -import type { NoteRecord, NoteSummary, NotesPanelOpenState } from '../../../../shared/notes-types' -import type { MarkdownViewMode } from '@/store/slices/editor' - -const SAVE_DEBOUNCE_MS = 700 - -type Draft = { - id: string | null - filePath: string | null - relativePath: string | null - title: string - bodyMarkdown: string - revision: number | null -} - -function emptyDraft(): Draft { - return { - id: null, - filePath: null, - relativePath: null, - title: 'Untitled note', - bodyMarkdown: '', - revision: null - } -} - -export default function ProjectNotesTabContent({ - worktreeId, - tabId, - noteId = null, - forceNew = false, - onDirtyChange -}: { - worktreeId: string - tabId: string - noteId?: string | null - forceNew?: boolean - onDirtyChange?: (dirty: boolean) => void -}): React.JSX.Element { - const worktree = useWorktreeById(worktreeId) - const repo = useRepoById(worktree?.repoId ?? null) - const projectId = repo?.id ?? worktree?.repoId ?? null - const settings = useAppStore((s) => s.settings) - - const [panelState, setPanelState] = useState<NotesPanelOpenState>({ state: 'noProject' }) - const [notes, setNotes] = useState<NoteSummary[]>([]) - const [draft, setDraft] = useState<Draft>(() => emptyDraft()) - const [dirty, setDirty] = useState(false) - const [saving, setSaving] = useState(false) - const [error, setError] = useState<string | null>(null) - const [viewMode, setViewMode] = useState<MarkdownViewMode>('rich') - const [saveAsOpen, setSaveAsOpen] = useState(false) - const [closePromptOpen, setClosePromptOpen] = useState(false) - const [saveAsTitle, setSaveAsTitle] = useState('Untitled note') - const [selectedNoteId, setSelectedNoteId] = useState<string | null>(noteId) - const saveAsInputRef = useRef<HTMLInputElement>(null) - const pendingCreateBodyRef = useRef<string | null>(null) - const pendingCloseRef = useRef<(() => void) | null>(null) - - const canSave = projectId !== null && (draft.id === null || draft.title.trim().length > 0) - - const refreshNotes = useCallback(async (): Promise<void> => { - if (!projectId) { - setNotes([]) - return - } - const result = await listRuntimeProjectNotes(settings, { projectId, worktreeId, limit: 100 }) - setNotes(result.notes) - }, [projectId, settings, worktreeId]) - - const loadPanelState = useCallback(async (): Promise<void> => { - try { - setError(null) - if (selectedNoteId && projectId) { - const result = await showRuntimeProjectNote(settings, { - projectId, - worktreeId, - note: selectedNoteId - }) - setPanelState({ state: 'active', projectId, worktreeId, note: result.note }) - setDraft({ - id: result.note.id, - filePath: result.note.filePath, - relativePath: result.note.relativePath, - title: result.note.title, - bodyMarkdown: result.note.bodyMarkdown, - revision: result.note.revision - }) - setDirty(false) - const state = useAppStore.getState() - state.setTabLabel(tabId, result.note.title) - state.setTabEntityId(tabId, getProjectNotesEntityId(projectId, result.note.id)) - await refreshNotes() - return - } - if (forceNew && projectId) { - setPanelState({ state: 'emptyDraft', projectId, worktreeId }) - setDraft(emptyDraft()) - setDirty(false) - useAppStore.getState().setTabLabel(tabId, 'Project Notes') - await refreshNotes() - return - } - const next = await resolveRuntimeNotesPanelState(settings, { projectId, worktreeId }) - setPanelState(next) - if (next.state === 'active') { - setDraft({ - id: next.note.id, - filePath: next.note.filePath, - relativePath: next.note.relativePath, - title: next.note.title, - bodyMarkdown: next.note.bodyMarkdown, - revision: next.note.revision - }) - const state = useAppStore.getState() - state.setTabLabel(tabId, next.note.title) - state.setTabEntityId(tabId, getProjectNotesEntityId(next.projectId, next.note.id)) - setDirty(false) - } else { - setDraft(emptyDraft()) - useAppStore - .getState() - .setTabEntityId(tabId, getProjectNotesEntityId(projectId ?? worktreeId)) - setDirty(false) - } - if (next.state === 'pickerRequired') { - setNotes(next.notes) - } else { - await refreshNotes() - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } - }, [forceNew, projectId, refreshNotes, selectedNoteId, settings, tabId, worktreeId]) - - useEffect(() => { - void loadPanelState() - }, [loadPanelState]) - - useEffect(() => { - setSelectedNoteId(noteId) - }, [noteId]) - - useEffect(() => { - useAppStore.getState().setTabDirty(tabId, dirty) - onDirtyChange?.(dirty) - }, [dirty, onDirtyChange, tabId]) - - useEffect(() => { - const listener = (): void => { - if (dirty) { - return - } - void loadPanelState() - } - window.addEventListener(NOTES_ACTIVE_CHANGED_EVENT, listener) - return () => window.removeEventListener(NOTES_ACTIVE_CHANGED_EVENT, listener) - }, [dirty, loadPanelState]) - - useEffect(() => { - const listener = (event: Event): void => { - const detail = (event as CustomEvent<ProjectNotesCloseRequestDetail>).detail - if (!detail || detail.tabId !== tabId) { - return - } - detail.claim() - if (!dirty) { - detail.close() - return - } - pendingCloseRef.current = detail.close - setClosePromptOpen(true) - } - window.addEventListener(ORCA_PROJECT_NOTES_REQUEST_CLOSE_EVENT, listener) - return () => window.removeEventListener(ORCA_PROJECT_NOTES_REQUEST_CLOSE_EVENT, listener) - }, [dirty, tabId]) - - const selectNote = useCallback( - async (noteId: string): Promise<void> => { - if (!projectId) { - return - } - const result = await showRuntimeProjectNote(settings, { projectId, worktreeId, note: noteId }) - await linkRuntimeProjectNote(settings, { - projectId, - worktreeId, - note: noteId, - kind: 'active' - }) - setSelectedNoteId(noteId) - setDraft({ - id: result.note.id, - filePath: result.note.filePath, - relativePath: result.note.relativePath, - title: result.note.title, - bodyMarkdown: result.note.bodyMarkdown, - revision: result.note.revision - }) - setPanelState({ state: 'active', projectId, worktreeId, note: result.note }) - const state = useAppStore.getState() - state.setTabLabel(tabId, result.note.title) - state.setTabEntityId(tabId, getProjectNotesEntityId(projectId, result.note.id)) - setDirty(false) - await refreshNotes() - }, - [projectId, refreshNotes, settings, tabId, worktreeId] - ) - - const saveDraft = useCallback( - async (bodyOverride?: string): Promise<NoteRecord | null> => { - if (!canSave || !projectId) { - return null - } - const bodyMarkdown = bodyOverride ?? draft.bodyMarkdown - if (!draft.id) { - // Why: project notes are markdown files. Match untitled Markdown tabs - // by asking for the user-facing name before creating the file instead - // of silently persisting "Untitled note" from autosave. - pendingCreateBodyRef.current = bodyMarkdown - setSaveAsTitle(draft.title.trim() || 'Untitled note') - setSaveAsOpen(true) - return null - } - setSaving(true) - try { - const result = await saveRuntimeProjectNote(settings, { - projectId, - worktreeId, - note: draft.id, - title: draft.title, - bodyMarkdown, - revision: draft.revision ?? undefined, - makeActive: true - }) - setDraft({ - id: result.note.id, - filePath: result.note.filePath, - relativePath: result.note.relativePath, - title: result.note.title, - bodyMarkdown: result.note.bodyMarkdown, - revision: result.note.revision - }) - setPanelState({ state: 'active', projectId, worktreeId, note: result.note }) - setSelectedNoteId(result.note.id) - const state = useAppStore.getState() - state.setTabLabel(tabId, result.note.title) - state.setTabEntityId(tabId, getProjectNotesEntityId(projectId, result.note.id)) - setDirty(false) - await refreshNotes() - notifyProjectNotesSelectionChanged() - return result.note - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - return null - } finally { - setSaving(false) - } - }, - [canSave, draft, projectId, refreshNotes, settings, tabId, worktreeId] - ) - - useEffect(() => { - if (!dirty || !canSave || !draft.id) { - return - } - const timer = window.setTimeout(() => { - void saveDraft() - }, SAVE_DEBOUNCE_MS) - return () => window.clearTimeout(timer) - }, [canSave, dirty, draft.id, saveDraft]) - - const createNewDraft = useCallback(() => { - setSelectedNoteId(null) - setDraft(emptyDraft()) - const state = useAppStore.getState() - state.setTabLabel(tabId, 'Project Notes') - state.setTabEntityId(tabId, getProjectNotesEntityId(projectId ?? worktreeId)) - setPanelState( - projectId ? { state: 'emptyDraft', projectId, worktreeId } : { state: 'noProject' } - ) - setDirty(false) - }, [projectId, tabId, worktreeId]) - - const markdownDocuments = useMemo<MarkdownDocument[]>( - () => - notes.map((note) => ({ - filePath: note.filePath, - relativePath: note.relativePath, - basename: note.relativePath.split('/').pop() ?? note.title, - name: note.title - })), - [notes] - ) - - const editorFilePath = - draft.filePath ?? `orca://project-notes/${projectId ?? 'project'}/untitled.md` - const editorPathLabel = draft.filePath - ? (draft.relativePath ?? draft.title) - : `notes/${draft.title.trim() || 'untitled'}.md` - - useEffect(() => { - if (error) { - toast.error(error) - } - }, [error]) - - useEffect(() => { - if (!saveAsOpen) { - return - } - requestAnimationFrame(() => saveAsInputRef.current?.select()) - }, [saveAsOpen]) - - const confirmCreateNote = useCallback(async (): Promise<void> => { - if (!projectId) { - return - } - const title = saveAsTitle.trim().replace(/\.md$/i, '') - if (!title) { - setError('Name cannot be empty') - return - } - setSaving(true) - try { - const result = await createRuntimeProjectNote(settings, { - projectId, - worktreeId, - title, - bodyMarkdown: pendingCreateBodyRef.current ?? draft.bodyMarkdown, - makeActive: true - }) - setDraft({ - id: result.note.id, - filePath: result.note.filePath, - relativePath: result.note.relativePath, - title: result.note.title, - bodyMarkdown: result.note.bodyMarkdown, - revision: result.note.revision - }) - setPanelState({ state: 'active', projectId, worktreeId, note: result.note }) - setSelectedNoteId(result.note.id) - const state = useAppStore.getState() - state.setTabLabel(tabId, result.note.title) - state.setTabEntityId(tabId, getProjectNotesEntityId(projectId, result.note.id)) - setDirty(false) - setSaveAsOpen(false) - pendingCreateBodyRef.current = null - await refreshNotes() - notifyProjectNotesSelectionChanged() - const pendingClose = pendingCloseRef.current - if (pendingClose) { - pendingCloseRef.current = null - pendingClose() - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } finally { - setSaving(false) - } - }, [draft.bodyMarkdown, projectId, refreshNotes, saveAsTitle, settings, tabId, worktreeId]) - - const handleClosePromptSave = useCallback(async (): Promise<void> => { - setClosePromptOpen(false) - const saved = await saveDraft() - if (!saved) { - if (draft.id) { - setClosePromptOpen(true) - } - return - } - const pendingClose = pendingCloseRef.current - pendingCloseRef.current = null - pendingClose?.() - }, [draft.id, saveDraft]) - - const handleClosePromptDiscard = useCallback(() => { - setClosePromptOpen(false) - setDirty(false) - const pendingClose = pendingCloseRef.current - pendingCloseRef.current = null - pendingClose?.() - }, []) - - const handleClosePromptCancel = useCallback(() => { - setClosePromptOpen(false) - pendingCloseRef.current = null - }, []) - - const handleSaveAsCancel = useCallback(() => { - setSaveAsOpen(false) - pendingCreateBodyRef.current = null - pendingCloseRef.current = null - }, []) - - return ( - <div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col bg-background"> - <div className="editor-header"> - <div className="editor-header-text"> - <div className="editor-header-path-row"> - <button - type="button" - className="editor-header-path" - title={editorFilePath} - onClick={() => { - void window.api.ui.writeClipboardText(editorFilePath) - }} - > - {editorPathLabel} - </button> - </div> - </div> - <EditorViewToggle - value={viewMode} - modes={['source', 'rich', 'preview']} - onChange={(next) => setViewMode(next as MarkdownViewMode)} - /> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - type="button" - variant="ghost" - size="icon-xs" - className="text-muted-foreground hover:text-foreground" - aria-label="More actions" - title="More actions" - > - <MoreHorizontal size={14} /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-64"> - <DropdownMenuLabel>Current note</DropdownMenuLabel> - <div className="px-2 pb-2"> - <Input - value={draft.title} - disabled={panelState.state === 'noProject'} - onChange={(event) => { - setDraft((current) => ({ ...current, title: event.target.value })) - setDirty(true) - }} - className="h-8 text-xs" - placeholder="Note title" - /> - </div> - <DropdownMenuItem onSelect={createNewDraft}> - <Plus className="size-3.5" /> - New note - </DropdownMenuItem> - <DropdownMenuItem - onSelect={() => { - void window.api.ui.writeClipboardText(draft.bodyMarkdown) - }} - > - <Copy className="size-3.5" /> - Copy Markdown - </DropdownMenuItem> - <DropdownMenuItem disabled={!canSave || saving} onSelect={() => void saveDraft()}> - <Save className="size-3.5" /> - Save now - </DropdownMenuItem> - {notes.length > 0 ? ( - <> - <DropdownMenuSeparator /> - <DropdownMenuLabel>Switch note</DropdownMenuLabel> - {notes.map((note) => ( - <DropdownMenuItem key={note.id} onSelect={() => void selectNote(note.id)}> - <FileText className="size-3.5" /> - <span className="truncate">{note.title}</span> - </DropdownMenuItem> - ))} - </> - ) : null} - </DropdownMenuContent> - </DropdownMenu> - </div> - - <div className="min-h-0 min-w-0 flex-1 overflow-hidden"> - {panelState.state === 'noProject' ? ( - <div className="flex h-full items-center justify-center px-4 text-sm text-muted-foreground"> - Open a project worktree to use notes. - </div> - ) : panelState.state === 'pickerRequired' ? ( - <div className="flex h-full flex-col overflow-y-auto px-8 py-6"> - <div className="mb-4 flex items-center justify-between gap-3"> - <div className="min-w-0"> - <div className="text-sm font-medium text-foreground">Project Notes</div> - <div className="mt-1 text-xs text-muted-foreground"> - Choose a saved note or start a new one. - </div> - </div> - <Button type="button" size="sm" variant="outline" onClick={createNewDraft}> - <Plus className="size-3.5" /> - New note - </Button> - </div> - <div className="space-y-1"> - {notes.map((note) => ( - <button - key={note.id} - type="button" - className="flex w-full items-start gap-2 rounded-md px-2.5 py-2 text-left hover:bg-accent/45" - onClick={() => void selectNote(note.id)} - > - <FileText className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" /> - <span className="min-w-0 flex-1"> - <span className="block truncate text-sm font-medium text-foreground"> - {note.title} - </span> - <span className="mt-0.5 line-clamp-2 block text-xs leading-5 text-muted-foreground"> - {note.preview || note.relativePath} - </span> - </span> - </button> - ))} - </div> - </div> - ) : viewMode === 'source' ? ( - <textarea - className="h-full w-full resize-none bg-background px-8 py-6 font-mono text-[13px] leading-6 text-foreground outline-none scrollbar-editor" - value={draft.bodyMarkdown} - spellCheck={false} - onChange={(event) => { - setDraft((current) => ({ ...current, bodyMarkdown: event.target.value })) - setDirty(true) - }} - onKeyDown={(event) => { - const isMac = navigator.userAgent.includes('Mac') - const saveShortcut = isMac ? event.metaKey : event.ctrlKey - if (saveShortcut && event.key.toLowerCase() === 's') { - event.preventDefault() - void saveDraft() - } - }} - /> - ) : viewMode === 'preview' ? ( - <MarkdownPreview - content={draft.bodyMarkdown} - filePath={editorFilePath} - scrollCacheKey={`notes:${draft.id ?? 'new'}:preview`} - markdownDocuments={markdownDocuments} - onOpenDocument={(document) => { - const note = notes.find((candidate) => candidate.filePath === document.filePath) - if (note) { - void selectNote(note.id) - } - }} - /> - ) : ( - <RichMarkdownEditor - fileId={draft.id ?? 'new-note'} - content={draft.bodyMarkdown} - filePath={editorFilePath} - worktreeId={worktreeId} - scrollCacheKey={`notes:${draft.id ?? 'new'}`} - onContentChange={(content) => { - setDraft((current) => ({ ...current, bodyMarkdown: content })) - setDirty(true) - }} - onDirtyStateHint={(nextDirty) => { - if (nextDirty) { - setDirty(true) - } - }} - onSave={(content) => { - setDraft((current) => ({ ...current, bodyMarkdown: content })) - setDirty(true) - void saveDraft(content) - }} - markdownDocuments={markdownDocuments} - /> - )} - </div> - <Dialog - open={saveAsOpen} - onOpenChange={(isOpen) => { - if (isOpen) { - return - } - handleSaveAsCancel() - }} - > - <DialogContent showCloseButton={false} className="max-w-[340px]"> - <DialogHeader> - <DialogTitle className="text-sm">Save project note</DialogTitle> - <DialogDescription className="text-xs"> - Name this markdown note before saving it in Orca project notes. - </DialogDescription> - </DialogHeader> - <div> - <label className="mb-1 block text-[11px] font-medium text-muted-foreground">Name</label> - <div className="flex items-center gap-1.5"> - <Input - ref={saveAsInputRef} - value={saveAsTitle} - onChange={(event) => setSaveAsTitle(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - void confirmCreateNote() - } - }} - className="h-8 text-sm" - placeholder="note name" - /> - <span className="shrink-0 text-xs text-muted-foreground">.md</span> - </div> - </div> - <DialogFooter className="mt-1"> - <Button variant="outline" size="sm" onClick={handleSaveAsCancel}> - Cancel - </Button> - <Button size="sm" disabled={saving} onClick={() => void confirmCreateNote()}> - Save - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - <Dialog - open={closePromptOpen} - onOpenChange={(isOpen) => { - if (!isOpen) { - handleClosePromptCancel() - } - }} - > - <DialogContent className="max-w-sm"> - <DialogHeader> - <DialogTitle className="text-sm">Unsaved Changes</DialogTitle> - <DialogDescription className="text-xs"> - "{draft.title.trim() || 'Project Notes'}" has unsaved changes. Do you want - to save before closing? - </DialogDescription> - </DialogHeader> - <DialogFooter className="gap-2"> - <Button type="button" variant="outline" size="sm" onClick={handleClosePromptCancel}> - Cancel - </Button> - <Button type="button" variant="outline" size="sm" onClick={handleClosePromptDiscard}> - Don't Save - </Button> - <Button - type="button" - size="sm" - disabled={saving} - onClick={() => void handleClosePromptSave()} - > - Save - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - </div> - ) -} diff --git a/src/renderer/src/components/right-sidebar/NotesPanel.tsx b/src/renderer/src/components/right-sidebar/NotesPanel.tsx deleted file mode 100644 index b54ffc4d125..00000000000 --- a/src/renderer/src/components/right-sidebar/NotesPanel.tsx +++ /dev/null @@ -1,364 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { Copy, FileText, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger -} from '@/components/ui/context-menu' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import { useAppStore } from '@/store' -import { useActiveWorktree, useRepoById } from '@/store/selectors' -import { cn } from '@/lib/utils' -import { getProjectNotesEntityId, openProjectNotesTab } from '@/lib/open-project-notes-tab' -import { NOTES_ACTIVE_CHANGED_EVENT } from '@/lib/notes-events' -import { - deleteRuntimeProjectNote, - listRuntimeProjectNotes, - renameRuntimeProjectNote -} from '@/runtime/runtime-notes-client' -import type { NoteSummary } from '../../../../shared/notes-types' - -export default function NotesPanel(): React.JSX.Element { - const activeWorktree = useActiveWorktree() - const repo = useRepoById(activeWorktree?.repoId ?? null) - const projectId = repo?.id ?? activeWorktree?.repoId ?? null - const worktreeId = activeWorktree?.id ?? null - const settings = useAppStore((s) => s.settings) - - const [notes, setNotes] = useState<NoteSummary[]>([]) - const [loading, setLoading] = useState(false) - const [error, setError] = useState<string | null>(null) - const [renamingNoteId, setRenamingNoteId] = useState<string | null>(null) - const [renameValue, setRenameValue] = useState('') - const [deleteTarget, setDeleteTarget] = useState<NoteSummary | null>(null) - const [deleting, setDeleting] = useState(false) - const renameCommittedRef = useRef(false) - const deleteConfirmButtonRef = useRef<HTMLButtonElement>(null) - - const refresh = useCallback(async (): Promise<void> => { - if (!projectId || !worktreeId) { - setNotes([]) - return - } - setLoading(true) - setError(null) - try { - const result = await listRuntimeProjectNotes(settings, { projectId, worktreeId, limit: 100 }) - setNotes(result.notes) - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } finally { - setLoading(false) - } - }, [projectId, settings, worktreeId]) - - useEffect(() => { - void refresh() - }, [refresh]) - - useEffect(() => { - const listener = (): void => { - void refresh() - } - window.addEventListener(NOTES_ACTIVE_CHANGED_EVENT, listener) - return () => window.removeEventListener(NOTES_ACTIVE_CHANGED_EVENT, listener) - }, [refresh]) - - const openNotes = useCallback(() => { - if (!worktreeId) { - return - } - void openProjectNotesTab(worktreeId) - }, [worktreeId]) - - const selectNote = useCallback( - async (noteId: string): Promise<void> => { - if (!projectId || !worktreeId) { - return - } - await openProjectNotesTab(worktreeId, noteId) - await refresh() - }, - [projectId, refresh, worktreeId] - ) - - const startRename = useCallback((note: NoteSummary) => { - renameCommittedRef.current = false - setRenamingNoteId(note.id) - setRenameValue(note.title) - }, []) - - const commitRename = useCallback( - async (note: NoteSummary): Promise<void> => { - if (renameCommittedRef.current) { - return - } - renameCommittedRef.current = true - const title = renameValue.trim() - setRenamingNoteId(null) - if (!projectId || !worktreeId || !title || title === note.title) { - return - } - try { - const result = await renameRuntimeProjectNote(settings, { - projectId, - worktreeId, - note: note.id, - title - }) - const entityId = getProjectNotesEntityId(projectId, note.id) - const state = useAppStore.getState() - for (const tabs of Object.values(state.unifiedTabsByWorktree)) { - for (const tab of tabs) { - if (tab.contentType === 'notes' && tab.entityId === entityId) { - state.setTabLabel(tab.id, result.note.title) - } - } - } - await refresh() - } catch (err) { - toast.error(err instanceof Error ? err.message : `Failed to rename '${note.title}'.`) - } - }, - [projectId, refresh, renameValue, settings, worktreeId] - ) - - const cancelRename = useCallback(() => { - renameCommittedRef.current = true - setRenamingNoteId(null) - }, []) - - const confirmDeleteNote = useCallback(async (): Promise<void> => { - if (!projectId || !worktreeId || !deleteTarget || deleting) { - return - } - setDeleting(true) - try { - await deleteRuntimeProjectNote(settings, { projectId, worktreeId, note: deleteTarget.id }) - const entityId = getProjectNotesEntityId(projectId, deleteTarget.id) - const state = useAppStore.getState() - const tabIdsToClose: string[] = [] - for (const tabs of Object.values(state.unifiedTabsByWorktree)) { - for (const tab of tabs) { - if (tab.contentType === 'notes' && tab.entityId === entityId) { - tabIdsToClose.push(tab.id) - } - } - } - for (const tabId of tabIdsToClose) { - useAppStore.getState().closeUnifiedTab(tabId) - } - await refresh() - toast.success(`'${deleteTarget.title}' deleted`) - setDeleteTarget(null) - } catch (err) { - toast.error(err instanceof Error ? err.message : `Failed to delete '${deleteTarget.title}'.`) - } finally { - setDeleting(false) - } - }, [deleteTarget, deleting, projectId, refresh, settings, worktreeId]) - - const copyNotePath = useCallback(async (note: NoteSummary): Promise<void> => { - await navigator.clipboard.writeText(note.relativePath) - toast.success('Note path copied') - }, []) - - if (!projectId || !worktreeId) { - return ( - <div className="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground"> - Open a project worktree to use notes. - </div> - ) - } - - return ( - <div className="flex h-full min-h-0 flex-col"> - <div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2"> - <div className="min-w-0 flex-1"> - <div className="truncate text-xs font-medium text-foreground">Project Notes</div> - <div className="line-clamp-2 text-[11px] leading-4 text-muted-foreground"> - Shared across all workspaces for {repo?.displayName ?? 'this repo'} - </div> - </div> - <Button - type="button" - variant="ghost" - size="icon-xs" - aria-label="Refresh notes" - onClick={() => void refresh()} - > - <RefreshCw className={cn('size-3.5', loading && 'animate-spin')} /> - </Button> - <Button - type="button" - variant="ghost" - size="icon-xs" - aria-label="New project notes tab" - onClick={openNotes} - > - <Plus className="size-3.5" /> - </Button> - </div> - - {error ? ( - <div className="border-b border-border px-3 py-2 text-xs text-destructive">{error}</div> - ) : null} - - <div className="min-h-0 flex-1 overflow-y-auto py-1"> - {notes.length === 0 ? ( - <div className="px-3 py-4"> - <div className="text-xs font-medium text-foreground">No project notes yet</div> - <div className="mt-1 text-[11px] leading-4 text-muted-foreground"> - Create one to keep repo context available across every workspace. - </div> - </div> - ) : ( - notes.map((note) => ( - <ContextMenu key={note.id}> - <ContextMenuTrigger asChild> - <div - role="button" - tabIndex={0} - className="mx-1.5 flex w-[calc(100%-0.75rem)] items-start gap-2 rounded-md px-2 py-2 text-left hover:bg-accent/45" - onClick={() => { - if (renamingNoteId !== note.id) { - void selectNote(note.id) - } - }} - onKeyDown={(event) => { - if (event.key !== 'Enter' && event.key !== ' ') { - return - } - event.preventDefault() - if (renamingNoteId !== note.id) { - void selectNote(note.id) - } - }} - > - <FileText className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" /> - <span className="min-w-0 flex-1"> - {renamingNoteId === note.id ? ( - <Input - autoFocus - value={renameValue} - className="h-6 px-2 text-xs" - onClick={(event) => event.stopPropagation()} - onChange={(event) => setRenameValue(event.target.value)} - onFocus={(event) => event.currentTarget.select()} - onBlur={() => void commitRename(note)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - void commitRename(note) - } else if (event.key === 'Escape') { - event.preventDefault() - cancelRename() - } - }} - /> - ) : ( - <span className="block truncate text-xs font-medium text-foreground"> - {note.title} - </span> - )} - <span className="mt-0.5 line-clamp-2 block text-[11px] leading-4 text-muted-foreground"> - {note.preview || note.relativePath} - </span> - </span> - </div> - </ContextMenuTrigger> - <ContextMenuContent> - <ContextMenuItem onSelect={() => void selectNote(note.id)}> - <FileText className="size-3.5" /> - Open - </ContextMenuItem> - <ContextMenuItem onSelect={() => startRename(note)}> - <Pencil className="size-3.5" /> - Rename - </ContextMenuItem> - <ContextMenuItem onSelect={() => void copyNotePath(note)}> - <Copy className="size-3.5" /> - Copy Path - </ContextMenuItem> - <ContextMenuSeparator /> - <ContextMenuItem variant="destructive" onSelect={() => setDeleteTarget(note)}> - <Trash2 className="size-3.5" /> - Delete - </ContextMenuItem> - </ContextMenuContent> - </ContextMenu> - )) - )} - </div> - - <Dialog - open={deleteTarget !== null} - onOpenChange={(open) => { - if (open) { - return - } - setDeleteTarget(null) - setDeleting(false) - }} - > - <DialogContent - className="max-w-md" - onOpenAutoFocus={(event) => { - event.preventDefault() - deleteConfirmButtonRef.current?.focus() - }} - > - <DialogHeader> - <DialogTitle className="text-sm">Delete Project Note</DialogTitle> - <DialogDescription className="text-xs"> - Delete{' '} - <span className="break-all font-medium text-foreground">{deleteTarget?.title}</span>? - This cannot be undone. - </DialogDescription> - </DialogHeader> - {deleteTarget ? ( - <div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs"> - <div className="break-all font-medium text-foreground">{deleteTarget.title}</div> - <div className="mt-1 break-all text-muted-foreground"> - {deleteTarget.relativePath} - </div> - </div> - ) : null} - <DialogFooter> - <Button - variant="outline" - disabled={deleting} - onClick={() => { - setDeleteTarget(null) - setDeleting(false) - }} - > - Cancel - </Button> - <Button - ref={deleteConfirmButtonRef} - variant="destructive" - disabled={deleting} - onClick={() => void confirmDeleteNote()} - > - <Trash2 className="size-4" /> - {deleting ? 'Deleting...' : 'Delete'} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - </div> - ) -} diff --git a/src/renderer/src/components/right-sidebar/index.tsx b/src/renderer/src/components/right-sidebar/index.tsx index fd1db766e59..a5a69821891 100644 --- a/src/renderer/src/components/right-sidebar/index.tsx +++ b/src/renderer/src/components/right-sidebar/index.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react' -import { Files, Search, GitBranch, ListChecks, Cable, PanelRight, FileText } from 'lucide-react' +import { Files, Search, GitBranch, ListChecks, Cable, PanelRight } from 'lucide-react' import { useAppStore } from '@/store' import { getRepoMapFromState, useActiveWorktree, useRepoById } from '@/store/selectors' import { cn } from '@/lib/utils' @@ -22,7 +22,6 @@ import SourceControl from './SourceControl' import SearchPanel from './Search' import ChecksPanel from './ChecksPanel' import PortsPanel from './PortsPanel' -import NotesPanel from './NotesPanel' const MIN_WIDTH = 220 // Why: long file names (e.g. construction drawing sheets, multi-part document @@ -88,12 +87,6 @@ const ACTIVITY_ITEMS: ActivityBarItem[] = [ title: 'Search', shortcut: `${isMac ? '\u21E7' : 'Shift+'}${mod}F` }, - { - id: 'notes', - icon: FileText, - title: 'Project Notes', - shortcut: '' - }, { id: 'source-control', icon: GitBranch, @@ -252,7 +245,6 @@ function RightSidebarInner(): React.JSX.Element { {effectiveTab === 'source-control' && <SourceControl />} {effectiveTab === 'checks' && <ChecksPanel />} {effectiveTab === 'ports' && <PortsPanel />} - {effectiveTab === 'notes' && <NotesPanel />} </div> </div> ) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index bdc5171e686..5cdfb2b7b5b 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -731,9 +731,6 @@ const WorktreeList = React.memo(function WorktreeList() { const needsTabs = showActiveOnly || sortBy === 'smart' const tabsByWorktree = useAppStore((s) => (needsTabs ? s.tabsByWorktree : null)) const ptyIdsByTabId = useAppStore((s) => (needsTabs ? s.ptyIdsByTabId : null)) - const unifiedTabsByWorktree = useAppStore((s) => - showActiveOnly ? s.unifiedTabsByWorktree : null - ) const browserTabsByWorktree = useAppStore((s) => showActiveOnly ? s.browserTabsByWorktree : null ) @@ -989,7 +986,6 @@ const WorktreeList = React.memo(function WorktreeList() { showActiveOnly, tabsByWorktree, ptyIdsByTabId, - unifiedTabsByWorktree, browserTabsByWorktree, activeWorktreeId, hideDefaultBranchWorkspace, @@ -1004,7 +1000,6 @@ const WorktreeList = React.memo(function WorktreeList() { repoMap, tabsByWorktree, ptyIdsByTabId, - unifiedTabsByWorktree, browserTabsByWorktree, sortedIds, worktreeMap, diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts index 7dc2e2dacae..47e5c0ec84f 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts @@ -13,9 +13,6 @@ import { export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { const tabs = useAppStore((s) => s.tabsByWorktree[worktreeId] ?? EMPTY_TABS) const browserTabs = useAppStore((s) => s.browserTabsByWorktree[worktreeId] ?? EMPTY_BROWSER_TABS) - const hasNotesSurface = useAppStore((s) => - (s.unifiedTabsByWorktree[worktreeId] ?? []).some((tab) => tab.contentType === 'notes') - ) const runtimePaneTitlesForWorktree = useAppStore( useShallow((s) => selectRuntimePaneTitlesForWorktree(s, worktreeId)) ) @@ -74,7 +71,6 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { browserTabs, ptyIdsByTabId: ptyIdsForWorktree, runtimePaneTitlesByTabId: runtimePaneTitlesForWorktree, - hasNotesSurface, hasPermission, hasLiveDone, hasRetainedDone @@ -84,7 +80,6 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { browserTabs, ptyIdsForWorktree, runtimePaneTitlesForWorktree, - hasNotesSurface, hasPermission, hasLiveDone, hasRetainedDone diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index becd27e9d3f..d53d8095dab 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -6,7 +6,7 @@ import { isDefaultBranchWorkspace, sidebarHasActiveFilters } from './visible-worktrees' -import type { Repo, Tab, TerminalTab, Worktree } from '../../../../shared/types' +import type { Repo, TerminalTab, Worktree } from '../../../../shared/types' function makeTab(id: string, worktreeId: string, ptyId: string | null): TerminalTab { return { @@ -21,21 +21,6 @@ function makeTab(id: string, worktreeId: string, ptyId: string | null): Terminal } } -function makeNotesTab(id: string, worktreeId: string): Tab { - return { - id, - entityId: `notes:${worktreeId}:${id}`, - groupId: 'group-1', - worktreeId, - contentType: 'notes', - label: 'Project Notes', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 0 - } -} - function makeWorktree(id: string, repoId = 'repo1'): Worktree { return { id, @@ -110,22 +95,6 @@ describe('computeVisibleWorktreeIds', () => { expect(result).toEqual([wt.id]) }) - it('treats project-notes tabs as active for the active-only filter', () => { - const notesWt = makeWorktree('wt-notes') - const unrelatedWt = makeWorktree('wt-unrelated') - - const result = computeVisibleWorktreeIds( - { repo1: [notesWt, unrelatedWt] }, - [notesWt.id, unrelatedWt.id], - visibleOptions({ - showActiveOnly: true, - unifiedTabsByWorktree: { [notesWt.id]: [makeNotesTab('notes-1', notesWt.id)] } - }) - ) - - expect(result).toEqual([notesWt.id]) - }) - it('keeps the currently active worktree visible even without PTYs', () => { const wt = makeWorktree('wt-active') diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 78cccf01c3f..2f6a632b834 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -1,4 +1,4 @@ -import type { Worktree, Repo, Tab, TerminalTab } from '../../../../shared/types' +import type { Worktree, Repo, TerminalTab } from '../../../../shared/types' import { buildWorktreeComparator, sortWorktreesSmart } from './smart-sort' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { useAppStore } from '@/store' @@ -81,7 +81,6 @@ export function computeVisibleWorktreeIds( showActiveOnly: boolean tabsByWorktree: Record<string, TerminalTab[]> | null ptyIdsByTabId: Record<string, string[]> | null - unifiedTabsByWorktree?: Record<string, Tab[]> | null browserTabsByWorktree?: Record<string, { id: string }[]> | null activeWorktreeId?: string | null // Why required: every caller (WorktreeList, getVisibleWorktreeIds @@ -115,15 +114,11 @@ export function computeVisibleWorktreeIds( opts.ptyIdsByTabId ? tabHasLivePty(opts.ptyIdsByTabId, tab.id) : false ) const hasBrowserTabs = (opts.browserTabsByWorktree?.[w.id] ?? []).length > 0 - const hasNotesTabs = (opts.unifiedTabsByWorktree?.[w.id] ?? []).some( - (tab) => tab.contentType === 'notes' - ) // Why: "Active only" should reflect the surfaces Orca can actually // restore into, not just PTY-backed terminals. A browser-tab worktree is - // still active from the user's point of view even if it has no live PTY. - // Project notes follow the same rule because they are workspace tabs - // backed by the notes store, not PTY-backed terminal state. - return hasLiveTerminal || hasBrowserTabs || hasNotesTabs || opts.activeWorktreeId === w.id + // still active from the user's point of view even if it has no live PTY, + // and the currently selected worktree should never vanish from the list. + return hasLiveTerminal || hasBrowserTabs || opts.activeWorktreeId === w.id }) } @@ -207,7 +202,6 @@ export function getVisibleWorktreeIds(): string[] { showActiveOnly: state.showActiveOnly, tabsByWorktree: state.tabsByWorktree, ptyIdsByTabId: state.ptyIdsByTabId, - unifiedTabsByWorktree: state.unifiedTabsByWorktree, browserTabsByWorktree: state.browserTabsByWorktree, activeWorktreeId: state.activeWorktreeId, hideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, diff --git a/src/renderer/src/components/tab-bar/ProjectNotesTab.tsx b/src/renderer/src/components/tab-bar/ProjectNotesTab.tsx deleted file mode 100644 index 415bf6d85ad..00000000000 --- a/src/renderer/src/components/tab-bar/ProjectNotesTab.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { useEffect, useState } from 'react' -import { useSortable } from '@dnd-kit/sortable' -import { Columns2, FileText, Rows2, X } from 'lucide-react' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from './SortableTab' -import type { TabDragItemData } from '../tab-group/useTabDragSplit' -import { - ACTIVE_TAB_INDICATOR_CLASSES, - getDropIndicatorClasses, - type DropIndicator -} from './drop-indicator' - -export type ProjectNotesTabState = { - id: string - label: string - isDirty: boolean -} - -export function ProjectNotesTab({ - tab, - isActive, - hasTabsToRight, - onActivate, - onClose, - onCloseToRight, - onSplitGroup, - dragData, - dropIndicator -}: { - tab: ProjectNotesTabState - isActive: boolean - hasTabsToRight: boolean - onActivate: () => void - onClose: () => void - onCloseToRight: () => void - onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void - dragData: TabDragItemData - dropIndicator?: DropIndicator -}): React.JSX.Element { - const { attributes, listeners, setNodeRef } = useSortable({ - id: tab.id, - data: dragData - }) - const [menuOpen, setMenuOpen] = useState(false) - const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 }) - - useEffect(() => { - const closeMenu = (): void => setMenuOpen(false) - window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu) - return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu) - }, []) - - return ( - <> - <div - ref={setNodeRef} - {...attributes} - {...listeners} - onClick={onActivate} - onContextMenuCapture={(event) => { - event.preventDefault() - window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT)) - setMenuPoint({ x: event.clientX, y: event.clientY }) - setMenuOpen(true) - }} - className={`group relative flex h-full min-w-[120px] max-w-[220px] items-center gap-1.5 border-l border-border px-2 text-xs ${ - hasTabsToRight ? 'border-r border-r-border/70' : '' - } ${isActive ? 'bg-background text-foreground' : 'bg-card text-muted-foreground hover:bg-muted/50 hover:text-foreground'}`} - role="tab" - aria-selected={isActive} - > - {dropIndicator ? <div className={getDropIndicatorClasses(dropIndicator)} /> : null} - <FileText className="size-3.5 shrink-0" /> - <span className="min-w-0 flex-1 truncate">{tab.label}</span> - <div className="relative flex size-4 shrink-0 items-center justify-center"> - {tab.isDirty ? ( - <span className="absolute size-1.5 rounded-full bg-foreground/60 group-hover:hidden" /> - ) : null} - <button - type="button" - className={`flex size-4 items-center justify-center rounded-sm ${ - tab.isDirty - ? 'hidden text-muted-foreground hover:bg-muted hover:text-foreground group-hover:flex' - : isActive - ? 'text-muted-foreground hover:bg-muted hover:text-foreground' - : 'text-transparent hover:!bg-muted hover:!text-foreground group-hover:text-muted-foreground' - }`} - onClick={(event) => { - event.stopPropagation() - onClose() - }} - aria-label="Close Project Notes" - > - <X className="size-3" /> - </button> - </div> - {isActive ? <div className={ACTIVE_TAB_INDICATOR_CLASSES} /> : null} - </div> - <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}> - <DropdownMenuTrigger asChild> - <span - className="fixed size-px" - style={{ left: menuPoint.x, top: menuPoint.y }} - aria-hidden - /> - </DropdownMenuTrigger> - <DropdownMenuContent align="start"> - <DropdownMenuItem onSelect={() => onSplitGroup('right', tab.id)}> - <Columns2 className="size-4" /> - Split Right - </DropdownMenuItem> - <DropdownMenuItem onSelect={() => onSplitGroup('down', tab.id)}> - <Rows2 className="size-4" /> - Split Down - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuItem onSelect={onCloseToRight}>Close Tabs to Right</DropdownMenuItem> - <DropdownMenuItem variant="destructive" onSelect={onClose}> - Close - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - </> - ) -} diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index e76a2c69ecc..1170506a349 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -26,7 +26,6 @@ vi.mock('react', async () => { memo: <T>(component: T) => component, useEffect: () => {}, useLayoutEffect: () => {}, - useCallback: <T>(callback: T) => callback, useMemo: <T>(factory: () => T) => factory(), useRef: <T>(current: T) => ({ current }), useState: <T>(initial: T) => [initial, vi.fn()] as const @@ -37,9 +36,6 @@ vi.mock('lucide-react', () => ({ FilePlus: function FilePlus() { return null }, - FileText: function FileText() { - return null - }, Globe: function Globe() { return null }, @@ -127,15 +123,6 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) { return { type: 'DropdownMenuShortcut', props } }, - DropdownMenuSub: function DropdownMenuSub(props: { children?: unknown }) { - return { type: 'DropdownMenuSub', props } - }, - DropdownMenuSubContent: function DropdownMenuSubContent(props: { children?: unknown }) { - return { type: 'DropdownMenuSubContent', props } - }, - DropdownMenuSubTrigger: function DropdownMenuSubTrigger(props: { children?: unknown }) { - return { type: 'DropdownMenuSubTrigger', props } - }, DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: unknown }) { return { type: 'DropdownMenuTrigger', props } } diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 91eef724ba0..7d969a810b1 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -5,7 +5,7 @@ * more clarity than the ~5 lines of bloat is worth. */ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { SortableContext } from '@dnd-kit/sortable' -import { FilePlus, Globe, Plus, TerminalSquare, FileText } from 'lucide-react' +import { FilePlus, Globe, Plus, TerminalSquare } from 'lucide-react' import type { BrowserTab as BrowserTabState, TerminalTab, @@ -17,7 +17,6 @@ import type { OpenFile } from '../../store/slices/editor' import SortableTab from './SortableTab' import EditorFileTab from './EditorFileTab' import BrowserTab, { getBrowserTabLabel } from './BrowserTab' -import { ProjectNotesTab, type ProjectNotesTabState } from './ProjectNotesTab' import { QuickLaunchAgentMenuItems } from './QuickLaunchButton' import type { DropIndicator } from './drop-indicator' import { reconcileTabOrder } from './reconcile-order' @@ -33,13 +32,8 @@ import { DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { listRuntimeProjectNotes } from '@/runtime/runtime-notes-client' -import type { NoteSummary } from '../../../../shared/notes-types' const isMac = navigator.userAgent.includes('Mac') const isWindows = navigator.userAgent.includes('Windows') @@ -64,8 +58,6 @@ type TabBarProps = { terminalOnly?: boolean showAgentLaunchItems?: boolean onNewFileTab?: () => void - onNewNotesTab?: (noteId?: string) => void - notesWorktreeId?: string | null /** Whether WSL is installed on this Windows machine. When true, the "+" * dropdown shows a WSL option under the terminal submenu. */ wslAvailable?: boolean @@ -74,17 +66,13 @@ type TabBarProps = { onTogglePaneExpand: (tabId: string) => void editorFiles?: (OpenFile & { tabId?: string })[] browserTabs?: (BrowserTabState & { tabId?: string })[] - notesTabs?: ProjectNotesTabState[] activeFileId?: string | null activeBrowserTabId?: string | null - activeNotesTabId?: string | null activeTabType?: WorkspaceVisibleTabType onActivateFile?: (fileId: string) => void onCloseFile?: (fileId: string) => void onActivateBrowserTab?: (tabId: string) => void onCloseBrowserTab?: (tabId: string) => void - onActivateNotesTab?: (tabId: string) => void - onCloseNotesTab?: (tabId: string) => void onDuplicateBrowserTab?: (tabId: string) => void onCloseAllFiles?: () => void onPinFile?: (fileId: string, tabId?: string) => void @@ -110,7 +98,6 @@ type TabItem = unifiedTabId: string data: BrowserTabState & { tabId?: string } } - | { type: 'notes'; id: string; unifiedTabId: string; data: ProjectNotesTabState } function getTabDragLabel(item: TabItem): string { if (item.type === 'terminal') { @@ -119,9 +106,6 @@ function getTabDragLabel(item: TabItem): string { if (item.type === 'browser') { return getBrowserTabLabel(item.data) } - if (item.type === 'notes') { - return item.data.label - } return getEditorDisplayLabel(item.data) } @@ -141,24 +125,18 @@ function TabBarInner({ terminalOnly = false, showAgentLaunchItems = true, onNewFileTab, - onNewNotesTab, - notesWorktreeId, onSetCustomTitle, onSetTabColor, onTogglePaneExpand, editorFiles, browserTabs, - notesTabs, activeFileId, activeBrowserTabId, - activeNotesTabId, activeTabType, onActivateFile, onCloseFile, onActivateBrowserTab, onCloseBrowserTab, - onActivateNotesTab, - onCloseNotesTab, onDuplicateBrowserTab, onCloseAllFiles, onPinFile, @@ -174,12 +152,7 @@ function TabBarInner({ const defaultWindowsPowerShellImplementation = useAppStore( (s) => s.settings?.terminalWindowsPowerShellImplementation ?? 'auto' ) - const settings = useAppStore((s) => s.settings) const [pwshAvailable, setPwshAvailable] = useState(false) - const [projectNotes, setProjectNotes] = useState<NoteSummary[]>([]) - const [projectNotesLoading, setProjectNotesLoading] = useState(false) - const [projectNotesError, setProjectNotesError] = useState<string | null>(null) - const [projectNotesMenuOpen, setProjectNotesMenuOpen] = useState(false) useEffect(() => { if (!isWindows) { setPwshAvailable(false) @@ -189,7 +162,6 @@ function TabBarInner({ void window.api.pwsh.isAvailable().then(setPwshAvailable) }, []) const resolvedGroupId = groupId ?? worktreeId - const targetNotesWorktreeId = notesWorktreeId ?? worktreeId const statusByRelativePath = useMemo( () => buildStatusMap(gitStatusByWorktree[worktreeId] ?? []), @@ -211,49 +183,6 @@ function TabBarInner({ return () => window.removeEventListener('blur', dismiss) }, [newTabMenuOpen]) - useEffect(() => { - if (!newTabMenuOpen) { - setProjectNotesMenuOpen(false) - return - } - const refreshProjectNotes = async (): Promise<void> => { - if (!onNewNotesTab) { - return - } - let context: { projectId: string; worktreeId: string } | null = null - if (targetNotesWorktreeId) { - const state = useAppStore.getState() - const worktree = Object.values(state.worktreesByRepo) - .flat() - .find((candidate) => candidate.id === targetNotesWorktreeId) - if (worktree) { - const repo = state.repos.find((candidate) => candidate.id === worktree.repoId) - context = { projectId: repo?.id ?? worktree.repoId, worktreeId: targetNotesWorktreeId } - } - } - if (!context) { - setProjectNotes([]) - setProjectNotesError(null) - return - } - setProjectNotesLoading(true) - setProjectNotesError(null) - try { - const result = await listRuntimeProjectNotes(settings, { - projectId: context.projectId, - worktreeId: context.worktreeId, - limit: 100 - }) - setProjectNotes(result.notes) - } catch (err) { - setProjectNotesError(err instanceof Error ? err.message : String(err)) - } finally { - setProjectNotesLoading(false) - } - } - void refreshProjectNotes() - }, [newTabMenuOpen, onNewNotesTab, settings, targetNotesWorktreeId]) - const terminalMap = useMemo(() => new Map(tabs.map((t) => [t.id, t])), [tabs]) const editorMap = useMemo( () => new Map((editorFiles ?? []).map((f) => [f.tabId ?? f.id, f])), @@ -263,22 +192,14 @@ function TabBarInner({ () => new Map((browserTabs ?? []).map((t) => [t.id, t])), [browserTabs] ) - const notesMap = useMemo(() => new Map((notesTabs ?? []).map((t) => [t.id, t])), [notesTabs]) const terminalIds = useMemo(() => tabs.map((t) => t.id), [tabs]) const editorFileIds = useMemo(() => editorFiles?.map((f) => f.tabId ?? f.id) ?? [], [editorFiles]) const browserTabIds = useMemo(() => browserTabs?.map((tab) => tab.id) ?? [], [browserTabs]) - const notesTabIds = useMemo(() => notesTabs?.map((tab) => tab.id) ?? [], [notesTabs]) // Build the unified ordered list, reconciling stored order with current items const orderedItems = useMemo(() => { - const ids = reconcileTabOrder( - tabBarOrder, - terminalIds, - editorFileIds, - browserTabIds, - notesTabIds - ) + const ids = reconcileTabOrder(tabBarOrder, terminalIds, editorFileIds, browserTabIds) const items: TabItem[] = [] for (const id of ids) { const terminal = terminalMap.get(id) @@ -306,23 +227,9 @@ function TabBarInner({ }) continue } - const notesTab = notesMap.get(id) - if (notesTab) { - items.push({ type: 'notes', id, unifiedTabId: notesTab.id, data: notesTab }) - } } return items - }, [ - tabBarOrder, - terminalIds, - editorFileIds, - browserTabIds, - notesTabIds, - terminalMap, - editorMap, - browserMap, - notesMap - ]) + }, [tabBarOrder, terminalIds, editorFileIds, browserTabIds, terminalMap, editorMap, browserMap]) const sortableIds = useMemo(() => orderedItems.map((item) => item.id), [orderedItems]) @@ -521,24 +428,6 @@ function TabBarInner({ /> ) } - if (item.type === 'notes') { - return ( - <ProjectNotesTab - key={item.id} - tab={item.data} - isActive={activeTabType === 'notes' && activeNotesTabId === item.id} - hasTabsToRight={index < orderedItems.length - 1} - onActivate={() => onActivateNotesTab?.(item.id)} - onClose={() => onCloseNotesTab?.(item.id)} - onCloseToRight={() => onCloseToRight(item.id)} - onSplitGroup={(direction, sourceVisibleTabId) => - onCreateSplitGroup?.(direction, sourceVisibleTabId) - } - dragData={dragData} - dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} - /> - ) - } return ( <EditorFileTab key={item.id} @@ -677,77 +566,6 @@ function TabBarInner({ <DropdownMenuShortcut>{NEW_FILE_SHORTCUT}</DropdownMenuShortcut> </DropdownMenuItem> )} - {onNewNotesTab && ( - <DropdownMenuSub open={projectNotesMenuOpen} onOpenChange={setProjectNotesMenuOpen}> - <DropdownMenuSubTrigger - className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" - onPointerEnter={() => setProjectNotesMenuOpen(true)} - onFocus={() => setProjectNotesMenuOpen(true)} - onClick={() => { - // Why: this row looks like the other creation commands in - // the + menu. Keep hover-to-pick-saved-note, but make a - // direct click create a fresh note instead of only opening - // the submenu. - onNewNotesTab() - setProjectNotesMenuOpen(false) - setNewTabMenuOpen(false) - }} - onKeyDown={(event) => { - if (event.key !== 'Enter' && event.key !== ' ') { - return - } - event.preventDefault() - onNewNotesTab() - setProjectNotesMenuOpen(false) - setNewTabMenuOpen(false) - }} - > - <FileText className="size-4 text-muted-foreground" /> - Project Notes - </DropdownMenuSubTrigger> - <DropdownMenuSubContent className="w-64"> - <DropdownMenuItem - onSelect={() => onNewNotesTab()} - className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" - > - <FileText className="size-3.5 text-muted-foreground" /> - New Project Note - </DropdownMenuItem> - <DropdownMenuSeparator /> - {projectNotesLoading ? ( - <DropdownMenuItem disabled className="text-muted-foreground"> - Loading notes... - </DropdownMenuItem> - ) : projectNotesError ? ( - <DropdownMenuItem disabled className="text-destructive"> - Failed to load notes - </DropdownMenuItem> - ) : projectNotes.length === 0 ? ( - <DropdownMenuItem disabled className="text-muted-foreground"> - No saved notes - </DropdownMenuItem> - ) : ( - <div className="max-h-64 overflow-y-auto pr-0.5"> - {projectNotes.map((note) => ( - <DropdownMenuItem - key={note.id} - onSelect={() => onNewNotesTab(note.id)} - className="items-start gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5" - > - <FileText className="mt-0.5 size-3.5 text-muted-foreground" /> - <span className="min-w-0 flex-1"> - <span className="block truncate font-medium">{note.title}</span> - <span className="block truncate text-[11px] leading-4 text-muted-foreground"> - {note.preview || note.relativePath} - </span> - </span> - </DropdownMenuItem> - ))} - </div> - )} - </DropdownMenuSubContent> - </DropdownMenuSub> - )} {showAgentLaunchItems ? ( <> <DropdownMenuSeparator /> diff --git a/src/renderer/src/components/tab-bar/TabDragPreview.tsx b/src/renderer/src/components/tab-bar/TabDragPreview.tsx index 314dad9859b..ad0e51591f1 100644 --- a/src/renderer/src/components/tab-bar/TabDragPreview.tsx +++ b/src/renderer/src/components/tab-bar/TabDragPreview.tsx @@ -1,4 +1,4 @@ -import { FileCode, Globe, Terminal as TerminalIcon, FileText } from 'lucide-react' +import { FileCode, Globe, Terminal as TerminalIcon } from 'lucide-react' import type { TabDragItemData } from '../tab-group/useTabDragSplit' // Why: rendered inside dnd-kit's DragOverlay (a document-level portal), so @@ -9,13 +9,7 @@ import type { TabDragItemData } from '../tab-group/useTabDragSplit' // the wrapper's top-left. export default function TabDragPreview({ drag }: { drag: TabDragItemData }): React.JSX.Element { const Icon = - drag.tabType === 'browser' - ? Globe - : drag.tabType === 'editor' - ? FileCode - : drag.tabType === 'notes' - ? FileText - : TerminalIcon + drag.tabType === 'browser' ? Globe : drag.tabType === 'editor' ? FileCode : TerminalIcon return ( <div className="pointer-events-none flex h-full w-full items-center gap-1.5 rounded-sm border border-border bg-accent px-2 text-xs text-foreground shadow-md"> <Icon className="h-3.5 w-3.5 shrink-0" /> diff --git a/src/renderer/src/components/tab-bar/group-tab-order.ts b/src/renderer/src/components/tab-bar/group-tab-order.ts index b0d4fa2f1e6..5ca2dfa81a8 100644 --- a/src/renderer/src/components/tab-bar/group-tab-order.ts +++ b/src/renderer/src/components/tab-bar/group-tab-order.ts @@ -3,7 +3,7 @@ import type { AppState } from '../../store/types' import { reconcileTabOrder } from './reconcile-order' export type VisibleTabRef = { - type: 'terminal' | 'editor' | 'browser' | 'notes' + type: 'terminal' | 'editor' | 'browser' id: string tabId?: string } @@ -12,7 +12,6 @@ export type ActiveTabNavOrderIds = { terminalIds?: string[] editorIds?: string[] browserIds?: string[] - notesIds?: string[] } /** @@ -63,7 +62,6 @@ export function getGroupVisibleTabOrder( const seenTerminals = new Set<string>() const seenBrowsers = new Set<string>() const seenEditors = new Set<string>() - const seenNotes = new Set<string>() for (const unifiedId of group.tabOrder) { const tab = tabsById.get(unifiedId) if (!tab) { @@ -81,12 +79,6 @@ export function getGroupVisibleTabOrder( } seenBrowsers.add(tab.entityId) result.push({ type: 'browser', id: tab.entityId, tabId: tab.id }) - } else if (tab.contentType === 'notes') { - if (seenNotes.has(tab.id)) { - continue - } - seenNotes.add(tab.id) - result.push({ type: 'notes', id: tab.id, tabId: tab.id }) } else { if (!editorEntityIds.has(tab.entityId) || seenEditors.has(tab.id)) { continue @@ -131,11 +123,6 @@ export function getActiveTabNavOrder( ids.editorIds ?? state.openFiles.filter((f) => f.worktreeId === worktreeId).map((f) => f.id) const browserIds = ids.browserIds ?? (state.browserTabsByWorktree?.[worktreeId] ?? []).map((t) => t.id) - const notesIds = - ids.notesIds ?? - (state.unifiedTabsByWorktree?.[worktreeId] ?? []) - .filter((tab) => tab.contentType === 'notes') - .map((tab) => tab.id) const activeGroupId = state.activeGroupIdByWorktree[worktreeId] const group = activeGroupId @@ -160,13 +147,11 @@ export function getActiveTabNavOrder( state.tabBarOrderByWorktree[worktreeId], terminalIds, editorIds, - browserIds, - notesIds + browserIds ) const terminalIdSet = new Set(terminalIds) const editorIdSet = new Set(editorIds) const browserIdSet = new Set(browserIds) - const notesIdSet = new Set(notesIds) const result: VisibleTabRef[] = [] for (const id of visibleIds) { if (terminalIdSet.has(id)) { @@ -175,8 +160,6 @@ export function getActiveTabNavOrder( result.push({ type: 'editor', id }) } else if (browserIdSet.has(id)) { result.push({ type: 'browser', id }) - } else if (notesIdSet.has(id)) { - result.push({ type: 'notes', id, tabId: id }) } } return result diff --git a/src/renderer/src/components/tab-bar/reconcile-order.ts b/src/renderer/src/components/tab-bar/reconcile-order.ts index af2b4629ae3..6fce831d986 100644 --- a/src/renderer/src/components/tab-bar/reconcile-order.ts +++ b/src/renderer/src/components/tab-bar/reconcile-order.ts @@ -7,10 +7,9 @@ export function reconcileTabOrder( storedOrder: string[] | undefined, terminalIds: string[], editorIds: string[], - browserIds: string[] = [], - notesIds: string[] = [] + browserIds: string[] = [] ): string[] { - const validIds = new Set([...terminalIds, ...editorIds, ...browserIds, ...notesIds]) + const validIds = new Set([...terminalIds, ...editorIds, ...browserIds]) // Why: storedOrder is persisted group tab order and is mutated by many // codepaths (drop/move/reorder/hydrate). A stale or racey write can leave // the same tab id twice in the list, which surfaces as React's "two @@ -25,7 +24,7 @@ export function reconcileTabOrder( inResult.add(id) } } - for (const id of [...terminalIds, ...editorIds, ...browserIds, ...notesIds]) { + for (const id of [...terminalIds, ...editorIds, ...browserIds]) { if (!inResult.has(id)) { result.push(id) inResult.add(id) diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index ec92e9748a2..bd86341ad33 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: this pane shell coordinates terminals, editor tabs, browser slots, and notes tabs so split-group routing stays in one place. */ import { lazy, Suspense, useEffect, useMemo, useState } from 'react' import { useDroppable } from '@dnd-kit/core' import { Columns2, Ellipsis, Rows2, X } from 'lucide-react' @@ -20,13 +19,8 @@ import { type TabDropZone } from './useTabDragSplit' import { tabGroupBodyAnchorName } from './tab-group-body-anchor' -import { - getProjectNoteIdFromEntityId, - isNewProjectNoteEntityId -} from '@/lib/open-project-notes-tab' const EditorPanel = lazy(() => import('../editor/EditorPanel')) -const ProjectNotesTabContent = lazy(() => import('../notes/ProjectNotesTabContent')) export default function TabGroupPanel({ groupId, @@ -62,8 +56,7 @@ export default function TabGroupPanel({ }, []) const model = useTabGroupWorkspaceModel({ groupId, worktreeId }) - const { activeTab, browserItems, commands, editorItems, notesItems, tabBarOrder, terminalTabs } = - model + const { activeTab, browserItems, commands, editorItems, tabBarOrder, terminalTabs } = model const { setNodeRef: setBodyDropRef } = useDroppable({ id: getTabPaneBodyDroppableId(groupId), data: { @@ -126,30 +119,23 @@ export default function TabGroupPanel({ wslAvailable={wslAvailable} onNewBrowserTab={commands.newBrowserTab} onNewFileTab={commands.newFileTab} - onNewNotesTab={commands.newNotesTab} onSetCustomTitle={commands.setTabCustomTitle} onSetTabColor={commands.setTabColor} onTogglePaneExpand={() => {}} editorFiles={editorItems} browserTabs={browserItems} - notesTabs={notesItems} activeFileId={ - activeTab?.contentType === 'terminal' || - activeTab?.contentType === 'browser' || - activeTab?.contentType === 'notes' + activeTab?.contentType === 'terminal' || activeTab?.contentType === 'browser' ? null : activeTab?.id } activeBrowserTabId={activeTab?.contentType === 'browser' ? activeTab.entityId : null} - activeNotesTabId={activeTab?.contentType === 'notes' ? activeTab.id : null} activeTabType={ activeTab?.contentType === 'terminal' ? 'terminal' : activeTab?.contentType === 'browser' ? 'browser' - : activeTab?.contentType === 'notes' - ? 'notes' - : 'editor' + : 'editor' } onActivateFile={commands.activateEditor} onCloseFile={commands.closeItem} @@ -162,15 +148,6 @@ export default function TabGroupPanel({ commands.closeItem(item.id) } }} - onActivateNotesTab={commands.activateNotes} - onCloseNotesTab={(notesTabId) => { - const item = model.groupTabs.find( - (candidate) => candidate.id === notesTabId && candidate.contentType === 'notes' - ) - if (item) { - commands.closeItem(item.id) - } - }} onDuplicateBrowserTab={commands.duplicateBrowserTab} onCloseAllFiles={commands.closeAllEditorTabsInGroup} onPinFile={(_fileId, tabId) => { @@ -361,36 +338,9 @@ export default function TabGroupPanel({ style={bodyAnchorStyle} > {activeDropZone ? <TabGroupDropOverlay zone={activeDropZone} /> : null} - {model.groupTabs - .filter((tab) => tab.contentType === 'notes') - .map((notesTab) => ( - <div - key={notesTab.id} - className={`absolute inset-0 min-h-0 min-w-0 ${ - activeTab?.id === notesTab.id ? 'flex' : 'hidden' - }`} - > - <Suspense - fallback={ - <div className="flex flex-1 items-center justify-center text-sm text-muted-foreground"> - Loading notes... - </div> - } - > - <ProjectNotesTabContent - worktreeId={worktreeId} - tabId={notesTab.id} - noteId={getProjectNoteIdFromEntityId(notesTab.entityId)} - forceNew={isNewProjectNoteEntityId(notesTab.entityId)} - /> - </Suspense> - </div> - ))} - {activeTab && activeTab.contentType !== 'terminal' && - activeTab.contentType !== 'browser' && - activeTab.contentType !== 'notes' && ( + activeTab.contentType !== 'browser' && ( <div className="absolute inset-0 flex min-h-0 min-w-0"> {/* Why: split groups render editor/browser content inside a plain relative pane body instead of the legacy flex column in diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.ts b/src/renderer/src/components/tab-group/useTabDragSplit.ts index 838e9deac94..22c7f5d4d99 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.ts @@ -34,7 +34,7 @@ export type TabDragItemData = { groupId: string unifiedTabId: string visibleTabId: string - tabType: 'terminal' | 'editor' | 'browser' | 'notes' + tabType: 'terminal' | 'editor' | 'browser' /** Rendered by the DragOverlay ghost that follows the cursor across * groups. Source tab strips use overflow-hidden, so without the overlay * the dragged tab would be invisible once the cursor leaves its own diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index 593653ef4a2..6059902b579 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -14,13 +14,9 @@ import { extractIpcErrorMessage } from '../../lib/ipc-error' import { destroyWorkspaceWebviews } from '../../store/slices/browser-webview-cleanup' import { requestEditorFileClose } from '../editor/editor-autosave' import { focusTerminalTabSurface } from '../../lib/focus-terminal-tab-surface' -import { getProjectNotesEntityId } from '../../lib/open-project-notes-tab' -import { requestProjectNotesTabClose } from '../../lib/project-notes-close-request' -import { linkRuntimeProjectNote, showRuntimeProjectNote } from '@/runtime/runtime-notes-client' export type GroupEditorItem = OpenFile & { tabId: string } export type GroupBrowserItem = BrowserTabState & { tabId: string } -export type GroupNotesItem = { id: string; label: string; entityId: string; isDirty: boolean } const EMPTY_GROUPS: readonly TabGroup[] = [] const EMPTY_UNIFIED_TABS: readonly Tab[] = [] @@ -144,19 +140,6 @@ export function useTabGroupWorkspaceModel({ [groupTabs, worktreeState.browserTabs] ) - const notesItems = useMemo<GroupNotesItem[]>( - () => - groupTabs - .filter((item) => item.contentType === 'notes') - .map((item) => ({ - id: item.id, - label: item.label, - entityId: item.entityId, - isDirty: item.isDirty === true - })), - [groupTabs] - ) - const closeEditorIfUnreferenced = useCallback( (entityId: string, closingTabId: string) => { const otherReference = (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).some( @@ -210,14 +193,6 @@ export function useTabGroupWorkspaceModel({ } else if (item.contentType === 'browser') { destroyWorkspaceWebviews(useAppStore.getState().browserPagesByWorkspace, item.entityId) closeBrowserTab(item.entityId) - } else if (item.contentType === 'notes') { - requestProjectNotesTabClose(item.id, () => { - closeUnifiedTab(item.id) - if (!opts?.skipEmptyCheck) { - leaveWorktreeIfEmpty() - } - }) - return } else { const canCloseTab = closeEditorIfUnreferenced(item.entityId, item.id) if (!canCloseTab) { @@ -251,10 +226,6 @@ export function useTabGroupWorkspaceModel({ } else if (item.contentType === 'browser') { destroyWorkspaceWebviews(useAppStore.getState().browserPagesByWorkspace, item.entityId) closeBrowserTab(item.entityId) - } else if (item.contentType === 'notes') { - requestProjectNotesTabClose(item.id, () => { - closeUnifiedTab(item.id) - }) } else { const canCloseTab = closeEditorIfUnreferenced(item.entityId, item.id) if (canCloseTab) { @@ -312,21 +283,6 @@ export function useTabGroupWorkspaceModel({ [activateTab, focusGroup, groupId, groupTabs, setActiveBrowserTab, setActiveTabType, worktreeId] ) - const activateNotes = useCallback( - (tabId: string) => { - const item = groupTabs.find( - (candidate) => candidate.id === tabId && candidate.contentType === 'notes' - ) - if (!item) { - return - } - focusGroup(worktreeId, groupId) - activateTab(item.id) - setActiveTabType('notes') - }, - [activateTab, focusGroup, groupId, groupTabs, setActiveTabType, worktreeId] - ) - const createSplitGroup = useCallback( (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId?: string) => { const sourceTab = @@ -465,7 +421,6 @@ export function useTabGroupWorkspaceModel({ activeTab, browserItems, editorItems, - notesItems, terminalTabs, tabBarOrder, groupTabs, @@ -476,7 +431,6 @@ export function useTabGroupWorkspaceModel({ }, activateBrowser, activateEditor, - activateNotes, activateTerminal, closeAllEditorTabsInGroup, closeGroup, @@ -527,40 +481,6 @@ export function useTabGroupWorkspaceModel({ toast.error(extractIpcErrorMessage(err, 'Failed to create untitled markdown file.')) } }, - newNotesTab: async (noteId?: string) => { - const projectId = worktree?.repoId ?? worktreeId - let label = 'Project Notes' - if (noteId) { - try { - const settings = useAppStore.getState().settings - const result = await showRuntimeProjectNote(settings, { - projectId, - worktreeId, - note: noteId - }) - label = result.note.title - await linkRuntimeProjectNote(settings, { - projectId, - worktreeId, - note: noteId, - kind: 'active' - }) - } catch { - label = 'Project Notes' - } - } - const tab = useAppStore.getState().createUnifiedTab(worktreeId, 'notes', { - targetGroupId: groupId, - label, - // Why: each Project Notes tab is an editor surface, not the note - // identity itself. Give it a unique entity id so users can keep - // more than one notes tab open in the same pane. - entityId: getProjectNotesEntityId(projectId, noteId) - }) - focusGroup(worktreeId, groupId) - activateTab(tab.id) - setActiveTabType('notes') - }, newTerminalTab: () => { const terminal = createTab(worktreeId, groupId) setActiveTab(terminal.id) diff --git a/src/renderer/src/components/terminal/tab-type-cycle.ts b/src/renderer/src/components/terminal/tab-type-cycle.ts index 1fcd48d62f7..af86198f317 100644 --- a/src/renderer/src/components/terminal/tab-type-cycle.ts +++ b/src/renderer/src/components/terminal/tab-type-cycle.ts @@ -1,4 +1,4 @@ -export type TabCycleType = 'terminal' | 'editor' | 'browser' | 'notes' +export type TabCycleType = 'terminal' | 'editor' | 'browser' export type TypeCyclableTab = { type: TabCycleType diff --git a/src/renderer/src/components/ui/dropdown-menu.tsx b/src/renderer/src/components/ui/dropdown-menu.tsx index cd3dad0ba98..0861a7d57f7 100644 --- a/src/renderer/src/components/ui/dropdown-menu.tsx +++ b/src/renderer/src/components/ui/dropdown-menu.tsx @@ -32,7 +32,7 @@ function DropdownMenuContent({ data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn( - 'relative z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-[11px] border border-black/14 bg-[rgba(255,255,255,0.82)] p-1 text-black dark:text-white shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:bg-[rgba(0,0,0,0.72)] dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', + 'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-[11px] border border-black/14 bg-[rgba(255,255,255,0.82)] p-1 text-black dark:text-white shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:bg-[rgba(0,0,0,0.72)] dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', className )} // Why: Electron's -webkit-app-region: drag on the titlebar captures @@ -209,11 +209,11 @@ function DropdownMenuSubContent({ <DropdownMenuPrimitive.SubContent data-slot="dropdown-menu-sub-content" className={cn( - 'relative z-50 min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-[11px] border border-black/14 bg-[rgba(255,255,255,0.82)] p-1 text-black dark:text-white shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:bg-[rgba(0,0,0,0.72)] dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', + 'z-50 min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-[11px] border border-black/14 bg-[rgba(255,255,255,0.82)] p-1 text-black dark:text-white shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:bg-[rgba(0,0,0,0.72)] dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', className )} - // Why: submenus must escape the parent menu's scroll clipping; the - // portal also needs no-drag for titlebar-overlapping menus in Electron. + // Why: same no-drag fix as DropdownMenuContent — titlebar drag region + // would otherwise capture clicks when submenu overlaps it. style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties} {...props} /> diff --git a/src/renderer/src/hooks/ipc-tab-switch.ts b/src/renderer/src/hooks/ipc-tab-switch.ts index 88fac6dd73e..632d65e532d 100644 --- a/src/renderer/src/hooks/ipc-tab-switch.ts +++ b/src/renderer/src/hooks/ipc-tab-switch.ts @@ -67,9 +67,6 @@ function applyNextTab(store: AppStoreState, next: TypeCyclableTab): void { store.activateTab?.(next.tabId) } store.setActiveTabType('browser') - } else if (next.type === 'notes') { - store.activateTab?.(next.tabId ?? next.id) - store.setActiveTabType('notes') } else { // Why: `setActiveFile` targets the file entity (its implicit activateTab // picks the first matching tab in the active group); `activateTab(tabId)` diff --git a/src/renderer/src/hooks/resolve-zoom-target.ts b/src/renderer/src/hooks/resolve-zoom-target.ts index f03b3786cd0..bc70e777afa 100644 --- a/src/renderer/src/hooks/resolve-zoom-target.ts +++ b/src/renderer/src/hooks/resolve-zoom-target.ts @@ -4,7 +4,7 @@ */ export function resolveZoomTarget(args: { activeView: 'terminal' | 'settings' | 'tasks' | 'activity' | 'automations' | 'space' - activeTabType: 'terminal' | 'editor' | 'browser' | 'notes' + activeTabType: 'terminal' | 'editor' | 'browser' activeElement: unknown }): 'terminal' | 'editor' | 'ui' { const { activeView, activeTabType, activeElement } = args @@ -35,7 +35,7 @@ export function resolveZoomTarget(args: { if (activeView !== 'terminal') { return 'ui' } - if (activeTabType === 'editor' || activeTabType === 'notes' || editorFocused) { + if (activeTabType === 'editor' || editorFocused) { return 'editor' } // Why: terminal tabs should keep using per-pane terminal font zoom even when diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 238c2b8cb3a..863a69abb25 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -34,7 +34,6 @@ import { destroyPersistentWebview } from '@/components/browser-pane/webview-regi import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' import { detectLanguage } from '@/lib/language-detect' import { track } from '@/lib/telemetry' -import { requestProjectNotesTabClose } from '@/lib/project-notes-close-request' export { resolveZoomTarget } from './resolve-zoom-target' @@ -864,15 +863,6 @@ export function useIpcEvents(): void { const store = useAppStore.getState() if (store.activeTabType === 'browser' && store.activeBrowserTabId) { store.closeBrowserTab(store.activeBrowserTabId) - return - } - if (store.activeTabType === 'notes' && store.activeWorktreeId) { - const activeTab = store.getActiveTab(store.activeWorktreeId) - if (activeTab?.contentType === 'notes') { - requestProjectNotesTabClose(activeTab.id, () => { - store.closeUnifiedTab(activeTab.id) - }) - } } }) ) diff --git a/src/renderer/src/lib/notes-events.ts b/src/renderer/src/lib/notes-events.ts deleted file mode 100644 index ede0a9b9647..00000000000 --- a/src/renderer/src/lib/notes-events.ts +++ /dev/null @@ -1 +0,0 @@ -export const NOTES_ACTIVE_CHANGED_EVENT = 'orca:notes-active-changed' diff --git a/src/renderer/src/lib/open-project-notes-tab.ts b/src/renderer/src/lib/open-project-notes-tab.ts deleted file mode 100644 index 24c9fa6ca92..00000000000 --- a/src/renderer/src/lib/open-project-notes-tab.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { useAppStore } from '@/store' -import { NOTES_ACTIVE_CHANGED_EVENT } from '@/lib/notes-events' -import { linkRuntimeProjectNote, showRuntimeProjectNote } from '@/runtime/runtime-notes-client' -import { createBrowserUuid } from '@/lib/browser-uuid' - -export function getProjectNotesEntityId(projectId: string, noteId?: string): string { - if (noteId) { - return `notes:${projectId}:note:${noteId}` - } - return `notes:${projectId}:new:${createBrowserUuid()}` -} - -export function getProjectNoteIdFromEntityId(entityId: string): string | null { - const [, , kind, noteId] = entityId.split(':') - return kind === 'note' && noteId ? noteId : null -} - -export function isNewProjectNoteEntityId(entityId: string): boolean { - const [, , kind] = entityId.split(':') - return kind === 'new' -} - -export async function openProjectNotesTab(worktreeId: string, noteId?: string): Promise<void> { - const state = useAppStore.getState() - const targetGroupId = - state.activeGroupIdByWorktree[worktreeId] ?? (state.groupsByWorktree[worktreeId] ?? [])[0]?.id - const worktree = Object.values(state.worktreesByRepo) - .flat() - .find((candidate) => candidate.id === worktreeId) - const repo = state.repos.find((candidate) => candidate.id === worktree?.repoId) - const projectId = repo?.id ?? worktree?.repoId ?? null - const settings = state.settings - - if (noteId && projectId) { - await linkRuntimeProjectNote(settings, { projectId, worktreeId, note: noteId, kind: 'active' }) - } - - let label = 'Project Notes' - if (noteId && projectId) { - try { - const result = await showRuntimeProjectNote(settings, { projectId, worktreeId, note: noteId }) - label = result.note.title - } catch { - label = 'Project Notes' - } - } - - state.setActiveView('terminal') - - const tab = state.createUnifiedTab(worktreeId, 'notes', { - targetGroupId, - label, - entityId: getProjectNotesEntityId(projectId ?? worktree?.repoId ?? worktreeId, noteId) - }) - state.focusGroup(worktreeId, tab.groupId) - state.activateTab(tab.id) - state.setActiveTabType('notes') - if (noteId) { - notifyProjectNotesSelectionChanged() - } -} - -export function notifyProjectNotesSelectionChanged(): void { - window.dispatchEvent(new CustomEvent(NOTES_ACTIVE_CHANGED_EVENT)) -} diff --git a/src/renderer/src/lib/project-notes-close-request.test.ts b/src/renderer/src/lib/project-notes-close-request.test.ts deleted file mode 100644 index afdafb769fe..00000000000 --- a/src/renderer/src/lib/project-notes-close-request.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - ORCA_PROJECT_NOTES_REQUEST_CLOSE_EVENT, - requestProjectNotesTabClose, - type ProjectNotesCloseRequestDetail -} from './project-notes-close-request' - -type WindowEventStub = Pick<Window, 'addEventListener' | 'removeEventListener' | 'dispatchEvent'> - -beforeEach(() => { - const eventTarget = new EventTarget() - vi.stubGlobal('window', { - addEventListener: eventTarget.addEventListener.bind(eventTarget), - removeEventListener: eventTarget.removeEventListener.bind(eventTarget), - dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget) - } satisfies WindowEventStub) -}) - -afterEach(() => { - vi.unstubAllGlobals() -}) - -describe('requestProjectNotesTabClose', () => { - it('dispatches a close request and lets a mounted notes tab claim it', () => { - const close = vi.fn() - const listener = vi.fn((event: Event) => { - const detail = (event as CustomEvent<ProjectNotesCloseRequestDetail>).detail - detail.claim() - expect(detail.tabId).toBe('tab-1') - expect(detail.close).toBe(close) - }) - window.addEventListener(ORCA_PROJECT_NOTES_REQUEST_CLOSE_EVENT, listener) - try { - requestProjectNotesTabClose('tab-1', close) - expect(listener).toHaveBeenCalledTimes(1) - expect(close).not.toHaveBeenCalled() - } finally { - window.removeEventListener(ORCA_PROJECT_NOTES_REQUEST_CLOSE_EVENT, listener) - } - }) - - it('closes immediately when no notes tab claims the request', () => { - const close = vi.fn() - requestProjectNotesTabClose('tab-1', close) - expect(close).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/renderer/src/lib/project-notes-close-request.ts b/src/renderer/src/lib/project-notes-close-request.ts deleted file mode 100644 index 3984d665d65..00000000000 --- a/src/renderer/src/lib/project-notes-close-request.ts +++ /dev/null @@ -1,28 +0,0 @@ -export const ORCA_PROJECT_NOTES_REQUEST_CLOSE_EVENT = 'orca:project-notes-request-close' - -export type ProjectNotesCloseRequestDetail = { - tabId: string - close: () => void - claim: () => void -} - -export function requestProjectNotesTabClose(tabId: string, close: () => void): void { - let claimed = false - window.dispatchEvent( - new CustomEvent<ProjectNotesCloseRequestDetail>(ORCA_PROJECT_NOTES_REQUEST_CLOSE_EVENT, { - detail: { - tabId, - close, - claim: () => { - claimed = true - } - } - }) - ) - // Why: Project Notes tabs are normally mounted so dirty state can prompt on - // close, but close requests should still complete if a tab shell exists - // before its lazy content has attached a listener. - if (!claimed) { - close() - } -} diff --git a/src/renderer/src/lib/worktree-status.test.ts b/src/renderer/src/lib/worktree-status.test.ts index 8b16fa295ea..681bc1e74cd 100644 --- a/src/renderer/src/lib/worktree-status.test.ts +++ b/src/renderer/src/lib/worktree-status.test.ts @@ -188,28 +188,4 @@ describe('resolveWorktreeStatus', () => { expect(status).toBe('active') }) - - it('treats a notes-only worktree as active without promoting unrelated worktrees', () => { - const notesWorktreeStatus = resolveWorktreeStatus({ - tabs: [], - browserTabs: [], - ptyIdsByTabId: {}, - hasNotesSurface: true, - hasPermission: false, - hasLiveDone: false, - hasRetainedDone: false - }) - const unrelatedWorktreeStatus = resolveWorktreeStatus({ - tabs: [], - browserTabs: [], - ptyIdsByTabId: {}, - hasNotesSurface: false, - hasPermission: false, - hasLiveDone: false, - hasRetainedDone: false - }) - - expect(notesWorktreeStatus).toBe('active') - expect(unrelatedWorktreeStatus).toBe('inactive') - }) }) diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index afec4bd728f..1f2380d6807 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -98,7 +98,6 @@ export function resolveWorktreeStatus(args: { browserTabs: { id: string }[] ptyIdsByTabId: Record<string, string[]> runtimePaneTitlesByTabId?: Record<string, Record<number, string>> - hasNotesSurface?: boolean hasPermission: boolean hasLiveDone: boolean hasRetainedDone: boolean @@ -126,8 +125,5 @@ export function resolveWorktreeStatus(args: { if (args.hasLiveDone || args.hasRetainedDone) { return 'done' } - if (heuristic === 'inactive' && args.hasNotesSurface) { - return 'active' - } return heuristic } diff --git a/src/renderer/src/runtime/runtime-notes-client.test.ts b/src/renderer/src/runtime/runtime-notes-client.test.ts deleted file mode 100644 index ee15a592106..00000000000 --- a/src/renderer/src/runtime/runtime-notes-client.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - createRuntimeProjectNote, - deleteRuntimeProjectNote, - linkRuntimeProjectNote, - listRuntimeProjectNotes, - renameRuntimeProjectNote, - resolveRuntimeNotesPanelState, - saveRuntimeProjectNote, - showRuntimeProjectNote -} from './runtime-notes-client' -import { - createCompatibleRuntimeStatusResponseIfNeeded, - type RuntimeEnvironmentCallRequest -} from './runtime-compatibility-test-fixture' -import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' - -const notesList = vi.fn() -const notesShow = vi.fn() -const notesCreate = vi.fn() -const notesSave = vi.fn() -const notesRename = vi.fn() -const notesDelete = vi.fn() -const notesAppend = vi.fn() -const notesSearch = vi.fn() -const notesLink = vi.fn() -const notesPanelState = vi.fn() -const runtimeCall = vi.fn() -const runtimeEnvironmentCall = vi.fn() -const runtimeEnvironmentTransportCall = vi.fn() - -beforeEach(() => { - clearRuntimeCompatibilityCacheForTests() - notesList.mockReset() - notesShow.mockReset() - notesCreate.mockReset() - notesSave.mockReset() - notesRename.mockReset() - notesDelete.mockReset() - notesAppend.mockReset() - notesSearch.mockReset() - notesLink.mockReset() - notesPanelState.mockReset() - runtimeCall.mockReset() - runtimeEnvironmentCall.mockReset() - runtimeEnvironmentTransportCall.mockReset() - runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { - return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) - }) - vi.stubGlobal('window', { - api: { - notes: { - list: notesList, - show: notesShow, - create: notesCreate, - save: notesSave, - rename: notesRename, - delete: notesDelete, - append: notesAppend, - search: notesSearch, - link: notesLink, - panelState: notesPanelState - }, - runtime: { call: runtimeCall }, - runtimeEnvironments: { call: runtimeEnvironmentTransportCall } - } - }) -}) - -describe('runtime notes client', () => { - it('uses local notes IPC when no remote runtime is active', async () => { - notesList.mockResolvedValue({ notes: [], totalCount: 0, truncated: false }) - - await listRuntimeProjectNotes( - { activeRuntimeEnvironmentId: null }, - { projectId: 'repo-1', worktreeId: 'wt-1', limit: 100 } - ) - - expect(notesList).toHaveBeenCalledWith({ - projectId: 'repo-1', - worktreeId: 'wt-1', - limit: 100 - }) - expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - }) - - it('routes note reads through the active runtime environment', async () => { - runtimeEnvironmentCall.mockResolvedValue({ - id: 'rpc-1', - ok: true, - result: { notes: [], totalCount: 0, truncated: false }, - _meta: { runtimeId: 'remote-runtime' } - }) - const settings = { activeRuntimeEnvironmentId: 'env-1' } - - await listRuntimeProjectNotes(settings, { - projectId: 'repo-1', - worktreeId: 'wt-1', - limit: 100 - }) - runtimeEnvironmentCall.mockResolvedValueOnce({ - id: 'rpc-2', - ok: true, - result: { note: { id: 'note-1' }, linkKind: null }, - _meta: { runtimeId: 'remote-runtime' } - }) - await showRuntimeProjectNote(settings, { - projectId: 'repo-1', - worktreeId: 'wt-1', - note: 'note-1' - }) - - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { - selector: 'env-1', - method: 'note.list', - params: { worktree: 'wt-1', limit: 100 }, - timeoutMs: 15_000 - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { - selector: 'env-1', - method: 'note.show', - params: { worktree: 'wt-1', note: 'note-1' }, - timeoutMs: 15_000 - }) - }) - - it('routes note mutations through the active runtime environment', async () => { - runtimeEnvironmentCall.mockResolvedValue({ - id: 'rpc-1', - ok: true, - result: { note: { id: 'note-1' }, linkKind: 'active' }, - _meta: { runtimeId: 'remote-runtime' } - }) - const settings = { activeRuntimeEnvironmentId: 'env-1' } - const base = { projectId: 'repo-1', worktreeId: 'wt-1' } - - await createRuntimeProjectNote(settings, { ...base, title: 'Plan', bodyMarkdown: 'body' }) - await saveRuntimeProjectNote(settings, { - ...base, - note: 'note-1', - title: 'Plan', - bodyMarkdown: 'updated', - revision: 3, - makeActive: true - }) - await renameRuntimeProjectNote(settings, { ...base, note: 'note-1', title: 'Renamed' }) - await deleteRuntimeProjectNote(settings, { ...base, note: 'note-1' }) - await linkRuntimeProjectNote(settings, { ...base, note: 'note-1', kind: 'active' }) - - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { - selector: 'env-1', - method: 'note.create', - params: { - worktree: 'wt-1', - title: 'Plan', - bodyMarkdown: 'body', - makeActive: undefined, - createdBySessionId: undefined - }, - timeoutMs: 15_000 - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { - selector: 'env-1', - method: 'note.save', - params: { - worktree: 'wt-1', - note: 'note-1', - title: 'Plan', - bodyMarkdown: 'updated', - revision: 3, - makeActive: true, - updatedBySessionId: undefined - }, - timeoutMs: 15_000 - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, { - selector: 'env-1', - method: 'note.rename', - params: { - worktree: 'wt-1', - note: 'note-1', - title: 'Renamed', - updatedBySessionId: undefined - }, - timeoutMs: 15_000 - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, { - selector: 'env-1', - method: 'note.delete', - params: { worktree: 'wt-1', note: 'note-1' }, - timeoutMs: 15_000 - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, { - selector: 'env-1', - method: 'note.link', - params: { worktree: 'wt-1', note: 'note-1', kind: 'active' }, - timeoutMs: 15_000 - }) - }) - - it('routes panel state through the active runtime environment', async () => { - runtimeEnvironmentCall.mockResolvedValue({ - id: 'rpc-1', - ok: true, - result: { state: 'emptyDraft', projectId: 'repo-1', worktreeId: 'wt-1' }, - _meta: { runtimeId: 'remote-runtime' } - }) - - await resolveRuntimeNotesPanelState( - { activeRuntimeEnvironmentId: 'env-1' }, - { projectId: 'repo-1', worktreeId: 'wt-1' } - ) - - expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ - selector: 'env-1', - method: 'note.panelState', - params: { worktree: 'wt-1' }, - timeoutMs: 15_000 - }) - }) - - it('returns noProject for remote panel state without a project/worktree', async () => { - await expect( - resolveRuntimeNotesPanelState({ activeRuntimeEnvironmentId: 'env-1' }, {}) - ).resolves.toEqual({ state: 'noProject' }) - expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/runtime/runtime-notes-client.ts b/src/renderer/src/runtime/runtime-notes-client.ts deleted file mode 100644 index 669d9d8681c..00000000000 --- a/src/renderer/src/runtime/runtime-notes-client.ts +++ /dev/null @@ -1,229 +0,0 @@ -import type { GlobalSettings } from '../../../shared/types' -import type { - NoteAppendArgs, - NoteCreateArgs, - NoteDeleteArgs, - NoteDeleteResult, - NoteLink, - NoteLinkArgs, - NoteListArgs, - NoteListResult, - NoteMutationResult, - NoteRenameArgs, - NoteSaveArgs, - NoteSearchArgs, - NoteShowArgs, - NoteShowResult, - NotesPanelOpenState, - NotesPanelStateArgs -} from '../../../shared/notes-types' -import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' - -type RuntimeNotesSettings = Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined - -function requireWorktreeId(worktreeId: string | null | undefined): string { - if (!worktreeId?.trim()) { - throw new Error('Project notes require an active worktree on remote runtime servers.') - } - return worktreeId -} - -function noteTarget(settings: RuntimeNotesSettings): ReturnType<typeof getActiveRuntimeTarget> { - return getActiveRuntimeTarget(settings) -} - -export async function listRuntimeProjectNotes( - settings: RuntimeNotesSettings, - args: NoteListArgs -): Promise<NoteListResult> { - const target = noteTarget(settings) - if (target.kind === 'local') { - return window.api.notes.list(args) - } - return callRuntimeRpc<NoteListResult>( - target, - 'note.list', - { worktree: requireWorktreeId(args.worktreeId), limit: args.limit }, - { timeoutMs: 15_000 } - ) -} - -export async function showRuntimeProjectNote( - settings: RuntimeNotesSettings, - args: NoteShowArgs -): Promise<NoteShowResult> { - const target = noteTarget(settings) - if (target.kind === 'local') { - return window.api.notes.show(args) - } - return callRuntimeRpc<NoteShowResult>( - target, - 'note.show', - { worktree: requireWorktreeId(args.worktreeId), note: args.note }, - { timeoutMs: 15_000 } - ) -} - -export async function createRuntimeProjectNote( - settings: RuntimeNotesSettings, - args: NoteCreateArgs -): Promise<NoteMutationResult> { - const target = noteTarget(settings) - if (target.kind === 'local') { - return window.api.notes.create(args) - } - return callRuntimeRpc<NoteMutationResult>( - target, - 'note.create', - { - worktree: requireWorktreeId(args.worktreeId), - title: args.title, - bodyMarkdown: args.bodyMarkdown, - makeActive: args.makeActive, - createdBySessionId: args.createdBySessionId - }, - { timeoutMs: 15_000 } - ) -} - -export async function saveRuntimeProjectNote( - settings: RuntimeNotesSettings, - args: NoteSaveArgs -): Promise<NoteMutationResult> { - const target = noteTarget(settings) - if (target.kind === 'local') { - return window.api.notes.save(args) - } - return callRuntimeRpc<NoteMutationResult>( - target, - 'note.save', - { - worktree: requireWorktreeId(args.worktreeId), - note: args.note, - title: args.title, - bodyMarkdown: args.bodyMarkdown, - revision: args.revision, - makeActive: args.makeActive, - updatedBySessionId: args.updatedBySessionId - }, - { timeoutMs: 15_000 } - ) -} - -export async function renameRuntimeProjectNote( - settings: RuntimeNotesSettings, - args: NoteRenameArgs -): Promise<NoteMutationResult> { - const target = noteTarget(settings) - if (target.kind === 'local') { - return window.api.notes.rename(args) - } - return callRuntimeRpc<NoteMutationResult>( - target, - 'note.rename', - { - worktree: requireWorktreeId(args.worktreeId), - note: args.note, - title: args.title, - updatedBySessionId: args.updatedBySessionId - }, - { timeoutMs: 15_000 } - ) -} - -export async function deleteRuntimeProjectNote( - settings: RuntimeNotesSettings, - args: NoteDeleteArgs -): Promise<NoteDeleteResult> { - const target = noteTarget(settings) - if (target.kind === 'local') { - return window.api.notes.delete(args) - } - return callRuntimeRpc<NoteDeleteResult>( - target, - 'note.delete', - { worktree: requireWorktreeId(args.worktreeId), note: args.note }, - { timeoutMs: 15_000 } - ) -} - -export async function appendRuntimeProjectNote( - settings: RuntimeNotesSettings, - args: NoteAppendArgs -): Promise<NoteMutationResult> { - const target = noteTarget(settings) - if (target.kind === 'local') { - return window.api.notes.append(args) - } - return callRuntimeRpc<NoteMutationResult>( - target, - 'note.append', - { - worktree: requireWorktreeId(args.worktreeId), - note: args.note, - bodyMarkdown: args.bodyMarkdown, - makeActive: args.makeActive, - updatedBySessionId: args.updatedBySessionId - }, - { timeoutMs: 15_000 } - ) -} - -export async function searchRuntimeProjectNotes( - settings: RuntimeNotesSettings, - args: NoteSearchArgs -): Promise<NoteListResult> { - const target = noteTarget(settings) - if (target.kind === 'local') { - return window.api.notes.search(args) - } - return callRuntimeRpc<NoteListResult>( - target, - 'note.search', - { - worktree: requireWorktreeId(args.worktreeId), - query: args.query, - limit: args.limit - }, - { timeoutMs: 15_000 } - ) -} - -export async function linkRuntimeProjectNote( - settings: RuntimeNotesSettings, - args: NoteLinkArgs -): Promise<NoteLink> { - const target = noteTarget(settings) - if (target.kind === 'local') { - return window.api.notes.link(args) - } - return callRuntimeRpc<NoteLink>( - target, - 'note.link', - { - worktree: requireWorktreeId(args.worktreeId), - note: args.note, - kind: args.kind - }, - { timeoutMs: 15_000 } - ) -} - -export async function resolveRuntimeNotesPanelState( - settings: RuntimeNotesSettings, - args: NotesPanelStateArgs -): Promise<NotesPanelOpenState> { - const target = noteTarget(settings) - if (target.kind === 'local') { - return window.api.notes.panelState(args) - } - if (!args.projectId && !args.worktreeId) { - return { state: 'noProject' } - } - return callRuntimeRpc<NotesPanelOpenState>( - target, - 'note.panelState', - { worktree: requireWorktreeId(args.worktreeId) }, - { timeoutMs: 15_000 } - ) -} diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 4faba8b6fbc..74fe667f474 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -140,13 +140,7 @@ export type OpenFile = { mode: 'edit' | 'diff' | 'conflict-review' | 'markdown-preview' } -export type RightSidebarTab = - | 'explorer' - | 'search' - | 'source-control' - | 'checks' - | 'ports' - | 'notes' +export type RightSidebarTab = 'explorer' | 'search' | 'source-control' | 'checks' | 'ports' export type ActivityBarPosition = 'top' | 'side' export type MarkdownViewMode = 'source' | 'rich' | 'preview' diff --git a/src/renderer/src/store/slices/tabs-hydration.ts b/src/renderer/src/store/slices/tabs-hydration.ts index e01b50a3282..bca09f034ab 100644 --- a/src/renderer/src/store/slices/tabs-hydration.ts +++ b/src/renderer/src/store/slices/tabs-hydration.ts @@ -62,8 +62,7 @@ function hydrateUnifiedFormat( tabsByWorktree[worktreeId] = [...tabs] .map((tab) => ({ ...tab, - entityId: tab.entityId ?? tab.id, - isDirty: false + entityId: tab.entityId ?? tab.id })) .filter((tab) => { if (!isTransientEditorContentType(tab.contentType)) { diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index 3394284cf3e..32e245f1d2e 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -968,14 +968,6 @@ describe('TabsSlice', () => { store.getState().setUnifiedTabColor(tab.id, '#ff0000') expect(store.getState().unifiedTabsByWorktree[WT][0].color).toBe('#ff0000') }) - - it('setTabDirty updates dirty state', () => { - const tab = store.getState().createUnifiedTab(WT, 'notes') - store.getState().setTabDirty(tab.id, true) - expect(store.getState().unifiedTabsByWorktree[WT][0].isDirty).toBe(true) - store.getState().setTabDirty(tab.id, false) - expect(store.getState().unifiedTabsByWorktree[WT][0].isDirty).toBe(false) - }) }) // ─── pinTab / unpinTab ──────────────────────────────────────────── @@ -1511,47 +1503,5 @@ describe('TabsSlice', () => { groupId: restoredGroup?.id }) }) - - it('keeps project notes tabs even though they are not openFiles-backed editors', () => { - const groupId = 'g-1' - store.setState({ - unifiedTabsByWorktree: { - [WT]: [ - { - id: 'notes-tab-1', - entityId: 'notes:repo-1:note-1', - groupId, - worktreeId: WT, - contentType: 'notes', - label: 'Project Notes', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - groupsByWorktree: { - [WT]: [ - { - id: groupId, - worktreeId: WT, - activeTabId: 'notes-tab-1', - tabOrder: ['notes-tab-1'] - } - ] - }, - activeGroupIdByWorktree: { [WT]: groupId }, - tabsByWorktree: { [WT]: [] }, - openFiles: [] - }) - - const result = store.getState().reconcileWorktreeTabModel(WT) - - expect(result.renderableTabCount).toBe(1) - expect(result.activeRenderableTabId).toBe('notes-tab-1') - expect(store.getState().unifiedTabsByWorktree[WT]).toHaveLength(1) - expect(store.getState().groupsByWorktree[WT][0].activeTabId).toBe('notes-tab-1') - }) }) }) diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 42859580399..ca2a70eb32e 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -61,9 +61,7 @@ export type TabsSlice = { tabId: string ) => { closedTabId: string; wasLastTab: boolean; worktreeId: string } | null reorderUnifiedTabs: (groupId: string, tabIds: string[]) => void - setTabEntityId: (tabId: string, entityId: string) => void setTabLabel: (tabId: string, label: string) => void - setTabDirty: (tabId: string, isDirty: boolean) => void setTabCustomLabel: (tabId: string, label: string | null) => void setUnifiedTabColor: (tabId: string, color: string | null) => void pinTab: (tabId: string) => void @@ -230,13 +228,7 @@ function collapseGroupLayout( } function toVisibleTabType(contentType: TabContentType): WorkspaceVisibleTabType { - return contentType === 'browser' - ? 'browser' - : contentType === 'terminal' - ? 'terminal' - : contentType === 'notes' - ? 'notes' - : 'editor' + return contentType === 'browser' ? 'browser' : contentType === 'terminal' ? 'terminal' : 'editor' } function deriveActiveSurfaceForWorktree( @@ -439,8 +431,7 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set, sortOrder: nextOrder.length, createdAt: Date.now(), isPreview: init?.isPreview, - isPinned: init?.isPinned, - isDirty: false + isPinned: init?.isPinned } nextOrder = dedupeTabOrder([...nextOrder, created.id]) @@ -738,12 +729,6 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set, setTabLabel: (tabId, label) => set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { label }) ?? {}), - setTabEntityId: (tabId, entityId) => - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { entityId }) ?? {}), - - setTabDirty: (tabId, isDirty) => - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { isDirty }) ?? {}), - setTabCustomLabel: (tabId, label) => set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? {}), @@ -1375,12 +1360,6 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set, if (tab.contentType === 'browser') { return liveBrowserIds.has(tab.entityId) } - if (tab.contentType === 'notes') { - // Why: project notes are backed by the project notes store, not by - // openFiles. Treating them as editor-backed files makes reconcile - // prune valid notes tabs and can leave the workspace looking empty. - return true - } return liveEditorIds.has(tab.entityId) } diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 2493e78f45e..201f8570099 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -61,13 +61,7 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b } function toVisibleTabType(contentType: string): WorkspaceVisibleTabType { - return contentType === 'browser' - ? 'browser' - : contentType === 'terminal' - ? 'terminal' - : contentType === 'notes' - ? 'notes' - : 'editor' + return contentType === 'browser' ? 'browser' : contentType === 'terminal' ? 'terminal' : 'editor' } async function listWorktreesForRepo( diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 3d88e85d7ca..11f69041672 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -129,7 +129,6 @@ function createWebPreloadApi(): Partial<PreloadApi> { browser: createBrowserApi(), gh: createGitHubApi(), linear: createRuntimeNamespaceApi('linear'), - notes: createNotesApi(), hooks: createHooksApi(), stats: { getSummary: async () => @@ -635,23 +634,6 @@ function createRuntimeNamespaceApi(prefix: string): never { }) as never } -function createNotesApi(): NonNullable<Partial<PreloadApi>['notes']> { - const noteCall = (method: string) => (args: Record<string, unknown>) => - callRuntimeResult(method, { ...args, worktree: args.worktreeId }) - return { - list: noteCall('note.list'), - show: noteCall('note.show'), - create: noteCall('note.create'), - save: noteCall('note.save'), - rename: noteCall('note.rename'), - delete: noteCall('note.delete'), - append: noteCall('note.append'), - search: noteCall('note.search'), - link: noteCall('note.link'), - panelState: noteCall('note.panelState') - } as NonNullable<Partial<PreloadApi>['notes']> -} - function createHooksApi(): NonNullable<Partial<PreloadApi>['hooks']> { return { check: async ({ repoId }) => callRuntimeResult('repo.hooksCheck', { repo: repoId }), diff --git a/src/shared/notes-types.ts b/src/shared/notes-types.ts deleted file mode 100644 index 447beae89cb..00000000000 --- a/src/shared/notes-types.ts +++ /dev/null @@ -1,130 +0,0 @@ -export type NoteLinkKind = 'active' | 'referenced' - -export type NoteRecord = { - id: string - projectId: string - filePath: string - relativePath: string - title: string - bodyMarkdown: string - revision: number - createdAt: string - updatedAt: string - archivedAt: string | null - createdBySessionId?: string | null - updatedBySessionId?: string | null -} - -export type NoteLink = { - noteId: string - projectId: string - worktreeId: string - kind: NoteLinkKind - createdAt: string -} - -export type NoteSummary = Omit<NoteRecord, 'bodyMarkdown'> & { - preview: string - linkKind: NoteLinkKind | null -} - -export type NoteListResult = { - notes: NoteSummary[] - totalCount: number - truncated: boolean -} - -export type NoteShowResult = { - note: NoteRecord - linkKind: NoteLinkKind | null -} - -export type NoteMutationResult = { - note: NoteRecord - linkKind: NoteLinkKind | null -} - -export type NotesPanelOpenState = - | { state: 'noProject' } - | { state: 'emptyDraft'; projectId: string; worktreeId: string | null } - | { state: 'pickerRequired'; projectId: string; worktreeId: string | null; notes: NoteSummary[] } - | { state: 'active'; projectId: string; worktreeId: string | null; note: NoteRecord } - -export type NoteListArgs = { - projectId: string - worktreeId?: string | null - limit?: number -} - -export type NoteShowArgs = { - projectId: string - worktreeId?: string | null - note: string -} - -export type NoteCreateArgs = { - projectId: string - worktreeId?: string | null - title: string - bodyMarkdown?: string - makeActive?: boolean - createdBySessionId?: string | null -} - -export type NoteSaveArgs = { - projectId: string - worktreeId?: string | null - note: string - title?: string - bodyMarkdown: string - revision?: number - makeActive?: boolean - updatedBySessionId?: string | null -} - -export type NoteRenameArgs = { - projectId: string - worktreeId?: string | null - note: string - title: string - updatedBySessionId?: string | null -} - -export type NoteDeleteArgs = { - projectId: string - worktreeId?: string | null - note: string -} - -export type NoteDeleteResult = { - noteId: string - projectId: string -} - -export type NoteAppendArgs = { - projectId: string - worktreeId?: string | null - note: string - bodyMarkdown: string - makeActive?: boolean - updatedBySessionId?: string | null -} - -export type NoteSearchArgs = { - projectId: string - worktreeId?: string | null - query: string - limit?: number -} - -export type NoteLinkArgs = { - projectId: string - worktreeId: string - note: string - kind: NoteLinkKind -} - -export type NotesPanelStateArgs = { - projectId?: string | null - worktreeId?: string | null -} diff --git a/src/shared/types.ts b/src/shared/types.ts index 448ce8b0091..d2441f4e708 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -229,15 +229,9 @@ export type TabGroupLayoutNode = } // ─── Unified Tab ──────────────────────────────────────────────────── -export type TabContentType = - | 'terminal' - | 'editor' - | 'diff' - | 'conflict-review' - | 'browser' - | 'notes' +export type TabContentType = 'terminal' | 'editor' | 'diff' | 'conflict-review' | 'browser' -export type WorkspaceVisibleTabType = 'terminal' | 'editor' | 'browser' | 'notes' +export type WorkspaceVisibleTabType = 'terminal' | 'editor' | 'browser' export type Tab = { id: string // UUID for terminals, filePath for editors (preserves current convention) @@ -252,7 +246,6 @@ export type Tab = { createdAt: number isPreview?: boolean // preview tabs get replaced by next single-click open isPinned?: boolean // pinned tabs survive "close others" - isDirty?: boolean // unsaved tab-local content, currently used by Project Notes } export type TabGroup = { diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index d008f625330..2fd4eb3fa4b 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -65,16 +65,9 @@ const terminalTabSchema = z.object({ // ─── Unified tab model ────────────────────────────────────────────── -const tabContentTypeSchema = z.enum([ - 'terminal', - 'editor', - 'diff', - 'conflict-review', - 'browser', - 'notes' -]) +const tabContentTypeSchema = z.enum(['terminal', 'editor', 'diff', 'conflict-review', 'browser']) -const workspaceVisibleTabTypeSchema = z.enum(['terminal', 'editor', 'browser', 'notes']) +const workspaceVisibleTabTypeSchema = z.enum(['terminal', 'editor', 'browser']) const tabSchema = z.object({ id: z.string(), @@ -88,8 +81,7 @@ const tabSchema = z.object({ sortOrder: z.number(), createdAt: z.number(), isPreview: z.boolean().optional(), - isPinned: z.boolean().optional(), - isDirty: z.boolean().optional() + isPinned: z.boolean().optional() }) const tabGroupSchema = z.object({