diff --git a/apps/desktop/screenshots/mockBridge.ts b/apps/desktop/screenshots/mockBridge.ts index a417d6c..684dea3 100644 --- a/apps/desktop/screenshots/mockBridge.ts +++ b/apps/desktop/screenshots/mockBridge.ts @@ -684,6 +684,7 @@ const bridge: AiOptBridge = { stopTask: refused, removeTask: refused, taskOutput: async () => ({ text: '' }), + taskChanges: async () => ({ since: 'launch' as const, files: [], truncated: false }), runAll: refused, approveTasks: refused, runPlan: refused, diff --git a/apps/desktop/src/main/workbench/__tests__/taskChanges.test.ts b/apps/desktop/src/main/workbench/__tests__/taskChanges.test.ts new file mode 100644 index 0000000..e23ec99 --- /dev/null +++ b/apps/desktop/src/main/workbench/__tests__/taskChanges.test.ts @@ -0,0 +1,146 @@ +import { execFile, execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + GIT_SAFE_CONFIG, + isCommitId, + parseNameStatusZ, + parseNumstatZ, + readHeadCommit, + readTaskChanges, + splitPatch, + type GitExec, +} from '../taskChanges'; + +describe('task changes parsing', () => { + it('parses numstat -z including renames and binaries', () => { + expect(parseNumstatZ('3\t1\ta.ts\0-\t-\timg.png\0' + '2\t0\t\0old name.ts\0new name.ts\0')).toEqual([ + { path: 'a.ts', oldPath: null, additions: 3, deletions: 1 }, + { path: 'img.png', oldPath: null, additions: null, deletions: null }, + { path: 'new name.ts', oldPath: 'old name.ts', additions: 2, deletions: 0 }, + ]); + }); + + it('parses name-status -z', () => { + const map = parseNameStatusZ('M\0a.ts\0A\0b.ts\0D\0c.ts\0R087\0old.ts\0new.ts\0'); + expect([...map]).toEqual([ + ['a.ts', 'modified'], + ['b.ts', 'added'], + ['c.ts', 'deleted'], + ['new.ts', 'renamed'], + ]); + }); + + it('splits only at line-start diff headers', () => { + const text = 'diff --git a/x b/x\n+say diff --git inline\ndiff --git a/y b/y\n-1\n'; + expect(splitPatch(text)).toEqual(['diff --git a/x b/x\n+say diff --git inline\n', 'diff --git a/y b/y\n-1\n']); + expect(splitPatch('')).toEqual([]); + }); + + it('accepts only full commit ids', () => { + expect(isCommitId('a'.repeat(40))).toBe(true); + expect(isCommitId('HEAD')).toBe(false); + expect(isCommitId('--output=/tmp/x')).toBe(false); + expect(isCommitId('a'.repeat(39))).toBe(false); + }); +}); + +function gitAvailable(): boolean { + try { + execFileSync('git', ['--version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +const realExec: GitExec = (args) => + new Promise((resolve, reject) => { + execFile( + 'git', + [...args], + { + encoding: 'utf8', + env: { + PATH: process.env.PATH ?? '', + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: os.devNull, + GIT_AUTHOR_NAME: 't', + GIT_AUTHOR_EMAIL: 't@example.com', + GIT_COMMITTER_NAME: 't', + GIT_COMMITTER_EMAIL: 't@example.com', + }, + }, + (err, stdout) => { + const code = err && typeof (err as { code?: unknown }).code === 'number' ? (err as { code: number }).code : 0; + if (err && typeof (err as { code?: unknown }).code !== 'number') reject(err); + else resolve({ code, stdout }); + }, + ); + }); + +describe.skipIf(!gitAvailable())('readTaskChanges (real git)', () => { + let dir = ''; + const run = async (...args: string[]) => { + const out = await realExec(['-C', dir, ...args]); + if (out.code !== 0) throw new Error(`git ${args.join(' ')} failed`); + }; + + beforeEach(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'aiopt-taskchanges-')); + await run('init', '-q'); + fs.writeFileSync(path.join(dir, 'a.txt'), 'one\ntwo\n'); + fs.writeFileSync(path.join(dir, 'gone.txt'), 'bye\n'); + fs.mkdirSync(path.join(dir, 'sub')); + fs.writeFileSync(path.join(dir, 'sub', 'b.txt'), 'b\n'); + await run('add', '.'); + await run('commit', '-q', '-m', 'init'); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('diffs against the launch commit: commits since, uncommitted edits and untracked files', async () => { + const base = await readHeadCommit(realExec, dir); + expect(base && isCommitId(base)).toBe(true); + fs.writeFileSync(path.join(dir, 'a.txt'), 'one\n2\nthree\n'); + await run('commit', '-q', '-am', 'worker commit'); + fs.rmSync(path.join(dir, 'gone.txt')); + fs.writeFileSync(path.join(dir, 'new file.txt'), 'x\ny\n'); + + const out = await readTaskChanges(realExec, dir, base); + expect(out.since).toBe('launch'); + expect(out.truncated).toBe(false); + const byPath = new Map(out.files.map((f) => [f.path, f])); + expect(byPath.get('a.txt')).toMatchObject({ status: 'modified', additions: 2, deletions: 1 }); + expect(byPath.get('a.txt')?.patch).toContain('+three'); + expect(byPath.get('gone.txt')).toMatchObject({ status: 'deleted', deletions: 1 }); + expect(byPath.get('new file.txt')).toMatchObject({ status: 'untracked', additions: 2, deletions: 0 }); + expect(byPath.get('new file.txt')?.patch).toContain('+y'); + }); + + it('falls back to HEAD for an unknown base and limits a subfolder to itself', async () => { + fs.writeFileSync(path.join(dir, 'a.txt'), 'changed\n'); + fs.writeFileSync(path.join(dir, 'sub', 'b.txt'), 'b2\n'); + const sub = path.join(dir, 'sub'); + const out = await readTaskChanges(realExec, sub, 'f'.repeat(40)); + expect(out.since).toBe('head'); + expect(out.files.map((f) => f.path)).toEqual(['b.txt']); + }); + + it('never runs an external diff driver configured in the repo', async () => { + const marker = path.join(dir, 'ran'); + await run('config', 'diff.external', `sh -c 'touch ${marker}'`); + fs.writeFileSync(path.join(dir, 'a.txt'), 'changed\n'); + const out = await readTaskChanges(realExec, dir, null); + expect(out.files[0]?.patch).toContain('+changed'); + expect(fs.existsSync(marker)).toBe(false); + }); + + it('puts the hardening config before the subcommand', () => { + expect(GIT_SAFE_CONFIG).toContain('core.fsmonitor=false'); + }); +}); diff --git a/apps/desktop/src/main/workbench/__tests__/workbenchIpc.test.ts b/apps/desktop/src/main/workbench/__tests__/workbenchIpc.test.ts index 5cfb973..a54f34d 100644 --- a/apps/desktop/src/main/workbench/__tests__/workbenchIpc.test.ts +++ b/apps/desktop/src/main/workbench/__tests__/workbenchIpc.test.ts @@ -38,6 +38,7 @@ function setup(picked: string | null = '/work/app') { stopTask: vi.fn(async () => {}), removeTask: vi.fn(async () => {}), taskOutput: vi.fn(async () => 'out'), + taskChanges: vi.fn(async () => ({ since: 'launch' as const, files: [], truncated: false })), runAll: vi.fn(() => 0), approveTasks: vi.fn(), runPlan: vi.fn(), @@ -80,7 +81,7 @@ const CHANNELS = Object.entries(IPC_CHANNELS).filter(([key]) => key.startsWith(' describe('workbench ipc', () => { it('registers every workbench channel and asserts the trusted sender first', async () => { const { reg, manager, pickFolder, revealItem, folderOps } = setup(); - expect(CHANNELS).toHaveLength(37); + expect(CHANNELS).toHaveLength(38); for (const [, channel] of CHANNELS) { expect(await codeOf(reg.invoke(channel, { taskId: 'id1', folderId: 'id1', text: 'x' }, untrusted))).toBe( 'PERMISSION_DENIED', @@ -195,6 +196,9 @@ describe('workbench ipc', () => { const { reg, manager } = setup(); expect(await codeOf(reg.invoke(IPC_CHANNELS.workbenchTaskLaunch, { taskId: '../x' }, trusted))).toBe('INVALID_PARAMS'); expect(await codeOf(reg.invoke(IPC_CHANNELS.workbenchTaskStop, { taskId: 7 }, trusted))).toBe('INVALID_PARAMS'); + expect(await codeOf(reg.invoke(IPC_CHANNELS.workbenchTaskChanges, { taskId: '/etc' }, trusted))).toBe( + 'INVALID_PARAMS', + ); expect(await codeOf(reg.invoke(IPC_CHANNELS.workbenchFolderRemove, { folderId: 'A B' }, trusted))).toBe( 'INVALID_PARAMS', ); @@ -253,6 +257,9 @@ describe('workbench ipc', () => { await reg.invoke(IPC_CHANNELS.workbenchTaskMessage, { taskId: 'id1', text: 'go on' }, trusted); expect(manager.messageTask).toHaveBeenCalledWith('id1', 'go on'); expect(await reg.invoke(IPC_CHANNELS.workbenchTaskOutput, { taskId: 'id1' }, trusted)).toEqual({ text: 'out' }); + // Only the task id reaches the manager: a path in the payload is ignored. + await reg.invoke(IPC_CHANNELS.workbenchTaskChanges, { taskId: 'id1', path: '/etc' }, trusted); + expect(manager.taskChanges).toHaveBeenCalledWith('id1'); expect(await reg.invoke(IPC_CHANNELS.workbenchTaskRunAll, undefined, trusted)).toBe(SNAP); expect(manager.runAll).toHaveBeenCalledTimes(1); diff --git a/apps/desktop/src/main/workbench/__tests__/workbenchManager.test.ts b/apps/desktop/src/main/workbench/__tests__/workbenchManager.test.ts index 37e83c7..291b11a 100644 --- a/apps/desktop/src/main/workbench/__tests__/workbenchManager.test.ts +++ b/apps/desktop/src/main/workbench/__tests__/workbenchManager.test.ts @@ -774,6 +774,46 @@ describe('workbench tasks', () => { expect(s.wb.getSnapshot().tasks).toEqual([]); }); + it('records the launch HEAD and reads the task changes against it, in the task folder only', async () => { + const s = await withFolder(); + const base = 'a'.repeat(40); + s.exec.mockImplementation(async (_file: string, args: readonly string[]) => { + if (args.includes('rev-parse')) return { code: 0, stdout: `${base}\n`, stderr: '' }; + if (args.includes('--numstat')) return { code: 0, stdout: '1\t0\ta.ts\0', stderr: '' }; + if (args.includes('--name-status')) return { code: 0, stdout: 'M\0a.ts\0', stderr: '' }; + if (args.includes('--patch')) return { code: 0, stdout: 'diff --git a/a.ts b/a.ts\n@@ -0,0 +1 @@\n+x\n', stderr: '' }; + return { code: 0, stdout: '', stderr: '' }; + }); + const id = s.wb.createTask({ title: 'T', prompt: 'p', folderId: s.folderId }); + s.wb.launchTask(id); + await flush(); + const saved = JSON.parse(s.disk.get(path.join(DATA, 'tasks.json'))!); + expect(saved.tasks[0].baseCommit).toBe(base); + + const out = await s.wb.taskChanges(id); + expect(out).toMatchObject({ since: 'launch', truncated: false }); + expect(out.files).toEqual([ + { path: 'a.ts', oldPath: null, status: 'modified', additions: 1, deletions: 0, patch: expect.stringContaining('+x') }, + ]); + const gitCalls = s.exec.mock.calls.filter(([file]) => file === '/opt/bin/git').map(([, args]) => args); + for (const args of gitCalls) { + // Hardened, in the task's folder, never an external diff driver. + expect(args.slice(0, 2)).toEqual(['-c', 'core.fsmonitor=false']); + expect(args[args.indexOf('-C') + 1]).toBe('/work/app'); + if (args.includes('diff')) expect(args).toContain('--no-ext-diff'); + } + expect(gitCalls.some((args) => args.includes('diff') && args.includes(base))).toBe(true); + expect(await codeOfAsync(s.wb.taskChanges('nope000000'))).toBe('NOT_FOUND'); + }); + + it('has no changes to read for a task without a git checkout', async () => { + const s = await started(); + await s.wb.addFolder('/work/notes'); + const folderId = s.wb.getSnapshot().folders[0]!.id; + const id = s.wb.createTask({ title: 'T', prompt: 'p', folderId }); + expect(await codeOfAsync(s.wb.taskChanges(id))).toBe('PRECONDITION_FAILED'); + }); + it('marks a launch that herdr refuses as failed and cleans up', async () => { const s = await withFolder(); s.herdr.client.agentStart.mockRejectedValueOnce(new HerdrError('agent_pane_busy', 'busy')); diff --git a/apps/desktop/src/main/workbench/taskChanges.ts b/apps/desktop/src/main/workbench/taskChanges.ts new file mode 100644 index 0000000..35e4631 --- /dev/null +++ b/apps/desktop/src/main/workbench/taskChanges.ts @@ -0,0 +1,274 @@ +// Read-only view of what a task changed: `git diff` of its checkout against the commit the +// task started from, plus untracked files. Main resolves the directory from its own task +// record; the renderer only names the task. +// +// Every git call is hardened against running anything but git itself: no external diff or +// textconv driver, no fsmonitor hook, no pager, no optional index writes. Output is capped +// (file count, per-patch and total characters) before it crosses IPC. + +import { + WORKBENCH_LIMITS, + type TaskChangedFile, + type TaskChanges, + type TaskChangeStatus, +} from '../../shared/workbench'; + +export interface GitExecResult { + code: number | null; + stdout: string; +} + +/** execFile of git (no shell); resolves on a non-zero exit, rejects on a spawn failure / overflow. */ +export type GitExec = (args: readonly string[]) => Promise; + +const COMMIT_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/; + +export function isCommitId(value: unknown): value is string { + return typeof value === 'string' && COMMIT_RE.test(value); +} + +/** `-c` overrides placed before every subcommand. */ +export const GIT_SAFE_CONFIG: readonly string[] = [ + '-c', + 'core.fsmonitor=false', + '-c', + 'core.quotePath=false', + '-c', + 'color.ui=false', +]; + +/** Extra environment for those git calls (merged over the CLI allowlist env). */ +export const GIT_SAFE_ENV: Readonly> = { + GIT_OPTIONAL_LOCKS: '0', + GIT_TERMINAL_PROMPT: '0', + GIT_PAGER: 'cat', +}; + +const DIFF_FLAGS = ['--no-color', '--no-ext-diff', '--no-textconv', '--find-renames'] as const; + +function git(dir: string, args: readonly string[]): string[] { + return [...GIT_SAFE_CONFIG, '-C', dir, ...args]; +} + +/** The checkout's HEAD commit, or null (not a repo, no commits yet, git failed). */ +export async function readHeadCommit(exec: GitExec, dir: string): Promise { + try { + const out = await exec(git(dir, ['rev-parse', '--verify', '--quiet', 'HEAD^{commit}'])); + const id = out.stdout.trim(); + return out.code === 0 && isCommitId(id) ? id : null; + } catch { + return null; + } +} + +interface StatEntry { + path: string; + oldPath: string | null; + additions: number | null; + deletions: number | null; +} + +/** `git diff --numstat -z`: `a\td\tpath\0`, or `a\td\t\0old\0new\0` for a rename. */ +export function parseNumstatZ(text: string): StatEntry[] { + const parts = text.split('\0'); + const out: StatEntry[] = []; + let i = 0; + while (i < parts.length) { + const head = parts[i++] ?? ''; + if (head === '') continue; + const m = /^(-|\d+)\t(-|\d+)\t(.*)$/s.exec(head); + if (!m || m[3] === undefined) continue; + const additions = m[1] === '-' ? null : Number(m[1]); + const deletions = m[2] === '-' ? null : Number(m[2]); + if (m[3] !== '') { + out.push({ path: m[3], oldPath: null, additions, deletions }); + continue; + } + const oldPath = parts[i++]; + const path = parts[i++]; + if (!oldPath || !path) break; + out.push({ path, oldPath, additions, deletions }); + } + return out; +} + +/** `git diff --name-status -z`: status letter per new path. */ +export function parseNameStatusZ(text: string): Map { + const parts = text.split('\0'); + const out = new Map(); + let i = 0; + while (i < parts.length) { + const code = parts[i++] ?? ''; + if (code === '') continue; + const letter = code[0]; + if (letter === 'R' || letter === 'C') { + i++; // old path + const path = parts[i++]; + if (!path) break; + out.set(path, letter === 'R' ? 'renamed' : 'added'); + continue; + } + const path = parts[i++]; + if (!path) break; + out.set(path, letter === 'A' ? 'added' : letter === 'D' ? 'deleted' : 'modified'); + } + return out; +} + +/** Split a multi-file unified diff into one chunk per `diff --git` header. */ +export function splitPatch(text: string): string[] { + const chunks: string[] = []; + let start = -1; + let at = 0; + while (at <= text.length) { + const next = text.indexOf('diff --git ', at); + if (next === -1) break; + if (next === 0 || text[next - 1] === '\n') { + if (start !== -1) chunks.push(text.slice(start, next)); + start = next; + } + at = next + 1; + } + if (start !== -1) chunks.push(text.slice(start)); + return chunks; +} + +/** The new path a chunk's `diff --git a/ b/` header ends with matches `path`. */ +function chunkIsFor(chunk: string, path: string): boolean { + const eol = chunk.indexOf('\n'); + const header = eol === -1 ? chunk : chunk.slice(0, eol); + return header.endsWith(` b/${path}`) || header.endsWith(` "b/${path}"`); +} + +function countLines(patch: string): { additions: number; deletions: number } { + let additions = 0; + let deletions = 0; + let inHunk = false; + for (const line of patch.split('\n')) { + if (line.startsWith('@@')) inHunk = true; + else if (!inHunk) continue; + else if (line.startsWith('+')) additions++; + else if (line.startsWith('-')) deletions++; + } + return { additions, deletions }; +} + +function isBinaryPatch(patch: string): boolean { + return /^Binary files .* differ$/m.test(patch) || patch.includes('\nGIT binary patch\n'); +} + +/** + * Read what changed in `dir` since `base` (a commit id, or null for HEAD). Paths are relative + * to `dir` (`--relative`), so an in-place task in a repo subfolder sees only its folder. + */ +export async function readTaskChanges(exec: GitExec, dir: string, base: string | null): Promise { + const L = WORKBENCH_LIMITS; + let since: TaskChanges['since'] = 'head'; + let rev: string | null = null; + if (base && isCommitId(base)) { + const ok = await exec(git(dir, ['cat-file', '-e', `${base}^{commit}`])).catch(() => null); + if (ok && ok.code === 0) { + rev = base; + since = 'launch'; + } + } + if (!rev) rev = await readHeadCommit(exec, dir); + + let truncated = false; + const files: TaskChangedFile[] = []; + let budget = L.changesChars; + + if (rev) { + const range = [rev, '--']; + const [numstat, names] = await Promise.all([ + exec(git(dir, ['diff', ...DIFF_FLAGS, '--relative', '--numstat', '-z', ...range])), + exec(git(dir, ['diff', ...DIFF_FLAGS, '--relative', '--name-status', '-z', ...range])), + ]); + if (numstat.code !== 0 || names.code !== 0) throw new Error('git diff failed'); + const stats = parseNumstatZ(numstat.stdout); + const statuses = parseNameStatusZ(names.stdout); + if (stats.length > L.changedFiles) truncated = true; + for (const s of stats.slice(0, L.changedFiles)) { + files.push({ + path: s.path, + oldPath: s.oldPath, + status: statuses.get(s.path) ?? (s.oldPath ? 'renamed' : 'modified'), + additions: s.additions, + deletions: s.deletions, + patch: null, + }); + } + if (files.length > 0) { + // One call for every patch; a diff past execFile's buffer rejects and the list stays + // without patches (the counts above are still right). + const patch = await exec(git(dir, ['diff', ...DIFF_FLAGS, '--relative', '--patch', ...range])).catch(() => null); + if (!patch || patch.code !== 0) truncated = true; + else { + const chunks = splitPatch(patch.stdout); + let c = 0; + for (const f of files) { + // Same order as --numstat. A header git still quotes (a tab or quote in the name) + // is not matched: that file goes without a patch and the rest keep theirs. + let at = c; + while (at < chunks.length && !chunkIsFor(chunks[at] ?? '', f.path)) at++; + const chunk = chunks[at]; + if (chunk === undefined) continue; + c = at + 1; + if (f.additions === null || isBinaryPatch(chunk)) continue; + if (chunk.length > L.changePatchChars || chunk.length > budget) { + truncated = true; + continue; + } + f.patch = chunk; + budget -= chunk.length; + } + } + } + } + + // Untracked files are what a worker most often creates; git diff does not list them. + const others = await exec(git(dir, ['ls-files', '--others', '--exclude-standard', '-z'])).catch(() => null); + if (others && others.code === 0) { + // A nested repository is listed as `dir/`: not a file to show. + const paths = others.stdout.split('\0').filter((p) => p !== '' && !p.endsWith('/')); + let shown = 0; + for (const path of paths) { + if (files.length >= L.changedFiles) { + truncated = true; + break; + } + const entry: TaskChangedFile = { + path, + oldPath: null, + status: 'untracked', + additions: null, + deletions: null, + patch: null, + }; + files.push(entry); + if (shown >= L.untrackedPatches || budget <= 0) { + truncated = true; + continue; + } + shown++; + // `--no-index` exits 1 when the files differ (always, against /dev/null). + const out = await exec(git(dir, ['diff', ...DIFF_FLAGS, '--no-index', '--patch', '--', '/dev/null', path])).catch( + () => null, + ); + if (!out || (out.code !== 0 && out.code !== 1)) continue; + const chunk = out.stdout; + if (isBinaryPatch(chunk)) continue; + const counts = countLines(chunk); + entry.additions = counts.additions; + entry.deletions = counts.deletions; + if (chunk.length > L.changePatchChars || chunk.length > budget) { + truncated = true; + continue; + } + entry.patch = chunk; + budget -= chunk.length; + } + } + + return { since, files, truncated }; +} diff --git a/apps/desktop/src/main/workbench/workbenchIpc.ts b/apps/desktop/src/main/workbench/workbenchIpc.ts index 4435382..2d8efed 100644 --- a/apps/desktop/src/main/workbench/workbenchIpc.ts +++ b/apps/desktop/src/main/workbench/workbenchIpc.ts @@ -327,6 +327,12 @@ export function registerWorkbenchIpc( return { text: await manager.taskOutput(taskId(payload)) }; }); + // Read-only: main picks the checkout from its own task record (never a renderer path). + registry.register(IPC_CHANNELS.workbenchTaskChanges, async (payload, meta) => { + meta.assertTrustedSender(); + return manager.taskChanges(taskId(payload)); + }); + registry.register(IPC_CHANNELS.workbenchTaskRunAll, (_payload, meta) => { meta.assertTrustedSender(); manager.runAll(); diff --git a/apps/desktop/src/main/workbench/workbenchManager.ts b/apps/desktop/src/main/workbench/workbenchManager.ts index 771fc52..050e57f 100644 --- a/apps/desktop/src/main/workbench/workbenchManager.ts +++ b/apps/desktop/src/main/workbench/workbenchManager.ts @@ -59,6 +59,7 @@ import { type FilePreview, type FolderListing, type FolderView, + type TaskChanges, type TaskFailure, type TaskOrigin, type TaskStatus, @@ -115,6 +116,7 @@ import { } from './orchestratorExtension'; import { installManagedHerdr } from './herdrInstall'; import { createTimeline, parseTimeline } from './timeline'; +import { GIT_SAFE_ENV, isCommitId, readHeadCommit, readTaskChanges, type GitExec } from './taskChanges'; import { herdrExecForTarget, herdrSpawnArgv, @@ -244,6 +246,8 @@ export interface WorkbenchManager { stopTask(taskId: string): Promise; removeTask(taskId: string): Promise; taskOutput(taskId: string): Promise; + /** What a task changed in its checkout (read-only git; local herdr only). */ + taskChanges(taskId: string): Promise; /** Launch every proposed task that has a folder, up to the live limit; returns how many. */ runAll(): number; /** Approve tasks to run; each launches once its dependencies are done and a slot is free. */ @@ -281,6 +285,8 @@ export const POLL_MS = 3000; export const SWEEP_EVERY_POLLS = 20; const SERVER_READY_TRIES = 40; const SERVER_READY_DELAY_MS = 250; +/** One read-only git call of a task-changes read (a large diff can take a while). */ +const GIT_READ_TIMEOUT_MS = 20_000; /** Task changes that land together reach the coordinator as one update. */ export const NOTIFY_DEBOUNCE_MS = 1500; /** How much of a session file is read for its title (the first user message is near the top). */ @@ -347,6 +353,8 @@ interface TaskRecord { /** The repo the worktree belongs to (the folder's path at launch). */ worktreeRepo: string | null; repoKey: string | null; + /** The checkout's HEAD when the task last started fresh: what "its changes" are diffed against. */ + baseCommit: string | null; /** When the worker was last given a message (for the idle grace window). */ promptAt: number; /** The worker's terminal tail when it last stopped for review or input (for the coordinator). */ @@ -381,6 +389,7 @@ interface TasksDoc { worktreePath: string | null; worktreeRepo: string | null; repoKey: string | null; + baseCommit?: string | null; createdAt: number; updatedAt: number; dependsOn?: string[]; @@ -718,6 +727,7 @@ export function createWorkbenchManager(deps: WorkbenchDeps): WorkbenchManager { worktreePath, worktreeRepo, repoKey: worktreePath && typeof r.repoKey === 'string' ? r.repoKey : null, + baseCommit: isCommitId(r.baseCommit) ? r.baseCommit : null, promptAt: 0, outputTail: typeof r.output === 'string' ? sanitizeWorkbenchText(r.output).slice(-WORKBENCH_LIMITS.taskOutputTail) : '', @@ -759,6 +769,7 @@ export function createWorkbenchManager(deps: WorkbenchDeps): WorkbenchManager { worktreePath: t.worktreePath, worktreeRepo: t.worktreeRepo, repoKey: t.repoKey, + baseCommit: t.baseCommit, createdAt: t.createdAt, updatedAt: t.updatedAt, dependsOn: t.dependsOn, @@ -1793,6 +1804,7 @@ export function createWorkbenchManager(deps: WorkbenchDeps): WorkbenchManager { worktreePath: null, worktreeRepo: null, repoKey: null, + baseCommit: null, promptAt: 0, outputTail: '', dependsOn: [], @@ -1838,6 +1850,7 @@ export function createWorkbenchManager(deps: WorkbenchDeps): WorkbenchManager { t.folderId = patch.folderId; } if (patch.isolated !== undefined) t.isolated = patch.isolated; + t.baseCommit = null; } if (patch.dependsOn !== undefined) { if (patch.dependsOn === null) t.dependsOn = []; @@ -1936,8 +1949,11 @@ export function createWorkbenchManager(deps: WorkbenchDeps): WorkbenchManager { t.worktreePath = ref.path; t.worktreeRepo = folder.path; t.repoKey = ref.repoKey; + t.baseCommit = await launchBase(ref.path); } } else { + // In place: the folder's HEAD when the task first ran (a relaunch continues its work). + if (!t.baseCommit && folder.isGitRepo) t.baseCommit = await launchBase(folder.path); const ref = await client.workspaceCreate(folder.path, t.title); workspaceId = ref.workspaceId; paneId = ref.paneId; @@ -2158,6 +2174,48 @@ export function createWorkbenchManager(deps: WorkbenchDeps): WorkbenchManager { } } + // --- task changes (read-only git) ----------------------------------------------------- + + /** git on this machine, run with the hardened config (see taskChanges.ts); null without git. */ + function localGit(): GitExec | null { + const binary = gitBinary ?? deps.findBinary('git', candidateBinDirs(deps.env.PATH, deps.homeDir)); + if (!binary) return null; + const env = { + ...(cliEnv.PATH !== undefined ? cliEnv : baseEnv(candidateBinDirs(deps.env.PATH, deps.homeDir))), + ...GIT_SAFE_ENV, + }; + return (args) => deps.exec(binary, args, GIT_READ_TIMEOUT_MS, env); + } + + /** HEAD of a checkout this machine can read, for a task's base; null when remote or unknown. */ + async function launchBase(dir: string): Promise { + if (herdrSshTarget()) return null; + const exec = localGit(); + return exec ? readHeadCommit(exec, dir) : null; + } + + async function taskChanges(taskId: string): Promise { + const t = tasks.get(taskId); + if (!t) throwIpcError('NOT_FOUND', 'task not found'); + // A remote herdr's checkouts live on the other machine: nothing here to read. + if (herdrSshTarget()) throwIpcError('PRECONDITION_FAILED', 'changes are not available for a remote herdr'); + let dir: string | null = null; + if (t.isolated) dir = t.worktreePath; + else { + const folder = folderById(t.folderId); + dir = folder?.isGitRepo ? folder.path : null; + } + if (!dir || !deps.fs.isDirectory(dir)) throwIpcError('PRECONDITION_FAILED', 'the task has no git checkout'); + const exec = localGit(); + if (!exec) throwIpcError('PRECONDITION_FAILED', 'git is not available'); + try { + return await readTaskChanges(exec, dir, t.baseCommit); + } catch (err) { + log.warn('workbench.task_changes_failed', { code: errorCode(err) }); + throwIpcError('INTERNAL', 'could not read the changes'); + } + } + // --- polling -------------------------------------------------------------------------- async function poll(): Promise { @@ -2240,6 +2298,7 @@ export function createWorkbenchManager(deps: WorkbenchDeps): WorkbenchManager { stopTask: (id) => endTask(id, 'stop'), removeTask, taskOutput, + taskChanges, runAll, approveTasks, runPlan, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 0943891..71ee389 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -71,6 +71,7 @@ import type { FolderImportResult, FolderListing, TaskOutput, + TaskChanges, WorkbenchChatPage, WorkbenchSnapshot, } from '../shared/workbench'; @@ -515,6 +516,8 @@ const api = { /** The worker's recent terminal text, control characters stripped. */ taskOutput: (taskId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.workbenchTaskOutput, { taskId }), + taskChanges: (taskId: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.workbenchTaskChanges, { taskId }), runAll: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.workbenchTaskRunAll), /** Approve tasks to run; each starts once its dependencies are done. */ approveTasks: (taskIds: string[]): Promise => diff --git a/apps/desktop/src/renderer/features/workbench/FilePreviewPane.tsx b/apps/desktop/src/renderer/features/workbench/FilePreviewPane.tsx index 38439af..f16d44c 100644 --- a/apps/desktop/src/renderer/features/workbench/FilePreviewPane.tsx +++ b/apps/desktop/src/renderer/features/workbench/FilePreviewPane.tsx @@ -2,7 +2,10 @@ // rendered as HTML and never executed. A Markdown file also has a rendered view (MarkdownView // builds React elements from a parsed tree, never HTML); a toggle switches it with the source. The bytes come from the named // `workbench:folder-read` call (folder id + relative path; main confines, caps and refuses -// symlinks/hidden entries). Nothing of it is persisted on this side. +// symlinks/hidden entries). Nothing of it is persisted on this side. While open (and the window +// visible) the file is re-read every couple of seconds, so a plan an agent is writing follows +// along; the rendered view has a table of contents, and its relative links open their files +// here (resolved inside the same folder, and main confines the read again). // // The header can reference the file in the coordinator chat, or show it in the OS file manager // (main resolves the path and only reveals it — it never opens or runs the file). Lines can be @@ -11,7 +14,15 @@ // reference that names lines (`focus`), the preview shows the source, picks them and scrolls // them into view. -import { useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react'; +import { + useContext, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type PointerEvent as ReactPointerEvent, +} from 'react'; import { fontSize, radius, space, token } from '../../themes/tokens'; import { hoverBackground } from '../../lib/hover'; import type { TranslateFn } from '../../i18n'; @@ -20,12 +31,19 @@ import { WORKBENCH_LIMITS, type FilePreview, type FolderView } from '../../../sh import { CloseIcon, FileIcon, RefreshIcon } from './workbenchIcons'; import { IconButton } from './workbenchPanes'; import { formatBytes, lineNumbers, sliceLines, type LineRange } from './workbenchNav'; -import { isMarkdownPath } from './markdown'; -import { MarkdownView } from './MarkdownView'; -import { segmentButtonChrome, segmentedRootStyle } from '../../components/ui/controlStyles'; +import { isMarkdownPath, resolveRelativeHref } from './markdown'; +import { MarkdownModeToggle, MarkdownView, type MarkdownRenderers } from './MarkdownView'; +import { ChatLinksContext, LinkedText } from './workbenchChat'; type Runner = (action: () => Promise) => Promise; +/** How often an open file is re-read to follow changes. */ +const FOLLOW_INTERVAL_MS = 2000; + +function samePreview(a: FilePreview, b: FilePreview): boolean { + return a.size === b.size && a.binary === b.binary && a.truncated === b.truncated && a.text === b.text; +} + type State = { state: 'loading' } | { state: 'ready'; preview: FilePreview } | { state: 'error' }; export function FilePreviewPane({ @@ -62,6 +80,38 @@ export function FilePreviewPane({ }; }, [folder.id, relPath, reload]); + // Follow the file while it is open: an agent writing a plan shows up as it goes. The poll + // only runs while the window is visible, and an unchanged read changes nothing on screen. + const loaded = state.state === 'ready'; + useEffect(() => { + if (!loaded) return; + let live = true; + let busy = false; + const poll = (): void => { + if (busy || document.visibilityState !== 'visible') return; + busy = true; + readFolderFile(folder.id, relPath).then( + (next) => { + busy = false; + if (!live) return; + setState((prev) => + prev.state === 'ready' && samePreview(prev.preview, next) ? prev : { state: 'ready', preview: next }, + ); + }, + () => { + busy = false; + }, + ); + }; + const timer = window.setInterval(poll, FOLLOW_INTERVAL_MS); + window.addEventListener('focus', poll); + return () => { + live = false; + window.clearInterval(timer); + window.removeEventListener('focus', poll); + }; + }, [folder.id, relPath, reload, loaded]); + const text = state.state === 'ready' && !state.preview.binary ? state.preview.text : ''; const markdown = isMarkdownPath(relPath); // Markdown opens rendered; the choice holds while the pane stays open. @@ -155,6 +205,29 @@ export function FilePreviewPane({ }; const rangeLabel = range ? (range.start === range.end ? `${range.start}` : `${range.start}–${range.end}`) : ''; + // Paths in the text link to their files; a relative link opens its file from this folder. + const links = useContext(ChatLinksContext); + const renderers = useMemo( + () => ({ + text: links ? (s) => : undefined, + localLink: (href, children) => { + const target = links ? resolveRelativeHref(relPath, href) : null; + if (!links || !target) return {children}; + return ( + + ); + }, + }), + [links, folder.id, relPath], + ); + const name = relPath.split('/').pop() ?? relPath; const dir = relPath.includes('/') ? relPath.slice(0, relPath.lastIndexOf('/')) : ''; @@ -168,33 +241,7 @@ export function FilePreviewPane({ {state.state === 'ready' && {formatBytes(state.preview.size)}} {markdown && state.state === 'ready' && !state.preview.binary && ( -
- {(['rendered', 'source'] as const).map((m, index) => { - const active = mode === m; - return ( - - ); - })} -
+ )} setReload((n) => n + 1)} size={24}> @@ -240,7 +287,12 @@ export function FilePreviewPane({ {/* One scroller for gutter + text so the numbers stay aligned (lines never wrap). */} {rendered ? (
- +
) : (
ReactNode; + /** A link whose target is not http(s); absent, it shows as plain text. */ + localLink?: (href: string, children: ReactNode) => ReactNode; + /** A fenced code block; absent, a plain `
`. */
+  codeBlock?: (block: { lang: string; text: string }) => ReactNode;
+}
+
+interface Ctx extends MarkdownRenderers {
+  imageLabel: string;
+  heading: Record<1 | 2 | 3 | 4 | 5 | 6, CSSProperties>;
+}
+
+/** Past this width the document shows its table of contents beside the text. */
+const OUTLINE_MIN_WIDTH = 900;
+/** A table of contents only helps once a document has this many headings. */
+const OUTLINE_MIN_HEADINGS = 3;
+
+export function MarkdownView({
+  text,
+  imageLabel,
+  variant = 'document',
+  renderers,
+  outlineLabel,
+}: {
+  text: string;
+  imageLabel: string;
+  /** `document`: a padded reading column (preview); `chat`: fits inside a message. */
+  variant?: 'document' | 'chat';
+  renderers?: MarkdownRenderers;
+  /** Set to show a table of contents (document variant, wide enough, enough headings). */
+  outlineLabel?: string;
+}) {
   const blocks = useMemo(() => parseMarkdown(text), [text]);
-  return 
{renderBlocks(blocks, imageLabel)}
; + const chat = variant === 'chat'; + const ctx: Ctx = { ...renderers, imageLabel, heading: chat ? chatHeadingStyle : headingStyle }; + const body = ( +
+ {renderBlocks(blocks, ctx, true)} +
+ ); + if (chat || !outlineLabel) return body; + return ; +} + +/** The rendered / source switch in a Markdown view's header. */ +export function MarkdownModeToggle({ + mode, + onChange, + t, +}: { + mode: 'rendered' | 'source'; + onChange: (mode: 'rendered' | 'source') => void; + t: TranslateFn; +}) { + return ( +
+ {(['rendered', 'source'] as const).map((m, index) => { + const active = mode === m; + return ( + + ); + })} +
+ ); } -function renderBlocks(blocks: Block[], imageLabel: string): ReactNode { - return blocks.map((block, i) => {renderBlock(block, imageLabel)}); +function WithOutline({ blocks, label, body }: { blocks: Block[]; label: string; body: ReactNode }) { + const outline = useMemo(() => outlineOf(blocks), [blocks]); + const wrapRef = useRef(null); + const [wide, setWide] = useState(false); + useEffect(() => { + const el = wrapRef.current; + if (!el) return; + const observer = new ResizeObserver(() => setWide(el.clientWidth >= OUTLINE_MIN_WIDTH)); + observer.observe(el); + return () => observer.disconnect(); + }, []); + const show = wide && outline.length >= OUTLINE_MIN_HEADINGS; + const minLevel = Math.min(...outline.map((o) => o.level)); + const jump = (block: number): void => { + wrapRef.current?.querySelector(`[data-md-block="${block}"]`)?.scrollIntoView({ block: 'start' }); + }; + return ( +
+
{body}
+ {show && ( + + )} +
+ ); } -function renderBlock(block: Block, imageLabel: string): ReactNode { +function renderBlocks(blocks: Block[], ctx: Ctx, top = false): ReactNode { + return blocks.map((block, i) => {renderBlock(block, ctx, top ? i : undefined)}); +} + +function renderBlock(block: Block, ctx: Ctx, topIndex?: number): ReactNode { switch (block.type) { case 'heading': { const Tag = `h${block.level}` as const; - return {renderInline(block.children, imageLabel)}; + return ( + + {renderInline(block.children, ctx)} + + ); } case 'paragraph': - return

{renderInline(block.children, imageLabel)}

; + return

{renderInline(block.children, ctx)}

; case 'code': - return ( + return ctx.codeBlock ? ( +
{ctx.codeBlock({ lang: block.lang, text: block.text })}
+ ) : (
           {block.text}
         
); case 'quote': - return
{renderBlocks(block.children, imageLabel)}
; + return
{renderBlocks(block.children, ctx)}
; case 'list': { const items = block.items.map((item, i) => (
  • {item.checked !== null && ( )} -
    {renderBlocks(item.children, imageLabel)}
    +
    {renderBlocks(item.children, ctx)}
  • )); return block.ordered ? ( @@ -63,7 +192,7 @@ function renderBlock(block: Block, imageLabel: string): ReactNode { {block.head.map((cell, c) => ( - {renderInline(cell, imageLabel)} + {renderInline(cell, ctx)} ))} @@ -73,7 +202,7 @@ function renderBlock(block: Block, imageLabel: string): ReactNode { {row.map((cell, c) => ( - {renderInline(cell, imageLabel)} + {renderInline(cell, ctx)} ))} @@ -87,40 +216,46 @@ function renderBlock(block: Block, imageLabel: string): ReactNode { } } -function renderInline(nodes: Inline[], imageLabel: string): ReactNode { +function renderInline(nodes: Inline[], ctx: Ctx): ReactNode { return nodes.map((node, i) => { switch (node.type) { case 'text': - return {node.text}; + return {ctx.text ? ctx.text(node.text) : node.text}; case 'br': return
    ; case 'code': return ( - {node.text} + {ctx.text ? ctx.text(node.text) : node.text} ); case 'em': - return {renderInline(node.children, imageLabel)}; + return {renderInline(node.children, ctx)}; case 'strong': - return {renderInline(node.children, imageLabel)}; + return {renderInline(node.children, ctx)}; case 'del': - return {renderInline(node.children, imageLabel)}; - case 'link': - return isExternalHref(node.href) ? ( - - {renderInline(node.children, imageLabel)} - - ) : ( + return {renderInline(node.children, ctx)}; + case 'link': { + // A link's label is shown as written: linking paths inside it would nest buttons. + const label = renderInline(node.children, { ...ctx, text: undefined }); + if (isExternalHref(node.href)) + return ( + + {label} + + ); + if (ctx.localLink) return {ctx.localLink(node.href, label)}; + return ( - {renderInline(node.children, imageLabel)} + {label} ); + } case 'image': return ( - {node.alt || imageLabel} + {node.alt || ctx.imageLabel} ); } @@ -141,6 +276,16 @@ const rootStyle: CSSProperties = { overflowWrap: 'anywhere', }; +// A message: no reading-column padding; the bubble's line height and width rule. +const chatRootStyle: CSSProperties = { + maxWidth: '100%', + fontSize: fontSize.md, + lineHeight: 1.65, + color: token('text'), + userSelect: 'text', + overflowWrap: 'anywhere', +}; + const headingBase: CSSProperties = { margin: `${space.xl}px 0 ${space.md}px`, fontWeight: 600, lineHeight: 1.3 }; const headingRule: CSSProperties = { paddingBottom: space.sm, borderBottom: `1px solid ${token('border')}` }; const headingStyle: Record<1 | 2 | 3 | 4 | 5 | 6, CSSProperties> = { @@ -152,6 +297,16 @@ const headingStyle: Record<1 | 2 | 3 | 4 | 5 | 6, CSSProperties> = { 6: { ...headingBase, fontSize: fontSize.base, color: token('textMuted') }, }; +const chatHeadingBase: CSSProperties = { margin: `${space.lg}px 0 ${space.sm}px`, fontWeight: 600, lineHeight: 1.35 }; +const chatHeadingStyle: Record<1 | 2 | 3 | 4 | 5 | 6, CSSProperties> = { + 1: { ...chatHeadingBase, fontSize: 18 }, + 2: { ...chatHeadingBase, fontSize: 16 }, + 3: { ...chatHeadingBase, fontSize: fontSize.lg }, + 4: { ...chatHeadingBase, fontSize: fontSize.md }, + 5: { ...chatHeadingBase, fontSize: fontSize.md }, + 6: { ...chatHeadingBase, fontSize: fontSize.base, color: token('textMuted') }, +}; + const paragraphStyle: CSSProperties = { margin: `0 0 ${space.lg}px` }; const codeBlockStyle: CSSProperties = { @@ -221,3 +376,40 @@ const imageStyle: CSSProperties = { color: token('textMuted'), fontSize: fontSize.sm, }; + +const outlineStyle: CSSProperties = { + position: 'sticky', + top: 0, + width: 220, + flexShrink: 0, + maxHeight: '100vh', + overflowY: 'auto', + boxSizing: 'border-box', + padding: `${space.xl}px ${space.lg}px ${space.xl}px 0`, + display: 'flex', + flexDirection: 'column', + gap: 2, +}; +const outlineTitleStyle: CSSProperties = { + margin: `0 0 ${space.sm}px`, + paddingLeft: space.sm, + fontSize: fontSize.xs, + fontWeight: 600, + color: token('textMuted'), +}; +const outlineItemStyle: CSSProperties = { + display: 'block', + width: '100%', + padding: `3px ${space.sm}px`, + border: 'none', + borderRadius: radius.sm, + background: 'transparent', + color: token('textMuted'), + font: 'inherit', + fontSize: fontSize.sm, + textAlign: 'left', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + cursor: 'pointer', +}; diff --git a/apps/desktop/src/renderer/features/workbench/SnippetPane.tsx b/apps/desktop/src/renderer/features/workbench/SnippetPane.tsx index cfc5d14..db1d48f 100644 --- a/apps/desktop/src/renderer/features/workbench/SnippetPane.tsx +++ b/apps/desktop/src/renderer/features/workbench/SnippetPane.tsx @@ -1,13 +1,17 @@ // A temporary, read-only view of a code block from a chat message that has no source file // (pasted or typed code): the text with line numbers, never rendered as HTML or run. The text -// is the message's own and stays in memory; closing the view drops it. +// is the message's own and stays in memory; closing the view drops it. A coordinator reply +// opened "as a document" (`lang` markdown) shows rendered, with a table of contents and a +// switch to its source. -import type { CSSProperties } from 'react'; +import { useState, type CSSProperties } from 'react'; import { fontSize, space, token } from '../../themes/tokens'; import type { TranslateFn } from '../../i18n'; import { CloseIcon, FileIcon } from './workbenchIcons'; import { IconButton } from './workbenchPanes'; import { lineNumbers } from './workbenchNav'; +import { MarkdownModeToggle, MarkdownView } from './MarkdownView'; +import { useChatMarkdownRenderers } from './workbenchChat'; export function SnippetPane({ text, @@ -22,7 +26,10 @@ export function SnippetPane({ }) { const gutter = lineNumbers(text); const lines = gutter.split('\n').length; - const title = t('workbench.chat.snippetTitle'); + const markdown = lang === 'markdown'; + const [mode, setMode] = useState<'rendered' | 'source'>('rendered'); + const renderers = useChatMarkdownRenderers(t); + const title = t(markdown ? 'workbench.chat.documentTitle' : 'workbench.chat.snippetTitle'); return (
    @@ -33,17 +40,29 @@ export function SnippetPane({ {[lang, t('workbench.chat.snippetLines').replace('{{lines}}', String(lines))].filter(Boolean).join(' · ')}
    + {markdown && }
    - {/* One scroller for gutter + text so the numbers stay aligned (lines never wrap). */} -
    -
    -          {gutter}
    -        
    -
    {text}
    -
    + {markdown && mode === 'rendered' ? ( +
    + +
    + ) : ( + // One scroller for gutter + text so the numbers stay aligned (lines never wrap). +
    +
    +            {gutter}
    +          
    +
    {text}
    +
    + )} ); } @@ -94,6 +113,8 @@ const scrollStyle: CSSProperties = { outline: 'none', }; +const renderedScrollStyle: CSSProperties = { flex: 1, minHeight: 0, overflow: 'auto', outline: 'none' }; + const preBase: CSSProperties = { margin: 0, padding: `${space.md}px 0`, diff --git a/apps/desktop/src/renderer/features/workbench/TaskChangesPane.tsx b/apps/desktop/src/renderer/features/workbench/TaskChangesPane.tsx new file mode 100644 index 0000000..8dad5e6 --- /dev/null +++ b/apps/desktop/src/renderer/features/workbench/TaskChangesPane.tsx @@ -0,0 +1,306 @@ +// What a task changed, read-only: the files of its checkout against the commit it started +// from, each expandable into its diff. Main picks the checkout from its own task record and +// caps what it returns; nothing here writes, merges, or reverts. + +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; +import { fontSize, radius, space, token } from '../../themes/tokens'; +import { hoverBackground } from '../../lib/hover'; +import type { TranslateFn } from '../../i18n'; +import { readTaskChanges } from '../../lib/workbenchStore'; +import { ipcErrorFromUnknown } from '../../../shared/ipc-errors'; +import type { TaskChangedFile, TaskChanges, TaskChangeStatus, TaskView } from '../../../shared/workbench'; +import { changeTotals, diffRows, type DiffRow } from './diffView'; +import { ChevronIcon, RefreshIcon } from './workbenchIcons'; + +type State = + { kind: 'loading' } | { kind: 'ready'; changes: TaskChanges } | { kind: 'unavailable' } | { kind: 'error' }; + +const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'; + +const STATUS_MARK: Record = { + added: 'A', + modified: 'M', + deleted: 'D', + renamed: 'R', + untracked: 'U', +}; + +export function TaskChangesPane({ task, t }: { task: TaskView; t: TranslateFn }) { + const [state, setState] = useState({ kind: 'loading' }); + const [open, setOpen] = useState>(() => new Set()); + const seq = useRef(0); + + const load = useCallback(() => { + const mine = ++seq.current; + setState((s) => (s.kind === 'ready' ? s : { kind: 'loading' })); + readTaskChanges(task.id) + .then((changes) => { + if (mine === seq.current) setState({ kind: 'ready', changes }); + }) + .catch((err: unknown) => { + if (mine !== seq.current) return; + setState({ kind: ipcErrorFromUnknown(err).code === 'PRECONDITION_FAILED' ? 'unavailable' : 'error' }); + }); + }, [task.id]); + + // Re-read when the task moves (a worker stopping for review is when its changes matter). + useEffect(() => { + load(); + }, [load, task.status]); + + useEffect(() => { + setOpen(new Set()); + }, [task.id]); + + const toggle = (path: string): void => + setOpen((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + + const changes = state.kind === 'ready' ? state.changes : null; + const totals = useMemo(() => (changes ? changeTotals(changes.files) : null), [changes]); + + return ( +
    +
    + + {changes && totals ? ( + <> + {t('workbench.changes.files').replace('{{count}}', String(changes.files.length))} + {changes.files.length > 0 && ( + <> + {' · '} + +{totals.additions}{' '} + −{totals.deletions} + + )} + {' · '} + {changes.since === 'launch' + ? task.isolated + ? t('workbench.changes.sinceLaunch') + : t('workbench.changes.sinceLaunchInPlace') + : t('workbench.changes.sinceHead')} + + ) : null} + + +
    +
    + {state.kind === 'loading' &&

    {t('workbench.changes.loading')}

    } + {state.kind === 'unavailable' &&

    {t('workbench.changes.unavailable')}

    } + {state.kind === 'error' && ( +

    {t('workbench.changes.error')}

    + )} + {changes && changes.files.length === 0 &&

    {t('workbench.changes.empty')}

    } + {changes && changes.truncated &&

    {t('workbench.changes.truncated')}

    } + {changes && changes.files.length > 0 && ( +
      + {changes.files.map((f) => ( + toggle(f.path)} t={t} /> + ))} +
    + )} +
    +
    + ); +} + +function FileRow({ + file, + open, + onToggle, + t, +}: { + file: TaskChangedFile; + open: boolean; + onToggle: () => void; + t: TranslateFn; +}) { + const rows = useMemo(() => (open && file.patch ? diffRows(file.patch) : []), [open, file.patch]); + const markColor = + file.status === 'deleted' + ? token('danger') + : file.status === 'modified' || file.status === 'renamed' + ? token('warning') + : token('success'); + const label = file.oldPath ? `${file.oldPath} → ${file.path}` : file.path; + return ( +
  • + + {open && ( +
    + {file.patch === null || rows.length === 0 ? ( +

    + {file.additions === null + ? t('workbench.changes.binary') + : file.additions === 0 && file.deletions === 0 + ? t('workbench.changes.noContent') + : t('workbench.changes.noPatch')} +

    + ) : ( + + + {rows.map((r, i) => ( + + ))} + +
    + )} +
    + )} +
  • + ); +} + +function DiffLine({ row }: { row: DiffRow }) { + if (row.kind === 'hunk' || row.kind === 'note') { + return ( + + + {row.text} + + + ); + } + const color = row.kind === 'add' ? token('success') : row.kind === 'del' ? token('danger') : token('text'); + const sign = row.kind === 'add' ? '+' : row.kind === 'del' ? '−' : ' '; + return ( + + {row.oldNo ?? ''} + {row.newNo ?? ''} + + + {sign} + + {row.text} + + + ); +} + +const barStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: space.sm, + padding: `${space.xs}px ${space.md}px`, + borderBottom: `1px solid ${token('border')}`, + flexShrink: 0, +}; + +const iconButtonStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 24, + height: 24, + padding: 0, + border: 'none', + borderRadius: radius.sm, + background: 'transparent', + color: token('textMuted'), + cursor: 'pointer', + flexShrink: 0, +}; + +const hintStyle: CSSProperties = { + margin: 0, + padding: `${space.sm}px ${space.md}px`, + fontSize: fontSize.sm, + color: token('textMuted'), +}; + +const rowStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: space.sm, + width: '100%', + padding: `${space.xs}px ${space.md}px`, + border: 'none', + background: 'transparent', + color: token('text'), + cursor: 'pointer', + textAlign: 'left', + fontSize: fontSize.sm, +}; + +const pathStyle: CSSProperties = { + flex: 1, + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontFamily: MONO, + fontSize: fontSize.sm, + direction: 'rtl', + textAlign: 'left', + unicodeBidi: 'plaintext', +}; + +const patchBoxStyle: CSSProperties = { + overflowX: 'auto', + background: token('surface'), + borderTop: `1px solid ${token('border')}`, +}; + +const tableStyle: CSSProperties = { + borderCollapse: 'collapse', + minWidth: '100%', + fontFamily: MONO, + fontSize: fontSize.xs, + lineHeight: 1.5, +}; + +const cellStyle: CSSProperties = { + padding: `0 ${space.md}px 0 ${space.xs}px`, + whiteSpace: 'pre', + verticalAlign: 'top', +}; + +const numStyle: CSSProperties = { + padding: `0 ${space.xs}px`, + textAlign: 'right', + color: token('textMuted'), + userSelect: 'none', + verticalAlign: 'top', + minWidth: 32, + whiteSpace: 'nowrap', +}; diff --git a/apps/desktop/src/renderer/features/workbench/TaskNodeDetail.tsx b/apps/desktop/src/renderer/features/workbench/TaskNodeDetail.tsx index 29864fe..66d1ad5 100644 --- a/apps/desktop/src/renderer/features/workbench/TaskNodeDetail.tsx +++ b/apps/desktop/src/renderer/features/workbench/TaskNodeDetail.tsx @@ -4,7 +4,7 @@ // Every action is the same IPC call the task board makes; main re-checks each one. The // disabled states here only mirror main's rules (TASK_ACTIONS, proposalBlocker). -import { useState, type CSSProperties, type ReactNode } from 'react'; +import { useEffect, useState, type CSSProperties, type ReactNode } from 'react'; import { disabledOpacity, fontSize, radius, space, token } from '../../themes/tokens'; import { hoverBackground } from '../../lib/hover'; import type { TranslateFn } from '../../i18n'; @@ -20,6 +20,7 @@ import { ConfirmDialog } from '../../components/ConfirmDialog'; import { pendingDependencies, taskAllows, type TaskView, type WorkbenchSnapshot } from '../../../shared/workbench'; import { StatusLabel } from './TaskStatusLabel'; import { TaskChatPane } from './TaskChatPane'; +import { TaskChangesPane } from './TaskChangesPane'; import { TaskForm } from './TaskForm'; import { proposalBlocker } from './proposalBlocker'; @@ -48,6 +49,12 @@ export function TaskNodeDetail({ .filter((title): title is string => title !== undefined); const blocker = taskAllows('launch', task.status) ? proposalBlocker(task, wb) : null; const canLaunchNow = taskAllows('launch', task.status) && blocker === null; + // A task has a checkout to diff once it ran in a git folder (its worktree, or the folder itself). + const hasChanges = + folder?.isGitRepo === true && task.status !== 'proposed' && (!task.isolated || task.worktreeDisplay !== null); + const [view, setView] = useState<'chat' | 'changes'>('chat'); + useEffect(() => setView('chat'), [task.id]); + const shownView = hasChanges ? view : 'chat'; if (editing) { return ( @@ -148,7 +155,32 @@ export function TaskNodeDetail({

    {t('workbench.plan.rerunHint')}

    )} - + {hasChanges && ( +
    + {(['chat', 'changes'] as const).map((v) => ( + + ))} +
    + )} + {/* The chat stays mounted behind the changes view so its draft survives a switch. */} +
    + +
    + {shownView === 'changes' && } {confirmRemove && ( setView(beforeFile)} /> ) : view.kind === 'snippet' && snippet?.id === view.id ? ( - setView(beforeFile)} /> + setView(beforeFile)} /> ) : view.kind === 'timeline' ? ( setView({ kind: 'chat' })} /> ) : view.kind === 'task' && selectedTask ? ( @@ -668,11 +667,7 @@ function ChatEntry({ item, tasks, t }: { item: ChatItem; tasks: readonly TaskVie
    {item.thinking}
    )} - {item.text && ( -
    - -
    - )} + {item.text && } {item.streaming && item.text === '' && } ); diff --git a/apps/desktop/src/renderer/features/workbench/__tests__/diffView.test.ts b/apps/desktop/src/renderer/features/workbench/__tests__/diffView.test.ts new file mode 100644 index 0000000..3c20ce3 --- /dev/null +++ b/apps/desktop/src/renderer/features/workbench/__tests__/diffView.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { changeTotals, diffRows } from '../diffView'; + +describe('diffRows', () => { + it('drops the file header and numbers lines per hunk', () => { + const patch = [ + 'diff --git a/a.ts b/a.ts', + 'index 1..2 100644', + '--- a/a.ts', + '+++ b/a.ts', + '@@ -3,3 +3,3 @@ fn', + ' keep', + '-old', + '+new', + '\\ No newline at end of file', + '', + ].join('\n'); + expect(diffRows(patch)).toEqual([ + { kind: 'hunk', text: '@@ -3,3 +3,3 @@ fn', oldNo: null, newNo: null }, + { kind: 'ctx', text: 'keep', oldNo: 3, newNo: 3 }, + { kind: 'del', text: 'old', oldNo: 4, newNo: null }, + { kind: 'add', text: 'new', oldNo: null, newNo: 4 }, + { kind: 'note', text: 'No newline at end of file', oldNo: null, newNo: null }, + ]); + }); + + it('shows nothing for a patch without hunks (binary, mode change)', () => { + expect(diffRows('diff --git a/x b/x\nBinary files a/x and b/x differ\n')).toEqual([]); + }); + + it('adds up counts, skipping binaries', () => { + expect( + changeTotals([ + { additions: 2, deletions: 1 }, + { additions: null, deletions: null }, + { additions: 3, deletions: 0 }, + ]), + ).toEqual({ additions: 5, deletions: 1 }); + }); +}); diff --git a/apps/desktop/src/renderer/features/workbench/__tests__/markdown.test.ts b/apps/desktop/src/renderer/features/workbench/__tests__/markdown.test.ts index 0c38f4b..8d7a32e 100644 --- a/apps/desktop/src/renderer/features/workbench/__tests__/markdown.test.ts +++ b/apps/desktop/src/renderer/features/workbench/__tests__/markdown.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { isExternalHref, isMarkdownPath, parseInline, parseMarkdown, plainText } from '../markdown'; +import { + isDocumentReply, + isExternalHref, + isMarkdownPath, + outlineOf, + parseInline, + parseMarkdown, + plainText, + resolveRelativeHref, +} from '../markdown'; describe('markdown', () => { it('recognises Markdown file names', () => { @@ -131,3 +140,51 @@ describe('markdown', () => { expect(performance.now() - started).toBeLessThan(2000); }); }); + +describe('outlineOf', () => { + it('lists top-level headings down to level 3 with their block index', () => { + const blocks = parseMarkdown('# Plan\n\nintro\n\n## Step **one**\n\n#### deep\n\n> # quoted\n\n### Risks'); + expect(outlineOf(blocks)).toEqual([ + { block: 0, level: 1, text: 'Plan' }, + { block: 2, level: 2, text: 'Step one' }, + { block: 5, level: 3, text: 'Risks' }, + ]); + }); +}); + +describe('resolveRelativeHref', () => { + it('resolves a relative link against the file and drops anchors / queries', () => { + expect(resolveRelativeHref('docs/plan.md', 'step-1.md')).toBe('docs/step-1.md'); + expect(resolveRelativeHref('docs/plan.md', './a/b.md#part')).toBe('docs/a/b.md'); + expect(resolveRelativeHref('docs/plan.md', '../src/x.ts?raw')).toBe('src/x.ts'); + expect(resolveRelativeHref('plan.md', 'my%20notes.md')).toBe('my notes.md'); + }); + + it('refuses anything outside the folder or not a relative path', () => { + for (const href of [ + '../outside.md', + 'docs/../../x', + '/etc/passwd', + 'file:///etc/passwd', + 'javascript:alert(1)', + 'mailto:a@b.c', + '#top', + '', + 'a\\b.md', + 'a%5Cb.md', + '%E0%A4%A', + 'x%00.md', + ]) { + expect(resolveRelativeHref('plan.md', href)).toBeNull(); + } + }); +}); + +describe('isDocumentReply', () => { + it('flags long or plan-shaped replies only', () => { + expect(isDocumentReply('Done — 3 files changed.')).toBe(false); + expect(isDocumentReply('## Plan\n\n1. a')).toBe(true); + expect(isDocumentReply('| a | b |\n|---|---|\n| 1 | 2 |')).toBe(true); + expect(isDocumentReply('x'.repeat(800))).toBe(true); + }); +}); diff --git a/apps/desktop/src/renderer/features/workbench/diffView.ts b/apps/desktop/src/renderer/features/workbench/diffView.ts new file mode 100644 index 0000000..cb12a26 --- /dev/null +++ b/apps/desktop/src/renderer/features/workbench/diffView.ts @@ -0,0 +1,54 @@ +// A unified diff of one file, as rows the changes view shows: hunk headers, then context, +// added and removed lines with their old / new line numbers. The `diff --git` / `index` / +// `---` / `+++` header before the first hunk is dropped (the row above already names the file). + +export type DiffRowKind = 'hunk' | 'add' | 'del' | 'ctx' | 'note'; + +export interface DiffRow { + kind: DiffRowKind; + text: string; + oldNo: number | null; + newNo: number | null; +} + +const HUNK_RE = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/; + +export function diffRows(patch: string): DiffRow[] { + const rows: DiffRow[] = []; + let oldNo = 0; + let newNo = 0; + let inHunk = false; + const lines = patch.split('\n'); + if (lines.at(-1) === '') lines.pop(); + for (const line of lines) { + const hunk = HUNK_RE.exec(line); + if (hunk) { + inHunk = true; + oldNo = Number(hunk[1]); + newNo = Number(hunk[2]); + rows.push({ kind: 'hunk', text: line, oldNo: null, newNo: null }); + continue; + } + if (!inHunk) continue; + if (line.startsWith('+')) rows.push({ kind: 'add', text: line.slice(1), oldNo: null, newNo: newNo++ }); + else if (line.startsWith('-')) rows.push({ kind: 'del', text: line.slice(1), oldNo: oldNo++, newNo: null }); + else if (line.startsWith('\\')) rows.push({ kind: 'note', text: line.slice(1).trim(), oldNo: null, newNo: null }); + else if (line.startsWith('diff --git ')) inHunk = false; + else rows.push({ kind: 'ctx', text: line.slice(1), oldNo: oldNo++, newNo: newNo++ }); + } + return rows; +} + +/** Totals over the files that report counts (binary files report none). */ +export function changeTotals(files: readonly { additions: number | null; deletions: number | null }[]): { + additions: number; + deletions: number; +} { + let additions = 0; + let deletions = 0; + for (const f of files) { + additions += f.additions ?? 0; + deletions += f.deletions ?? 0; + } + return { additions, deletions }; +} diff --git a/apps/desktop/src/renderer/features/workbench/markdown.ts b/apps/desktop/src/renderer/features/workbench/markdown.ts index 88fe1c5..0f905ad 100644 --- a/apps/desktop/src/renderer/features/workbench/markdown.ts +++ b/apps/desktop/src/renderer/features/workbench/markdown.ts @@ -491,3 +491,55 @@ export function plainText(nodes: Inline[]): string { ) .join(''); } + +export interface OutlineEntry { + /** The heading's index among the document's top-level blocks. */ + block: number; + level: 1 | 2 | 3; + text: string; +} + +/** The document's top-level headings down to level 3, for a table of contents. */ +export function outlineOf(blocks: Block[]): OutlineEntry[] { + const out: OutlineEntry[] = []; + blocks.forEach((b, i) => { + if (b.type !== 'heading' || b.level > 3) return; + const text = plainText(b.children).replace(/\s+/g, ' ').trim(); + if (text) out.push({ block: i, level: b.level as 1 | 2 | 3, text }); + }); + return out; +} + +/** + * The file a relative link in the Markdown file at `fromRelPath` points to, as a path relative + * to the same folder; null for anything else (a URL or other scheme, an absolute path, a pure + * `#anchor`, or a path that climbs out of the folder). Main checks the path again on read. + */ +export function resolveRelativeHref(fromRelPath: string, href: string): string | null { + const bare = href.trim().replace(/[?#].*$/s, ''); + if (bare === '' || bare.includes('\\') || bare.startsWith('/') || /^[a-z][a-z0-9+.-]*:/i.test(bare)) return null; + let decoded: string; + try { + decoded = decodeURIComponent(bare); + } catch { + return null; + } + if (decoded.includes('\0') || decoded.includes('\\')) return null; + const parts = fromRelPath.split('/').slice(0, -1); + for (const seg of decoded.split('/')) { + if (seg === '' || seg === '.') continue; + if (seg === '..') { + if (parts.length === 0) return null; + parts.pop(); + } else parts.push(seg); + } + return parts.length > 0 ? parts.join('/') : null; +} + +/** A reply past this many characters reads better as a document. */ +const DOCUMENT_REPLY_CHARS = 800; + +/** Whether a chat reply is worth opening as a document: long, or structured like a plan. */ +export function isDocumentReply(text: string): boolean { + return text.length >= DOCUMENT_REPLY_CHARS || /^(#{1,3} |\|.*\|\s*$)/m.test(text); +} diff --git a/apps/desktop/src/renderer/features/workbench/workbenchChat.tsx b/apps/desktop/src/renderer/features/workbench/workbenchChat.tsx index dec9f08..232095c 100644 --- a/apps/desktop/src/renderer/features/workbench/workbenchChat.tsx +++ b/apps/desktop/src/renderer/features/workbench/workbenchChat.tsx @@ -41,6 +41,8 @@ import { type RefFolder, } from './fileRefs'; import { hasEntryDrag, readEntryDrag, type DraggedEntry } from './entryDrag'; +import { MarkdownView, type MarkdownRenderers } from './MarkdownView'; +import { isDocumentReply } from './markdown'; /** * What chat messages can open: a file reference opens the file preview (at its lines), a code @@ -100,6 +102,107 @@ export function LinkedText({ text, render = (s) => s }: { text: string; render?: return <>{parts}; } +/** A Markdown link to a local path: a link to the file when it resolves, plain text otherwise. */ +function ChatLocalLink({ href, children }: { href: string; children: ReactNode }) { + const links = useContext(ChatLinksContext); + const folders = links?.folders; + const match = useMemo(() => { + if (!folders) return null; + const target = href.trim(); + return findPathRefs(target, folders).find((c) => c.index === 0 && c.length === target.length) ?? null; + }, [href, folders]); + const probe = links?.probe; + useEffect(() => { + if (probe && match && !match.explicit) probe(match.targets); + }, [match, probe]); + const target = + !links || !match + ? undefined + : match.explicit + ? match.targets[0] + : match.targets.find((t) => links.fileExists(t) === true); + if (!links || !target) return {children}; + return ( + + ); +} + +/** How Markdown from a chat reply links: paths open their files, code blocks show like chat's. */ +export function useChatMarkdownRenderers(t: TranslateFn): MarkdownRenderers { + const links = useContext(ChatLinksContext); + return useMemo( + () => ({ + text: (s) => , + localLink: (href, children) => {children}, + codeBlock: ({ lang, text }) => ( + links.openSnippet({ text, lang }) } + : undefined, + }} + maxLines={COLLAPSE_CODE_LINES} + t={t} + render={(s) => s} + /> + ), + }), + [links, t], + ); +} + +/** + * A coordinator reply rendered as Markdown. Paths in it link to their files, code blocks show + * like the user's (collapsible, openable), and a finished long reply can open as a document. + */ +export function AssistantMarkdown({ text, t, done }: { text: string; t: TranslateFn; done: boolean }) { + const links = useContext(ChatLinksContext); + const renderers = useChatMarkdownRenderers(t); + return ( +
    +
    + +
    + {done && links && isDocumentReply(text) && ( + + )} +
    + ); +} + +const openDocumentStyle: CSSProperties = { + padding: '2px 8px', + marginLeft: -8, + border: 'none', + borderRadius: radius.sm, + background: 'transparent', + color: token('textMuted'), + font: 'inherit', + fontSize: fontSize.sm, + cursor: 'pointer', +}; + const refLinkStyle: CSSProperties = { display: 'inline', padding: 0, diff --git a/apps/desktop/src/renderer/global.css b/apps/desktop/src/renderer/global.css index f2955a1..0c345ca 100644 --- a/apps/desktop/src/renderer/global.css +++ b/apps/desktop/src/renderer/global.css @@ -154,3 +154,12 @@ body { .aiopt-chat-input::placeholder { color: var(--color-text-muted); } + +/* Rendered Markdown (features/workbench/MarkdownView.tsx): no gap above the first block or + * below the last, so a chat reply sits flush in its row. */ +.aiopt-md > :first-child { + margin-top: 0 !important; +} +.aiopt-md > :last-child { + margin-bottom: 0 !important; +} diff --git a/apps/desktop/src/renderer/i18n/locales/de/common.json b/apps/desktop/src/renderer/i18n/locales/de/common.json index 34f5e5a..23f709d 100644 --- a/apps/desktop/src/renderer/i18n/locales/de/common.json +++ b/apps/desktop/src/renderer/i18n/locales/de/common.json @@ -933,6 +933,8 @@ "openInFile": "In Datei öffnen", "openSnippet": "Anzeigen", "snippetTitle": "Codeausschnitt", + "documentTitle": "Antwort als Dokument", + "openAsDocument": "Als Dokument öffnen", "snippetLines": "{{lines}} Zeilen", "pasteLines": "Eingefügt #{{n}} · {{lines}} Zeilen", "pasteChars": "Eingefügt #{{n}} · {{chars}} Zeichen", @@ -1007,6 +1009,7 @@ "insertHint": "Text markieren oder auf die Zeilennummern klicken (Umschalt-Klick oder Ziehen für einen Bereich), um nur diese Zeilen zu zitieren.", "linesPicked": "Zeilen {{lines}} ausgewählt. Esc hebt die Auswahl auf.", "viewMode": "Ansicht", + "outline": "Inhalt", "rendered": "Vorschau", "source": "Quelltext", "image": "Bild", @@ -1097,6 +1100,30 @@ "stalled": "Braucht Sie" } }, + "changes": { + "tab": "Änderungen", + "tabChat": "Chat", + "files": "Dateien: {{count}}", + "sinceLaunch": "Seit Start der Aufgabe", + "sinceLaunchInPlace": "Alles, was sich in diesem Ordner seit Start der Aufgabe geändert hat, einschließlich Ihrer eigenen Bearbeitungen", + "sinceHead": "Nicht committete Änderungen (Start-Commit unbekannt)", + "loading": "Änderungen werden gelesen…", + "empty": "Keine Änderungen", + "unavailable": "Diese Aufgabe hat keinen Git-Checkout, dessen Änderungen angezeigt werden können.", + "error": "Änderungen konnten nicht gelesen werden.", + "truncated": "Zu viele Änderungen: Einige Dateien oder Diffs werden nicht angezeigt.", + "refresh": "Aktualisieren", + "binary": "Binärdatei", + "noPatch": "Diff zu groß zum Anzeigen", + "noContent": "Keine inhaltlichen Änderungen", + "status": { + "added": "Hinzugefügt", + "modified": "Geändert", + "deleted": "Gelöscht", + "renamed": "Umbenannt", + "untracked": "Nicht verfolgt" + } + }, "taskChat": { "title": "Aufgaben-Chat", "empty": "Wählen Sie eine Aufgabe, dann erscheint der Start-Prompt hier.", diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index dd6f553..7ee4c75 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -933,6 +933,8 @@ "openInFile": "Open in file", "openSnippet": "View", "snippetTitle": "Code snippet", + "documentTitle": "Reply as document", + "openAsDocument": "Open as document", "snippetLines": "{{lines}} lines", "pasteLines": "Pasted #{{n}} · {{lines}} lines", "pasteChars": "Pasted #{{n}} · {{chars}} chars", @@ -1007,6 +1009,7 @@ "insertHint": "Select text or click the line numbers (shift-click or drag for a range) to reference only those lines.", "linesPicked": "Lines {{lines}} picked. Press Esc to clear.", "viewMode": "View", + "outline": "Contents", "rendered": "Preview", "source": "Source", "image": "Image", @@ -1097,6 +1100,30 @@ "stalled": "Needs you" } }, + "changes": { + "tab": "Changes", + "tabChat": "Chat", + "files": "Files: {{count}}", + "sinceLaunch": "Since the task started", + "sinceLaunchInPlace": "Everything changed in this folder since the task started, including your own edits", + "sinceHead": "Uncommitted changes (the starting commit is unknown)", + "loading": "Reading changes…", + "empty": "No changes", + "unavailable": "This task has no git checkout to show changes for.", + "error": "Couldn’t read the changes.", + "truncated": "Too many changes: some files or diffs are not shown.", + "refresh": "Refresh", + "binary": "Binary file", + "noPatch": "Diff too large to show", + "noContent": "No content changes", + "status": { + "added": "Added", + "modified": "Modified", + "deleted": "Deleted", + "renamed": "Renamed", + "untracked": "Untracked" + } + }, "taskChat": { "title": "Task chat", "empty": "The initial prompt appears here after you select a task.", diff --git a/apps/desktop/src/renderer/i18n/locales/es/common.json b/apps/desktop/src/renderer/i18n/locales/es/common.json index 53918db..47d19ba 100644 --- a/apps/desktop/src/renderer/i18n/locales/es/common.json +++ b/apps/desktop/src/renderer/i18n/locales/es/common.json @@ -933,6 +933,8 @@ "openInFile": "Abrir en el archivo", "openSnippet": "Ver", "snippetTitle": "Fragmento de código", + "documentTitle": "Respuesta como documento", + "openAsDocument": "Abrir como documento", "snippetLines": "{{lines}} líneas", "pasteLines": "Pegado #{{n}} · {{lines}} líneas", "pasteChars": "Pegado #{{n}} · {{chars}} caracteres", @@ -1007,6 +1009,7 @@ "insertHint": "Selecciona texto o haz clic en los números de línea (Mayús+clic o arrastra para un rango) para citar solo esas líneas.", "linesPicked": "Líneas {{lines}} seleccionadas. Pulsa Esc para quitar la selección.", "viewMode": "Vista", + "outline": "Contenido", "rendered": "Vista previa", "source": "Código fuente", "image": "Imagen", @@ -1097,6 +1100,30 @@ "stalled": "Necesita tu atención" } }, + "changes": { + "tab": "Cambios", + "tabChat": "Chat", + "files": "Archivos: {{count}}", + "sinceLaunch": "Desde que empezó la tarea", + "sinceLaunchInPlace": "Todo lo que cambió en esta carpeta desde que empezó la tarea, incluidas tus propias ediciones", + "sinceHead": "Cambios sin confirmar (se desconoce el commit inicial)", + "loading": "Leyendo cambios…", + "empty": "Sin cambios", + "unavailable": "Esta tarea no tiene un checkout de git del que mostrar cambios.", + "error": "No se pudieron leer los cambios.", + "truncated": "Demasiados cambios: algunos archivos o diffs no se muestran.", + "refresh": "Actualizar", + "binary": "Archivo binario", + "noPatch": "Diff demasiado grande para mostrarlo", + "noContent": "Sin cambios en el contenido", + "status": { + "added": "Añadido", + "modified": "Modificado", + "deleted": "Eliminado", + "renamed": "Renombrado", + "untracked": "Sin seguimiento" + } + }, "taskChat": { "title": "Chat de la tarea", "empty": "Selecciona una tarea para ver el prompt inicial aquí.", diff --git a/apps/desktop/src/renderer/i18n/locales/fr/common.json b/apps/desktop/src/renderer/i18n/locales/fr/common.json index c568a88..b348ba7 100644 --- a/apps/desktop/src/renderer/i18n/locales/fr/common.json +++ b/apps/desktop/src/renderer/i18n/locales/fr/common.json @@ -933,6 +933,8 @@ "openInFile": "Ouvrir dans le fichier", "openSnippet": "Afficher", "snippetTitle": "Extrait de code", + "documentTitle": "Réponse en document", + "openAsDocument": "Ouvrir comme document", "snippetLines": "{{lines}} lignes", "pasteLines": "Collé #{{n}} · {{lines}} lignes", "pasteChars": "Collé #{{n}} · {{chars}} caractères", @@ -1007,6 +1009,7 @@ "insertHint": "Sélectionnez du texte ou cliquez sur les numéros de ligne (Maj+clic ou glisser pour une plage) pour ne citer que ces lignes.", "linesPicked": "Lignes {{lines}} sélectionnées. Échap pour annuler.", "viewMode": "Affichage", + "outline": "Sommaire", "rendered": "Aperçu", "source": "Source", "image": "Image", @@ -1097,6 +1100,30 @@ "stalled": "Action requise" } }, + "changes": { + "tab": "Modifications", + "tabChat": "Discussion", + "files": "Fichiers : {{count}}", + "sinceLaunch": "Depuis le début de la tâche", + "sinceLaunchInPlace": "Tout ce qui a changé dans ce dossier depuis le début de la tâche, y compris vos propres modifications", + "sinceHead": "Modifications non validées (le commit de départ est inconnu)", + "loading": "Lecture des modifications…", + "empty": "Aucune modification", + "unavailable": "Cette tâche n’a pas d’extraction git dont afficher les modifications.", + "error": "Impossible de lire les modifications.", + "truncated": "Trop de modifications : certains fichiers ou diffs ne sont pas affichés.", + "refresh": "Actualiser", + "binary": "Fichier binaire", + "noPatch": "Diff trop volumineux pour être affiché", + "noContent": "Aucune modification du contenu", + "status": { + "added": "Ajouté", + "modified": "Modifié", + "deleted": "Supprimé", + "renamed": "Renommé", + "untracked": "Non suivi" + } + }, "taskChat": { "title": "Chat de la tâche", "empty": "Sélectionnez une tâche pour afficher le prompt initial ici.", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index 1f86a37..b4a62ef 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -933,6 +933,8 @@ "openInFile": "ファイルで開く", "openSnippet": "表示", "snippetTitle": "コードスニペット", + "documentTitle": "返信ドキュメント", + "openAsDocument": "ドキュメントとして開く", "snippetLines": "{{lines}} 行", "pasteLines": "貼り付け #{{n}} · {{lines}} 行", "pasteChars": "貼り付け #{{n}} · {{chars}} 文字", @@ -1007,6 +1009,7 @@ "insertHint": "テキストを選択するか行番号をクリック(Shift+クリックやドラッグで範囲選択)すると、その行だけを引用できます。", "linesPicked": "{{lines}} 行目を選択中。Esc で解除します。", "viewMode": "表示", + "outline": "目次", "rendered": "プレビュー", "source": "ソース", "image": "画像", @@ -1097,6 +1100,30 @@ "stalled": "対応が必要" } }, + "changes": { + "tab": "変更", + "tabChat": "チャット", + "files": "{{count}} 件のファイル", + "sinceLaunch": "タスク開始以降", + "sinceLaunchInPlace": "タスク開始以降にこのフォルダーで変更されたすべて(自分の編集を含む)", + "sinceHead": "未コミットの変更(開始時のコミットが不明)", + "loading": "変更を読み込み中…", + "empty": "変更はありません", + "unavailable": "このタスクには変更を表示できる git チェックアウトがありません。", + "error": "変更を読み込めませんでした。", + "truncated": "変更が多すぎるため、一部のファイルや差分は表示されていません。", + "refresh": "更新", + "binary": "バイナリファイル", + "noPatch": "差分が大きすぎて表示できません", + "noContent": "内容の変更はありません", + "status": { + "added": "追加", + "modified": "変更", + "deleted": "削除", + "renamed": "名前変更", + "untracked": "未追跡" + } + }, "taskChat": { "title": "タスクチャット", "empty": "タスクを選ぶと、最初のプロンプトがここに表示されます。", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 888a347..c95f819 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -933,6 +933,8 @@ "openInFile": "파일에서 열기", "openSnippet": "보기", "snippetTitle": "코드 조각", + "documentTitle": "답변 문서", + "openAsDocument": "문서로 열기", "snippetLines": "{{lines}}줄", "pasteLines": "붙여넣기 #{{n}} · {{lines}}줄", "pasteChars": "붙여넣기 #{{n}} · {{chars}}자", @@ -1007,6 +1009,7 @@ "insertHint": "텍스트를 선택하거나 줄 번호를 클릭하면(Shift+클릭 또는 드래그로 범위 선택) 해당 줄만 인용할 수 있습니다.", "linesPicked": "{{lines}}행 선택됨. Esc를 눌러 해제하세요.", "viewMode": "보기", + "outline": "목차", "rendered": "미리보기", "source": "소스", "image": "이미지", @@ -1097,6 +1100,30 @@ "stalled": "확인 필요" } }, + "changes": { + "tab": "변경 사항", + "tabChat": "대화", + "files": "파일 {{count}}개", + "sinceLaunch": "작업 시작 이후", + "sinceLaunchInPlace": "작업 시작 이후 이 폴더에서 바뀐 모든 내용(직접 수정한 내용 포함)", + "sinceHead": "커밋되지 않은 변경 사항(시작 커밋을 알 수 없음)", + "loading": "변경 사항을 읽는 중…", + "empty": "변경 사항 없음", + "unavailable": "이 작업에는 변경 사항을 볼 수 있는 git 체크아웃이 없습니다.", + "error": "변경 사항을 읽을 수 없습니다.", + "truncated": "변경 사항이 너무 많아 일부 파일이나 diff가 표시되지 않습니다.", + "refresh": "새로 고침", + "binary": "바이너리 파일", + "noPatch": "diff가 너무 커서 표시할 수 없습니다", + "noContent": "내용 변경 없음", + "status": { + "added": "추가됨", + "modified": "수정됨", + "deleted": "삭제됨", + "renamed": "이름 변경됨", + "untracked": "추적되지 않음" + } + }, "taskChat": { "title": "태스크 채팅", "empty": "태스크를 선택하면 초기 프롬프트가 여기에 표시됩니다.", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index d58357c..f01fdc7 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -933,6 +933,8 @@ "openInFile": "在文件中打开", "openSnippet": "查看", "snippetTitle": "代码片段", + "documentTitle": "回复文档", + "openAsDocument": "以文档查看", "snippetLines": "{{lines}} 行", "pasteLines": "粘贴 #{{n}} · {{lines}} 行", "pasteChars": "粘贴 #{{n}} · {{chars}} 字符", @@ -1007,6 +1009,7 @@ "insertHint": "选中文本,或点击行号(按住 Shift 点击或拖动可选多行),即可只引用这些行。", "linesPicked": "已选第 {{lines}} 行,按 Esc 取消。", "viewMode": "查看方式", + "outline": "目录", "rendered": "预览", "source": "源码", "image": "图片", @@ -1097,6 +1100,30 @@ "stalled": "需要你处理" } }, + "changes": { + "tab": "改动", + "tabChat": "对话", + "files": "{{count}} 个文件", + "sinceLaunch": "自任务开始以来", + "sinceLaunchInPlace": "自任务开始以来此文件夹的全部改动(含你自己的修改)", + "sinceHead": "未提交的改动(任务开始时的提交未知)", + "loading": "正在读取改动…", + "empty": "没有改动", + "unavailable": "此任务没有可查看改动的 git 检出。", + "error": "无法读取改动。", + "truncated": "改动过多,部分文件或内容未显示。", + "refresh": "刷新", + "binary": "二进制文件", + "noPatch": "内容过长,未显示", + "noContent": "内容没有改动", + "status": { + "added": "新增", + "modified": "修改", + "deleted": "删除", + "renamed": "重命名", + "untracked": "未跟踪" + } + }, "taskChat": { "title": "任务对话", "empty": "选择任务后,初始提示会显示在这里。", diff --git a/apps/desktop/src/renderer/lib/workbenchStore.ts b/apps/desktop/src/renderer/lib/workbenchStore.ts index ff72821..1768d1f 100644 --- a/apps/desktop/src/renderer/lib/workbenchStore.ts +++ b/apps/desktop/src/renderer/lib/workbenchStore.ts @@ -19,6 +19,7 @@ import { type TimelineEvent, type FilePreview, type FolderListing, + type TaskChanges, type WorkbenchSnapshot, } from '../../shared/workbench'; import { EMPTY_CHAT_HISTORY, mergeChatWindow, prependChatPage, type ChatHistory } from './chatHistory'; @@ -221,3 +222,8 @@ export function copyFolderEntryPath(folderId: string, relPath: string, relative: export async function readTaskOutput(taskId: string): Promise { return (await wb().taskOutput(taskId)).text; } + +/** What a task changed in its checkout (read-only; main picks the checkout). */ +export function readTaskChanges(taskId: string): Promise { + return wb().taskChanges(taskId); +} diff --git a/apps/desktop/src/shared/ipc-channels.ts b/apps/desktop/src/shared/ipc-channels.ts index 040161b..a2dedec 100644 --- a/apps/desktop/src/shared/ipc-channels.ts +++ b/apps/desktop/src/shared/ipc-channels.ts @@ -63,6 +63,7 @@ import type { FolderImportResult, FolderListing, TaskOutput, + TaskChanges, WorkbenchChatPage, WorkbenchSnapshot, } from './workbench'; @@ -277,6 +278,7 @@ export const IPC_CHANNELS = { workbenchTaskStop: 'workbench:task-stop', workbenchTaskRemove: 'workbench:task-remove', workbenchTaskOutput: 'workbench:task-output', + workbenchTaskChanges: 'workbench:task-changes', workbenchTaskRunAll: 'workbench:task-run-all', workbenchTaskApprove: 'workbench:task-approve', workbenchPlanRun: 'workbench:plan-run', @@ -1311,6 +1313,7 @@ export interface IpcContract { [IPC_CHANNELS.workbenchTaskStop]: { request: WorkbenchTaskRef; result: WorkbenchSnapshot }; [IPC_CHANNELS.workbenchTaskRemove]: { request: WorkbenchTaskRef; result: WorkbenchSnapshot }; [IPC_CHANNELS.workbenchTaskOutput]: { request: WorkbenchTaskRef; result: TaskOutput }; + [IPC_CHANNELS.workbenchTaskChanges]: { request: WorkbenchTaskRef; result: TaskChanges }; [IPC_CHANNELS.workbenchTaskRunAll]: { request: void; result: WorkbenchSnapshot }; [IPC_CHANNELS.workbenchTaskApprove]: { request: WorkbenchTaskApproveRequest; result: WorkbenchSnapshot }; /** Approve every proposed task of one conversation's plan (the DAG runs to completion). */ diff --git a/apps/desktop/src/shared/workbench.ts b/apps/desktop/src/shared/workbench.ts index 61dfc16..3c329b4 100644 --- a/apps/desktop/src/shared/workbench.ts +++ b/apps/desktop/src/shared/workbench.ts @@ -72,6 +72,14 @@ export const WORKBENCH_LIMITS = { chatImages: 4, /** Base64 characters of one such image (≈ 7.5 MB decoded); the renderer scales larger ones down. */ chatImageBase64: 10 * 1024 * 1024, + /** Files one task-changes read lists; the rest are cut off. */ + changedFiles: 300, + /** Characters of one file's patch in a task-changes read (longer patches are dropped). */ + changePatchChars: 64_000, + /** Characters of all patches one task-changes read returns. */ + changesChars: 1_000_000, + /** Untracked files one task-changes read shows the contents of. */ + untrackedPatches: 30, } as const; /** Whether the orchestrator + herdr session are running. */ @@ -499,6 +507,33 @@ export interface TaskOutput { text: string; } +export type TaskChangeStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked'; + +/** One file a task changed (paths relative to the task's folder or worktree). */ +export interface TaskChangedFile { + path: string; + /** The path before a rename. */ + oldPath: string | null; + status: TaskChangeStatus; + /** Lines added / removed; null for a binary file. */ + additions: number | null; + deletions: number | null; + /** The unified diff of this file; null when binary, too long, or over the read's budget. */ + patch: string | null; +} + +/** + * What a task changed, read-only: a worktree task against the commit its checkout started + * from; an in-place task against the folder's HEAD at launch (so the user's own edits there + * since then show too). `since: 'head'` when that commit is unknown (only uncommitted changes). + */ +export interface TaskChanges { + since: 'launch' | 'head'; + files: TaskChangedFile[]; + /** Files or patches were cut off by the limits. */ + truncated: boolean; +} + /** One entry of a granted folder, as the file tree lists it. Symlinks are never listed. */ export interface FolderEntry { name: string; diff --git a/docs/dev-rules/credentials-and-local-storage.md b/docs/dev-rules/credentials-and-local-storage.md index 393ba12..3174177 100644 --- a/docs/dev-rules/credentials-and-local-storage.md +++ b/docs/dev-rules/credentials-and-local-storage.md @@ -30,7 +30,7 @@ Electron's `app.getPath(...)`: | Proxy identity (`proxy.json`: loopback port + per-binding route tokens) | `app.getPath('userData')` | Survives restart so a running agent's cached loopback config keeps working; user-owned. Route tokens are secret-class loopback credentials (NOT the real provider key, which stays in the OS secret store) — never log them; rotated only on rebind/clear. | | Binding drift snapshot (`binding-applied.json`) + binding profiles (`binding-profiles.json`) | `app.getPath('userData')` | Metadata only (URLs, model ids, token-*presence*, provider ids) — not upstream secrets. Used to detect when agent files under `$HOME` were edited outside AiOpt; see `agent-binding-drift.md`. | | Local database | `app.getPath('userData')` | Long-lived; migrated, never silently reset. | -| Workbench (`workbench/`: private pi config dir `pi-agent/` with `models.json` + `settings.json`, `orchestrator-extension.mjs`, an empty `orchestrator/` cwd, `folders.json`, `tasks.json`, `settings.json`, `conversations.json`, `timeline.json`, conversations in `sessions/`) | `app.getPath('userData')` | Rewritten on every workbench start except the user-owned `folders.json`, `tasks.json` (the task board, including each worker's last terminal output tail, which can quote file contents), `settings.json` (overrides only), `timeline.json` (the Workbench timeline: the last 500 task status changes and coordinator turns — task id, title, conversation uuid, statuses, what set a turn off and how it ended, times, durations; never prompts, messages or worker output; unreadable records are dropped on load), `conversations.json` (titles the user gave conversations, keyed by session uuid; an entry goes when its conversation is deleted) and `sessions/` (pi's JSONL transcripts, one per conversation; deleted only from the history list; AiOpt never writes into them). Images pasted, dropped or picked into the coordinator composer are read in the renderer and scaled to at most 2000 px. Main re-checks the count, type (png / jpeg / gif / webp), base64 and size, and that the bytes match the type's signature, then hands them to pi, which stores them base64 in that conversation's transcript. AiOpt itself never writes or logs image data, and the transcript view shows only how many images a message carried. Sidebar search reads the listed transcripts in main, up to a byte cap per file, and searches only the user's and the assistant's text, never tool calls or their output. The read text is cached in memory only, and only while the file is unchanged. The renderer names a conversation by its session uuid alone; main maps it to a regular file it listed itself in `sessions/`, never to a path from the renderer. `models.json` names the key only as `$AIOPT_WB_KEY`; the key itself is passed through the environment of the coordinator and of AiOpt's own herdr session (`herdr --session aiopt`), never written to disk, argv, or a log. The user's `~/.pi/agent` is never touched. Task worktrees live under herdr's own `~/.herdr/worktrees/`, and their branches are kept when a task is removed. The Explorer can create, rename, copy, move and trash entries **inside folders the user granted** (`main/workbench/folderOps.ts`). The renderer names every entry by folder id plus a relative path. Main resolves both the source and the destination with `lstat` on each component, refuses symlinks, `.git` / `node_modules` / `.DS_Store`, and a folder copied or moved into itself, never overwrites (a taken name becomes `name copy.ext`, `name copy 2.ext`, …), caps a recursive copy by entries and bytes, and skips symlinks inside it. Delete is `shell.trashItem` only, never a permanent delete. Files dropped in from Finder or Explorer are the one place an absolute path crosses IPC: preload turns each dropped `File` into a path, and main copies them into the folder only after a native confirmation that lists them. | +| Workbench (`workbench/`: private pi config dir `pi-agent/` with `models.json` + `settings.json`, `orchestrator-extension.mjs`, an empty `orchestrator/` cwd, `folders.json`, `tasks.json`, `settings.json`, `conversations.json`, `timeline.json`, conversations in `sessions/`) | `app.getPath('userData')` | Rewritten on every workbench start except the user-owned `folders.json`, `tasks.json` (the task board, including each worker's last terminal output tail, which can quote file contents, and the commit id a task's checkout was at when it started), `settings.json` (overrides only), `timeline.json` (the Workbench timeline: the last 500 task status changes and coordinator turns — task id, title, conversation uuid, statuses, what set a turn off and how it ended, times, durations; never prompts, messages or worker output; unreadable records are dropped on load), `conversations.json` (titles the user gave conversations, keyed by session uuid; an entry goes when its conversation is deleted) and `sessions/` (pi's JSONL transcripts, one per conversation; deleted only from the history list; AiOpt never writes into them). Images pasted, dropped or picked into the coordinator composer are read in the renderer and scaled to at most 2000 px. Main re-checks the count, type (png / jpeg / gif / webp), base64 and size, and that the bytes match the type's signature, then hands them to pi, which stores them base64 in that conversation's transcript. AiOpt itself never writes or logs image data, and the transcript view shows only how many images a message carried. Sidebar search reads the listed transcripts in main, up to a byte cap per file, and searches only the user's and the assistant's text, never tool calls or their output. The read text is cached in memory only, and only while the file is unchanged. The renderer names a conversation by its session uuid alone; main maps it to a regular file it listed itself in `sessions/`, never to a path from the renderer. `models.json` names the key only as `$AIOPT_WB_KEY`; the key itself is passed through the environment of the coordinator and of AiOpt's own herdr session (`herdr --session aiopt`), never written to disk, argv, or a log. The user's `~/.pi/agent` is never touched. Task worktrees live under herdr's own `~/.herdr/worktrees/`, and their branches are kept when a task is removed. A task's changes view reads its checkout with git in main (`main/workbench/taskChanges.ts`): the renderer sends only the task id, and main picks the directory from its own record (the worktree, or the folder for an in-place task). Git runs read-only — no optional locks, fsmonitor, pager, external diff or textconv — and what it returns is capped by file count, per-file diff size and total size. Nothing is written, cached or logged; a diff stays in renderer memory while the view is open. The Explorer can create, rename, copy, move and trash entries **inside folders the user granted** (`main/workbench/folderOps.ts`). The renderer names every entry by folder id plus a relative path. Main resolves both the source and the destination with `lstat` on each component, refuses symlinks, `.git` / `node_modules` / `.DS_Store`, and a folder copied or moved into itself, never overwrites (a taken name becomes `name copy.ext`, `name copy 2.ext`, …), caps a recursive copy by entries and bytes, and skips symlinks inside it. Delete is `shell.trashItem` only, never a permanent delete. Files dropped in from Finder or Explorer are the one place an absolute path crosses IPC: preload turns each dropped `File` into a path, and main copies them into the folder only after a native confirmation that lists them. | | Secrets | OS secret store, keyed under `userData` | Long-lived; encrypted at rest. | | OAuth provider tokens (access + refresh) | OS secret store (`main_oauth_provider_`) | Long-lived; encrypted at rest; main-only — never preload/renderer or agent on-disk config. See `oauth-providers.md`. | | Caches / rebuildable artifacts | `app.getPath('cache')` | Disposable; must be safe to delete. | diff --git a/i18n/GLOSSARY.md b/i18n/GLOSSARY.md index 7f5455f..987cf67 100644 --- a/i18n/GLOSSARY.md +++ b/i18n/GLOSSARY.md @@ -198,6 +198,18 @@ A target AI coding tool that AiOpt configures (Claude Code, Codex, Cursor, Gemin - **zh-CN**: Agent - forbidden: `客户端` +### Changes (`task-changes`) + +The read-only view of what a Workbench task changed in its checkout: the files with lines added and removed, and each file's diff, against the commit the task started from (plus untracked files). Viewing only; nothing is merged, committed, or reverted from it. Not 'diff' alone, 'modifications', or 'patch' in UI copy. + +- **zh-CN**: 改动 + - forbidden: `变更集`, `补丁` +- **ja**: 変更 +- **ko**: 변경 사항 +- **fr**: Modifications +- **de**: Änderungen +- **es**: Cambios + ### Trusted sender (`trusted-sender`) An IPC event whose origin is verified to be the app's own top-level renderer, using only fields taken from event.sender / event.senderFrame — never renderer-reported values. Term still under discussion. diff --git a/i18n/glossary.json b/i18n/glossary.json index b43ec36..b4fbf94 100644 --- a/i18n/glossary.json +++ b/i18n/glossary.json @@ -155,6 +155,14 @@ "translations": { "zh-CN": "时间线", "ja": "タイムライン", "ko": "타임라인", "fr": "Chronologie", "de": "Zeitleiste", "es": "Cronología" }, "forbidden": { "zh-CN": ["时间轴"] } }, + { + "id": "task-changes", + "en": "Changes", + "status": "proposed", + "note": "The read-only view of what a Workbench task changed in its checkout: the files with lines added and removed, and each file's diff, against the commit the task started from (plus untracked files). Viewing only; nothing is merged, committed, or reverted from it. Not 'diff' alone, 'modifications', or 'patch' in UI copy.", + "translations": { "zh-CN": "改动", "ja": "変更", "ko": "변경 사항", "fr": "Modifications", "de": "Änderungen", "es": "Cambios" }, + "forbidden": { "zh-CN": ["变更集", "补丁"] } + }, { "id": "agent-session", "en": "Session",