Add project notes (#1861)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-14 16:33:24 -04:00 committed by GitHub
parent a7eb3e7179
commit c73fd2c90b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 3695 additions and 78 deletions

View File

@ -62,7 +62,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
if (['open', 'status'].includes(commandPath[0])) {
return false
}
if (['repo', 'worktree', 'terminal', 'computer'].includes(commandPath[0])) {
if (['repo', 'worktree', 'terminal', 'computer', 'note'].includes(commandPath[0])) {
return false
}
return ![
@ -92,7 +92,8 @@ export function isCommandGroup(commandPath: string[]): boolean {
'dialog',
'storage',
'orchestration',
'computer'
'computer',
'note'
].includes(commandPath[0])) ||
(commandPath.length === 2 &&
commandPath[0] === 'storage' &&

View File

@ -14,6 +14,7 @@ import { BROWSER_ENV_HANDLERS } from './handlers/browser-env'
import { BROWSER_STORAGE_HANDLERS } from './handlers/browser-storage'
import { ORCHESTRATION_HANDLERS } from './handlers/orchestration'
import { COMPUTER_HANDLERS } from './handlers/computer'
import { NOTE_HANDLERS } from './handlers/note'
export type HandlerContext = {
flags: Map<string, string | boolean>
@ -40,7 +41,8 @@ function buildHandlers(): Map<string, CommandHandler> {
BROWSER_ENV_HANDLERS,
BROWSER_STORAGE_HANDLERS,
ORCHESTRATION_HANDLERS,
COMPUTER_HANDLERS
COMPUTER_HANDLERS,
NOTE_HANDLERS
]
for (const group of groups) {
for (const [key, handler] of Object.entries(group)) {

View File

@ -33,6 +33,7 @@ import type {
RuntimeWorktreePsResult,
RuntimeWorktreeRecord
} from '../shared/runtime-types'
import type { NoteListResult, NoteMutationResult, NoteShowResult } from '../shared/notes-types'
import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client'
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
@ -179,6 +180,39 @@ 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.'

95
src/cli/handlers/note.ts Normal file
View File

@ -0,0 +1,95 @@
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<string, string | boolean>,
cwd: string,
client: Parameters<CommandHandler>[0]['client']
): Promise<string> {
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<string, string | boolean>, 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<string, CommandHandler> = {
'note list': async ({ flags, client, cwd, json }) => {
const result = await client.call<NoteListResult>('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<NoteShowResult>('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<NoteMutationResult>('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<NoteMutationResult>('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<NoteListResult>('note.search', {
worktree: await getNoteWorktreeSelector(flags, cwd, client),
query: getRequiredStringFlag(flags, 'query'),
limit: getOptionalPositiveIntegerFlag(flags, 'limit')
})
printResult(result, json, formatNoteList)
}
}

View File

@ -4,11 +4,13 @@ import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic'
import { CORE_COMMAND_SPECS } from './core'
import { ORCHESTRATION_COMMAND_SPECS } from './orchestration'
import { COMPUTER_COMMAND_SPECS } from './computer'
import { NOTE_COMMAND_SPECS } from './note'
export const COMMAND_SPECS: CommandSpec[] = [
...CORE_COMMAND_SPECS,
...BROWSER_BASIC_COMMAND_SPECS,
...BROWSER_ADVANCED_COMMAND_SPECS,
...ORCHESTRATION_COMMAND_SPECS,
...COMPUTER_COMMAND_SPECS
...COMPUTER_COMMAND_SPECS,
...NOTE_COMMAND_SPECS
]

37
src/cli/specs/note.ts Normal file
View File

@ -0,0 +1,37 @@
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 <selector>] [--limit <n>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'limit']
},
{
path: ['note', 'show'],
summary: 'Show a project note',
usage: 'orca note show --note <selector> [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'note', 'worktree']
},
{
path: ['note', 'create'],
summary: 'Create a project note',
usage:
'orca note create --title <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']
}
]

40
src/main/ipc/notes.ts Normal file
View File

@ -0,0 +1,40 @@
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)
)
}

View File

@ -10,6 +10,7 @@ const {
registerStatsHandlersMock,
registerMemoryHandlersMock,
registerNotebookHandlersMock,
registerNotesHandlersMock,
registerNotificationHandlersMock,
registerDeveloperPermissionHandlersMock,
registerComputerUsePermissionHandlersMock,
@ -46,6 +47,7 @@ const {
registerStatsHandlersMock: vi.fn(),
registerMemoryHandlersMock: vi.fn(),
registerNotebookHandlersMock: vi.fn(),
registerNotesHandlersMock: vi.fn(),
registerNotificationHandlersMock: vi.fn(),
registerDeveloperPermissionHandlersMock: vi.fn(),
registerComputerUsePermissionHandlersMock: vi.fn(),
@ -118,6 +120,10 @@ vi.mock('./notebook', () => ({
registerNotebookHandlers: registerNotebookHandlersMock
}))
vi.mock('./notes', () => ({
registerNotesHandlers: registerNotesHandlersMock
}))
vi.mock('./notifications', () => ({
registerNotificationHandlers: registerNotificationHandlersMock
}))
@ -218,6 +224,7 @@ describe('registerCoreHandlers', () => {
registerStatsHandlersMock.mockReset()
registerMemoryHandlersMock.mockReset()
registerNotebookHandlersMock.mockReset()
registerNotesHandlersMock.mockReset()
registerNotificationHandlersMock.mockReset()
registerDeveloperPermissionHandlersMock.mockReset()
registerComputerUsePermissionHandlersMock.mockReset()

View File

@ -16,6 +16,7 @@ import { registerStatsHandlers } from './stats'
import { registerMemoryHandlers } from './memory'
import { registerRateLimitHandlers } from './rate-limits'
import { registerRuntimeHandlers } from './runtime'
import { registerNotesHandlers } from './notes'
import { registerNotificationHandlers } from './notifications'
import { registerNotebookHandlers } from './notebook'
import { registerOnboardingHandlers } from './onboarding'
@ -112,6 +113,7 @@ export function registerCoreHandlers(
registerFilesystemHandlers(store)
registerFilesystemWatcherHandlers()
registerRuntimeHandlers(runtime)
registerNotesHandlers(runtime)
registerClipboardHandlers()
registerUpdaterHandlers(store)
warmSystemFontFamilies()

View File

@ -166,6 +166,7 @@ describe('registerWorktreeHandlers', () => {
recordOptimisticReconcileToken: ReturnType<typeof vi.fn>
reconcileWorktreeBaseStatus: ReturnType<typeof vi.fn>
clearOptimisticReconcileToken: ReturnType<typeof vi.fn>
unlinkNotesWorktree: ReturnType<typeof vi.fn>
}
beforeEach(() => {
@ -301,7 +302,8 @@ describe('registerWorktreeHandlers', () => {
emitWorktreeBaseStatus: vi.fn(),
recordOptimisticReconcileToken: vi.fn().mockReturnValue('token-1'),
reconcileWorktreeBaseStatus: vi.fn(),
clearOptimisticReconcileToken: vi.fn()
clearOptimisticReconcileToken: vi.fn(),
unlinkNotesWorktree: vi.fn().mockResolvedValue(undefined)
}
registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never)
})

View File

@ -442,6 +442,7 @@ export function registerWorktreeHandlers(
}
await provider.removeWorktree(worktreePath, args.force)
runtime.clearOptimisticReconcileToken(args.worktreeId)
await runtime.unlinkNotesWorktree(repoId, args.worktreeId)
store.removeWorktreeMeta(args.worktreeId)
deleteWorktreeHistoryDir(args.worktreeId)
notifyWorktreesChanged(mainWindow, repoId)
@ -479,6 +480,7 @@ 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()
@ -488,6 +490,7 @@ export function registerWorktreeHandlers(
throw new Error(formatWorktreeRemovalError(error, worktreePath, args.force ?? false))
}
runtime.clearOptimisticReconcileToken(args.worktreeId)
await runtime.unlinkNotesWorktree(repoId, args.worktreeId)
store.removeWorktreeMeta(args.worktreeId)
deleteWorktreeHistoryDir(args.worktreeId)
invalidateAuthorizedRootsCache()

View File

@ -0,0 +1,76 @@
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([])
})
})

View File

@ -0,0 +1,586 @@
/* 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]
}
}

View File

@ -11,6 +11,19 @@ import {
import { OrchestrationDb } from './orchestration/db'
import { OrcaRuntimeService } from './orca-runtime'
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,
@ -1607,6 +1620,120 @@ 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()
@ -1790,6 +1917,79 @@ 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'))
@ -1890,6 +2090,70 @@ 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

View File

@ -9,7 +9,7 @@ import {
import type { AgentStatus } from '../../shared/agent-detection'
import { gitExecFileAsync } from '../git/runner'
import { isWslPath, parseWslPath, getWslHome } from '../wsl'
import { randomUUID } from 'crypto'
import { createHash, randomUUID } from 'crypto'
import { join, posix, win32 } from 'path'
import { open, rm, stat } from 'fs/promises'
import { OrchestrationDb } from './orchestration/db'
@ -108,7 +108,7 @@ import type {
BrowserConsoleResult,
BrowserNetworkLogResult
} from '../../shared/runtime-types'
import { BrowserWindow, ipcMain } from 'electron'
import { app, BrowserWindow, ipcMain } from 'electron'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import { browserManager } from '../browser/browser-manager'
import { BrowserError } from '../browser/cdp-bridge'
@ -163,6 +163,24 @@ 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,
NoteListArgs,
NoteListResult,
NoteMutationResult,
NoteRenameArgs,
NoteSaveArgs,
NoteSearchArgs,
NoteShowArgs,
NoteShowResult,
NotesPanelOpenState,
NotesPanelStateArgs
} from '../../shared/notes-types'
type RuntimeAccountServices = {
claudeAccounts: ClaudeAccountService
@ -711,6 +729,7 @@ export class OrcaRuntimeService {
private optimisticReconcileTokens = new Map<string, string>()
private readonly getLocalProviderFn: (() => IPtyProvider) | null
private accountServices: RuntimeAccountServices | null = null
private _notesStore: NotesMarkdownStore | null = null
constructor(
store: RuntimeStore | null = null,
@ -755,6 +774,17 @@ 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
}
@ -3694,6 +3724,134 @@ 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 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 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
@ -3898,20 +4056,65 @@ export class OrcaRuntimeService {
// unknown repository or worktree path".
invalidateAuthorizedRootsCache()
this.notifier?.worktreesChanged(repo.id)
const shouldActivate = args.activate === true || args.runHooks === true || Boolean(args.startup)
const shouldActivate = args.activate === true || args.runHooks === true
let didSpawnStartup = false
let didSpawnSetup = false
if (args.startup && this.ptyController?.spawn) {
try {
// Why: automation startup must not depend on a renderer TerminalPane
// mounting. Runtime-spawned PTYs run immediately and the UI adopts the
// session later, matching `orca terminal create` background semantics.
await this.createTerminal(`path:${worktree.path}`, {
command: args.startup.command,
env: args.startup.env
})
didSpawnStartup = true
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
warning = warning
? `${warning} Also failed to create the startup terminal for ${worktreePath}: ${message}`
: `Failed to create the startup terminal for ${worktreePath}: ${message}`
console.warn(`[worktree-create] ${warning}`)
}
}
if (didSpawnStartup && setup && this.ptyController?.spawn) {
try {
// Why: reveal-on-adopt can create the startup tab before renderer
// activation handles setup. Spawn setup in runtime too so startup+setup
// cannot be skipped by the renderer's "terminal already exists" guard.
await this.createTerminal(`path:${worktree.path}`, {
title: 'Setup',
command: buildSetupRunnerCommand(
setup.runnerScriptPath,
process.platform === 'win32' ? 'windows' : 'posix'
),
env: setup.envVars
})
didSpawnSetup = true
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
warning = warning
? `${warning} Also failed to create the setup terminal for ${worktreePath}: ${message}`
: `Failed to create the setup terminal for ${worktreePath}: ${message}`
console.warn(`[worktree-create] ${warning}`)
}
}
if (shouldActivate) {
// Why: plain CLI creates should not steal the user's current workspace.
// Startup launches still use renderer activation because they are an
// explicit request to start visible work in the new worktree.
if (args.startup) {
this.notifier?.activateWorktree(repo.id, worktree.id, setup, args.startup)
// Explicit activation and hook-running still use renderer activation so
// the user can watch prompts/output in a visible pane.
const activationSetup = didSpawnSetup ? undefined : setup
if (args.startup && !didSpawnStartup) {
this.notifier?.activateWorktree(repo.id, worktree.id, activationSetup, args.startup)
} else {
this.notifier?.activateWorktree(repo.id, worktree.id, setup)
this.notifier?.activateWorktree(repo.id, worktree.id, activationSetup)
}
} else if (this.ptyController?.spawn) {
try {
await this.createTerminal(`path:${worktree.path}`)
if (setup) {
if (!didSpawnStartup) {
await this.createTerminal(`path:${worktree.path}`)
}
if (setup && !didSpawnSetup) {
await this.createTerminal(`path:${worktree.path}`, {
title: 'Setup',
command: buildSetupRunnerCommand(
@ -4367,6 +4570,7 @@ 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()
@ -4379,6 +4583,7 @@ 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()
@ -4994,6 +5199,42 @@ 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')

View File

@ -43,6 +43,9 @@ 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'
])

View File

@ -12,6 +12,7 @@ import { ACCOUNT_METHODS } from './accounts'
import { COMPUTER_METHODS } from './computer'
import { SESSION_TAB_METHODS } from './session-tabs'
import { FILE_METHODS } from './files'
import { NOTE_METHODS } from './notes'
// Why: a flat manifest keeps registration order explicit and provides one
// grep-point for "what methods does the RPC server expose?" — useful when
@ -29,5 +30,6 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...ACCOUNT_METHODS,
...COMPUTER_METHODS,
...SESSION_TAB_METHODS,
...FILE_METHODS
...FILE_METHODS,
...NOTE_METHODS
]

View File

@ -0,0 +1,85 @@
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()
})
const NoteAppendParams = NoteScopedParams.extend({
note: requiredString('Missing note selector'),
bodyMarkdown: requiredString('Missing note body'),
makeActive: z.boolean().optional()
})
const NoteSearchParams = NoteScopedParams.extend({
query: requiredString('Missing search query'),
limit: OptionalFiniteNumber
})
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
})
}),
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
})
}),
defineMethod({
name: 'note.search',
params: NoteSearchParams,
handler: async (params, { runtime }) =>
await runtime.searchNotes({
worktreeSelector: params.worktree,
query: params.query,
limit: params.limit
})
})
]

View File

@ -178,6 +178,24 @@ 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: {
@ -1062,6 +1080,18 @@ 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>

View File

@ -324,6 +324,19 @@ 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'),

View File

@ -29,6 +29,7 @@ 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'
@ -49,6 +50,7 @@ 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
@ -649,6 +651,16 @@ 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()
@ -969,6 +981,13 @@ 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
}
@ -1187,6 +1206,7 @@ function Terminal(): React.JSX.Element | null {
onNewTerminalWithShell={handleNewTab}
onNewBrowserTab={handleNewBrowserTab}
onNewFileTab={handleNewFile}
onNewNotesTab={handleNewNotesTab}
wslAvailable={wslAvailable}
onSetCustomTitle={setTabCustomTitle}
onSetTabColor={setTabColor}

View File

@ -172,7 +172,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'>('terminal')
const previousActiveTabTypeRef = useRef<'browser' | 'editor' | 'terminal' | 'notes'>('terminal')
const previousBrowserPageIdRef = useRef<string | null>(null)
const previousBrowserFocusTargetRef = useRef<'webview' | 'address-bar'>('webview')
const wasVisibleRef = useRef(false)

View File

@ -1,3 +1,4 @@
/* 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 { Maximize2, Minimize2, Minus } from 'lucide-react'
import TabBar from '@/components/tab-bar/TabBar'
@ -11,10 +12,13 @@ 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 { useAppStore } from '@/store'
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'
export { FloatingTerminalToggleButton } from './FloatingTerminalToggleButton'
import {
@ -24,6 +28,7 @@ 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
@ -46,6 +51,7 @@ 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())
@ -54,6 +60,9 @@ 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)
@ -144,6 +153,7 @@ 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] ?? []
@ -162,6 +172,7 @@ export function FloatingTerminalPanel({
const closeFloatingTab = useCallback(
(tabId: string) => {
closeTab(tabId)
setActiveSurface('terminal')
},
[closeTab]
)
@ -190,6 +201,72 @@ 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
let label = 'Project Notes'
if (noteId && projectId) {
try {
const result = await window.api.notes.show({
projectId,
worktreeId: activeWorktreeId,
note: noteId
})
label = result.note.title
} catch {
label = 'Project Notes'
}
if (projectId) {
await window.api.notes.link({
projectId,
worktreeId: activeWorktreeId,
note: noteId,
kind: 'active'
})
notifyProjectNotesSelectionChanged()
}
}
const id = `floating-project-notes:${globalThis.crypto.randomUUID()}`
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) {
@ -292,19 +369,31 @@ export function FloatingTerminalPanel({
activeTabId={activeTab?.id ?? null}
worktreeId={FLOATING_TERMINAL_WORKTREE_ID}
expandedPaneByTabId={expandedPaneByTabId}
onActivate={(tabId) => setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tabId)}
onActivate={(tabId) => {
setActiveSurface('terminal')
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)
}
activeTabType="terminal"
notesTabs={activeWorktreeId ? notesTabs : []}
activeNotesTabId={activeSurface === 'notes' ? activeNotesTabId : null}
onActivateNotesTab={(tabId) => {
setActiveNotesTabId(tabId)
setActiveSurface('notes')
}}
onCloseNotesTab={closeFloatingNotesTab}
activeTabType={activeSurface}
tabBarOrder={tabBarOrder}
/>
</div>
@ -352,27 +441,42 @@ export function FloatingTerminalPanel({
</div>
<div className="relative min-h-0 flex-1 overflow-hidden bg-background">
{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}
{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}
</div>
</div>
{showOrchestrationSetup ? (

View File

@ -0,0 +1,681 @@
/* 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 {
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 [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 window.api.notes.list({ projectId, worktreeId, limit: 100 })
setNotes(result.notes)
}, [projectId, worktreeId])
const loadPanelState = useCallback(async (): Promise<void> => {
try {
setError(null)
if (selectedNoteId && projectId) {
const result = await window.api.notes.show({ 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 window.api.notes.panelState({ 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, 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 window.api.notes.show({ projectId, worktreeId, note: noteId })
await window.api.notes.link({ 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, 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 window.api.notes.save({
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, 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 window.api.notes.create({
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, 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">
&quot;{draft.title.trim() || 'Project Notes'}&quot; 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&apos;t Save
</Button>
<Button
type="button"
size="sm"
disabled={saving}
onClick={() => void handleClosePromptSave()}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -0,0 +1,358 @@
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 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 [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 window.api.notes.list({ projectId, worktreeId, limit: 100 })
setNotes(result.notes)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
}
}, [projectId, 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 window.api.notes.rename({
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, 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 window.api.notes.delete({ 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, 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>
)
}

View File

@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Files, Search, GitBranch, ListChecks, Cable, PanelRight } from 'lucide-react'
import { Files, Search, GitBranch, ListChecks, Cable, PanelRight, FileText } from 'lucide-react'
import { useAppStore } from '@/store'
import { getRepoMapFromState, useActiveWorktree, useRepoById } from '@/store/selectors'
import { cn } from '@/lib/utils'
@ -22,6 +22,7 @@ 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
@ -87,6 +88,12 @@ 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,
@ -245,6 +252,7 @@ function RightSidebarInner(): React.JSX.Element {
{effectiveTab === 'source-control' && <SourceControl />}
{effectiveTab === 'checks' && <ChecksPanel />}
{effectiveTab === 'ports' && <PortsPanel />}
{effectiveTab === 'notes' && <NotesPanel />}
</div>
</div>
)

View File

@ -153,6 +153,9 @@ const WorktreeCard = React.memo(function WorktreeCard({
// ── GRANULAR selectors: only subscribe to THIS worktree's data ──
const tabs = useAppStore((s) => s.tabsByWorktree[worktree.id] ?? EMPTY_TABS)
const browserTabs = useAppStore((s) => s.browserTabsByWorktree[worktree.id] ?? EMPTY_BROWSER_TABS)
const hasNotesSurface = useAppStore((s) =>
(s.unifiedTabsByWorktree[worktree.id] ?? []).some((tab) => tab.contentType === 'notes')
)
// Why: keep these as separate shallow selectors. Combining them into one
// returned object nests freshly-created maps under fresh keys, so Zustand's
// shallow memoization sees every unrelated store write as a change and
@ -287,6 +290,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
browserTabs,
ptyIdsByTabId: ptyIdsForWorktree,
runtimePaneTitlesByTabId: runtimePaneTitlesForWorktree,
hasNotesSurface,
hasPermission,
hasLiveDone,
hasRetainedDone
@ -296,6 +300,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
browserTabs,
ptyIdsForWorktree,
runtimePaneTitlesForWorktree,
hasNotesSurface,
hasPermission,
hasLiveDone,
hasRetainedDone

View File

@ -581,6 +581,9 @@ 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
)
@ -828,6 +831,7 @@ const WorktreeList = React.memo(function WorktreeList() {
showActiveOnly,
tabsByWorktree,
ptyIdsByTabId,
unifiedTabsByWorktree,
browserTabsByWorktree,
activeWorktreeId,
hideDefaultBranchWorkspace,
@ -842,6 +846,7 @@ const WorktreeList = React.memo(function WorktreeList() {
repoMap,
tabsByWorktree,
ptyIdsByTabId,
unifiedTabsByWorktree,
browserTabsByWorktree,
sortedIds,
worktreeMap,

View File

@ -6,7 +6,7 @@ import {
isDefaultBranchWorkspace,
sidebarHasActiveFilters
} from './visible-worktrees'
import type { Repo, TerminalTab, Worktree } from '../../../../shared/types'
import type { Repo, Tab, TerminalTab, Worktree } from '../../../../shared/types'
function makeTab(id: string, worktreeId: string, ptyId: string | null): TerminalTab {
return {
@ -21,6 +21,21 @@ 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,
@ -95,6 +110,22 @@ 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')

View File

@ -1,4 +1,4 @@
import type { Worktree, Repo, TerminalTab } from '../../../../shared/types'
import type { Worktree, Repo, Tab, TerminalTab } from '../../../../shared/types'
import { buildWorktreeComparator, sortWorktreesSmart } from './smart-sort'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { useAppStore } from '@/store'
@ -81,6 +81,7 @@ 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
@ -114,11 +115,15 @@ 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,
// and the currently selected worktree should never vanish from the list.
return hasLiveTerminal || hasBrowserTabs || opts.activeWorktreeId === w.id
// 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
})
}
@ -202,6 +207,7 @@ 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,

View File

@ -0,0 +1,131 @@
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>
</>
)
}

View File

@ -26,6 +26,7 @@ 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
@ -36,6 +37,9 @@ vi.mock('lucide-react', () => ({
FilePlus: function FilePlus() {
return null
},
FileText: function FileText() {
return null
},
Globe: function Globe() {
return null
},
@ -123,6 +127,15 @@ 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 }
}

View File

@ -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 } from 'lucide-react'
import { FilePlus, Globe, Plus, TerminalSquare, FileText } from 'lucide-react'
import type {
BrowserTab as BrowserTabState,
TerminalTab,
@ -17,6 +17,7 @@ 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'
@ -32,8 +33,12 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import type { NoteSummary } from '../../../../shared/notes-types'
const isMac = navigator.userAgent.includes('Mac')
const isWindows = navigator.userAgent.includes('Windows')
@ -58,6 +63,8 @@ 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
@ -66,13 +73,17 @@ 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
@ -98,6 +109,7 @@ 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') {
@ -106,6 +118,9 @@ 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)
}
@ -125,18 +140,24 @@ 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,
@ -153,6 +174,10 @@ function TabBarInner({
(s) => s.settings?.terminalWindowsPowerShellImplementation ?? 'auto'
)
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)
@ -162,6 +187,8 @@ function TabBarInner({
void window.api.pwsh.isAvailable().then(setPwshAvailable)
}, [])
const resolvedGroupId = groupId ?? worktreeId
const targetNotesWorktreeId = notesWorktreeId ?? worktreeId
const statusByRelativePath = useMemo(
() => buildStatusMap(gitStatusByWorktree[worktreeId] ?? []),
[worktreeId, gitStatusByWorktree]
@ -182,6 +209,49 @@ 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 window.api.notes.list({
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, 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])),
@ -191,14 +261,22 @@ 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)
const ids = reconcileTabOrder(
tabBarOrder,
terminalIds,
editorFileIds,
browserTabIds,
notesTabIds
)
const items: TabItem[] = []
for (const id of ids) {
const terminal = terminalMap.get(id)
@ -224,10 +302,25 @@ function TabBarInner({
unifiedTabId: browserTab.tabId ?? browserTab.id,
data: browserTab
})
continue
}
const notesTab = notesMap.get(id)
if (notesTab) {
items.push({ type: 'notes', id, unifiedTabId: notesTab.id, data: notesTab })
}
}
return items
}, [tabBarOrder, terminalIds, editorFileIds, browserTabIds, terminalMap, editorMap, browserMap])
}, [
tabBarOrder,
terminalIds,
editorFileIds,
browserTabIds,
notesTabIds,
terminalMap,
editorMap,
browserMap,
notesMap
])
const sortableIds = useMemo(() => orderedItems.map((item) => item.id), [orderedItems])
@ -426,6 +519,24 @@ 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}
@ -564,6 +675,77 @@ 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 />

View File

@ -1,4 +1,4 @@
import { FileCode, Globe, Terminal as TerminalIcon } from 'lucide-react'
import { FileCode, Globe, Terminal as TerminalIcon, FileText } from 'lucide-react'
import type { TabDragItemData } from '../tab-group/useTabDragSplit'
// Why: rendered inside dnd-kit's DragOverlay (a document-level portal), so
@ -9,7 +9,13 @@ 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 : TerminalIcon
drag.tabType === 'browser'
? Globe
: drag.tabType === 'editor'
? FileCode
: drag.tabType === 'notes'
? FileText
: 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" />

View File

@ -3,7 +3,7 @@ import type { AppState } from '../../store/types'
import { reconcileTabOrder } from './reconcile-order'
export type VisibleTabRef = {
type: 'terminal' | 'editor' | 'browser'
type: 'terminal' | 'editor' | 'browser' | 'notes'
id: string
tabId?: string
}
@ -56,6 +56,7 @@ 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) {
@ -73,6 +74,12 @@ 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
@ -114,6 +121,9 @@ export function getActiveTabNavOrder(
const terminalIds = (state.tabsByWorktree[worktreeId] ?? []).map((t) => t.id)
const editorIds = state.openFiles.filter((f) => f.worktreeId === worktreeId).map((f) => f.id)
const browserIds = (state.browserTabsByWorktree[worktreeId] ?? []).map((t) => t.id)
const notesIds = (state.unifiedTabsByWorktree[worktreeId] ?? [])
.filter((tab) => tab.contentType === 'notes')
.map((tab) => tab.id)
const activeGroupId = state.activeGroupIdByWorktree[worktreeId]
const group = activeGroupId
@ -138,11 +148,13 @@ export function getActiveTabNavOrder(
state.tabBarOrderByWorktree[worktreeId],
terminalIds,
editorIds,
browserIds
browserIds,
notesIds
)
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)) {
@ -151,6 +163,8 @@ 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

View File

@ -7,9 +7,10 @@ export function reconcileTabOrder(
storedOrder: string[] | undefined,
terminalIds: string[],
editorIds: string[],
browserIds: string[] = []
browserIds: string[] = [],
notesIds: string[] = []
): string[] {
const validIds = new Set([...terminalIds, ...editorIds, ...browserIds])
const validIds = new Set([...terminalIds, ...editorIds, ...browserIds, ...notesIds])
// 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
@ -24,7 +25,7 @@ export function reconcileTabOrder(
inResult.add(id)
}
}
for (const id of [...terminalIds, ...editorIds, ...browserIds]) {
for (const id of [...terminalIds, ...editorIds, ...browserIds, ...notesIds]) {
if (!inResult.has(id)) {
result.push(id)
inResult.add(id)

View File

@ -1,3 +1,4 @@
/* 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'
@ -19,8 +20,13 @@ 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,
@ -56,7 +62,8 @@ export default function TabGroupPanel({
}, [])
const model = useTabGroupWorkspaceModel({ groupId, worktreeId })
const { activeTab, browserItems, commands, editorItems, tabBarOrder, terminalTabs } = model
const { activeTab, browserItems, commands, editorItems, notesItems, tabBarOrder, terminalTabs } =
model
const { setNodeRef: setBodyDropRef } = useDroppable({
id: getTabPaneBodyDroppableId(groupId),
data: {
@ -119,23 +126,30 @@ 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 === 'terminal' ||
activeTab?.contentType === 'browser' ||
activeTab?.contentType === 'notes'
? 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'
: 'editor'
: activeTab?.contentType === 'notes'
? 'notes'
: 'editor'
}
onActivateFile={commands.activateEditor}
onCloseFile={commands.closeItem}
@ -148,6 +162,15 @@ 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) => {
@ -338,9 +361,36 @@ 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 !== 'browser' &&
activeTab.contentType !== 'notes' && (
<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

View File

@ -34,7 +34,7 @@ export type TabDragItemData = {
groupId: string
unifiedTabId: string
visibleTabId: string
tabType: 'terminal' | 'editor' | 'browser'
tabType: 'terminal' | 'editor' | 'browser' | 'notes'
/** 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

View File

@ -14,9 +14,12 @@ 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'
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[] = []
@ -140,6 +143,19 @@ 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(
@ -193,6 +209,14 @@ 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) {
@ -226,6 +250,10 @@ 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) {
@ -283,6 +311,21 @@ 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 =
@ -421,6 +464,7 @@ export function useTabGroupWorkspaceModel({
activeTab,
browserItems,
editorItems,
notesItems,
terminalTabs,
tabBarOrder,
groupTabs,
@ -431,6 +475,7 @@ export function useTabGroupWorkspaceModel({
},
activateBrowser,
activateEditor,
activateNotes,
activateTerminal,
closeAllEditorTabsInGroup,
closeGroup,
@ -474,6 +519,30 @@ 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 result = await window.api.notes.show({ projectId, worktreeId, note: noteId })
label = result.note.title
await window.api.notes.link({ 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)

View File

@ -1,4 +1,4 @@
export type TabCycleType = 'terminal' | 'editor' | 'browser'
export type TabCycleType = 'terminal' | 'editor' | 'browser' | 'notes'
export type TypeCyclableTab = {
type: TabCycleType

View File

@ -32,7 +32,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'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',
'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',
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(
'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',
'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',
className
)}
// Why: same no-drag fix as DropdownMenuContent — titlebar drag region
// would otherwise capture clicks when submenu overlaps it.
// Why: submenus must escape the parent menu's scroll clipping; the
// portal also needs no-drag for titlebar-overlapping menus in Electron.
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
{...props}
/>

View File

@ -67,6 +67,9 @@ 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)`

View File

@ -4,7 +4,7 @@
*/
export function resolveZoomTarget(args: {
activeView: 'terminal' | 'settings' | 'tasks' | 'activity' | 'automations'
activeTabType: 'terminal' | 'editor' | 'browser'
activeTabType: 'terminal' | 'editor' | 'browser' | 'notes'
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' || editorFocused) {
if (activeTabType === 'editor' || activeTabType === 'notes' || editorFocused) {
return 'editor'
}
// Why: terminal tabs should keep using per-pane terminal font zoom even when

View File

@ -34,6 +34,7 @@ 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'
@ -766,6 +767,15 @@ 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)
})
}
}
})
)

View File

@ -0,0 +1 @@
export const NOTES_ACTIVE_CHANGED_EVENT = 'orca:notes-active-changed'

View File

@ -0,0 +1,62 @@
import { useAppStore } from '@/store'
import { NOTES_ACTIVE_CHANGED_EVENT } from '@/lib/notes-events'
export function getProjectNotesEntityId(projectId: string, noteId?: string): string {
if (noteId) {
return `notes:${projectId}:note:${noteId}`
}
return `notes:${projectId}:new:${globalThis.crypto.randomUUID()}`
}
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
if (noteId && projectId) {
await window.api.notes.link({ projectId, worktreeId, note: noteId, kind: 'active' })
}
let label = 'Project Notes'
if (noteId && projectId) {
try {
const result = await window.api.notes.show({ 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))
}

View File

@ -0,0 +1,47 @@
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)
})
})

View File

@ -0,0 +1,28 @@
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()
}
}

View File

@ -188,4 +188,28 @@ 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')
})
})

View File

@ -98,6 +98,7 @@ 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
@ -125,5 +126,8 @@ export function resolveWorktreeStatus(args: {
if (args.hasLiveDone || args.hasRetainedDone) {
return 'done'
}
if (heuristic === 'inactive' && args.hasNotesSurface) {
return 'active'
}
return heuristic
}

View File

@ -124,7 +124,13 @@ export type OpenFile = {
mode: 'edit' | 'diff' | 'conflict-review' | 'markdown-preview'
}
export type RightSidebarTab = 'explorer' | 'search' | 'source-control' | 'checks' | 'ports'
export type RightSidebarTab =
| 'explorer'
| 'search'
| 'source-control'
| 'checks'
| 'ports'
| 'notes'
export type ActivityBarPosition = 'top' | 'side'
export type MarkdownViewMode = 'source' | 'rich' | 'preview'

View File

@ -61,7 +61,8 @@ function hydrateUnifiedFormat(
tabsByWorktree[worktreeId] = [...tabs]
.map((tab) => ({
...tab,
entityId: tab.entityId ?? tab.id
entityId: tab.entityId ?? tab.id,
isDirty: false
}))
.filter((tab) => {
if (!isTransientEditorContentType(tab.contentType)) {

View File

@ -962,6 +962,14 @@ 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 ────────────────────────────────────────────
@ -1491,5 +1499,47 @@ 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')
})
})
})

View File

@ -60,7 +60,9 @@ 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
@ -227,7 +229,13 @@ function collapseGroupLayout(
}
function toVisibleTabType(contentType: TabContentType): WorkspaceVisibleTabType {
return contentType === 'browser' ? 'browser' : contentType === 'terminal' ? 'terminal' : 'editor'
return contentType === 'browser'
? 'browser'
: contentType === 'terminal'
? 'terminal'
: contentType === 'notes'
? 'notes'
: 'editor'
}
function deriveActiveSurfaceForWorktree(
@ -430,7 +438,8 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
sortOrder: nextOrder.length,
createdAt: Date.now(),
isPreview: init?.isPreview,
isPinned: init?.isPinned
isPinned: init?.isPinned,
isDirty: false
}
nextOrder = dedupeTabOrder([...nextOrder, created.id])
@ -728,6 +737,12 @@ 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 }) ?? {}),
@ -1359,6 +1374,12 @@ 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)
}

View File

@ -59,7 +59,13 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b
}
function toVisibleTabType(contentType: string): WorkspaceVisibleTabType {
return contentType === 'browser' ? 'browser' : contentType === 'terminal' ? 'terminal' : 'editor'
return contentType === 'browser'
? 'browser'
: contentType === 'terminal'
? 'terminal'
: contentType === 'notes'
? 'notes'
: 'editor'
}
export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> = (set, get) => ({

130
src/shared/notes-types.ts Normal file
View File

@ -0,0 +1,130 @@
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
}

View File

@ -201,9 +201,15 @@ export type TabGroupLayoutNode =
}
// ─── Unified Tab ────────────────────────────────────────────────────
export type TabContentType = 'terminal' | 'editor' | 'diff' | 'conflict-review' | 'browser'
export type TabContentType =
| 'terminal'
| 'editor'
| 'diff'
| 'conflict-review'
| 'browser'
| 'notes'
export type WorkspaceVisibleTabType = 'terminal' | 'editor' | 'browser'
export type WorkspaceVisibleTabType = 'terminal' | 'editor' | 'browser' | 'notes'
export type Tab = {
id: string // UUID for terminals, filePath for editors (preserves current convention)
@ -218,6 +224,7 @@ 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 = {

View File

@ -65,9 +65,16 @@ const terminalTabSchema = z.object({
// ─── Unified tab model ──────────────────────────────────────────────
const tabContentTypeSchema = z.enum(['terminal', 'editor', 'diff', 'conflict-review', 'browser'])
const tabContentTypeSchema = z.enum([
'terminal',
'editor',
'diff',
'conflict-review',
'browser',
'notes'
])
const workspaceVisibleTabTypeSchema = z.enum(['terminal', 'editor', 'browser'])
const workspaceVisibleTabTypeSchema = z.enum(['terminal', 'editor', 'browser', 'notes'])
const tabSchema = z.object({
id: z.string(),
@ -81,7 +88,8 @@ const tabSchema = z.object({
sortOrder: z.number(),
createdAt: z.number(),
isPreview: z.boolean().optional(),
isPinned: z.boolean().optional()
isPinned: z.boolean().optional(),
isDirty: z.boolean().optional()
})
const tabGroupSchema = z.object({