diff --git a/package.json b/package.json index 02e62a9bfdc..931cb6b01a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.64-rc.0", + "version": "1.4.64-rc.2", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/src/main/daemon/daemon-health-socket-cleanup.test.ts b/src/main/daemon/daemon-health-socket-cleanup.test.ts index 8d2f8e9ad96..a6404ed0220 100644 --- a/src/main/daemon/daemon-health-socket-cleanup.test.ts +++ b/src/main/daemon/daemon-health-socket-cleanup.test.ts @@ -42,12 +42,7 @@ describe('daemon health socket listener cleanup', () => { const result = healthCheckDaemon(socketPath, tokenPath) socket.emit('connect') - socket.emit( - 'data', - Buffer.from( - '{"type":"hello","ok":true}\n{"id":"health-1","ok":true}\n{"id":"health-2","ok":true}\n' - ) - ) + socket.emit('data', Buffer.from('{"type":"hello","ok":true}\n{"id":"health-1","ok":true}\n')) await expect(result).resolves.toBe(true) expect(socket.listenerCount('connect')).toBe(0) diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index 6bf71a37f98..92220f8d548 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -74,36 +74,15 @@ describe('daemon health', () => { }) it('passes when a daemon answers ping', async () => { - const ptySpawnHealthCheck = vi.fn(async () => {}) const server = new DaemonServer({ socketPath, tokenPath, - ptySpawnHealthCheck, spawnSubprocess: () => createMockSubprocess() }) await server.start() try { await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(true) - expect(ptySpawnHealthCheck).toHaveBeenCalledOnce() - } finally { - await server.shutdown() - } - }) - - it('fails when a protocol-healthy daemon cannot spawn PTYs', async () => { - const server = new DaemonServer({ - socketPath, - tokenPath, - ptySpawnHealthCheck: vi.fn(async () => { - throw new Error('stale node-pty helper') - }), - spawnSubprocess: () => createMockSubprocess() - }) - await server.start() - - try { - await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(false) } finally { await server.shutdown() } diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index 2f13e1502dc..a4e95ce72bf 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -21,8 +21,6 @@ const KILL_WAIT_MS = 3_000 const KILL_POLL_MS = 100 const START_TIME_TOLERANCE_MS = 1_500 -export type DaemonHealthCheckResult = 'healthy' | 'unhealthy' | 'pty-spawn-unhealthy' - type ParsedDaemonPid = { pid: number startedAtMs: number | null @@ -67,13 +65,10 @@ function canConnectSocket(socketPath: string): Promise { }) } -export function checkDaemonHealth( - socketPath: string, - tokenPath: string -): Promise { +export function healthCheckDaemon(socketPath: string, tokenPath: string): Promise { return new Promise((resolve) => { if (process.platform !== 'win32' && !existsSync(socketPath)) { - resolve('unhealthy') + resolve(false) return } @@ -81,13 +76,13 @@ export function checkDaemonHealth( try { token = readFileSync(tokenPath, 'utf8').trim() } catch { - resolve('unhealthy') + resolve(false) return } let settled = false let sock: Socket | null = null - const settle = (result: DaemonHealthCheckResult): void => { + const settle = (result: boolean): void => { if (settled) { return } @@ -102,7 +97,7 @@ export function checkDaemonHealth( sock?.off('connect', onConnect) sock?.off('data', onData) } - const onError = (): void => settle('unhealthy') + const onError = (): void => settle(false) const onConnect = (): void => { const hello: HelloMessage = { type: 'hello', @@ -133,13 +128,13 @@ export function checkDaemonHealth( try { message = JSON.parse(line) as Record } catch { - settle('unhealthy') + settle(false) return } if (message.type === 'hello') { if (!(message as HelloResponse).ok) { - settle('unhealthy') + settle(false) return } sock?.write(encodeNdjson({ id: 'health-1', type: 'ping' })) @@ -147,24 +142,12 @@ export function checkDaemonHealth( } if (message.id === 'health-1') { - if (!message.ok) { - settle('unhealthy') - return - } - // Why: protocol ping only proves the socket loop is alive. New - // terminals also depend on node-pty's native helper state inside - // the daemon process, which can go stale after dev rebuilds. - sock?.write(encodeNdjson({ id: 'health-2', type: 'ptySpawnHealth' })) - continue - } - - if (message.id === 'health-2') { - settle(message.ok ? 'healthy' : 'pty-spawn-unhealthy') + settle(message.ok === true) return } } } - const timer = setTimeout(() => settle('unhealthy'), HEALTH_CHECK_TIMEOUT_MS) + const timer = setTimeout(() => settle(false), HEALTH_CHECK_TIMEOUT_MS) sock = connect({ path: socketPath }) sock.on('error', onError) @@ -175,10 +158,6 @@ export function checkDaemonHealth( }) } -export async function healthCheckDaemon(socketPath: string, tokenPath: string): Promise { - return (await checkDaemonHealth(socketPath, tokenPath)) === 'healthy' -} - function isSystemResolverHealth(value: unknown): value is SystemResolverHealth { return value === 'healthy' || value === 'unhealthy' || value === 'unknown' } diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index e320609ef09..14df83d33c5 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -20,7 +20,6 @@ const { writeFileSyncMock, netConnectMock, forkMock, - checkDaemonHealthMock, healthCheckDaemonMock, getMacDaemonSystemResolverHealthMock, getDaemonLaunchIdentityMock, @@ -67,7 +66,6 @@ const { } }) - const checkDaemonHealthMock = vi.fn(async () => 'healthy') const healthCheckDaemonMock = vi.fn(async () => true) const getMacDaemonSystemResolverHealthMock = vi.fn(() => 'healthy') const getDaemonLaunchIdentityMock = vi.fn(() => 'match') @@ -103,7 +101,6 @@ const { writeFileSyncMock, netConnectMock, forkMock, - checkDaemonHealthMock, healthCheckDaemonMock, getMacDaemonSystemResolverHealthMock, getDaemonLaunchIdentityMock, @@ -175,7 +172,6 @@ vi.mock('child_process', () => ({ fork: forkMock })) vi.mock('net', () => ({ connect: netConnectMock })) vi.mock('./daemon-health', () => ({ - checkDaemonHealth: checkDaemonHealthMock, getDaemonLaunchIdentity: getDaemonLaunchIdentityMock, getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock, healthCheckDaemon: healthCheckDaemonMock, @@ -272,9 +268,8 @@ async function importFresh() { setLocalPtyProviderMock.mockClear() unbindLocalProviderListenersMock.mockClear() rebindLocalProviderListenersMock.mockClear() - checkDaemonHealthMock.mockClear() - checkDaemonHealthMock.mockResolvedValue('healthy') healthCheckDaemonMock.mockClear() + healthCheckDaemonMock.mockResolvedValue(true) getMacDaemonSystemResolverHealthMock.mockReset() getMacDaemonSystemResolverHealthMock.mockReturnValue('healthy') getDaemonLaunchIdentityMock.mockClear() @@ -1031,7 +1026,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { probeSocketExistsMock.mockImplementation( (p?: string) => p === '/fake/app/out/main/daemon-entry.js' ) - checkDaemonHealthMock.mockResolvedValueOnce('unhealthy') + healthCheckDaemonMock.mockResolvedValueOnce(false) const mod = await importFresh() getAppPathMock.mockReturnValue('/fake/app/out/main') await mod.initDaemonPtyProvider() @@ -1074,7 +1069,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { }) it('removes detached daemon startup listeners after readiness', async () => { - checkDaemonHealthMock.mockResolvedValueOnce('unhealthy') + healthCheckDaemonMock.mockResolvedValueOnce(false) const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -1129,7 +1124,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { }) it('removes detached daemon startup listeners after startup error', async () => { - checkDaemonHealthMock.mockResolvedValueOnce('unhealthy') + healthCheckDaemonMock.mockResolvedValueOnce(false) const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -1173,7 +1168,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(child.unref).not.toHaveBeenCalled() }) - it('preserves a spawn-unhealthy daemon when it owns live sessions', async () => { + it('preserves a health-check-failing daemon when it owns live sessions', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -1198,7 +1193,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> - checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy') + healthCheckDaemonMock.mockResolvedValueOnce(false) await launcher('/fake/socket', '/fake/token') @@ -1208,7 +1203,51 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(forkMock).not.toHaveBeenCalled() }) - it('replaces a spawn-unhealthy daemon when no live sessions would be lost', async () => { + it('replaces a health-check-failing daemon when live sessions cannot be verified', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + daemonClientMock.mockImplementationOnce(function MockDaemonClient() { + return { + ensureConnected: vi.fn(async () => { + throw new Error('daemon is wedged') + }), + request: vi.fn(), + disconnect: vi.fn() + } + }) + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + healthCheckDaemonMock.mockResolvedValueOnce(false) + forkMock.mockImplementationOnce(() => ({ + pid: 12345, + on(event: string, cb: (arg?: unknown) => void) { + if (event === 'message') { + queueMicrotask(() => cb({ type: 'ready' })) + } + return this + }, + off() { + return this + }, + disconnect: vi.fn(), + unref: vi.fn() + })) + + await launcher('/fake/socket', '/fake/token') + + expect(killStaleDaemonMock).toHaveBeenCalledWith( + '/fake/userData/daemon', + '/fake/socket', + '/fake/token' + ) + expect(forkMock).toHaveBeenCalled() + }) + + it('replaces a health-check-failing daemon when no live sessions would be lost', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -1216,7 +1255,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> - checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy') + healthCheckDaemonMock.mockResolvedValueOnce(false) forkMock.mockImplementationOnce(() => { const handlers: Record void)[]> = { message: [], diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index ff1795cfbb5..41f601d7835 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -28,10 +28,10 @@ import { type ListSessionsResult } from './types' import { - checkDaemonHealth, getMacDaemonSystemResolverHealth, getDaemonLaunchIdentity, getProcessStartedAtMs, + healthCheckDaemon, isDaemonStaleForCurrentBundle, killStaleDaemon } from './daemon-health' @@ -174,77 +174,65 @@ async function shouldPreserveDaemonWithLiveSessions( function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { return async (socketPath, tokenPath) => { const entryPath = getDaemonEntryPath() - const health = await checkDaemonHealth(socketPath, tokenPath) - if (health !== 'unhealthy') { - if (health === 'pty-spawn-unhealthy') { - if ( - await shouldPreserveDaemonWithLiveSessions( - socketPath, - tokenPath, - 'that cannot spawn new PTYs' + const healthy = await healthCheckDaemon(socketPath, tokenPath) + if (healthy) { + const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath) + if (resolverHealth === 'unhealthy') { + const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) + if (liveSessionCount !== 0) { + console.warn( + liveSessionCount === null + ? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified' + : `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` ) - ) { return createPreservedDaemonHandle(runtimeDir) } - console.warn('[daemon] Replacing daemon that cannot spawn new PTYs') + console.warn('[daemon] Replacing daemon with unavailable macOS system resolver') await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) } else { - const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath) - if (resolverHealth === 'unhealthy') { - const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) - if (liveSessionCount !== 0) { - console.warn( - liveSessionCount === null - ? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified' - : `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` - ) + // Why: a protocol-healthy daemon can outlive the app bundle that + // launched it. In dev this happens after deleting/rebuilding a + // worktree; in packaged apps it happens when the stable + // /Applications/Orca.app path is replaced during update. + const identity = await getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath) + const stalePackagedBundle = + app.isPackaged && + (await isDaemonStaleForCurrentBundle(runtimeDir, socketPath, tokenPath, app.getVersion())) + if (identity === 'mismatch' || stalePackagedBundle) { + // Why: replacing a healthy daemon kills its child PTYs; defer code + // freshness until no live terminal sessions would be lost. + const replacementLabel = stalePackagedBundle + ? 'launched before the current app bundle was installed' + : 'launched from a different app path' + if (await shouldPreserveDaemonWithLiveSessions(socketPath, tokenPath, replacementLabel)) { return createPreservedDaemonHandle(runtimeDir) } - console.warn('[daemon] Replacing daemon with unavailable macOS system resolver') + console.warn( + stalePackagedBundle + ? '[daemon] Replacing daemon launched before the current app bundle was installed' + : '[daemon] Replacing daemon launched from a different app path' + ) await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) } else { - // Why: a protocol-healthy daemon can outlive the app bundle that - // launched it. In dev this happens after deleting/rebuilding a - // worktree; in packaged apps it happens when the stable - // /Applications/Orca.app path is replaced during update. - const identity = await getDaemonLaunchIdentity( - runtimeDir, - socketPath, - tokenPath, - entryPath - ) - const stalePackagedBundle = - app.isPackaged && - (await isDaemonStaleForCurrentBundle( - runtimeDir, - socketPath, - tokenPath, - app.getVersion() - )) - if (identity === 'mismatch' || stalePackagedBundle) { - // Why: replacing a healthy daemon kills its child PTYs; defer code - // freshness until no live terminal sessions would be lost. - const replacementLabel = stalePackagedBundle - ? 'launched before the current app bundle was installed' - : 'launched from a different app path' - if ( - await shouldPreserveDaemonWithLiveSessions(socketPath, tokenPath, replacementLabel) - ) { - return createPreservedDaemonHandle(runtimeDir) - } - console.warn( - stalePackagedBundle - ? '[daemon] Replacing daemon launched before the current app bundle was installed' - : '[daemon] Replacing daemon launched from a different app path' - ) - await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) - } else { - // Why: daemon is already running from a previous app session and - // responded to a protocol-level ping. Safe to reuse. - return createPreservedDaemonHandle(runtimeDir) - } + // Why: daemon is already running from a previous app session and + // responded to a protocol-level ping. Safe to reuse. + return createPreservedDaemonHandle(runtimeDir) } } + } else { + // Why: a busy machine (e.g. right after an update) can time out the + // health check while the daemon is alive and owning terminals. Killing + // it would destroy every live session, so re-verify with a session list + // first. Only a verified non-empty list preserves: a daemon that cannot + // even list sessions cannot serve terminals, and replacing it is the + // only recovery. + const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) + if (liveSessionCount !== null && liveSessionCount > 0) { + console.warn( + `[daemon] Preserving daemon that failed the health check because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` + ) + return createPreservedDaemonHandle(runtimeDir) + } } // Why: a raw socket can outlive a broken or wedged daemon. Kill by PID @@ -367,6 +355,14 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { export async function initDaemonPtyProvider(signal?: AbortSignal): Promise { logDaemonMilestone('daemon-init-start') + // Why: e2e coverage for the startup PTY gate (#5232) needs a daemon init + // that deterministically outlasts the first-window timeout. Real triggers + // (stale-daemon cleanup, legacy probes on a busy disk) are not controllable + // from a test. + const e2eInitDelayMs = Number(process.env.ORCA_E2E_DAEMON_INIT_DELAY_MS) + if (Number.isFinite(e2eInitDelayMs) && e2eInitDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, e2eInitDelayMs)) + } const runtimeDir = getRuntimeDir() const newSpawner = new DaemonSpawner({ diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index 7d10ce1f10a..b7fbca39edf 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -198,19 +198,19 @@ describe('DaemonServer', () => { expect(result).toEqual({ pong: true }) }) - it('handles ptySpawnHealth through the daemon process', async () => { - const ptySpawnHealthCheck = vi.fn(async () => {}) - server = new DaemonServer({ - socketPath, - tokenPath, - ptySpawnHealthCheck, - spawnSubprocess: () => createMockSubprocess() - }) - await server.start() + it('replies with an error to unknown request types and keeps serving', async () => { + await startServer() const c = await connectClient() - await expect(c.request('ptySpawnHealth', undefined)).resolves.toEqual({ healthy: true }) - expect(ptySpawnHealthCheck).toHaveBeenCalledOnce() + // Why: older app builds still send the removed ptySpawnHealth probe. + // The daemon must reject it gracefully so a downgraded client lands on + // its session-preserving branch instead of crashing the daemon. + await expect(c.request('ptySpawnHealth', undefined)).rejects.toThrow( + 'Unknown request type: ptySpawnHealth' + ) + await expect(c.request<{ pong: boolean }>('ping', undefined)).resolves.toEqual({ + pong: true + }) }) it('handles systemResolverHealth', async () => { diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 7d9940470aa..558a41b53f9 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -11,7 +11,6 @@ import { TerminalHost } from './terminal-host' import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' import type { SubprocessHandle } from './session' -import { checkPtySpawnHealth } from './pty-subprocess' import { PROTOCOL_VERSION, NOTIFY_PREFIX, @@ -23,7 +22,6 @@ import { export type DaemonServerOptions = { socketPath: string tokenPath: string - ptySpawnHealthCheck?: () => Promise spawnSubprocess: (opts: { sessionId: string cols: number @@ -47,7 +45,6 @@ export class DaemonServer { private host: TerminalHost private socketPath: string private tokenPath: string - private ptySpawnHealthCheck: () => Promise private clients = new Map() private streamDataBatcher = new DaemonStreamDataBatcher((clientId) => this.clients.get(clientId)) @@ -65,7 +62,6 @@ export class DaemonServer { this.tokenPath = opts.tokenPath this.token = randomUUID() this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess }) - this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth } async start(): Promise { @@ -379,10 +375,6 @@ export class DaemonServer { case 'systemResolverHealth': return { health: await readCurrentProcessMacSystemResolverHealth() } - case 'ptySpawnHealth': - await this.ptySpawnHealthCheck() - return { healthy: true } - case 'shutdown': if (request.payload.killSessions) { this.host.dispose() diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index ad62e6272d1..a9a5fef3253 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -1,19 +1,13 @@ /* oxlint-disable max-lines -- Why: exercises full PTY subprocess surface (spawn setup, signal routing, data events, platform-specific shell configs, and Windows PowerShell implementations) with co-located test scenarios to prevent fixture drift. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'fs' +import { mkdtempSync, realpathSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import type * as LocalPtyUtils from '../providers/local-pty-utils' -const { - spawnMock, - isPwshAvailableMock, - validateWorkingDirectoryMock, - getNodePtySpawnHelperCandidatesMock -} = vi.hoisted(() => ({ +const { spawnMock, isPwshAvailableMock, validateWorkingDirectoryMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), isPwshAvailableMock: vi.fn(), - getNodePtySpawnHelperCandidatesMock: vi.fn(), validateWorkingDirectoryMock: vi.fn((cwd: string) => { if (cwd.includes('definitely-missing')) { throw new Error( @@ -35,12 +29,11 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - getNodePtySpawnHelperCandidates: getNodePtySpawnHelperCandidatesMock, validateWorkingDirectory: validateWorkingDirectoryMock } }) -import { checkPtySpawnHealth, createPtySubprocess } from './pty-subprocess' +import { createPtySubprocess } from './pty-subprocess' const ORCA_SHELL_WRAPPER_ENV = [ 'ORCA_ATTRIBUTION_SHIM_DIR', @@ -86,10 +79,6 @@ describe('createPtySubprocess', () => { isPwshAvailableMock.mockReturnValue(false) previousUserDataPath = process.env.ORCA_USER_DATA_PATH userDataPath = mkdtempSync(join(tmpdir(), 'daemon-pty-subprocess-test-')) - const spawnHelperPath = join(userDataPath, 'spawn-helper') - writeFileSync(spawnHelperPath, '') - getNodePtySpawnHelperCandidatesMock.mockReset() - getNodePtySpawnHelperCandidatesMock.mockReturnValue([spawnHelperPath]) process.env.ORCA_USER_DATA_PATH = userDataPath for (const key of ORCA_SHELL_WRAPPER_ENV) { savedWrapperEnv[key] = process.env[key] @@ -180,54 +169,6 @@ describe('createPtySubprocess', () => { ) }) - it('checks macOS PTY spawn health with a short-lived shell', async () => { - const proc = mockPtyProcess() - spawnMock.mockReturnValue(proc) - const platform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { value: 'darwin' }) - - try { - const result = checkPtySpawnHealth() - proc._simulateExit(0) - await expect(result).resolves.toBeUndefined() - expect(proc.kill).not.toHaveBeenCalled() - } finally { - if (platform) { - Object.defineProperty(process, 'platform', platform) - } - } - - expect(spawnMock).toHaveBeenCalledWith( - '/bin/sh', - ['-c', 'exit 0'], - expect.objectContaining({ - cols: 2, - rows: 1, - cwd: userDataPath, - name: 'xterm-256color' - }) - ) - }) - - it('surfaces stale node-pty helper failures during macOS PTY spawn health', async () => { - spawnMock.mockImplementation(() => { - throw new Error( - "node-pty: posix_spawn failed: ENOENT (errno 2, No such file or directory) - helper='/tmp/deleted/spawn-helper'" - ) - }) - const platform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { value: 'darwin' }) - - try { - await expect(checkPtySpawnHealth()).rejects.toThrow('Daemon failed to spawn shell "/bin/sh"') - await expect(checkPtySpawnHealth()).rejects.toThrow('posix_spawn failed: ENOENT') - } finally { - if (platform) { - Object.defineProperty(process, 'platform', platform) - } - } - }) - it('returns a SubprocessHandle with correct pid', () => { const proc = mockPtyProcess(42) spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 53287122abc..8f6fb96a313 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -29,7 +29,6 @@ import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../gi import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const -const PTY_SPAWN_HEALTH_TIMEOUT_MS = 2_000 export type PtySubprocessOptions = { sessionId: string @@ -232,75 +231,6 @@ function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string): return formatted } -export async function checkPtySpawnHealth(): Promise { - if (process.platform !== 'darwin') { - return - } - - ensureNodePtySpawnHelperExecutable() - preflightMacNodePtySpawnEnvironment() - - const cwd = isExistingDirectory(process.env.ORCA_USER_DATA_PATH) - ? process.env.ORCA_USER_DATA_PATH - : getDefaultCwd() - - let proc: pty.IPty - try { - proc = pty.spawn('/bin/sh', ['-c', 'exit 0'], { - name: 'xterm-256color', - cols: 2, - rows: 1, - cwd, - env: { - ...process.env, - TERM: 'xterm-256color' - } - }) - } catch (err) { - throw formatPtySpawnError(err, '/bin/sh', cwd) - } - - await new Promise((resolve, reject) => { - let settled = false - let exitDisposable: { dispose(): void } | undefined - const finish = (error?: Error, opts?: { kill?: boolean }): void => { - if (settled) { - return - } - settled = true - clearTimeout(timer) - exitDisposable?.dispose() - if (opts?.kill) { - try { - proc.kill() - } catch { - // Best-effort cleanup for a short-lived health probe. - } - } - if (error) { - reject(error) - return - } - resolve() - } - const timer = setTimeout(() => { - finish(new Error(`PTY spawn health check timed out after ${PTY_SPAWN_HEALTH_TIMEOUT_MS}ms`), { - kill: true - }) - }, PTY_SPAWN_HEALTH_TIMEOUT_MS) - - // Why: ping only proves the daemon protocol is alive. A real short-lived - // PTY spawn catches stale node-pty helper paths captured by this process. - exitDisposable = proc.onExit(({ exitCode }) => { - if (exitCode === 0) { - finish() - return - } - finish(new Error(`PTY spawn health check exited with code ${exitCode}`)) - }) - }) -} - function normalizeForegroundProcessName(processName: string | null | undefined): string | null { const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? '' if (!trimmed || trimmed === 'xterm-256color') { diff --git a/src/main/daemon/slow-daemon-session-verification.test.ts b/src/main/daemon/slow-daemon-session-verification.test.ts new file mode 100644 index 00000000000..8c3ad71c081 --- /dev/null +++ b/src/main/daemon/slow-daemon-session-verification.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { connect, createServer, type Server, type Socket } from 'net' +import { tmpdir } from 'os' +import { join } from 'path' +import { mkdtempSync, rmSync } from 'fs' +import { DaemonServer } from './daemon-server' +import { DaemonClient } from './client' +import { healthCheckDaemon } from './daemon-health' +import type { ListSessionsResult } from './types' +import type { SubprocessHandle } from './session' + +// Why: terminals were lost after app updates because a busy machine could +// time out the 3s startup health check against a daemon that was alive and +// owning sessions, and the unhealthy path killed it. The fix re-verifies +// with listSessions, which has far larger budgets (5s hello, 30s request). +// This test reproduces the production asymmetry against a REAL daemon by +// inserting a response delay that exceeds the health-check budget but fits +// the verification budgets, and asserts the guard's two inputs disagree the +// way the fix depends on. +const RESPONSE_DELAY_MS = 3_500 + +function createMockSubprocess(): SubprocessHandle { + return { + pid: 55555, + getForegroundProcess: vi.fn(() => null), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + forceKill: vi.fn(), + signal: vi.fn(), + onData: vi.fn(), + onExit: vi.fn(), + dispose: vi.fn() + } +} + +/** Forwards client bytes to the daemon immediately, but delays every daemon + * response so each round-trip looks like a daemon under heavy load. */ +function startDelayProxy(listenPath: string, upstreamPath: string): Server { + const proxy = createServer((clientSocket: Socket) => { + const upstream = connect(upstreamPath) + clientSocket.on('data', (chunk) => upstream.write(chunk)) + upstream.on('data', (chunk) => { + setTimeout(() => { + if (!clientSocket.destroyed) { + clientSocket.write(chunk) + } + }, RESPONSE_DELAY_MS) + }) + const teardown = (): void => { + clientSocket.destroy() + upstream.destroy() + } + clientSocket.on('close', teardown) + clientSocket.on('error', teardown) + upstream.on('close', () => { + setTimeout(teardown, RESPONSE_DELAY_MS) + }) + upstream.on('error', teardown) + }) + proxy.listen(listenPath) + return proxy +} + +describe('slow daemon session verification', () => { + let dir: string + let daemonSocketPath: string + let proxySocketPath: string + let tokenPath: string + let server: DaemonServer + let proxy: Server + const clients: DaemonClient[] = [] + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'daemon-slow-verification-test-')) + daemonSocketPath = join(dir, 'daemon.sock') + proxySocketPath = join(dir, 'proxy.sock') + tokenPath = join(dir, 'daemon.token') + }) + + afterEach(async () => { + for (const client of clients.splice(0)) { + client.disconnect() + } + await new Promise((resolve) => proxy?.close(() => resolve())) + await server?.shutdown() + rmSync(dir, { recursive: true, force: true }) + }) + + it( + 'fails the 3s health check against a slow daemon while listSessions still verifies its live session', + { timeout: 60_000 }, + async () => { + server = new DaemonServer({ + socketPath: daemonSocketPath, + tokenPath, + spawnSubprocess: () => createMockSubprocess() + }) + await server.start() + + const directClient = new DaemonClient({ socketPath: daemonSocketPath, tokenPath }) + clients.push(directClient) + await directClient.ensureConnected() + await directClient.request('createOrAttach', { + sessionId: 'wt-1@@live-session', + cols: 80, + rows: 24 + }) + + proxy = startDelayProxy(proxySocketPath, daemonSocketPath) + + // The exact pre-fix kill trigger: the daemon is alive but too slow for + // the health-check budget. + await expect(healthCheckDaemon(proxySocketPath, tokenPath)).resolves.toBe(false) + + // The fix's re-verification against the SAME slow daemon: the larger + // client budgets absorb the latency and prove the session is alive. + const verificationClient = new DaemonClient({ socketPath: proxySocketPath, tokenPath }) + clients.push(verificationClient) + await verificationClient.ensureConnected() + const result = await verificationClient.request('listSessions', undefined) + const liveSessionCount = result.sessions.filter((session) => session.isAlive).length + expect(liveSessionCount).toBe(1) + } + ) +}) diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index abac7f20dee..924e322f84a 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -182,11 +182,6 @@ export type SystemResolverHealthRequest = { type: 'systemResolverHealth' } -export type PtySpawnHealthRequest = { - id: string - type: 'ptySpawnHealth' -} - export type GetSnapshotRequest = { id: string type: 'getSnapshot' @@ -210,7 +205,6 @@ export type DaemonRequest = | ShutdownRequest | PingRequest | SystemResolverHealthRequest - | PtySpawnHealthRequest | GetSnapshotRequest // ─── RPC Responses (Daemon → Client, on control socket) ──────────── diff --git a/src/main/github/issues.ts b/src/main/github/issues.ts index 6322e60dd2d..4f8c5ad9887 100644 --- a/src/main/github/issues.ts +++ b/src/main/github/issues.ts @@ -235,10 +235,19 @@ export async function updateIssue( if (updates.state) { await acquire() try { - const cmd = updates.state === 'closed' ? 'close' : 'reopen' - await ghExecFileAsync(['issue', cmd, String(issueNumber), '--repo', repo], { - ...ghOptions - }) + if (updates.state === 'closed') { + const closeArgs = ['issue', 'close', String(issueNumber), '--repo', repo] + if (updates.stateReason === 'completed') { + closeArgs.push('--reason', 'completed') + } else if (updates.stateReason === 'not_planned') { + closeArgs.push('--reason', 'not planned') + } else if (updates.stateReason === 'duplicate' && updates.duplicateOf) { + closeArgs.push('--duplicate-of', String(updates.duplicateOf)) + } + await ghExecFileAsync(closeArgs, ghOptions) + } else { + await ghExecFileAsync(['issue', 'reopen', String(issueNumber), '--repo', repo], ghOptions) + } } catch (err) { const stderr = err instanceof Error ? err.message : String(err) // Treat "already closed/open" as a no-op diff --git a/src/main/github/project-view/mutations.ts b/src/main/github/project-view/mutations.ts index 3d3d66757ea..cd72c9e8ce1 100644 --- a/src/main/github/project-view/mutations.ts +++ b/src/main/github/project-view/mutations.ts @@ -171,15 +171,29 @@ export async function updateIssueBySlug( if (!args.updates || typeof args.updates !== 'object') { return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } } } - const { title, body, state, addLabels, removeLabels, addAssignees, removeAssignees } = - args.updates + const { + title, + body, + state, + stateReason, + duplicateOf, + addLabels, + removeLabels, + addAssignees, + removeAssignees + } = args.updates // Title / body / state go through PATCH /repos/{owner}/{repo}/issues/{n}. // Labels/assignees go through their dedicated endpoints. const base = `repos/${args.owner}/${args.repo}/issues/${args.number}` // 1) PATCH body - if (title !== undefined || body !== undefined || state !== undefined) { + if ( + title !== undefined || + body !== undefined || + state !== undefined || + stateReason !== undefined + ) { const patchArgs: string[] = ['-X', 'PATCH', base] if (title !== undefined) { patchArgs.push('--raw-field', `title=${title}`) @@ -190,6 +204,12 @@ export async function updateIssueBySlug( if (state !== undefined) { patchArgs.push('--raw-field', `state=${state}`) } + if (stateReason !== undefined) { + patchArgs.push('--raw-field', `state_reason=${stateReason}`) + } + if (duplicateOf !== undefined) { + patchArgs.push('--raw-field', `duplicate_of=${duplicateOf}`) + } const r = await runRest(patchArgs) if (!r.ok) { return { ok: false, error: r.error } diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index 6a3a57bebe8..d48c28d2540 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: GitLab remote parsing coverage needs many URL/host fixtures against the same mocked git/glab helpers. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { gitExecFileAsyncMock, glabExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({ @@ -22,96 +21,12 @@ import { getGlabKnownHosts, getProjectRef, getProjectRefForRemote, - parseGitLabProjectRef, parseGlabApiResponse, parseGlabAuthStatusHosts, resolveIssueSource } from './gl-utils' import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' -describe('gitlab project ref parsing', () => { - it('parses HTTPS and SSH GitLab.com remotes', () => { - expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git')).toEqual({ - host: 'gitlab.com', - path: 'acme/widgets' - }) - expect(parseGitLabProjectRef('git@gitlab.com:stablyai/orca.git')).toEqual({ - host: 'gitlab.com', - path: 'stablyai/orca' - }) - }) - - it('preserves nested group paths', () => { - expect(parseGitLabProjectRef('git@gitlab.com:group/subgroup/project.git')).toEqual({ - host: 'gitlab.com', - path: 'group/subgroup/project' - }) - expect(parseGitLabProjectRef('https://gitlab.com/g1/g2/g3/proj.git')).toEqual({ - host: 'gitlab.com', - path: 'g1/g2/g3/proj' - }) - }) - - it('returns null for non-GitLab hosts when host not in knownHosts', () => { - expect(parseGitLabProjectRef('git@github.com:stablyai/orca.git')).toBeNull() - expect(parseGitLabProjectRef('git@example.com:foo/bar.git')).toBeNull() - }) - - it('matches self-hosted hosts when included in knownHosts', () => { - expect( - parseGitLabProjectRef('git@gitlab.example.com:team/api.git', [ - 'gitlab.com', - 'gitlab.example.com' - ]) - ).toEqual({ host: 'gitlab.example.com', path: 'team/api' }) - }) - - it('parses GitLab remotes with non-standard ports without treating the port as a path segment', () => { - expect( - parseGitLabProjectRef('ssh://git@gitlab.example.com:2222/team/api.git', [ - 'gitlab.com', - 'gitlab.example.com' - ]) - ).toEqual({ host: 'gitlab.example.com', path: 'team/api' }) - expect( - parseGitLabProjectRef('https://gitlab.example.com:8443/team/api.git', [ - 'gitlab.com', - 'gitlab.example.com' - ]) - ).toEqual({ host: 'gitlab.example.com', path: 'team/api' }) - }) - - it('rejects single-segment paths (host root or user-only)', () => { - expect(parseGitLabProjectRef('git@gitlab.com:foo.git')).toBeNull() - expect(parseGitLabProjectRef('https://gitlab.com/foo.git')).toBeNull() - }) - - it('handles missing .git suffix', () => { - expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets')).toEqual({ - host: 'gitlab.com', - path: 'acme/widgets' - }) - }) - - it('strips trailing slashes after .git suffixes', () => { - expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git/')).toEqual({ - host: 'gitlab.com', - path: 'acme/widgets' - }) - expect(parseGitLabProjectRef('ssh://git@gitlab.com/acme/widgets.git/')).toEqual({ - host: 'gitlab.com', - path: 'acme/widgets' - }) - }) - - it('preserves git protocol remote support', () => { - expect(parseGitLabProjectRef('git://gitlab.com/acme/widgets.git')).toEqual({ - host: 'gitlab.com', - path: 'acme/widgets' - }) - }) -}) - describe('gitlab project ref resolution', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() @@ -376,6 +291,24 @@ gitlab.example.com: expect(parseGlabAuthStatusHosts(out)).toContain('gitlab.example.com') }) + it('extracts hosts from bare auth-status section headers', () => { + const out = ` +gitlab.com + ✓ Logged in to gitlab.com as user1 (/home/user/.config/glab-cli/config.yml) + ✓ Token: ************************** +gitlab.internal + ✓ Logged in as user2 + ✓ Token: ************************** +Self-hosted-git + ✓ Logged in as user3 + ` + expect(parseGlabAuthStatusHosts(out).sort()).toEqual([ + 'gitlab.com', + 'gitlab.internal', + 'self-hosted-git' + ]) + }) + it('returns empty list for output with no hosts', () => { expect(parseGlabAuthStatusHosts('Not logged in.')).toEqual([]) }) diff --git a/src/main/gitlab/gl-utils.ts b/src/main/gitlab/gl-utils.ts index 9ae15ef23f9..e65f6fecf28 100644 --- a/src/main/gitlab/gl-utils.ts +++ b/src/main/gitlab/gl-utils.ts @@ -1,9 +1,16 @@ import { execFile } from 'child_process' import { promisify } from 'util' import { gitExecFileAsync, glabExecFileAsync } from '../git/runner' -import type { ClassifiedError, GitLabProjectRef, IssueSourcePreference } from '../../shared/types' +import type { ClassifiedError, IssueSourcePreference } from '../../shared/types' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { clearProjectRefInFlight, runProjectRefProbeOnce } from './project-ref-inflight' +import { + DEFAULT_GITLAB_HOSTS, + normalizeGitLabHost, + parseGitLabProjectRef, + parseRemoteProjectRefCandidate, + type ProjectRef +} from './project-ref-parser' // Why: legacy generic execFile wrapper — only used by callers that don't need // WSL-aware routing. Repo-scoped callers should use glabExecFileAsync from @@ -98,11 +105,8 @@ export function classifyListIssuesError(stderr: string): ClassifiedError { return { type: c.type, message: readMessages[c.type] } } -// ── Project ref resolution ────────────────────────────────────────── -// Why: alias the shared shape so `src/shared/types.ts#GitLabProjectRef` -// remains the single source of truth while main-side call sites can use -// the short local name `ProjectRef`. -export type ProjectRef = GitLabProjectRef +export { DEFAULT_GITLAB_HOSTS, parseGitLabProjectRef } +export type { ProjectRef } const PROJECT_REF_CACHE_MAX_ENTRIES = 512 const projectRefCache = new Map() @@ -129,61 +133,6 @@ function rememberProjectRefCacheEntry(cacheKey: string, value: ProjectRef | null } } -/** - * Hosts always treated as GitLab. Self-hosted instances are added at - * runtime via `getGlabKnownHosts()`, which inspects `glab auth status`. - */ -export const DEFAULT_GITLAB_HOSTS = ['gitlab.com'] as const - -function normalizeHost(value: string): string { - return value.trim().toLowerCase() -} - -function stripGitSuffix(path: string): string { - return path.replace(/\/+$/, '').replace(/\.git$/i, '') -} - -function makeProjectRef( - host: string, - path: string, - knownHosts: readonly string[] -): ProjectRef | null { - const normalizedHost = normalizeHost(host) - if (!knownHosts.map(normalizeHost).includes(normalizedHost)) { - return null - } - const normalizedPath = stripGitSuffix(path.replace(/^\/+/, '')).trim() - // Reject paths without at least one group segment — `gitlab.com:foo` - // alone is not a project reference. - if (!normalizedPath.includes('/')) { - return null - } - return { host: normalizedHost, path: normalizedPath } -} - -export function parseGitLabProjectRef( - remoteUrl: string, - knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS -): ProjectRef | null { - const trimmed = remoteUrl.trim() - if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) { - const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/) - if (scpLike) { - return makeProjectRef(scpLike[1], scpLike[2], knownHosts) - } - } - - try { - const url = new URL(trimmed) - if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) { - return null - } - return makeProjectRef(url.hostname, url.pathname, knownHosts) - } catch { - return null - } -} - export async function getProjectRefForRemote( repoPath: string, remoteName: string, @@ -224,6 +173,17 @@ async function resolveProjectRefForRemote( rememberProjectRefCacheEntry(cacheKey, result) return result } + const remoteCandidate = parseRemoteProjectRefCandidate(stdout) + if ( + remoteCandidate && + (await isGlabConfiguredForRemoteHost(repoPath, remoteCandidate, connectionId)) + ) { + // Why: `glab auth status` is process-global and can be stale or formatted + // differently across versions; the origin host itself is the durable repo context. + rememberGlabKnownHost(remoteCandidate.host) + rememberProjectRefCacheEntry(cacheKey, remoteCandidate) + return remoteCandidate + } } catch { if (connectionId) { // Why: remote SSH failures are often transient tunnel/process errors. @@ -315,6 +275,36 @@ export function glabHostnameArgs( let knownHostsCache: readonly string[] | null = null +function rememberGlabKnownHost(host: string): void { + const normalizedHost = normalizeGitLabHost(host) + if (!knownHostsCache || knownHostsCache.map(normalizeGitLabHost).includes(normalizedHost)) { + return + } + knownHostsCache = [...knownHostsCache, normalizedHost] +} + +async function isGlabConfiguredForRemoteHost( + repoPath: string, + projectRef: Pick, + connectionId?: string | null +): Promise { + try { + const result = await glabExecFileAsync( + ['auth', 'status', '--hostname', projectRef.host], + glabRepoExecOptions(repoPath, connectionId) + ) + return result !== undefined + } catch (error) { + const execLike = error as { stdout?: unknown; stderr?: unknown; message?: unknown } + const output = + [execLike.stdout, execLike.stderr, execLike.message] + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .join('\n') || String(error) + const hosts = parseGlabAuthStatusHosts(output).map(normalizeGitLabHost) + return hosts.includes(normalizeGitLabHost(projectRef.host)) + } +} + /** @internal — exposed for tests only */ export function _resetKnownHostsCache(): void { knownHostsCache = null @@ -397,9 +387,10 @@ export function parseGlabAuthStatusHosts(output: string): string[] { hosts.add(m[1].toLowerCase()) } for (const line of output.split('\n')) { - const m = line.match(/^([a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]{2,}):\s*$/) - if (m) { - hosts.add(m[1].toLowerCase()) + const bareLine = line.trim() + const hostLine = bareLine.endsWith(':') ? bareLine.slice(0, -1) : bareLine + if (line === bareLine && /^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(hostLine)) { + hosts.add(hostLine.toLowerCase()) } } return Array.from(hosts) diff --git a/src/main/gitlab/project-ref-parser.test.ts b/src/main/gitlab/project-ref-parser.test.ts new file mode 100644 index 00000000000..53adc0c5aa5 --- /dev/null +++ b/src/main/gitlab/project-ref-parser.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { parseGitLabProjectRef, parseRemoteProjectRefCandidate } from './project-ref-parser' + +describe('gitlab project ref parsing', () => { + it('parses HTTPS and SSH GitLab.com remotes', () => { + expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git')).toEqual({ + host: 'gitlab.com', + path: 'acme/widgets' + }) + expect(parseGitLabProjectRef('git@gitlab.com:stablyai/orca.git')).toEqual({ + host: 'gitlab.com', + path: 'stablyai/orca' + }) + }) + + it('preserves nested group paths', () => { + expect(parseGitLabProjectRef('git@gitlab.com:group/subgroup/project.git')).toEqual({ + host: 'gitlab.com', + path: 'group/subgroup/project' + }) + expect(parseGitLabProjectRef('https://gitlab.com/g1/g2/g3/proj.git')).toEqual({ + host: 'gitlab.com', + path: 'g1/g2/g3/proj' + }) + }) + + it('returns null for non-GitLab hosts when host not in knownHosts', () => { + expect(parseGitLabProjectRef('git@github.com:stablyai/orca.git')).toBeNull() + expect(parseGitLabProjectRef('git@example.com:foo/bar.git')).toBeNull() + }) + + it('matches self-hosted hosts when included in knownHosts', () => { + expect( + parseGitLabProjectRef('git@gitlab.example.com:team/api.git', [ + 'gitlab.com', + 'gitlab.example.com' + ]) + ).toEqual({ host: 'gitlab.example.com', path: 'team/api' }) + }) + + it('parses GitLab remotes with non-standard ports without treating the port as a path segment', () => { + expect( + parseGitLabProjectRef('ssh://git@gitlab.example.com:2222/team/api.git', [ + 'gitlab.com', + 'gitlab.example.com' + ]) + ).toEqual({ host: 'gitlab.example.com', path: 'team/api' }) + expect( + parseGitLabProjectRef('https://gitlab.example.com:8443/team/api.git', [ + 'gitlab.com', + 'gitlab.example.com' + ]) + ).toEqual({ host: 'gitlab.example.com', path: 'team/api' }) + }) + + it('rejects single-segment paths (host root or user-only)', () => { + expect(parseGitLabProjectRef('git@gitlab.com:foo.git')).toBeNull() + expect(parseGitLabProjectRef('https://gitlab.com/foo.git')).toBeNull() + }) + + it('handles missing .git suffix', () => { + expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets')).toEqual({ + host: 'gitlab.com', + path: 'acme/widgets' + }) + }) + + it('strips trailing slashes after .git suffixes', () => { + expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git/')).toEqual({ + host: 'gitlab.com', + path: 'acme/widgets' + }) + expect(parseGitLabProjectRef('ssh://git@gitlab.com/acme/widgets.git/')).toEqual({ + host: 'gitlab.com', + path: 'acme/widgets' + }) + }) + + it('preserves git protocol remote support', () => { + expect(parseGitLabProjectRef('git://gitlab.com/acme/widgets.git')).toEqual({ + host: 'gitlab.com', + path: 'acme/widgets' + }) + }) +}) + +describe('gitlab remote project ref candidates', () => { + it('extracts self-hosted candidates before the host is trusted', () => { + expect(parseRemoteProjectRefCandidate('git@gitlab.internal:team/orca.git')).toEqual({ + host: 'gitlab.internal', + path: 'team/orca' + }) + }) + + it('rejects non-git URLs and single-segment project paths', () => { + expect(parseRemoteProjectRefCandidate('file:///tmp/repo')).toBeNull() + expect(parseRemoteProjectRefCandidate('git@gitlab.internal:team.git')).toBeNull() + }) +}) diff --git a/src/main/gitlab/project-ref-parser.ts b/src/main/gitlab/project-ref-parser.ts new file mode 100644 index 00000000000..e42c0e2081b --- /dev/null +++ b/src/main/gitlab/project-ref-parser.ts @@ -0,0 +1,84 @@ +import type { GitLabProjectRef } from '../../shared/types' + +export type ProjectRef = GitLabProjectRef + +/** + * Hosts always treated as GitLab. Self-hosted instances are added at + * runtime via `getGlabKnownHosts()`, which inspects `glab auth status`. + */ +export const DEFAULT_GITLAB_HOSTS = ['gitlab.com'] as const + +export function normalizeGitLabHost(value: string): string { + return value.trim().toLowerCase() +} + +function stripGitSuffix(path: string): string { + return path.replace(/\/+$/, '').replace(/\.git$/i, '') +} + +function makeProjectRefForTrustedHost(host: string, path: string): ProjectRef | null { + const normalizedHost = normalizeGitLabHost(host) + const normalizedPath = stripGitSuffix(path.replace(/^\/+/, '')).trim() + // Reject paths without at least one group segment — `gitlab.com:foo` + // alone is not a project reference. + if (!normalizedPath.includes('/')) { + return null + } + return { host: normalizedHost, path: normalizedPath } +} + +function makeProjectRef( + host: string, + path: string, + knownHosts: readonly string[] +): ProjectRef | null { + const normalizedHost = normalizeGitLabHost(host) + const normalizedKnownHosts = knownHosts.map(normalizeGitLabHost) + if (!normalizedKnownHosts.includes(normalizedHost)) { + return null + } + return makeProjectRefForTrustedHost(normalizedHost, path) +} + +export function parseRemoteProjectRefCandidate(remoteUrl: string): ProjectRef | null { + const trimmed = remoteUrl.trim() + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) { + const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/) + if (scpLike) { + return makeProjectRefForTrustedHost(scpLike[1], scpLike[2]) + } + } + + try { + const url = new URL(trimmed) + if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) { + return null + } + return makeProjectRefForTrustedHost(url.hostname, url.pathname) + } catch { + return null + } +} + +export function parseGitLabProjectRef( + remoteUrl: string, + knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS +): ProjectRef | null { + const trimmed = remoteUrl.trim() + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) { + const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/) + if (scpLike) { + return makeProjectRef(scpLike[1], scpLike[2], knownHosts) + } + } + + try { + const url = new URL(trimmed) + if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) { + return null + } + return makeProjectRef(url.hostname, url.pathname, knownHosts) + } catch { + return null + } +} diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 4d16b5dc902..9d569f46375 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -671,6 +671,26 @@ describe('Store', () => { expect(store.getUI().projectOrderBy).toBe('manual') }) + it('shows the manual-default notice for upgraded profiles without projectOrderBy', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [{ id: 'repo-1', path: '/tmp/repo', displayName: 'Repo', badgeColor: '#000000' }], + ui: {} + }) + const store = await createStore() + expect(store.getUI().projectOrderManualDefaultNoticeDismissed).toBe(false) + }) + + it('hides the manual-default notice for profiles that already chose recent', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [{ id: 'repo-1', path: '/tmp/repo', displayName: 'Repo', badgeColor: '#000000' }], + ui: { projectOrderBy: 'recent' } + }) + const store = await createStore() + expect(store.getUI().projectOrderManualDefaultNoticeDismissed).toBe(true) + }) + // ── 2. Load from existing valid file ───────────────────────────────── it('reads repos from an existing data file', async () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 20f0a7a358e..a1e7ea44acf 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -429,6 +429,11 @@ function normalizeProjectOrderBy(projectOrderBy: unknown): PersistedState['ui'][ return getDefaultUIState().projectOrderBy } +import { + isExistingPersistedProfile, + resolveProjectOrderManualDefaultNoticeDismissed +} from '../shared/project-order-manual-default-notice' + function normalizeRightSidebarTab(tab: unknown): PersistedState['ui']['rightSidebarTab'] { if ( tab === 'explorer' || @@ -2457,12 +2462,28 @@ export class Store { parsed.ui?.setupGuideSidebarDismissed, normalizedOnboarding ) + const projectOrderManualDefaultNoticeDismissed = + resolveProjectOrderManualDefaultNoticeDismissed({ + rawDismissed: parsed.ui?.projectOrderManualDefaultNoticeDismissed, + rawProjectOrderBy: parsed.ui?.projectOrderBy, + isExistingProfile: isExistingPersistedProfile({ + repoCount: parsed.repos?.length ?? 0, + onboardingClosedAt: normalizedOnboarding.closedAt, + ui: parsed.ui + }) + }) if ( parsed.ui?.setupGuideSidebarDismissed !== setupGuideSidebarDismissed && (setupGuideSidebarDismissed || parsed.ui?.setupGuideSidebarDismissed !== undefined) ) { this.loadNeedsSave = true } + if ( + parsed.ui?.projectOrderManualDefaultNoticeDismissed !== + projectOrderManualDefaultNoticeDismissed + ) { + this.loadNeedsSave = true + } return { ...defaults.ui, ...stripMainOwnedTelemetryMarkerFromUI(parsed.ui), @@ -2471,6 +2492,7 @@ export class Store { rightSidebarOpen, rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab), setupGuideSidebarDismissed, + projectOrderManualDefaultNoticeDismissed, setupGuideBrowserMilestoneMigrated: typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean' ? parsed.ui.setupGuideBrowserMilestoneMigrated diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index ad7dac06356..f607f801cf3 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -153,6 +153,7 @@ const UiUpdate = z groupBy: z.enum(['none', 'workspace-status', 'repo', 'pr-status']).optional(), showWorkspaceLineage: z.boolean().optional(), sortBy: z.enum(['name', 'smart', 'recent', 'repo', 'manual']).optional(), + projectOrderBy: z.enum(['manual', 'recent']).optional(), showActiveOnly: z.boolean().optional(), hideSleepingWorkspaces: z.boolean().optional(), showSleepingWorkspaces: z.boolean().optional(), @@ -205,6 +206,7 @@ const UiUpdate = z starNagCompleted: z.boolean().optional(), trustedOrcaHooks: z.record(z.string(), z.unknown()).optional(), setupScriptPromptDismissedRepoIds: StringArray.optional(), + projectOrderManualDefaultNoticeDismissed: z.boolean().optional(), usageEmptyStateDismissed: z.boolean().optional(), petVisible: z.boolean().optional(), petId: z.string().optional(), diff --git a/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts b/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts new file mode 100644 index 00000000000..c801d7db454 --- /dev/null +++ b/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + gitExecFileAsyncMock, + glabExecFileAsyncMock, + ghExecFileAsyncMock, + getAzureDevOpsRepoSlugMock, + getBitbucketRepoSlugMock, + getGiteaRepoSlugMock, + getHostedReviewForBranchMock, + getRepoSlugMock, + getSshGitProviderMock +} = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), + glabExecFileAsyncMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + getAzureDevOpsRepoSlugMock: vi.fn(), + getBitbucketRepoSlugMock: vi.fn(), + getGiteaRepoSlugMock: vi.fn(), + getHostedReviewForBranchMock: vi.fn(), + getRepoSlugMock: vi.fn(), + getSshGitProviderMock: vi.fn() +})) + +vi.mock('../git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + glabExecFileAsync: glabExecFileAsyncMock, + ghExecFileAsync: ghExecFileAsyncMock, + extractExecError: vi.fn() +})) + +vi.mock('../github/client', () => ({ + createGitHubPullRequest: vi.fn(), + getRepoSlug: getRepoSlugMock, + getPRForBranch: vi.fn() +})) + +vi.mock('../bitbucket/client', () => ({ + getBitbucketRepoSlug: getBitbucketRepoSlugMock, + getBitbucketPullRequestForBranch: vi.fn(), + getBitbucketPullRequest: vi.fn() +})) + +vi.mock('../azure-devops/client', () => ({ + getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock, + getAzureDevOpsPullRequestForBranch: vi.fn(), + getAzureDevOpsPullRequest: vi.fn() +})) + +vi.mock('../gitea/client', () => ({ + getGiteaRepoSlug: getGiteaRepoSlugMock, + getGiteaPullRequestForBranch: vi.fn(), + getGiteaPullRequest: vi.fn() +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock +})) + +vi.mock('./hosted-review', () => ({ + getHostedReviewForBranch: getHostedReviewForBranchMock +})) + +import { _resetKnownHostsCache, _resetProjectRefCache } from '../gitlab/gl-utils' +import { getHostedReviewCreationEligibility } from './hosted-review-creation' + +function resetMocks(): void { + for (const mock of [ + gitExecFileAsyncMock, + glabExecFileAsyncMock, + ghExecFileAsyncMock, + getAzureDevOpsRepoSlugMock, + getBitbucketRepoSlugMock, + getGiteaRepoSlugMock, + getHostedReviewForBranchMock, + getRepoSlugMock, + getSshGitProviderMock + ]) { + mock.mockReset() + } + _resetKnownHostsCache() + _resetProjectRefCache() +} + +function mockNonGitLabProviders(): void { + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue(null) +} + +describe('GitLab self-hosted hosted review creation eligibility', () => { + beforeEach(() => { + resetMocks() + mockNonGitLabProviders() + getHostedReviewForBranchMock.mockResolvedValue(null) + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'git@gitlab.internal:team/orca.git\n', + stderr: '' + }) + }) + + it('enables MR creation when glab recognizes the self-hosted origin host', async () => { + glabExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'auth' && args[1] === 'status' && args.includes('--hostname')) { + return { + stdout: `gitlab.internal + ✓ Logged in as user +`, + stderr: '' + } + } + if (args[0] === 'auth' && args[1] === 'status') { + return { + stdout: `gitlab.com + ✓ Logged in to gitlab.com as user +`, + stderr: '' + } + } + return { stdout: '', stderr: '' } + }) + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/self-hosted-mr', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'gitlab', + canCreate: true, + blockedReason: null, + nextAction: null, + head: 'feature/self-hosted-mr' + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { + cwd: '/repo' + }) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['auth', 'status', '--hostname', 'gitlab.internal'], + { cwd: '/repo' } + ) + }) + + it('classifies known-but-unauthenticated self-hosted GitLab as auth_required', async () => { + glabExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'auth' && args[1] === 'status' && args.includes('--hostname')) { + const error = new Error('invalid token provided') as Error & { + stdout: string + stderr: string + } + error.stdout = `gitlab.internal + ! Invalid token provided +` + error.stderr = '' + throw error + } + if (args[0] === 'auth' && args[1] === 'status') { + return { + stdout: `gitlab.com + ✓ Logged in to gitlab.com as user +gitlab.internal + ! Invalid token provided +`, + stderr: '' + } + } + return { stdout: '', stderr: '' } + }) + + const result = await getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/self-hosted-mr', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + expect(result).toMatchObject({ + provider: 'gitlab', + canCreate: false, + blockedReason: 'auth_required', + nextAction: 'authenticate' + }) + }) +}) diff --git a/src/main/startup/first-window-startup-services.test.ts b/src/main/startup/first-window-startup-services.test.ts index 640efd6493d..889c128d59b 100644 --- a/src/main/startup/first-window-startup-services.test.ts +++ b/src/main/startup/first-window-startup-services.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS, + LOCAL_PTY_STARTUP_FAIL_OPEN_TIMEOUT_MS, startFirstWindowStartupServices } from './first-window-startup-services' @@ -83,7 +84,49 @@ describe('startFirstWindowStartupServices', () => { expect(onAgentHookServerError).toHaveBeenCalledWith(expect.any(Error)) }) - it('fails open the first window and local PTY startup while aborting hung services', async () => { + it('opens the first window at the window timeout without aborting a slow daemon or opening the PTY gate', async () => { + vi.useFakeTimers() + const onDaemonError = vi.fn() + let daemonSignal: AbortSignal | undefined + let resolveDaemon!: () => void + + try { + const started = startFirstWindowStartupServices({ + startDaemonPtyProvider: (signal) => { + daemonSignal = signal + return new Promise((resolve) => { + resolveDaemon = resolve + }) + }, + startAgentHookServer: () => Promise.resolve(), + onDaemonError, + onAgentHookServerError: vi.fn() + }) + + let ptyGateOpened = false + void started.localPtyReady.then(() => { + ptyGateOpened = true + }) + + await vi.advanceTimersByTimeAsync(FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS) + await expect(started.firstWindowReady).resolves.toBeUndefined() + + // Why: opening the PTY gate before the daemon attempt finishes would + // spawn non-restorable LocalPtyProvider fallback terminals (#5232). + expect(ptyGateOpened).toBe(false) + expect(daemonSignal?.aborted).toBe(false) + expect(onDaemonError).not.toHaveBeenCalled() + + resolveDaemon() + await expect(started.localPtyReady).resolves.toBeUndefined() + expect(daemonSignal?.aborted).toBe(false) + expect(onDaemonError).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('fails open the local PTY gate at the hard cap while aborting hung services', async () => { vi.useFakeTimers() const onDaemonError = vi.fn() const onAgentHookServerError = vi.fn() @@ -101,7 +144,7 @@ describe('startFirstWindowStartupServices', () => { }) await Promise.resolve() - await vi.advanceTimersByTimeAsync(FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS) + await vi.advanceTimersByTimeAsync(LOCAL_PTY_STARTUP_FAIL_OPEN_TIMEOUT_MS) await expect(started.firstWindowReady).resolves.toBeUndefined() await expect(started.localPtyReady).resolves.toBeUndefined() diff --git a/src/main/startup/first-window-startup-services.ts b/src/main/startup/first-window-startup-services.ts index 653eda3a299..f87ee689a65 100644 --- a/src/main/startup/first-window-startup-services.ts +++ b/src/main/startup/first-window-startup-services.ts @@ -16,6 +16,12 @@ type FirstWindowStartupServicesResult = { } export const FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS = 12_000 +// Why: a slow (but succeeding) daemon start must not flip terminals to the +// LocalPtyProvider fallback — local PTYs are killed on quit, so panes bound to +// them lose their daemon sessions permanently (#5232). The PTY gate therefore +// waits for the daemon attempt itself and only fail-opens at a hard cap that +// exists solely as a deadlock backstop. +export const LOCAL_PTY_STARTUP_FAIL_OPEN_TIMEOUT_MS = 60_000 function startService( label: string, @@ -60,33 +66,40 @@ export function startFirstWindowStartupServices({ }: FirstWindowStartupServices): FirstWindowStartupServicesResult { // Why: daemon startup and hook-server binding are independent, but both gate // restored terminals; run them together so cold-start latency is max(), not sum(). - // The first window and local PTY startup both fail open after the timeout. - // The timeout also aborts slow services so late daemon swaps cannot strand - // any fallback LocalPtyProvider PTYs that spawn after the barrier opens. + // The first window fails open quickly so the user sees the app; the local PTY + // gate waits for the services themselves (a slow daemon must not flip spawns + // to the non-restorable LocalPtyProvider fallback) and only fails open at the + // hard cap, which also aborts the services so a late daemon swap cannot + // strand any fallback PTYs that spawn after the gate opens. const daemon = startService('daemon PTY provider', startDaemonPtyProvider, onDaemonError) const hooks = startService('agent hook server', startAgentHookServer, onAgentHookServerError) const allServicesReady = Promise.all([daemon.ready, hooks.ready]).then(() => undefined) - let timeout: ReturnType | null = null - let resolveTimedOut!: () => void - const timedOut = new Promise((resolve) => { - resolveTimedOut = resolve + let windowTimeout: ReturnType | null = null + let failOpenTimeout: ReturnType | null = null + const servicesSettled = allServicesReady.finally(() => { + if (windowTimeout) { + clearTimeout(windowTimeout) + } + if (failOpenTimeout) { + clearTimeout(failOpenTimeout) + } }) const firstWindowReady = Promise.race([ - allServicesReady.finally(() => { - if (timeout) { - clearTimeout(timeout) - } - }), + servicesSettled, new Promise((resolve) => { - timeout = setTimeout(() => { + windowTimeout = setTimeout(resolve, FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS) + }) + ]) + const localPtyReady = Promise.race([ + servicesSettled, + new Promise((resolve) => { + failOpenTimeout = setTimeout(() => { daemon.reportTimeout() hooks.reportTimeout() - resolveTimedOut() resolve() - }, FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS) + }, LOCAL_PTY_STARTUP_FAIL_OPEN_TIMEOUT_MS) }) ]) - const localPtyReady = Promise.race([allServicesReady, timedOut]) return { firstWindowReady, localPtyReady } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f32075604c8..d4a2d1320a4 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1087,6 +1087,11 @@ function App(): React.JSX.Element { // Don't let one pane's failure block the rest. } } + // Why: agent provider session ids live only in agentStatusByPaneKey, + // which is in-memory. Capture them into the persisted sleeping-session + // map so a daemon/session death while the app is closed can still + // cold-restore via the agent's resume command (#5232). + useAppStore.getState().captureAllSleepingAgentSessions() // Why: re-read state after capture() calls populated scrollback buffers // into the store via Zustand setters. The earlier read is only for the // gating flags and would miss those updates. diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 2116e35d4bc..1e7da6b1a00 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -941,6 +941,61 @@ background: color-mix(in srgb, var(--sidebar-foreground) 12%, var(--sidebar)); } +/* Why: one-shot sidebar education cards need stronger separation than + worktree-sidebar-accent, which sits almost on top of the sidebar fill. */ +.worktree-sidebar-notice-card { + position: relative; + border: 1px solid + color-mix(in srgb, var(--worktree-sidebar-foreground) 24%, var(--worktree-sidebar-border)); + background: color-mix(in srgb, var(--worktree-sidebar-foreground) 9%, var(--worktree-sidebar)); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--worktree-sidebar-foreground) 8%, transparent), + 0 1px 2px color-mix(in srgb, var(--worktree-sidebar-foreground) 10%, transparent); +} + +.dark .worktree-sidebar-notice-card { + border-color: color-mix( + in srgb, + var(--worktree-sidebar-foreground) 32%, + var(--worktree-sidebar-border) + ); + background: color-mix(in srgb, var(--worktree-sidebar-foreground) 14%, var(--worktree-sidebar)); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--worktree-sidebar-foreground) 12%, transparent), + 0 1px 4px rgb(0 0 0 / 0.28); +} + +/* Why: the project-order notice sits directly under the Projects header; a + caret aligned to that label reads as a callout instead of a random card. */ +.worktree-sidebar-notice-card--to-section-title::before { + content: ''; + position: absolute; + top: -5px; + left: 1.75rem; + width: 10px; + height: 10px; + transform: rotate(45deg); + border-left: 1px solid + color-mix(in srgb, var(--worktree-sidebar-foreground) 24%, var(--worktree-sidebar-border)); + border-top: 1px solid + color-mix(in srgb, var(--worktree-sidebar-foreground) 24%, var(--worktree-sidebar-border)); + background: color-mix(in srgb, var(--worktree-sidebar-foreground) 9%, var(--worktree-sidebar)); +} + +.dark .worktree-sidebar-notice-card--to-section-title::before { + border-left-color: color-mix( + in srgb, + var(--worktree-sidebar-foreground) 32%, + var(--worktree-sidebar-border) + ); + border-top-color: color-mix( + in srgb, + var(--worktree-sidebar-foreground) 32%, + var(--worktree-sidebar-border) + ); + background: color-mix(in srgb, var(--worktree-sidebar-foreground) 14%, var(--worktree-sidebar)); +} + /* Why: the detected command is editable, but a pure app-background fill reads like a black box in dark mode and overpowers the prompt card. */ .setup-script-prompt-command { diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index 3ae4118970d..612d79e3448 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -96,6 +96,101 @@ background: color-mix(in srgb, var(--muted) 38%, var(--background)); } +.github-markdown-composer-tabbed { + border-radius: 8px; +} + +.github-markdown-composer-tabbar { + display: flex; + align-items: stretch; + justify-content: space-between; + gap: 8px; + min-height: 40px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 72%, transparent); + background: color-mix(in srgb, var(--muted) 28%, var(--background)); +} + +.github-markdown-composer-tabs { + display: flex; + align-items: stretch; + gap: 0; +} + +.github-markdown-composer-tab { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 72px; + padding: 0 14px; + border: none; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--muted-foreground); + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.github-markdown-composer-tab:hover { + color: var(--foreground); +} + +.github-markdown-composer-tab.is-active { + margin-bottom: -1px; + border-bottom-color: transparent; + background: var(--background); + color: var(--foreground); +} + +.github-markdown-composer-tabbar-toolbar { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + justify-content: flex-end; + overflow-x: auto; +} + +.github-markdown-composer-tabbar-toolbar .rich-markdown-editor-toolbar { + min-height: 38px; + padding: 4px 8px; + box-shadow: none; + background: transparent; +} + +.github-markdown-composer-preview { + padding: 12px 14px 18px; +} + +.github-markdown-composer-attachment { + display: flex; + width: 100%; + align-items: center; + gap: 6px; + padding: 8px 12px; + border: none; + border-top: 1px solid color-mix(in srgb, var(--border) 72%, transparent); + background: transparent; + color: var(--muted-foreground); + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.github-markdown-composer-attachment:hover:not(:disabled) { + color: var(--foreground); + background: color-mix(in srgb, var(--accent) 40%, transparent); +} + +.github-markdown-composer-attachment:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.github-issue-comment-composer .github-markdown-composer-tabbed { + border-color: color-mix(in srgb, var(--border) 82%, transparent); +} + .orca-diff-comment-add-btn.rich-markdown-comment-add-btn { display: flex; width: 22px; diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 1ed30f20225..041ef30765f 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -37,8 +37,6 @@ import { Pencil, Plus, RefreshCw, - Send, - Settings, UndoDot, Users, Wrench, @@ -134,6 +132,9 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' +import { GitHubWorkItemLabelPopoverContent } from '@/components/github/GitHubWorkItemLabelPopoverContent' +import { GitHubWorkItemAssigneePopoverContent } from '@/components/github/GitHubWorkItemAssigneePopoverContent' +import { GitHubIssueCommentComposer } from '@/components/github/GitHubIssueCommentComposer' import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' import { getGitHubPRReviewerRows, @@ -2682,12 +2683,12 @@ function ConversationTab({
-
+
{comment.authorAvatarUrl ? ( -
+
- + {translate('auto.components.GitHubItemDialog.1506916c09', 'Comments')} {comments.length > 0 && ( @@ -3001,13 +3002,13 @@ function ConversationTab({ )} {comments.length === 0 ? ( -
+

{translate('auto.components.GitHubItemDialog.5a94f3d0e9', 'No comments yet.')} -

+

) : visibleComments.length === 0 ? ( -
+

{getPRCommentAudienceEmptyLabel(commentFilter)} -

+

) : (
{visibleCommentGroups.map(renderCommentGroup)} @@ -3017,13 +3018,19 @@ function ConversationTab({ ) : null} {detailsLoaded && repoPath && ( - )}
@@ -4399,39 +4406,6 @@ async function runPullRequestStateUpdate(args: { } } -function GitHubLabelsSettingsLink({ - url, - separated, - onOpen -}: { - url: string | null - separated?: boolean - onOpen?: () => void -}): React.JSX.Element | null { - if (!url) { - return null - } - - return ( -
- -
- ) -} - function GHEditSection({ item, repoPath, @@ -4732,18 +4706,6 @@ function GHEditSection({ return null } - const checkIcon = ( - - - - ) - if (layout === 'sidebar') { return (
@@ -4918,39 +4853,15 @@ function GHEditSection({ className="popover-scroll-content scrollbar-sleek w-60 p-1" align="end" > - {repoLabels.error ? ( -
- {repoLabels.error} -
- ) : null} - {!repoLabels.error ? ( -
- {repoLabels.data.map((label) => ( - - ))} -
- ) : null} - 0} - onOpen={() => setLabelPopoverOpen(false)} + setLabelPopoverOpen(false)} /> @@ -5111,39 +5022,15 @@ function GHEditSection({ - {repoLabels.error ? ( -
- {repoLabels.error} -
- ) : null} - {!repoLabels.error ? ( -
- {repoLabels.data.map((label) => ( - - ))} -
- ) : null} - 0} - onOpen={() => setLabelPopoverOpen(false)} + setLabelPopoverOpen(false)} />
@@ -5175,41 +5062,14 @@ function GHEditSection({ - {repoAssignees.error ? ( -
- {repoAssignees.error} -
- ) : ( -
- {repoAssignees.data.map((user) => ( - - ))} -
- )} +
@@ -5276,96 +5136,6 @@ function GHEditSection({ ) } -function GHCommentComposer({ - className, - repoPath, - repoId, - issueNumber, - itemType, - onCommentAdded -}: { - className?: string - repoPath: string - repoId?: string | null - issueNumber: number - itemType: 'issue' | 'pr' - onCommentAdded: (comment: PRComment) => void -}): React.JSX.Element { - const [body, setBody] = useState('') - const [submitting, setSubmitting] = useState(false) - const mountedRef = useMountedRef() - - const handleSubmit = useCallback(async () => { - const trimmed = body.trim() - if (!trimmed) { - return - } - setSubmitting(true) - try { - const result = await addIssueCommentForRepo({ - repoPath, - repoId: repoId ?? undefined, - number: issueNumber, - body: trimmed, - type: itemType - }) - if (!mountedRef.current) { - return - } - if (result.ok) { - setBody('') - // Why: use the comment returned by GitHub so the optimistic row shows - // the real login/avatar immediately instead of waiting for a reopen. - onCommentAdded(result.comment) - } else { - toast.error( - result.error ?? - translate('auto.components.GitHubItemDialog.082515176a', 'Failed to add comment') - ) - } - } catch (err) { - if (mountedRef.current) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.GitHubItemDialog.082515176a', 'Failed to add comment') - ) - } - } finally { - if (mountedRef.current) { - setSubmitting(false) - } - } - }, [body, mountedRef, repoPath, repoId, issueNumber, itemType, onCommentAdded]) - - return ( -
- void handleSubmit()} - /> - -
- ) -} - // Why: the dialog doesn't carry the resolved PR-source slug the Tasks view's // list cache carries, so we reach into workItemsCache to recover it. We scope // the lookup to the dialog's own `repoPath` via the public diff --git a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx index dc03f202875..b63dc248665 100644 --- a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx +++ b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx @@ -1,26 +1,13 @@ -import React, { useCallback, useEffect, useRef } from 'react' +import React, { useEffect, useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' import { EditorContent, useEditor } from '@tiptap/react' import type { Editor } from '@tiptap/react' import Placeholder from '@tiptap/extension-placeholder' -import { - Bold, - Code, - Heading1, - Heading2, - Italic, - Link as LinkIcon, - List, - ListOrdered, - ListTodo, - LoaderCircle, - Pilcrow, - Quote, - Strikethrough -} from 'lucide-react' +import { LoaderCircle } from 'lucide-react' import { createRichMarkdownExtensions } from '@/components/editor/rich-markdown-extensions' import { encodeRawMarkdownHtmlForRichEditor } from '@/components/editor/raw-markdown-html' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { LinearIssueMarkdownToolbar } from '@/components/LinearIssueMarkdownToolbar' import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' @@ -34,258 +21,17 @@ type LinearIssueMarkdownDescriptionEditorProps = { submitShortcutLabel: string } -type LinearIssueMarkdownToolbarButtonProps = { - active?: boolean - disabled?: boolean - label: string - onClick: () => void - children: React.ReactNode -} - -const linearIssueMarkdownExtensions = [ - ...createRichMarkdownExtensions(), - Placeholder.configure({ - placeholder: translate( - 'auto.components.LinearIssueMarkdownDescriptionEditor.4f2fddc2b7', - 'No description provided.' - ) - }) -] - -function LinearIssueMarkdownToolbarButton({ - active = false, - disabled = false, - label, - onClick, - children -}: LinearIssueMarkdownToolbarButtonProps): React.JSX.Element { - return ( - - - - - - {label} - - - ) -} - -function LinearIssueMarkdownToolbarSeparator(): React.JSX.Element { - return
-} - -function applyLinearIssueLink(editor: Editor | null): void { - if (!editor) { - return - } - if (editor.isActive('link')) { - editor.chain().focus().unsetLink().run() - return - } - - const previousHref = editor.getAttributes('link').href as string | undefined - const href = window.prompt( - translate('auto.components.LinearIssueMarkdownDescriptionEditor.5c16ec8f14', 'Link URL'), - previousHref ?? '' - ) - if (href === null) { - editor.chain().focus().run() - return - } - - const trimmed = href.trim() - if (!trimmed) { - editor.chain().focus().unsetLink().run() - return - } - - editor.chain().focus().extendMarkRange('link').setLink({ href: trimmed }).run() -} - -function LinearIssueMarkdownToolbar({ - editor, - disabled -}: { - editor: Editor | null - disabled: boolean -}): React.JSX.Element { - const runCommand = useCallback( - (command: (editor: Editor) => void) => { - if (!editor || disabled) { - return - } - command(editor) - }, - [disabled, editor] - ) - - return ( -
- runCommand((nextEditor) => nextEditor.chain().focus().setParagraph().run())} - > - - - - runCommand((nextEditor) => nextEditor.chain().focus().toggleHeading({ level: 1 }).run()) - } - > - - - - runCommand((nextEditor) => nextEditor.chain().focus().toggleHeading({ level: 2 }).run()) - } - > - - - - runCommand((nextEditor) => nextEditor.chain().focus().toggleBold().run())} - > - - - runCommand((nextEditor) => nextEditor.chain().focus().toggleItalic().run())} - > - - - runCommand((nextEditor) => nextEditor.chain().focus().toggleStrike().run())} - > - - - runCommand((nextEditor) => nextEditor.chain().focus().toggleCode().run())} - > - - - - - runCommand((nextEditor) => nextEditor.chain().focus().toggleBulletList().run()) - } - > - - - - runCommand((nextEditor) => nextEditor.chain().focus().toggleOrderedList().run()) - } - > - - - - runCommand((nextEditor) => nextEditor.chain().focus().toggleTaskList().run()) - } - > - - - - - runCommand((nextEditor) => nextEditor.chain().focus().toggleBlockquote().run()) - } - > - - - runCommand(applyLinearIssueLink)} - > - - -
- ) +function createLinearIssueMarkdownExtensions() { + const extensions = createRichMarkdownExtensions() + return [ + ...extensions, + Placeholder.configure({ + placeholder: translate( + 'auto.components.LinearIssueMarkdownDescriptionEditor.4f2fddc2b7', + 'No description provided.' + ) + }) + ] } export function LinearIssueMarkdownDescriptionEditor({ @@ -296,46 +42,57 @@ export function LinearIssueMarkdownDescriptionEditor({ disabled, submitShortcutLabel }: LinearIssueMarkdownDescriptionEditorProps): React.JSX.Element { + const { i18n } = useTranslation() + const language = i18n.resolvedLanguage ?? i18n.language const lastEditorMarkdownRef = useRef(value) const editorRef = useRef(null) + const linearIssueMarkdownExtensions = useMemo(() => { + // Why: Tiptap freezes extension options when the editor is created; the + // language value is the recreation key for translated extension options. + void language + return createLinearIssueMarkdownExtensions() + }, [language]) - const editor = useEditor({ - immediatelyRender: false, - extensions: linearIssueMarkdownExtensions, - content: encodeRawMarkdownHtmlForRichEditor(value), - contentType: 'markdown', - editable: !disabled, - editorProps: { - attributes: { - class: 'rich-markdown-editor', - spellcheck: 'true', - 'aria-label': 'Issue description' - }, - handleKeyDown: (_view, event) => { - if (!isScreenSubmitShortcut(event)) { - return false + const editor = useEditor( + { + immediatelyRender: false, + extensions: linearIssueMarkdownExtensions, + content: encodeRawMarkdownHtmlForRichEditor(value), + contentType: 'markdown', + editable: !disabled, + editorProps: { + attributes: { + class: 'rich-markdown-editor', + spellcheck: 'true', + 'aria-label': 'Issue description' + }, + handleKeyDown: (_view, event) => { + if (!isScreenSubmitShortcut(event)) { + return false + } + event.preventDefault() + editorRef.current?.commands.blur() + return true } - event.preventDefault() - editorRef.current?.commands.blur() - return true + }, + onFocus: () => { + window.api.ui.setMarkdownEditorFocused(true) + }, + onBlur: ({ editor: nextEditor }) => { + window.api.ui.setMarkdownEditorFocused(false) + const nextValue = nextEditor.getMarkdown() + lastEditorMarkdownRef.current = nextValue + onChange(nextValue) + onSave(nextValue) + }, + onUpdate: ({ editor: nextEditor }) => { + const nextValue = nextEditor.getMarkdown() + lastEditorMarkdownRef.current = nextValue + onChange(nextValue) } }, - onFocus: () => { - window.api.ui.setMarkdownEditorFocused(true) - }, - onBlur: ({ editor: nextEditor }) => { - window.api.ui.setMarkdownEditorFocused(false) - const nextValue = nextEditor.getMarkdown() - lastEditorMarkdownRef.current = nextValue - onChange(nextValue) - onSave(nextValue) - }, - onUpdate: ({ editor: nextEditor }) => { - const nextValue = nextEditor.getMarkdown() - lastEditorMarkdownRef.current = nextValue - onChange(nextValue) - } - }) + [language] + ) useEffect(() => { editorRef.current = editor diff --git a/src/renderer/src/components/LinearIssueMarkdownToolbar.tsx b/src/renderer/src/components/LinearIssueMarkdownToolbar.tsx new file mode 100644 index 00000000000..8ecdf5673c1 --- /dev/null +++ b/src/renderer/src/components/LinearIssueMarkdownToolbar.tsx @@ -0,0 +1,267 @@ +import React, { useCallback } from 'react' +import type { Editor } from '@tiptap/react' +import { useTranslation } from 'react-i18next' +import { + Bold, + Code, + Heading1, + Heading2, + Italic, + Link as LinkIcon, + List, + ListOrdered, + ListTodo, + Pilcrow, + Quote, + Strikethrough +} from 'lucide-react' + +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' + +type LinearIssueMarkdownToolbarButtonProps = { + active?: boolean + disabled?: boolean + label: string + onClick: () => void + children: React.ReactNode +} + +function LinearIssueMarkdownToolbarButton({ + active = false, + disabled = false, + label, + onClick, + children +}: LinearIssueMarkdownToolbarButtonProps): React.JSX.Element { + return ( + + + + + + {label} + + + ) +} + +function LinearIssueMarkdownToolbarSeparator(): React.JSX.Element { + return
+} + +function applyLinearIssueLink(editor: Editor | null): void { + if (!editor) { + return + } + if (editor.isActive('link')) { + editor.chain().focus().unsetLink().run() + return + } + + const previousHref = editor.getAttributes('link').href as string | undefined + const href = window.prompt( + translate('auto.components.LinearIssueMarkdownDescriptionEditor.5c16ec8f14', 'Link URL'), + previousHref ?? '' + ) + if (href === null) { + editor.chain().focus().run() + return + } + + const trimmed = href.trim() + if (!trimmed) { + editor.chain().focus().unsetLink().run() + return + } + + editor.chain().focus().extendMarkRange('link').setLink({ href: trimmed }).run() +} + +export function LinearIssueMarkdownToolbar({ + editor, + disabled +}: { + editor: Editor | null + disabled: boolean +}): React.JSX.Element { + // Why: this toolbar can outlive editor recreation, so subscribe directly to language changes. + useTranslation() + const runCommand = useCallback( + (command: (editor: Editor) => void) => { + if (!editor || disabled) { + return + } + command(editor) + }, + [disabled, editor] + ) + + return ( +
+ runCommand((nextEditor) => nextEditor.chain().focus().setParagraph().run())} + > + + + + runCommand((nextEditor) => nextEditor.chain().focus().toggleHeading({ level: 1 }).run()) + } + > + + + + runCommand((nextEditor) => nextEditor.chain().focus().toggleHeading({ level: 2 }).run()) + } + > + + + + runCommand((nextEditor) => nextEditor.chain().focus().toggleBold().run())} + > + + + runCommand((nextEditor) => nextEditor.chain().focus().toggleItalic().run())} + > + + + runCommand((nextEditor) => nextEditor.chain().focus().toggleStrike().run())} + > + + + runCommand((nextEditor) => nextEditor.chain().focus().toggleCode().run())} + > + + + + + runCommand((nextEditor) => nextEditor.chain().focus().toggleBulletList().run()) + } + > + + + + runCommand((nextEditor) => nextEditor.chain().focus().toggleOrderedList().run()) + } + > + + + + runCommand((nextEditor) => nextEditor.chain().focus().toggleTaskList().run()) + } + > + + + + + runCommand((nextEditor) => nextEditor.chain().focus().toggleBlockquote().run()) + } + > + + + runCommand(applyLinearIssueLink)} + > + + +
+ ) +} diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 268110e46f1..70b29ba4cf6 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -2,6 +2,7 @@ composer card markup together so the inline and modal variants share one UI surface without splitting the controlled form into hard-to-follow fragments. */ import React from 'react' +import { useTranslation } from 'react-i18next' import { AlertTriangle, Check, @@ -99,15 +100,50 @@ type NewWorkspaceComposerCardProps = { sparseControlsEnabled?: boolean } -const SSH_STATUS_LABELS: Record = { - disconnected: 'SSH not connected', - connecting: 'Connecting SSH...', - 'auth-failed': 'SSH authentication failed', - 'deploying-relay': 'Preparing SSH connection...', - connected: 'Connected', - reconnecting: 'Reconnecting SSH...', - 'reconnection-failed': 'SSH reconnection failed', - error: translate('auto.components.NewWorkspaceComposerCard.a239038146', 'SSH connection error') +const SSH_STATUS_LABELS: Partial> = { + get disconnected() { + return translate( + 'auto.components.NewWorkspaceComposerCard.sshNotConnected', + 'SSH not connected' + ) + }, + get connecting() { + return translate('auto.components.NewWorkspaceComposerCard.connectingSsh', 'Connecting SSH...') + }, + get 'auth-failed'() { + return translate( + 'auto.components.NewWorkspaceComposerCard.sshAuthenticationFailed', + 'SSH authentication failed' + ) + }, + get 'deploying-relay'() { + return translate( + 'auto.components.NewWorkspaceComposerCard.preparingSshConnection', + 'Preparing SSH connection...' + ) + }, + get connected() { + return translate('auto.components.NewWorkspaceComposerCard.connected', 'Connected') + }, + get reconnecting() { + return translate( + 'auto.components.NewWorkspaceComposerCard.reconnectingSsh', + 'Reconnecting SSH...' + ) + }, + get 'reconnection-failed'() { + return translate( + 'auto.components.NewWorkspaceComposerCard.sshReconnectionFailed', + 'SSH reconnection failed' + ) + }, + get error() { + return translate('auto.components.NewWorkspaceComposerCard.a239038146', 'SSH connection error') + } +} + +function getSshStatusLabel(status: SshConnectionStatus): string { + return SSH_STATUS_LABELS[status] ?? status } function SetupCommandPreview({ @@ -284,6 +320,9 @@ export default function NewWorkspaceComposerCard({ onSparseSelectPreset, sparseControlsEnabled = true }: NewWorkspaceComposerCardProps): React.JSX.Element { + // Why: this form uses the lightweight translate() helper directly; subscribe + // so an already-open create dialog repaints when the UI language changes. + useTranslation() const { isFileDragOver, dragHandlers } = useComposerFileDragOver() const openModal = useAppStore((s) => s.openModal) const activeModal = useAppStore((s) => s.activeModal) @@ -297,8 +336,8 @@ export default function NewWorkspaceComposerCard({ return repo?.displayName ?? repo?.path ?? 'This project' }, [eligibleRepos, repoId]) const sshStatusLabel = selectedRepoSshStatus - ? SSH_STATUS_LABELS[selectedRepoSshStatus] - : 'Not connected' + ? getSshStatusLabel(selectedRepoSshStatus) + : translate('auto.components.NewWorkspaceComposerCard.notConnected', 'Not connected') const connectButtonLabel = selectedRepoSshStatus === 'disconnected' || selectedRepoSshStatus === null ? 'Connect' diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 13a851dc944..a6b919511c4 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -133,6 +133,8 @@ import { useAllWorktrees } from '@/store/selectors' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' +import { GitHubWorkItemLabelPopoverContent } from '@/components/github/GitHubWorkItemLabelPopoverContent' +import { GitHubWorkItemAssigneePopoverContent } from '@/components/github/GitHubWorkItemAssigneePopoverContent' import { getGitHubPRReviewerRows, normalizeGitHubReviewerLogins @@ -200,6 +202,25 @@ function parseOwnerRepoFromItemUrl(url: string): GitHubOwnerRepo | null { } } +function getGitHubRepositoryLabelsUrl(itemUrl: string): string | null { + try { + const parsed = new URL(itemUrl) + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + return null + } + const segments = parsed.pathname.split('/').filter(Boolean) + if (segments.length < 2) { + return null + } + parsed.pathname = `/${segments[0]}/${segments[1]}/labels` + parsed.search = '' + parsed.hash = '' + return parsed.toString() + } catch { + return null + } +} + const MonacoCodeExcerpt = lazy(() => import('@/components/editor/MonacoCodeExcerpt')) export type ItemDialogTab = 'conversation' | 'checks' | 'files' @@ -4948,6 +4969,7 @@ function GHEditSection({ const patchWorkItem = useAppStore((s) => s.patchWorkItem) const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) const { isPending, run } = useImmediateMutation() + const repositoryLabelsUrl = useMemo(() => getGitHubRepositoryLabelsUrl(item.url), [item.url]) // Why: when the dialog opens from a Project view, mutations route through // *BySlug IPCs and we must keep `projectViewCache` in sync alongside // `workItemsCache` — `patchWorkItem` only walks the latter, so without this @@ -5188,18 +5210,6 @@ function GHEditSection({ return null } - const checkIcon = ( - - - - ) - return (
{/* State */} @@ -5269,34 +5279,16 @@ function GHEditSection({ - {repoLabels.error ? ( -
- {repoLabels.error} -
- ) : ( -
- {repoLabels.data.map((label) => ( - - ))} -
- )} + setLabelPopoverOpen(false)} + />
@@ -5327,41 +5319,14 @@ function GHEditSection({ - {repoAssignees.error ? ( -
- {repoAssignees.error} -
- ) : ( -
- {repoAssignees.data.map((user) => ( - - ))} -
- )} +
diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 74ae96ee901..a16377b9f68 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -188,12 +188,16 @@ type BrowserOverlayAnchor = { const BROWSER_ANNOTATION_INTENT_OPTIONS = [ { value: 'change', - label: translate('auto.components.browser.pane.BrowserPane.143204e423', 'Change'), + get label() { + return translate('auto.components.browser.pane.BrowserPane.143204e423', 'Change') + }, icon: PencilLine }, { value: 'question', - label: translate('auto.components.browser.pane.BrowserPane.b5ba6085de', 'Question'), + get label() { + return translate('auto.components.browser.pane.BrowserPane.b5ba6085de', 'Question') + }, icon: MessageCircleQuestionMark } ] as const diff --git a/src/renderer/src/components/editor/EditorViewToggle.tsx b/src/renderer/src/components/editor/EditorViewToggle.tsx index 82b07c3a224..2a097af088a 100644 --- a/src/renderer/src/components/editor/EditorViewToggle.tsx +++ b/src/renderer/src/components/editor/EditorViewToggle.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { useTranslation } from 'react-i18next' import { Code, Eye, @@ -27,29 +28,41 @@ type ViewModeMetadata = { label: string; icon: LucideIcon; title?: string } const DEFAULT_VIEW_MODE_METADATA: Record = { source: { - label: translate('auto.components.editor.EditorViewToggle.4d6ccb7ba6', 'Source'), + get label() { + return translate('auto.components.editor.EditorViewToggle.4d6ccb7ba6', 'Source') + }, icon: Code }, rich: { - label: translate('auto.components.editor.EditorViewToggle.aff15f94f5', 'Rich Editor'), + get label() { + return translate('auto.components.editor.EditorViewToggle.aff15f94f5', 'Rich Editor') + }, icon: Pencil }, preview: { - label: translate('auto.components.editor.EditorViewToggle.0d193dc03c', 'Preview'), + get label() { + return translate('auto.components.editor.EditorViewToggle.0d193dc03c', 'Preview') + }, icon: Eye }, edit: { - label: translate('auto.components.editor.EditorViewToggle.ac3bb87913', 'Edit'), + get label() { + return translate('auto.components.editor.EditorViewToggle.ac3bb87913', 'Edit') + }, icon: FileText }, changes: { - label: translate('auto.components.editor.EditorViewToggle.4837f3f578', 'Changes'), + get label() { + return translate('auto.components.editor.EditorViewToggle.4837f3f578', 'Changes') + }, icon: GitCompareArrows, // Why: "Changes" collides with the Source Control sidebar's "Branch // Changes" section, which diffs against the base ref. This toggle shows // uncommitted changes (working tree vs HEAD), so disambiguate in the // hover title without repeating the button label. - title: translate('auto.components.editor.EditorViewToggle.167f45888c', 'Uncommitted changes') + get title() { + return translate('auto.components.editor.EditorViewToggle.167f45888c', 'Uncommitted changes') + } } } @@ -58,14 +71,18 @@ const DEFAULT_VIEW_MODE_METADATA: Record = // which we don't offer, so callers can override the per-mode presentation. export const CSV_VIEW_MODE_METADATA: Partial> = { rich: { - label: translate('auto.components.editor.EditorViewToggle.e408aa9cd5', 'Table'), + get label() { + return translate('auto.components.editor.EditorViewToggle.e408aa9cd5', 'Table') + }, icon: TableIcon } } export const NOTEBOOK_VIEW_MODE_METADATA: Partial> = { rich: { - label: translate('auto.components.editor.EditorViewToggle.b3410cd5e0', 'Notebook'), + get label() { + return translate('auto.components.editor.EditorViewToggle.b3410cd5e0', 'Notebook') + }, icon: NotebookText } } @@ -83,6 +100,9 @@ export default function EditorViewToggle({ onChange, metadataOverride }: EditorViewToggleProps): React.JSX.Element { + // Why: metadata labels are lightweight getters, so subscribe this compact + // control to repaint when the active language changes. + useTranslation() return ( (null) diff --git a/src/renderer/src/components/editor/markdown-rich-mode.ts b/src/renderer/src/components/editor/markdown-rich-mode.ts index 2cd1aebf7d0..a6ca7dfdfe2 100644 --- a/src/renderer/src/components/editor/markdown-rich-mode.ts +++ b/src/renderer/src/components/editor/markdown-rich-mode.ts @@ -17,10 +17,12 @@ type UnsupportedMatch = { const UNSUPPORTED_PATTERNS: UnsupportedMatch[] = [ { reason: 'html-or-jsx', - message: translate( - 'auto.components.editor.markdown.rich.mode.57128b73e1', - 'Editable only in code mode because this file contains HTML, JSX, or MDX.' - ), + get message() { + return translate( + 'auto.components.editor.markdown.rich.mode.57128b73e1', + 'Editable only in code mode because this file contains HTML, JSX, or MDX.' + ) + }, // Why: the rich editor preserves common embedded markup via placeholder // tokens before parsing, but any HTML shape that still fails round-trip // must fall back instead of risking silent source corruption. @@ -28,18 +30,22 @@ const UNSUPPORTED_PATTERNS: UnsupportedMatch[] = [ }, { reason: 'reference-links', - message: translate( - 'auto.components.editor.markdown.rich.mode.2fd2b44073', - 'Editable only in code mode because this file contains reference-style links.' - ), + get message() { + return translate( + 'auto.components.editor.markdown.rich.mode.2fd2b44073', + 'Editable only in code mode because this file contains reference-style links.' + ) + }, pattern: /^\[[^\]]+\]:\s+\S+/m }, { reason: 'footnotes', - message: translate( - 'auto.components.editor.markdown.rich.mode.7a8ce7c7da', - 'Editable only in code mode because this file contains footnotes.' - ), + get message() { + return translate( + 'auto.components.editor.markdown.rich.mode.7a8ce7c7da', + 'Editable only in code mode because this file contains footnotes.' + ) + }, pattern: /^\[\^[^\]]+\]:\s+/m } ] diff --git a/src/renderer/src/components/editor/rich-markdown-slash-command-catalog.tsx b/src/renderer/src/components/editor/rich-markdown-slash-command-catalog.tsx new file mode 100644 index 00000000000..889ec4e1039 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-slash-command-catalog.tsx @@ -0,0 +1,406 @@ +import type {} from '@tiptap/extension-mathematics' +import { + ChevronRight, + Heading1, + Heading2, + Heading3, + ImageIcon, + List, + ListOrdered, + Quote, + Sigma, + Table2, + Workflow +} from 'lucide-react' +import { translate } from '@/i18n/i18n' + +import { + icon, + insertCodeBlock, + insertTextWithSelection, + insertToggle, + textIcon, + type SlashCommand +} from './rich-markdown-slash-command-primitives' + +export type { + SlashCommand, + SlashCommandGroup, + SlashCommandIcon, + SlashCommandId, + SlashMenuState +} from './rich-markdown-slash-command-primitives' + +export const slashCommands: SlashCommand[] = [ + { + id: 'heading-1', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.e66e7f04c6', + 'Heading 1' + ) + }, + aliases: ['h1', 'title'], + icon: icon(Heading1), + group: 'Headings', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.570611864e', + 'Large section heading.' + ) + }, + run: (editor) => { + // Use setHeading (not toggleHeading) so the slash command is idempotent — + // invoking "/h1" on an existing H1 should keep it as H1, not revert to paragraph. + editor.chain().focus().setHeading({ level: 1 }).run() + } + }, + { + id: 'toggle-h1', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.41482b15ce', + 'Toggle Heading 1' + ) + }, + aliases: ['toggle-h1', 'toggle heading', 'details heading', 'collapse heading'], + icon: icon(ChevronRight), + group: 'Headings', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.3294a2c0cc', + 'Create a collapsible section with a large heading summary.' + ) + }, + run: (editor) => { + insertToggle(editor, 'heading-1') + } + }, + { + id: 'heading-2', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.c209a116b7', + 'Heading 2' + ) + }, + aliases: ['h2'], + icon: icon(Heading2), + group: 'Headings', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.45cf7ceb3f', + 'Medium section heading.' + ) + }, + run: (editor) => { + // Use setHeading (not toggleHeading) so the slash command is idempotent — + // invoking "/h2" on an existing H2 should keep it as H2, not revert to paragraph. + editor.chain().focus().setHeading({ level: 2 }).run() + } + }, + { + id: 'heading-3', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.30566ee962', + 'Heading 3' + ) + }, + aliases: ['h3'], + icon: icon(Heading3), + group: 'Headings', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.4920740259', + 'Small section heading.' + ) + }, + run: (editor) => { + // Use setHeading (not toggleHeading) so the slash command is idempotent — + // invoking "/h3" on an existing H3 should keep it as H3, not revert to paragraph. + editor.chain().focus().setHeading({ level: 3 }).run() + } + }, + { + id: 'blockquote', + get label() { + return translate('auto.components.editor.rich.markdown.slash.commands.c4c775778b', 'Quote') + }, + aliases: ['quote', 'blockquote'], + icon: icon(Quote), + group: 'Basic blocks', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.6a3def14de', + 'Insert a blockquote.' + ) + }, + run: (editor) => { + editor.chain().focus().toggleBlockquote().run() + } + }, + { + id: 'ordered-list', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.ed4cf0ebce', + 'Numbered List' + ) + }, + aliases: ['ordered', 'ol', 'numbered'], + icon: icon(ListOrdered), + group: 'Basic blocks', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.8e00aba296', + 'Create an ordered list.' + ) + }, + run: (editor) => { + editor.chain().focus().toggleOrderedList().run() + } + }, + { + id: 'bullet-list', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.56ff3237e7', + 'Bullet List' + ) + }, + aliases: ['bullet', 'ul', 'list'], + icon: icon(List), + group: 'Basic blocks', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.c9b9e826b8', + 'Create an unordered list.' + ) + }, + run: (editor) => { + editor.chain().focus().toggleBulletList().run() + } + }, + { + id: 'task-list', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.d0d2cdfbdb', + 'Check List' + ) + }, + aliases: ['todo', 'task', 'checkbox'], + icon: icon(List), + group: 'Basic blocks', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.d766f44867', + 'Create a checklist.' + ) + }, + run: (editor) => { + editor.chain().focus().toggleTaskList().run() + } + }, + { + id: 'text', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.58abdb9d41', + 'Paragraph' + ) + }, + aliases: ['paragraph', 'plain'], + icon: icon(List), + group: 'Basic blocks', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.9a7fe896dc', + 'Start a normal paragraph.' + ) + }, + run: (editor) => { + editor.chain().focus().setParagraph().run() + } + }, + { + id: 'toggle-text', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.f82c78a2ee', + 'Toggle Text' + ) + }, + aliases: ['toggle', 'details', 'collapse', 'toggle-text'], + icon: icon(ChevronRight), + group: 'Basic blocks', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.972ef9aeea', + 'Create a collapsible text section.' + ) + }, + run: (editor) => { + insertToggle(editor) + } + }, + { + id: 'code-block', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.624b50cf25', + 'Code Block' + ) + }, + aliases: ['code', 'snippet'], + icon: icon(List), + group: 'Basic blocks', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.89e327e054', + 'Insert a fenced code block.' + ) + }, + run: (editor) => { + editor.chain().focus().toggleCodeBlock().run() + } + }, + { + id: 'divider', + get label() { + return translate('auto.components.editor.rich.markdown.slash.commands.ae8377cf6b', 'Divider') + }, + aliases: ['divider', 'rule', 'hr'], + icon: icon(List), + group: 'Basic blocks', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.fae45ef4d3', + 'Insert a horizontal rule.' + ) + }, + run: (editor) => { + editor.chain().focus().setHorizontalRule().run() + } + }, + { + id: 'table', + get label() { + return translate('auto.components.editor.rich.markdown.slash.commands.19ea597868', 'Table') + }, + aliases: ['grid', 'columns', 'rows'], + icon: icon(Table2), + group: 'Advanced', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.67faab829b', + 'Insert a 3x3 markdown table.' + ) + }, + run: (editor) => { + editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() + } + }, + { + id: 'mermaid', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.e516d3f6e3', + 'Mermaid Diagram' + ) + }, + aliases: ['diagram', 'flowchart', 'chart', 'graph'], + icon: icon(Workflow), + group: 'Advanced', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.0ed9a7b38c', + 'Insert a Mermaid fenced block.' + ) + }, + run: (editor) => { + insertCodeBlock(editor, 'mermaid', 'graph TD\n A[Start] --> B[End]') + } + }, + { + id: 'inline-math', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.2bf5544faf', + 'Inline Math' + ) + }, + aliases: ['math', 'latex', 'equation', 'formula'], + icon: icon(Sigma), + group: 'Advanced', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.565907cf7a', + 'Insert inline LaTeX math.' + ) + }, + run: (editor) => { + editor.commands.insertInlineMath({ latex: 'x' }) + } + }, + { + id: 'math-block', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.6993a38ad1', + 'Math Block' + ) + }, + aliases: ['display math', 'latex block', 'equation block'], + icon: icon(Sigma), + group: 'Advanced', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.ae7d0f3f37', + 'Insert display LaTeX math.' + ) + }, + run: (editor) => { + editor.commands.insertBlockMath({ latex: 'x' }) + } + }, + { + id: 'image', + get label() { + return translate('auto.components.editor.rich.markdown.slash.commands.572be8e524', 'Image') + }, + aliases: ['image', 'img'], + icon: icon(ImageIcon), + group: 'Media', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.3324eb391a', + 'Insert an image from your computer.' + ) + }, + // Why: window.prompt() is not supported in Electron's renderer process, + // so image URL input is handled by an inline input bar in RichMarkdownEditor. + run: (editor) => { + editor.chain().focus().run() + } + }, + { + id: 'emoji', + get label() { + return translate('auto.components.editor.rich.markdown.slash.commands.8a30cbaeca', 'Emoji') + }, + aliases: ['smile', 'reaction', 'icon'], + icon: textIcon('🙂'), + group: 'Others', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.07e1b32396', + 'Insert a plain Unicode emoji.' + ) + }, + run: (editor) => { + insertTextWithSelection(editor, '🙂') + } + } +] diff --git a/src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts b/src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts new file mode 100644 index 00000000000..2ad2dd822bc --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts @@ -0,0 +1,117 @@ +import type React from 'react' +import type { Editor } from '@tiptap/react' +import { TextSelection } from '@tiptap/pm/state' + +export type SlashMenuState = { + query: string + from: number + to: number + left: number + top: number +} + +export type SlashCommandId = + | 'text' + | 'toggle-text' + | 'heading-1' + | 'toggle-h1' + | 'heading-2' + | 'heading-3' + | 'task-list' + | 'bullet-list' + | 'ordered-list' + | 'blockquote' + | 'code-block' + | 'divider' + | 'image' + | 'table' + | 'mermaid' + | 'inline-math' + | 'math-block' + | 'emoji' + +export type SlashCommandIcon = + | { kind: 'component'; component: React.ComponentType<{ className?: string }> } + | { kind: 'text'; value: string } + +export type SlashCommandGroup = 'Headings' | 'Basic blocks' | 'Advanced' | 'Media' | 'Others' + +export type SlashCommand = { + id: SlashCommandId + label: string + aliases: string[] + icon: SlashCommandIcon + group: SlashCommandGroup + description: string + run: (editor: Editor) => void +} + +export function icon(component: React.ComponentType<{ className?: string }>): SlashCommandIcon { + return { kind: 'component', component } +} + +export function textIcon(value: string): SlashCommandIcon { + return { kind: 'text', value } +} + +export function insertTextWithSelection( + editor: Editor, + text: string, + selectionStartOffset?: number, + selectionEndOffset = selectionStartOffset +): void { + editor.commands.command(({ state, dispatch }) => { + const from = state.selection.from + const tr = state.tr.insertText(text, from, state.selection.to) + + if (selectionStartOffset !== undefined) { + const selectionFrom = from + selectionStartOffset + const selectionTo = from + (selectionEndOffset ?? selectionStartOffset) + tr.setSelection(TextSelection.create(tr.doc, selectionFrom, selectionTo)) + } + + dispatch?.(tr.scrollIntoView()) + return true + }) +} + +export function insertCodeBlock(editor: Editor, language: string, text: string): void { + editor.commands.command(({ state, dispatch }) => { + const codeBlockType = state.schema.nodes.codeBlock + if (!codeBlockType) { + return false + } + const node = codeBlockType.create({ language }, text ? state.schema.text(text) : undefined) + const tr = state.tr.replaceSelectionWith(node).scrollIntoView() + const cursor = tr.selection.from + 1 + tr.setSelection(TextSelection.create(tr.doc, cursor, cursor)) + dispatch?.(tr) + return true + }) +} + +export function insertToggle(editor: Editor, variant?: 'heading-1'): void { + const insertAt = editor.state.selection.from + + editor + .chain() + .focus() + .insertContentAt(insertAt, { + type: 'details', + attrs: { + open: true, + ...(variant ? { variant } : {}) + }, + content: [ + { + type: 'detailsSummary' + }, + { + type: 'detailsContent', + content: [{ type: 'paragraph' }] + } + ] + }) + .setTextSelection(insertAt + 1) + .run() +} diff --git a/src/renderer/src/components/editor/rich-markdown-slash-commands.tsx b/src/renderer/src/components/editor/rich-markdown-slash-commands.tsx index 12b633a9763..c241676a94a 100644 --- a/src/renderer/src/components/editor/rich-markdown-slash-commands.tsx +++ b/src/renderer/src/components/editor/rich-markdown-slash-commands.tsx @@ -1,135 +1,15 @@ import type React from 'react' import type { Editor } from '@tiptap/react' -import { TextSelection } from '@tiptap/pm/state' -import type {} from '@tiptap/extension-mathematics' -import { - ChevronRight, - Heading1, - Heading2, - Heading3, - ImageIcon, - List, - ListOrdered, - Quote, - Sigma, - Table2, - Workflow -} from 'lucide-react' -import { translate } from '@/i18n/i18n' +import type { SlashCommand, SlashMenuState } from './rich-markdown-slash-command-catalog' -export type SlashMenuState = { - query: string - from: number - to: number - left: number - top: number -} - -export type SlashCommandId = - | 'text' - | 'toggle-text' - | 'heading-1' - | 'toggle-h1' - | 'heading-2' - | 'heading-3' - | 'task-list' - | 'bullet-list' - | 'ordered-list' - | 'blockquote' - | 'code-block' - | 'divider' - | 'image' - | 'table' - | 'mermaid' - | 'inline-math' - | 'math-block' - | 'emoji' - -export type SlashCommandIcon = - | { kind: 'component'; component: React.ComponentType<{ className?: string }> } - | { kind: 'text'; value: string } - -export type SlashCommandGroup = 'Headings' | 'Basic blocks' | 'Advanced' | 'Media' | 'Others' - -export type SlashCommand = { - id: SlashCommandId - label: string - aliases: string[] - icon: SlashCommandIcon - group: SlashCommandGroup - description: string - run: (editor: Editor) => void -} - -function icon(component: React.ComponentType<{ className?: string }>): SlashCommandIcon { - return { kind: 'component', component } -} - -function textIcon(value: string): SlashCommandIcon { - return { kind: 'text', value } -} - -function insertTextWithSelection( - editor: Editor, - text: string, - selectionStartOffset?: number, - selectionEndOffset = selectionStartOffset -): void { - editor.commands.command(({ state, dispatch }) => { - const from = state.selection.from - const tr = state.tr.insertText(text, from, state.selection.to) - - if (selectionStartOffset !== undefined) { - const selectionFrom = from + selectionStartOffset - const selectionTo = from + (selectionEndOffset ?? selectionStartOffset) - tr.setSelection(TextSelection.create(tr.doc, selectionFrom, selectionTo)) - } - - dispatch?.(tr.scrollIntoView()) - return true - }) -} - -function insertCodeBlock(editor: Editor, language: string, text: string): void { - editor.commands.command(({ state, dispatch }) => { - const codeBlockType = state.schema.nodes.codeBlock - if (!codeBlockType) { - return false - } - const node = codeBlockType.create({ language }, text ? state.schema.text(text) : undefined) - const tr = state.tr.replaceSelectionWith(node).scrollIntoView() - const cursor = tr.selection.from + 1 - tr.setSelection(TextSelection.create(tr.doc, cursor, cursor)) - dispatch?.(tr) - return true - }) -} - -function insertToggle(editor: Editor, variant?: 'heading-1'): void { - const insertAt = editor.state.selection.from - - editor - .chain() - .focus() - .insertContentAt(insertAt, { - type: 'details', - attrs: { - open: true, - ...(variant ? { variant } : {}) - }, - content: [ - { - type: 'detailsSummary' - }, - { - type: 'detailsContent', - content: [{ type: 'paragraph' }] - } - ] - }) - .setTextSelection(insertAt + 1) - .run() -} +export { slashCommands } from './rich-markdown-slash-command-catalog' +export type { + SlashCommand, + SlashCommandGroup, + SlashCommandIcon, + SlashCommandId, + SlashMenuState +} from './rich-markdown-slash-command-catalog' /** * Executes a slash command by first deleting the typed slash text, then @@ -157,215 +37,6 @@ export function runSlashCommand( command.run(editor) } -export const slashCommands: SlashCommand[] = [ - { - id: 'heading-1', - label: translate("auto.components.editor.rich.markdown.slash.commands.e66e7f04c6", "Heading 1"), - aliases: ['h1', 'title'], - icon: icon(Heading1), - group: 'Headings', - description: translate("auto.components.editor.rich.markdown.slash.commands.570611864e", "Large section heading."), - run: (editor) => { - // Use setHeading (not toggleHeading) so the slash command is idempotent — - // invoking "/h1" on an existing H1 should keep it as H1, not revert to paragraph. - editor.chain().focus().setHeading({ level: 1 }).run() - } - }, - { - id: 'toggle-h1', - label: translate("auto.components.editor.rich.markdown.slash.commands.41482b15ce", "Toggle Heading 1"), - aliases: ['toggle-h1', 'toggle heading', 'details heading', 'collapse heading'], - icon: icon(ChevronRight), - group: 'Headings', - description: translate("auto.components.editor.rich.markdown.slash.commands.3294a2c0cc", "Create a collapsible section with a large heading summary."), - run: (editor) => { - insertToggle(editor, 'heading-1') - } - }, - { - id: 'heading-2', - label: translate("auto.components.editor.rich.markdown.slash.commands.c209a116b7", "Heading 2"), - aliases: ['h2'], - icon: icon(Heading2), - group: 'Headings', - description: translate("auto.components.editor.rich.markdown.slash.commands.45cf7ceb3f", "Medium section heading."), - run: (editor) => { - // Use setHeading (not toggleHeading) so the slash command is idempotent — - // invoking "/h2" on an existing H2 should keep it as H2, not revert to paragraph. - editor.chain().focus().setHeading({ level: 2 }).run() - } - }, - { - id: 'heading-3', - label: translate("auto.components.editor.rich.markdown.slash.commands.30566ee962", "Heading 3"), - aliases: ['h3'], - icon: icon(Heading3), - group: 'Headings', - description: translate("auto.components.editor.rich.markdown.slash.commands.4920740259", "Small section heading."), - run: (editor) => { - // Use setHeading (not toggleHeading) so the slash command is idempotent — - // invoking "/h3" on an existing H3 should keep it as H3, not revert to paragraph. - editor.chain().focus().setHeading({ level: 3 }).run() - } - }, - { - id: 'blockquote', - label: translate("auto.components.editor.rich.markdown.slash.commands.c4c775778b", "Quote"), - aliases: ['quote', 'blockquote'], - icon: icon(Quote), - group: 'Basic blocks', - description: translate("auto.components.editor.rich.markdown.slash.commands.6a3def14de", "Insert a blockquote."), - run: (editor) => { - editor.chain().focus().toggleBlockquote().run() - } - }, - { - id: 'ordered-list', - label: translate("auto.components.editor.rich.markdown.slash.commands.ed4cf0ebce", "Numbered List"), - aliases: ['ordered', 'ol', 'numbered'], - icon: icon(ListOrdered), - group: 'Basic blocks', - description: translate("auto.components.editor.rich.markdown.slash.commands.8e00aba296", "Create an ordered list."), - run: (editor) => { - editor.chain().focus().toggleOrderedList().run() - } - }, - { - id: 'bullet-list', - label: translate("auto.components.editor.rich.markdown.slash.commands.56ff3237e7", "Bullet List"), - aliases: ['bullet', 'ul', 'list'], - icon: icon(List), - group: 'Basic blocks', - description: translate("auto.components.editor.rich.markdown.slash.commands.c9b9e826b8", "Create an unordered list."), - run: (editor) => { - editor.chain().focus().toggleBulletList().run() - } - }, - { - id: 'task-list', - label: translate("auto.components.editor.rich.markdown.slash.commands.d0d2cdfbdb", "Check List"), - aliases: ['todo', 'task', 'checkbox'], - icon: icon(List), - group: 'Basic blocks', - description: translate("auto.components.editor.rich.markdown.slash.commands.d766f44867", "Create a checklist."), - run: (editor) => { - editor.chain().focus().toggleTaskList().run() - } - }, - { - id: 'text', - label: translate("auto.components.editor.rich.markdown.slash.commands.58abdb9d41", "Paragraph"), - aliases: ['paragraph', 'plain'], - icon: icon(List), - group: 'Basic blocks', - description: translate("auto.components.editor.rich.markdown.slash.commands.9a7fe896dc", "Start a normal paragraph."), - run: (editor) => { - editor.chain().focus().setParagraph().run() - } - }, - { - id: 'toggle-text', - label: translate("auto.components.editor.rich.markdown.slash.commands.f82c78a2ee", "Toggle Text"), - aliases: ['toggle', 'details', 'collapse', 'toggle-text'], - icon: icon(ChevronRight), - group: 'Basic blocks', - description: translate("auto.components.editor.rich.markdown.slash.commands.972ef9aeea", "Create a collapsible text section."), - run: (editor) => { - insertToggle(editor) - } - }, - { - id: 'code-block', - label: translate("auto.components.editor.rich.markdown.slash.commands.624b50cf25", "Code Block"), - aliases: ['code', 'snippet'], - icon: icon(List), - group: 'Basic blocks', - description: translate("auto.components.editor.rich.markdown.slash.commands.89e327e054", "Insert a fenced code block."), - run: (editor) => { - editor.chain().focus().toggleCodeBlock().run() - } - }, - { - id: 'divider', - label: translate("auto.components.editor.rich.markdown.slash.commands.ae8377cf6b", "Divider"), - aliases: ['divider', 'rule', 'hr'], - icon: icon(List), - group: 'Basic blocks', - description: translate("auto.components.editor.rich.markdown.slash.commands.fae45ef4d3", "Insert a horizontal rule."), - run: (editor) => { - editor.chain().focus().setHorizontalRule().run() - } - }, - { - id: 'table', - label: translate("auto.components.editor.rich.markdown.slash.commands.19ea597868", "Table"), - aliases: ['grid', 'columns', 'rows'], - icon: icon(Table2), - group: 'Advanced', - description: translate("auto.components.editor.rich.markdown.slash.commands.67faab829b", "Insert a 3x3 markdown table."), - run: (editor) => { - editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() - } - }, - { - id: 'mermaid', - label: translate("auto.components.editor.rich.markdown.slash.commands.e516d3f6e3", "Mermaid Diagram"), - aliases: ['diagram', 'flowchart', 'chart', 'graph'], - icon: icon(Workflow), - group: 'Advanced', - description: translate("auto.components.editor.rich.markdown.slash.commands.0ed9a7b38c", "Insert a Mermaid fenced block."), - run: (editor) => { - insertCodeBlock(editor, 'mermaid', 'graph TD\n A[Start] --> B[End]') - } - }, - { - id: 'inline-math', - label: translate("auto.components.editor.rich.markdown.slash.commands.2bf5544faf", "Inline Math"), - aliases: ['math', 'latex', 'equation', 'formula'], - icon: icon(Sigma), - group: 'Advanced', - description: translate("auto.components.editor.rich.markdown.slash.commands.565907cf7a", "Insert inline LaTeX math."), - run: (editor) => { - editor.commands.insertInlineMath({ latex: 'x' }) - } - }, - { - id: 'math-block', - label: translate("auto.components.editor.rich.markdown.slash.commands.6993a38ad1", "Math Block"), - aliases: ['display math', 'latex block', 'equation block'], - icon: icon(Sigma), - group: 'Advanced', - description: translate("auto.components.editor.rich.markdown.slash.commands.ae7d0f3f37", "Insert display LaTeX math."), - run: (editor) => { - editor.commands.insertBlockMath({ latex: 'x' }) - } - }, - { - id: 'image', - label: translate("auto.components.editor.rich.markdown.slash.commands.572be8e524", "Image"), - aliases: ['image', 'img'], - icon: icon(ImageIcon), - group: 'Media', - description: translate("auto.components.editor.rich.markdown.slash.commands.3324eb391a", "Insert an image from your computer."), - // Why: window.prompt() is not supported in Electron's renderer process, - // so image URL input is handled by an inline input bar in RichMarkdownEditor. - run: (editor) => { - editor.chain().focus().run() - } - }, - { - id: 'emoji', - label: translate("auto.components.editor.rich.markdown.slash.commands.8a30cbaeca", "Emoji"), - aliases: ['smile', 'reaction', 'icon'], - icon: textIcon('🙂'), - group: 'Others', - description: translate("auto.components.editor.rich.markdown.slash.commands.07e1b32396", "Insert a plain Unicode emoji."), - run: (editor) => { - insertTextWithSelection(editor, '🙂') - } - } -] - /** * Inspects the editor selection to decide whether the slash-command menu * should be open (and where to position it), or dismissed. diff --git a/src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.tsx b/src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.tsx index 2e79dc2bb2c..9787a501e90 100644 --- a/src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.tsx +++ b/src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.tsx @@ -142,38 +142,50 @@ type AgentCapabilitySetupRow = { const AGENT_CAPABILITY_SETUP_ROWS: readonly AgentCapabilitySetupRow[] = [ { id: 'orchestration', - title: translate( - 'auto.components.feature.wall.AgentCapabilitiesSetupAction.ac07f8887f', - 'Agent Orchestration' - ), - description: translate( - 'auto.components.feature.wall.AgentCapabilitiesSetupAction.c61c91e642', - 'Let agents coordinate through Orca to keep large, multi-step tasks moving to completion.' - ), + get title() { + return translate( + 'auto.components.feature.wall.AgentCapabilitiesSetupAction.ac07f8887f', + 'Agent Orchestration' + ) + }, + get description() { + return translate( + 'auto.components.feature.wall.AgentCapabilitiesSetupAction.c61c91e642', + 'Let agents coordinate through Orca to keep large, multi-step tasks moving to completion.' + ) + }, icon: }, { id: 'browserUse', - title: translate( - 'auto.components.feature.wall.AgentCapabilitiesSetupAction.e638da007a', - 'Agent Browser Use' - ), - description: translate( - 'auto.components.feature.wall.AgentCapabilitiesSetupAction.5e8fe5a72d', - "Give agents direct access to Orca's browser so they can test pages, capture screenshots, and act on what they see." - ), + get title() { + return translate( + 'auto.components.feature.wall.AgentCapabilitiesSetupAction.e638da007a', + 'Agent Browser Use' + ) + }, + get description() { + return translate( + 'auto.components.feature.wall.AgentCapabilitiesSetupAction.5e8fe5a72d', + "Give agents direct access to Orca's browser so they can test pages, capture screenshots, and act on what they see." + ) + }, icon: }, { id: 'computerUse', - title: translate( - 'auto.components.feature.wall.AgentCapabilitiesSetupAction.362a07517d', - 'Computer Use' - ), - description: translate( - 'auto.components.feature.wall.AgentCapabilitiesSetupAction.1b51644c2d', - 'Let agents control the desktop, moving the cursor, clicking, and typing in any app.' - ), + get title() { + return translate( + 'auto.components.feature.wall.AgentCapabilitiesSetupAction.362a07517d', + 'Computer Use' + ) + }, + get description() { + return translate( + 'auto.components.feature.wall.AgentCapabilitiesSetupAction.1b51644c2d', + 'Let agents control the desktop, moving the cursor, clicking, and typing in any app.' + ) + }, icon: } ] diff --git a/src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx index def5e2993cd..3d229ddaa91 100644 --- a/src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx +++ b/src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx @@ -127,17 +127,22 @@ const TERM_ENTRIES: readonly { entry: TermEntry; minPhase: Phase }[] = [ { entry: { kind: 'ok', - html: ( - <> - {translate('auto.components.feature.wall.BrowserAnimatedVisual.4fa59ca545', '✓ Updated')}{' '} - + get html() { + return ( + <> {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.051c97d15a', - '.pp-card[data-card="starter"] .pp-cta' - )} - - - ) + 'auto.components.feature.wall.BrowserAnimatedVisual.4fa59ca545', + '✓ Updated' + )}{' '} + + {translate( + 'auto.components.feature.wall.BrowserAnimatedVisual.051c97d15a', + '.pp-card[data-card="starter"] .pp-cta' + )} + + + ) + } }, minPhase: 'updated' }, @@ -156,14 +161,16 @@ const TERM_ENTRIES: readonly { entry: TermEntry; minPhase: Phase }[] = [ { entry: { kind: 'ok', - html: ( - <> - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.eb88125c6f', - '✓ Verified — Try free still works.' - )} - - ) + get html() { + return ( + <> + {translate( + 'auto.components.feature.wall.BrowserAnimatedVisual.eb88125c6f', + '✓ Verified — Try free still works.' + )} + + ) + } }, minPhase: 'verified' } diff --git a/src/renderer/src/components/feature-wall/FeatureTourPreview.tsx b/src/renderer/src/components/feature-wall/FeatureTourPreview.tsx index 2632e530210..c92b6c4d237 100644 --- a/src/renderer/src/components/feature-wall/FeatureTourPreview.tsx +++ b/src/renderer/src/components/feature-wall/FeatureTourPreview.tsx @@ -7,48 +7,26 @@ import { MailGlyph, WorkingSpinner } from './feature-tour-preview-glyphs' +import { + FEATURE_TOUR_ORCHESTRATION_CHILDREN, + FEATURE_TOUR_PREVIEW_COPY +} from './feature-tour-preview-copy' import { FeatureTourWorkspaceCard } from './FeatureTourWorkspaceCard' +import { FeatureTourTerminalFrame } from './FeatureTourTerminalFrame' import { translate } from '@/i18n/i18n' -type FrameId = 1 | 2 | 3 | 4 - -export type FeatureTourPreviewFrameCopy = { - id: FrameId - title: string - caption: string -} - -export const FEATURE_TOUR_PREVIEW_COPY: readonly FeatureTourPreviewFrameCopy[] = [ - { - id: 1, - title: translate("auto.components.feature.wall.FeatureTourPreview.56a0271428", "Isolated workspaces"), - caption: - 'Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.' - }, - { - id: 2, - title: translate("auto.components.feature.wall.FeatureTourPreview.e44269e97d", "Agent orchestration"), - caption: 'Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.' - }, - { - id: 3, - title: translate("auto.components.feature.wall.FeatureTourPreview.ef737dcee1", "GitHub & Linear tasks"), - caption: - 'Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.' - }, - { - id: 4, - title: translate("auto.components.feature.wall.FeatureTourPreview.1aa8a9a24a", "Splittable terminal"), - caption: - 'Open any workspace to return to its terminal, then split panes for tests, logs, and agents.' - } -] +export { FEATURE_TOUR_PREVIEW_COPY } from './feature-tour-preview-copy' +export type { FeatureTourPreviewFrameCopy } from './feature-tour-preview-copy' function WorkspaceFrame(): JSX.Element { return (
- {translate("auto.components.feature.wall.FeatureTourPreview.56a0271428", "Isolated workspaces")}
+ {translate( + 'auto.components.feature.wall.FeatureTourPreview.56a0271428', + 'Isolated workspaces' + )} +
{/* Why: 3 cards in a row tells the "ship several at once" story by composition; the wide preview aspect (~4.9:1) makes a vertical stack read as wasted space. The grid auto-sizes (no flex-1) so the cards @@ -57,7 +35,10 @@ function WorkspaceFrame(): JSX.Element {
@@ -78,22 +65,6 @@ function WorkspaceFrame(): JSX.Element { ) } -type OrchChildAgent = 'claude' | 'codex' | 'opencode-go' - -const ORCH_CHILDREN: readonly { - key: 'top' | 'mid' | 'bot' - position: string - label: string - agent: OrchChildAgent -}[] = [ - // Why: card vertical centers anchor to 18% / 50% / 82% — the same Y - // endpoints the dashed SVG paths terminate at — so the connectors land on - // each card's center regardless of card height. - { key: 'top', position: 'top-[18%] -translate-y-1/2', label: translate("auto.components.feature.wall.FeatureTourPreview.b1f17bcc74", "PR 1/3"), agent: 'claude' }, - { key: 'mid', position: 'top-1/2 -translate-y-1/2', label: translate("auto.components.feature.wall.FeatureTourPreview.cfdfd4d6b4", "PR 2/3"), agent: 'codex' }, - { key: 'bot', position: 'top-[82%] -translate-y-1/2', label: translate("auto.components.feature.wall.FeatureTourPreview.ec4a73f5e6", "PR 3/3"), agent: 'opencode-go' } -] - function OrchestrationFrame(): JSX.Element { // Why: a horizontal fan (root → 3 children L→R) reads naturally as // "fans out and ships parallel PRs" at the wide aspect; the previous @@ -104,7 +75,11 @@ function OrchestrationFrame(): JSX.Element { return (
- {translate("auto.components.feature.wall.FeatureTourPreview.e44269e97d", "Agent orchestration")}
+ {translate( + 'auto.components.feature.wall.FeatureTourPreview.e44269e97d', + 'Agent orchestration' + )} +
{/* Why: viewBox is percent-units (100×100, preserveAspectRatio="none") so endpoints anchor to the same percentage anchors as the cards @@ -158,20 +133,28 @@ function OrchestrationFrame(): JSX.Element {
- {translate("auto.components.feature.wall.FeatureTourPreview.cebc7769cd", "redesign auth flow")} + {translate( + 'auto.components.feature.wall.FeatureTourPreview.cebc7769cd', + 'redesign auth flow' + )} +
- {translate("auto.components.feature.wall.FeatureTourPreview.5171768676", "orchestrating 3 agents")} + {translate( + 'auto.components.feature.wall.FeatureTourPreview.5171768676', + 'orchestrating 3 agents' + )} +
{/* Why: children mirror the parent's WorkspaceCard composition so the fan reads as "coordinator workspace dispatches to 3 child workspaces, each running its own agent." */} - {ORCH_CHILDREN.map(({ key, position, label, agent }) => ( + {FEATURE_TOUR_ORCHESTRATION_CHILDREN.map(({ key, position, label, agent }) => (
- {agent === "claude" ? ( + {agent === 'claude' ? ( - ) : agent === "codex" ? ( + ) : agent === 'codex' ? ( ) : ( @@ -200,7 +183,7 @@ function OrchestrationFrame(): JSX.Element {
))} - {ORCH_CHILDREN.map(({ key }) => ( + {FEATURE_TOUR_ORCHESTRATION_CHILDREN.map(({ key }) => (
@@ -218,12 +201,17 @@ function TasksFrame(): JSX.Element { return (
- {translate("auto.components.feature.wall.FeatureTourPreview.bee6b4088d", "GitHub & Linear tasks")}
+ {translate( + 'auto.components.feature.wall.FeatureTourPreview.bee6b4088d', + 'GitHub & Linear tasks' + )} +
- {translate("auto.components.feature.wall.FeatureTourPreview.0688842445", "GH #1799")} + {translate('auto.components.feature.wall.FeatureTourPreview.0688842445', 'GH #1799')} + {/* Why: surrounding rows show only the issue number + a skeleton so the user's eye is drawn to the row that has real text — the one the cursor clicks on. */} @@ -231,12 +219,18 @@ function TasksFrame(): JSX.Element {
- {translate("auto.components.feature.wall.FeatureTourPreview.fc0cc0b267", "GH #1842")} + {translate('auto.components.feature.wall.FeatureTourPreview.fc0cc0b267', 'GH #1842')} + - {translate("auto.components.feature.wall.FeatureTourPreview.c1f28c03b2", "Worktree picker truncates")} + {translate( + 'auto.components.feature.wall.FeatureTourPreview.c1f28c03b2', + 'Worktree picker truncates' + )} + - {translate("auto.components.feature.wall.FeatureTourPreview.40bbd92ef4", "Start")}
- {translate("auto.components.feature.wall.FeatureTourPreview.d54aefe09e", "LIN-329")} + {translate('auto.components.feature.wall.FeatureTourPreview.d54aefe09e', 'LIN-329')} +
@@ -271,7 +266,11 @@ function TasksFrame(): JSX.Element {
- {translate("auto.components.feature.wall.FeatureTourPreview.3822d8d14b", "fix/worktree-picker-truncates")} + {translate( + 'auto.components.feature.wall.FeatureTourPreview.3822d8d14b', + 'fix/worktree-picker-truncates' + )} + @@ -281,83 +280,11 @@ function TasksFrame(): JSX.Element {
-
{translate("auto.components.feature.wall.FeatureTourPreview.2a7cfc82c8", "Linked to GH #1842")}
-
-
-
- ) -} - -function TerminalFrame(): JSX.Element { - return ( -
-
- {translate("auto.components.feature.wall.FeatureTourPreview.1aa8a9a24a", "Splittable terminal")}
-
-
- - - - - {translate("auto.components.feature.wall.FeatureTourPreview.04d54d50ec", "orca · zsh")} -
-
-
-
- $ - - {translate("auto.components.feature.wall.FeatureTourPreview.6218a9014d", "pnpm playwright test")} -
-
-
- {translate("auto.components.feature.wall.FeatureTourPreview.8279e9d95b", "Running 12 tests")}
-
- ✓ - {translate("auto.components.feature.wall.FeatureTourPreview.24fedd5a52", "login.spec.ts")} -
-
- - {translate("auto.components.feature.wall.FeatureTourPreview.6ed43cb0e0", "dashboard.spec.ts")} -
-
-
-
-
- $ - {translate("auto.components.feature.wall.FeatureTourPreview.771d8881c2", "claude")} -
-
-
- - {translate("auto.components.feature.wall.FeatureTourPreview.952d3ddd9a", "session started")} -
-
- {translate("auto.components.feature.wall.FeatureTourPreview.1170621527", ">")} - {translate("auto.components.feature.wall.FeatureTourPreview.ef8b164dd1", "review src/auth")} -
-
- - {translate("auto.components.feature.wall.FeatureTourPreview.304ad0dfc1", "Thinking...")} -
-
+
+ {translate( + 'auto.components.feature.wall.FeatureTourPreview.2a7cfc82c8', + 'Linked to GH #1842' + )}
@@ -385,7 +312,7 @@ export function FeatureTourPreview(props: { className?: string }): JSX.Element {
- +
{FEATURE_TOUR_PREVIEW_COPY.map((frame) => ( diff --git a/src/renderer/src/components/feature-wall/FeatureTourTerminalFrame.tsx b/src/renderer/src/components/feature-wall/FeatureTourTerminalFrame.tsx new file mode 100644 index 00000000000..d21314126c2 --- /dev/null +++ b/src/renderer/src/components/feature-wall/FeatureTourTerminalFrame.tsx @@ -0,0 +1,122 @@ +import type { JSX } from 'react' +import { ClaudeIcon } from '../status-bar/icons' +import { translate } from '@/i18n/i18n' + +export function FeatureTourTerminalFrame(): JSX.Element { + return ( +
+
+ {translate( + 'auto.components.feature.wall.FeatureTourPreview.1aa8a9a24a', + 'Splittable terminal' + )} +
+
+
+ + + + + {translate('auto.components.feature.wall.FeatureTourPreview.04d54d50ec', 'orca · zsh')} + +
+
+
+
+ $ + + {translate( + 'auto.components.feature.wall.FeatureTourPreview.6218a9014d', + 'pnpm playwright test' + )} + +
+
+
+ {translate( + 'auto.components.feature.wall.FeatureTourPreview.8279e9d95b', + 'Running 12 tests' + )} +
+
+ ✓ + + {translate( + 'auto.components.feature.wall.FeatureTourPreview.24fedd5a52', + 'login.spec.ts' + )} + +
+
+ + + {translate( + 'auto.components.feature.wall.FeatureTourPreview.6ed43cb0e0', + 'dashboard.spec.ts' + )} + +
+
+
+
+
+ $ + + {translate('auto.components.feature.wall.FeatureTourPreview.771d8881c2', 'claude')} + +
+
+
+ + + {translate( + 'auto.components.feature.wall.FeatureTourPreview.952d3ddd9a', + 'session started' + )} + +
+
+ + {translate('auto.components.feature.wall.FeatureTourPreview.1170621527', '>')} + + + {translate( + 'auto.components.feature.wall.FeatureTourPreview.ef8b164dd1', + 'review src/auth' + )} + +
+
+ + + {translate( + 'auto.components.feature.wall.FeatureTourPreview.304ad0dfc1', + 'Thinking...' + )} + +
+
+
+
+
+
+ ) +} diff --git a/src/renderer/src/components/feature-wall/ReviewPRViewAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/ReviewPRViewAnimatedVisual.tsx index 590d0cd2475..be3148a0e7c 100644 --- a/src/renderer/src/components/feature-wall/ReviewPRViewAnimatedVisual.tsx +++ b/src/renderer/src/components/feature-wall/ReviewPRViewAnimatedVisual.tsx @@ -1,9 +1,10 @@ -import { useEffect, useRef } from 'react' +import { useRef } from 'react' import type { ComponentType, JSX, ReactNode } from 'react' import { Files, GitBranch, ListChecks, MessageSquare, Search } from 'lucide-react' import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { ReviewPRViewVisualStyles } from './review-animated-visual-pr-view-styles' import { CheckTinyIcon, ChevDownIcon, CursorIcon } from './review-animated-visual-shared' +import { useReviewPrViewAnimation } from './review-pr-view-animation' import { translate } from '@/i18n/i18n' type SidebarTabId = 'explorer' | 'search' | 'source-control' | 'checks' @@ -13,10 +14,46 @@ const SIDEBAR_TABS: readonly { icon: ComponentType<{ className?: string; size?: number }> label: string }[] = [ - { id: 'explorer', icon: Files, label: translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.6e3f5223c5", "Explorer") }, - { id: 'search', icon: Search, label: translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.8e715588e4", "Search") }, - { id: 'source-control', icon: GitBranch, label: translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.d7f80060ca", "Source Control") }, - { id: 'checks', icon: ListChecks, label: translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.ab2901bce6", "Checks") } + { + id: 'explorer', + icon: Files, + get label() { + return translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.6e3f5223c5', + 'Explorer' + ) + } + }, + { + id: 'search', + icon: Search, + get label() { + return translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.8e715588e4', + 'Search' + ) + } + }, + { + id: 'source-control', + icon: GitBranch, + get label() { + return translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.d7f80060ca', + 'Source Control' + ) + } + }, + { + id: 'checks', + icon: ListChecks, + get label() { + return translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.ab2901bce6', + 'Checks' + ) + } + } ] function SidebarTabs(props: { active: SidebarTabId; interactiveChecks?: boolean }): JSX.Element { @@ -85,20 +122,6 @@ function CommentCard(props: { index: number; path: string; children: ReactNode } ) } -function moveCursor( - root: HTMLElement, - cursor: HTMLElement, - anchor: HTMLElement, - ox = 0, - oy = 0 -): void { - const rootRect = root.getBoundingClientRect() - const anchorRect = anchor.getBoundingClientRect() - cursor.style.transform = `translate(${anchorRect.left - rootRect.left + ox}px, ${ - anchorRect.top - rootRect.top + oy - }px)` -} - // Why: the Review PR visual follows the approved HTML mock beat-for-beat. The // real app keeps Explorer / Checks in one right-sidebar surface, so the // animation selects Checks before the PR status content appears. @@ -106,205 +129,7 @@ export function ReviewPRViewAnimatedVisual(props: { reducedMotion: boolean }): J const { reducedMotion } = props const rootRef = useRef(null) - useEffect(() => { - const root = rootRef.current - if (!root) { - return - } - - const sidebarPeek = root.querySelector('[data-checks-sidebar-peek]') - const prCard = root.querySelector('[data-pr-view-card]') - const cursor = root.querySelector('[data-cursor]') - const explorerTab = root.querySelector('[data-explorer-tab]') - const checksTab = root.querySelector('[data-checks-tab]') - const checksTooltip = root.querySelector('[data-checks-tooltip]') - const checksBlock = root.querySelector('[data-checks-block]') - const commentsBlock = root.querySelector('[data-comments-block]') - const comments = Array.from(root.querySelectorAll('[data-comment-card]')) - const commentsCount = root.querySelector('[data-comments-count]') - const checkSummary = root.querySelector('[data-check-summary]') - const checkSummaryLabel = root.querySelector('[data-check-summary-label]') - const checkSummaryMeta = root.querySelector('[data-check-summary-meta]') - const verifyRow = root.querySelector('[data-check-row="verify"]') - const verifyState = root.querySelector('[data-check-verify-state]') - const mergeBtn = root.querySelector('[data-merge-btn]') - if ( - !sidebarPeek || - !prCard || - !cursor || - !explorerTab || - !checksTab || - !checksTooltip || - !checksBlock || - !commentsBlock || - !commentsCount || - !checkSummary || - !checkSummaryLabel || - !checkSummaryMeta || - !verifyRow || - !verifyState || - !mergeBtn - ) { - return - } - - const rootEl: HTMLDivElement = root - const sidebarPeekEl: HTMLDivElement = sidebarPeek - const prCardEl: HTMLDivElement = prCard - const cursorEl: HTMLDivElement = cursor - const explorerTabEl: HTMLSpanElement = explorerTab - const checksTabEl: HTMLSpanElement = checksTab - const checksTooltipEl: HTMLSpanElement = checksTooltip - const checksBlockEl: HTMLDivElement = checksBlock - const commentsBlockEl: HTMLDivElement = commentsBlock - const commentsCountEl: HTMLSpanElement = commentsCount - const checkSummaryEl: HTMLDivElement = checkSummary - const checkSummaryLabelEl: HTMLSpanElement = checkSummaryLabel - const checkSummaryMetaEl: HTMLSpanElement = checkSummaryMeta - const verifyRowEl: HTMLDivElement = verifyRow - const verifyStateEl: HTMLSpanElement = verifyState - const mergeBtnEl: HTMLButtonElement = mergeBtn - - let cancelled = false - const timers: number[] = [] - const wait = (ms: number): Promise => - new Promise((resolve) => { - const id = window.setTimeout(() => resolve(), ms) - timers.push(id) - }) - - function resetState(): void { - sidebarPeekEl.classList.add('is-visible') - sidebarPeekEl.classList.remove('is-hiding') - prCardEl.classList.remove('is-visible') - explorerTabEl.classList.add('is-active') - checksTabEl.classList.remove('is-active', 'is-hovered') - checksTooltipEl.classList.remove('is-visible') - cursorEl.classList.remove('is-visible', 'is-clicking') - cursorEl.style.transition = 'none' - cursorEl.style.transform = 'translate(-30px, 220px)' - void cursorEl.offsetWidth - cursorEl.style.transition = '' - checksBlockEl.classList.remove('is-visible') - commentsBlockEl.classList.remove('is-visible') - comments.forEach((el) => el.classList.remove('is-visible')) - commentsCountEl.textContent = '0' - checkSummaryEl.classList.remove('is-done') - checkSummaryLabelEl.textContent = '1 pending' - checkSummaryMetaEl.textContent = 'verify' - verifyRowEl.classList.remove('is-done') - verifyStateEl.textContent = 'Running' - mergeBtnEl.classList.remove('is-ready') - } - - function showFinalState(): void { - resetState() - sidebarPeekEl.classList.add('is-hiding') - prCardEl.classList.add('is-visible') - checksBlockEl.classList.add('is-visible') - commentsBlockEl.classList.add('is-visible') - comments.forEach((el) => el.classList.add('is-visible')) - commentsCountEl.textContent = String(comments.length) - checkSummaryEl.classList.add('is-done') - checkSummaryLabelEl.textContent = 'Checks passed' - checkSummaryMetaEl.textContent = '3 checks' - verifyRowEl.classList.add('is-done') - verifyStateEl.textContent = 'Passed' - mergeBtnEl.classList.add('is-ready') - cursorEl.classList.remove('is-visible') - } - - if (reducedMotion) { - showFinalState() - return - } - - async function loop(): Promise { - while (!cancelled) { - resetState() - await wait(420) - if (cancelled) { - return - } - - cursorEl.classList.add('is-visible') - moveCursor(rootEl, cursorEl, checksTabEl, 5, 6) - checksTabEl.classList.add('is-hovered') - await wait(260) - if (cancelled) { - return - } - checksTooltipEl.classList.add('is-visible') - await wait(1300) - if (cancelled) { - return - } - - cursorEl.classList.add('is-clicking') - await wait(220) - if (cancelled) { - return - } - cursorEl.classList.remove('is-clicking') - checksTooltipEl.classList.remove('is-visible') - checksTabEl.classList.remove('is-hovered') - explorerTabEl.classList.remove('is-active') - checksTabEl.classList.add('is-active') - await wait(420) - if (cancelled) { - return - } - - sidebarPeekEl.classList.add('is-hiding') - prCardEl.classList.add('is-visible') - cursorEl.classList.remove('is-visible') - await wait(560) - if (cancelled) { - return - } - - checksBlockEl.classList.add('is-visible') - await wait(1050) - if (cancelled) { - return - } - - verifyRowEl.classList.add('is-done') - verifyStateEl.textContent = 'Passed' - checkSummaryEl.classList.add('is-done') - checkSummaryLabelEl.textContent = 'Checks passed' - checkSummaryMetaEl.textContent = '3 checks' - mergeBtnEl.classList.add('is-ready') - - await wait(560) - if (cancelled) { - return - } - commentsBlockEl.classList.add('is-visible') - await wait(260) - if (cancelled) { - return - } - - for (let i = 0; i < comments.length; i++) { - comments[i]?.classList.add('is-visible') - commentsCountEl.textContent = String(i + 1) - await wait(520) - if (cancelled) { - return - } - } - - await wait(2900) - } - } - - void loop() - return () => { - cancelled = true - timers.forEach((timer) => window.clearTimeout(timer)) - } - }, [reducedMotion]) + useReviewPrViewAnimation(rootRef, reducedMotion) return (
@@ -312,7 +137,12 @@ export function ReviewPRViewAnimatedVisual(props: { reducedMotion: boolean }): J
-
{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.6e3f5223c5", "Explorer")}
+
+ {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.6e3f5223c5', + 'Explorer' + )} +
@@ -327,38 +157,89 @@ export function ReviewPRViewAnimatedVisual(props: { reducedMotion: boolean }): J
#2351 - {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.dfe313e0c9", "OPEN")} + + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.dfe313e0c9', + 'OPEN' + )} + +
+
+ {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.0aab7ab84a', + 'Add local diagnostics error tracking' + )}
-
{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.0aab7ab84a", "Add local diagnostics error tracking")}
- {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.9a097cae12", "1 pending")} + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.9a097cae12', + '1 pending' + )} + - {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.d340c052fb", "verify")} + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.d340c052fb', + 'verify' + )} +
- {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.d340c052fb", "verify")} + + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.d340c052fb', + 'verify' + )} + - {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.8ed213397c", "Running")} + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.8ed213397c', + 'Running' + )} +
- {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.2ef0b97954", "typecheck")} - {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.ca36f7b27c", "Passed")} + + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.2ef0b97954', + 'typecheck' + )} + + + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.ca36f7b27c', + 'Passed' + )} +
- {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.25f6838e43", "lint")} - {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.ca36f7b27c", "Passed")} + + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.25f6838e43', + 'lint' + )} + + + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.ca36f7b27c', + 'Passed' + )} +
@@ -366,15 +247,43 @@ export function ReviewPRViewAnimatedVisual(props: { reducedMotion: boolean }): J
- {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.7a8b896e11", "Comments")} + + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.7a8b896e11', + 'Comments' + )} + - 0 {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.fb1a856b6d", "open")} + 0{' '} + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.fb1a856b6d', + 'open' + )} +
- {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.71828fba75", "Can we include the failing command in the diagnostic payload?")} + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.71828fba75', + 'Can we include the failing command in the diagnostic payload?' + )} + - {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.6f4c2d7cb7", "Add a coverage case for")}{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.c2062da7ec", "stderr")} {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.7c2808ecff", "truncation before merge.")} + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.6f4c2d7cb7', + 'Add a coverage case for' + )} + + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.c2062da7ec', + 'stderr' + )} + {' '} + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.7c2808ecff', + 'truncation before merge.' + )} +
diff --git a/src/renderer/src/components/feature-wall/TasksAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/TasksAnimatedVisual.tsx index 73d711449be..af04080f4de 100644 --- a/src/renderer/src/components/feature-wall/TasksAnimatedVisual.tsx +++ b/src/renderer/src/components/feature-wall/TasksAnimatedVisual.tsx @@ -15,10 +15,12 @@ type Issue = { const ISSUES: readonly Issue[] = [ { number: 1842, - title: translate( - 'auto.components.feature.wall.TasksAnimatedVisual.b13375617e', - 'Worktree picker truncates names' - ) + get title() { + return translate( + 'auto.components.feature.wall.TasksAnimatedVisual.b13375617e', + 'Worktree picker truncates names' + ) + } } ] diff --git a/src/renderer/src/components/feature-wall/feature-tour-preview-copy.ts b/src/renderer/src/components/feature-wall/feature-tour-preview-copy.ts new file mode 100644 index 00000000000..1aa0dc8abbc --- /dev/null +++ b/src/renderer/src/components/feature-wall/feature-tour-preview-copy.ts @@ -0,0 +1,109 @@ +import { translate } from '@/i18n/i18n' + +type FrameId = 1 | 2 | 3 | 4 + +export type FeatureTourPreviewFrameCopy = { + id: FrameId + title: string + caption: string +} + +export const FEATURE_TOUR_PREVIEW_COPY: readonly FeatureTourPreviewFrameCopy[] = [ + { + id: 1, + get title() { + return translate( + 'auto.components.feature.wall.FeatureTourPreview.56a0271428', + 'Isolated workspaces' + ) + }, + get caption() { + return translate( + 'auto.components.feature.wall.FeatureTourPreview.47f16ecf34', + 'Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.' + ) + } + }, + { + id: 2, + get title() { + return translate( + 'auto.components.feature.wall.FeatureTourPreview.e44269e97d', + 'Agent orchestration' + ) + }, + get caption() { + return translate( + 'auto.components.feature.wall.FeatureTourPreview.70aa182266', + 'Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.' + ) + } + }, + { + id: 3, + get title() { + return translate( + 'auto.components.feature.wall.FeatureTourPreview.ef737dcee1', + 'GitHub & Linear tasks' + ) + }, + get caption() { + return translate( + 'auto.components.feature.wall.FeatureTourPreview.f10c14dd9d', + 'Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.' + ) + } + }, + { + id: 4, + get title() { + return translate( + 'auto.components.feature.wall.FeatureTourPreview.1aa8a9a24a', + 'Splittable terminal' + ) + }, + get caption() { + return translate( + 'auto.components.feature.wall.FeatureTourPreview.5d6ee181b6', + 'Open any workspace to return to its terminal, then split panes for tests, logs, and agents.' + ) + } + } +] + +export type FeatureTourOrchestrationChildAgent = 'claude' | 'codex' | 'opencode-go' + +export const FEATURE_TOUR_ORCHESTRATION_CHILDREN: readonly { + key: 'top' | 'mid' | 'bot' + position: string + label: string + agent: FeatureTourOrchestrationChildAgent +}[] = [ + // Why: card vertical centers anchor to 18% / 50% / 82% — the same Y + // endpoints the dashed SVG paths terminate at — so the connectors land on + // each card's center regardless of card height. + { + key: 'top', + position: 'top-[18%] -translate-y-1/2', + get label() { + return translate('auto.components.feature.wall.FeatureTourPreview.b1f17bcc74', 'PR 1/3') + }, + agent: 'claude' + }, + { + key: 'mid', + position: 'top-1/2 -translate-y-1/2', + get label() { + return translate('auto.components.feature.wall.FeatureTourPreview.cfdfd4d6b4', 'PR 2/3') + }, + agent: 'codex' + }, + { + key: 'bot', + position: 'top-[82%] -translate-y-1/2', + get label() { + return translate('auto.components.feature.wall.FeatureTourPreview.ec4a73f5e6', 'PR 3/3') + }, + agent: 'opencode-go' + } +] diff --git a/src/renderer/src/components/feature-wall/review-pr-view-animation.ts b/src/renderer/src/components/feature-wall/review-pr-view-animation.ts new file mode 100644 index 00000000000..bda794340f9 --- /dev/null +++ b/src/renderer/src/components/feature-wall/review-pr-view-animation.ts @@ -0,0 +1,261 @@ +import { useEffect } from 'react' +import type { RefObject } from 'react' +import { translate } from '@/i18n/i18n' + +function getReviewPrAnimatedStatusCopy(): { + pendingLabel: string + verifyLabel: string + runningLabel: string + checksPassedLabel: string + checksCountLabel: string + passedLabel: string +} { + return { + pendingLabel: translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.9a097cae12', + '1 pending' + ), + verifyLabel: translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.d340c052fb', + 'verify' + ), + runningLabel: translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.8ed213397c', + 'Running' + ), + checksPassedLabel: translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.a6c8b9e32f', + 'Checks passed' + ), + checksCountLabel: translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.f4d5e1a7b2', + '3 checks' + ), + passedLabel: translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.ca36f7b27c', + 'Passed' + ) + } +} + +function moveCursor( + root: HTMLElement, + cursor: HTMLElement, + anchor: HTMLElement, + ox = 0, + oy = 0 +): void { + const rootRect = root.getBoundingClientRect() + const anchorRect = anchor.getBoundingClientRect() + cursor.style.transform = `translate(${anchorRect.left - rootRect.left + ox}px, ${ + anchorRect.top - rootRect.top + oy + }px)` +} + +export function useReviewPrViewAnimation( + rootRef: RefObject, + reducedMotion: boolean +): void { + useEffect(() => { + const root = rootRef.current + if (!root) { + return + } + + const sidebarPeek = root.querySelector('[data-checks-sidebar-peek]') + const prCard = root.querySelector('[data-pr-view-card]') + const cursor = root.querySelector('[data-cursor]') + const explorerTab = root.querySelector('[data-explorer-tab]') + const checksTab = root.querySelector('[data-checks-tab]') + const checksTooltip = root.querySelector('[data-checks-tooltip]') + const checksBlock = root.querySelector('[data-checks-block]') + const commentsBlock = root.querySelector('[data-comments-block]') + const comments = Array.from(root.querySelectorAll('[data-comment-card]')) + const commentsCount = root.querySelector('[data-comments-count]') + const checkSummary = root.querySelector('[data-check-summary]') + const checkSummaryLabel = root.querySelector('[data-check-summary-label]') + const checkSummaryMeta = root.querySelector('[data-check-summary-meta]') + const verifyRow = root.querySelector('[data-check-row="verify"]') + const verifyState = root.querySelector('[data-check-verify-state]') + const mergeBtn = root.querySelector('[data-merge-btn]') + if ( + !sidebarPeek || + !prCard || + !cursor || + !explorerTab || + !checksTab || + !checksTooltip || + !checksBlock || + !commentsBlock || + !commentsCount || + !checkSummary || + !checkSummaryLabel || + !checkSummaryMeta || + !verifyRow || + !verifyState || + !mergeBtn + ) { + return + } + + const rootEl: HTMLDivElement = root + const sidebarPeekEl: HTMLDivElement = sidebarPeek + const prCardEl: HTMLDivElement = prCard + const cursorEl: HTMLDivElement = cursor + const explorerTabEl: HTMLSpanElement = explorerTab + const checksTabEl: HTMLSpanElement = checksTab + const checksTooltipEl: HTMLSpanElement = checksTooltip + const checksBlockEl: HTMLDivElement = checksBlock + const commentsBlockEl: HTMLDivElement = commentsBlock + const commentsCountEl: HTMLSpanElement = commentsCount + const checkSummaryEl: HTMLDivElement = checkSummary + const checkSummaryLabelEl: HTMLSpanElement = checkSummaryLabel + const checkSummaryMetaEl: HTMLSpanElement = checkSummaryMeta + const verifyRowEl: HTMLDivElement = verifyRow + const verifyStateEl: HTMLSpanElement = verifyState + const mergeBtnEl: HTMLButtonElement = mergeBtn + + let cancelled = false + const timers: number[] = [] + const wait = (ms: number): Promise => + new Promise((resolve) => { + const id = window.setTimeout(() => resolve(), ms) + timers.push(id) + }) + + function resetState(): void { + sidebarPeekEl.classList.add('is-visible') + sidebarPeekEl.classList.remove('is-hiding') + prCardEl.classList.remove('is-visible') + explorerTabEl.classList.add('is-active') + checksTabEl.classList.remove('is-active', 'is-hovered') + checksTooltipEl.classList.remove('is-visible') + cursorEl.classList.remove('is-visible', 'is-clicking') + cursorEl.style.transition = 'none' + cursorEl.style.transform = 'translate(-30px, 220px)' + void cursorEl.offsetWidth + cursorEl.style.transition = '' + checksBlockEl.classList.remove('is-visible') + commentsBlockEl.classList.remove('is-visible') + comments.forEach((el) => el.classList.remove('is-visible')) + commentsCountEl.textContent = '0' + checkSummaryEl.classList.remove('is-done') + const copy = getReviewPrAnimatedStatusCopy() + checkSummaryLabelEl.textContent = copy.pendingLabel + checkSummaryMetaEl.textContent = copy.verifyLabel + verifyRowEl.classList.remove('is-done') + verifyStateEl.textContent = copy.runningLabel + mergeBtnEl.classList.remove('is-ready') + } + + function showFinalState(): void { + resetState() + sidebarPeekEl.classList.add('is-hiding') + prCardEl.classList.add('is-visible') + checksBlockEl.classList.add('is-visible') + commentsBlockEl.classList.add('is-visible') + comments.forEach((el) => el.classList.add('is-visible')) + commentsCountEl.textContent = String(comments.length) + checkSummaryEl.classList.add('is-done') + const copy = getReviewPrAnimatedStatusCopy() + checkSummaryLabelEl.textContent = copy.checksPassedLabel + checkSummaryMetaEl.textContent = copy.checksCountLabel + verifyRowEl.classList.add('is-done') + verifyStateEl.textContent = copy.passedLabel + mergeBtnEl.classList.add('is-ready') + cursorEl.classList.remove('is-visible') + } + + if (reducedMotion) { + showFinalState() + return + } + + async function loop(): Promise { + while (!cancelled) { + resetState() + await wait(420) + if (cancelled) { + return + } + + cursorEl.classList.add('is-visible') + moveCursor(rootEl, cursorEl, checksTabEl, 5, 6) + checksTabEl.classList.add('is-hovered') + await wait(260) + if (cancelled) { + return + } + checksTooltipEl.classList.add('is-visible') + await wait(1300) + if (cancelled) { + return + } + + cursorEl.classList.add('is-clicking') + await wait(220) + if (cancelled) { + return + } + cursorEl.classList.remove('is-clicking') + checksTooltipEl.classList.remove('is-visible') + checksTabEl.classList.remove('is-hovered') + explorerTabEl.classList.remove('is-active') + checksTabEl.classList.add('is-active') + await wait(420) + if (cancelled) { + return + } + + sidebarPeekEl.classList.add('is-hiding') + prCardEl.classList.add('is-visible') + cursorEl.classList.remove('is-visible') + await wait(560) + if (cancelled) { + return + } + + checksBlockEl.classList.add('is-visible') + await wait(1050) + if (cancelled) { + return + } + + verifyRowEl.classList.add('is-done') + const copy = getReviewPrAnimatedStatusCopy() + verifyStateEl.textContent = copy.passedLabel + checkSummaryEl.classList.add('is-done') + checkSummaryLabelEl.textContent = copy.checksPassedLabel + checkSummaryMetaEl.textContent = copy.checksCountLabel + mergeBtnEl.classList.add('is-ready') + + await wait(560) + if (cancelled) { + return + } + commentsBlockEl.classList.add('is-visible') + await wait(260) + if (cancelled) { + return + } + + for (let i = 0; i < comments.length; i++) { + comments[i]?.classList.add('is-visible') + commentsCountEl.textContent = String(i + 1) + await wait(520) + if (cancelled) { + return + } + } + + await wait(2900) + } + } + + void loop() + return () => { + cancelled = true + timers.forEach((timer) => window.clearTimeout(timer)) + } + }, [reducedMotion, rootRef]) +} diff --git a/src/renderer/src/components/github/CloseReasonDropdown.tsx b/src/renderer/src/components/github/CloseReasonDropdown.tsx new file mode 100644 index 00000000000..6002529cbdf --- /dev/null +++ b/src/renderer/src/components/github/CloseReasonDropdown.tsx @@ -0,0 +1,61 @@ +import { Check, ChevronDown } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/button' +import { CLOSE_ISSUE_REASONS } from './github-issue-close-reasons' +import type { GitHubIssueCloseReason } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +export function CloseReasonDropdown({ + closeReason, + disabled, + onCloseReasonChange +}: { + closeReason: GitHubIssueCloseReason + disabled: boolean + onCloseReasonChange: (reason: GitHubIssueCloseReason) => void +}): React.JSX.Element { + return ( + + + + + + {CLOSE_ISSUE_REASONS.map((option) => ( + onCloseReasonChange(option.reason)}> +
+ {closeReason === option.reason ? ( + + ) : ( + + )} +
+
+ {option.icon} + {option.label} +
+

{option.description}

+
+
+
+ ))} +
+
+ ) +} diff --git a/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx b/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx new file mode 100644 index 00000000000..7de32c72d92 --- /dev/null +++ b/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx @@ -0,0 +1,369 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { LoaderCircle } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { ButtonGroup } from '@/components/ui/button-group' +import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' +import { CLOSE_ISSUE_REASONS } from '@/components/github/github-issue-close-reasons' +import { CloseReasonDropdown } from '@/components/github/CloseReasonDropdown' +import { + addIssueCommentForRepo, + githubAvatarUrl, + runIssueStateUpdate, + type GitHubIssueCommentProjectOrigin +} from '@/components/github/github-issue-comment-helpers' +import { useMountedRef } from '@/hooks/useMountedRef' +import { cn } from '@/lib/utils' +import { useAppStore } from '@/store' +import type { + GitHubIssueCloseReason, + GitHubOwnerRepo, + GitHubViewer, + GitHubWorkItem, + PRComment +} from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +export function GitHubIssueCommentComposer({ + className, + repoPath, + repoId, + issueNumber, + itemType, + itemState, + itemId, + projectOrigin, + previewGithubRepo, + onCommentAdded, + onStateChange, + onMutated +}: { + className?: string + repoPath: string + repoId?: string | null + issueNumber: number + itemType: 'issue' | 'pr' + itemState?: GitHubWorkItem['state'] + itemId?: string + projectOrigin?: GitHubIssueCommentProjectOrigin + previewGithubRepo?: GitHubOwnerRepo | null + onCommentAdded: (comment: PRComment) => void + onStateChange?: (state: GitHubWorkItem['state']) => void + onMutated?: () => void +}): React.JSX.Element { + const [body, setBody] = useState('') + const [submitting, setSubmitting] = useState(false) + const [statePending, setStatePending] = useState(false) + const [closeReason, setCloseReason] = useState('completed') + const [viewer, setViewer] = useState(null) + const mountedRef = useMountedRef() + const viewerRequestIdRef = useRef(0) + const patchWorkItem = useAppStore((s) => s.patchWorkItem) + const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) + + useEffect(() => { + const requestId = ++viewerRequestIdRef.current + void window.api.gh + .viewer() + .then((nextViewer) => { + if (mountedRef.current && requestId === viewerRequestIdRef.current) { + setViewer(nextViewer) + } + }) + .catch(() => { + if (mountedRef.current && requestId === viewerRequestIdRef.current) { + setViewer(null) + } + }) + return () => { + viewerRequestIdRef.current += 1 + } + }, [mountedRef]) + + const selectedCloseReason = + CLOSE_ISSUE_REASONS.find((option) => option.reason === closeReason) ?? CLOSE_ISSUE_REASONS[0] + + const canMutateIssueState = + itemType === 'issue' && + itemState !== undefined && + itemState !== 'closed' && + Boolean(onStateChange) && + Boolean(repoPath || projectOrigin) + + const canReopenIssue = + itemType === 'issue' && + itemState === 'closed' && + Boolean(onStateChange) && + Boolean(repoPath || projectOrigin) + + const patchProjectRowIfNeeded = useCallback( + (state: GitHubWorkItem['state']) => { + if (!projectOrigin) { + return + } + patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, { state }) + }, + [patchProjectRowContent, projectOrigin] + ) + + const applyStatePatch = useCallback( + (state: GitHubWorkItem['state']) => { + onStateChange?.(state) + if (itemId) { + patchWorkItem(itemId, { state }, repoId ?? undefined) + } + patchProjectRowIfNeeded(state) + }, + [itemId, onStateChange, patchProjectRowIfNeeded, patchWorkItem, repoId] + ) + + const handleSubmit = useCallback(async () => { + const trimmed = body.trim() + if (!trimmed) { + return + } + setSubmitting(true) + try { + const result = await addIssueCommentForRepo({ + repoPath, + repoId: repoId ?? undefined, + number: issueNumber, + body: trimmed, + type: itemType + }) + if (!mountedRef.current) { + return + } + if (result.ok) { + setBody('') + onCommentAdded(result.comment) + } else { + toast.error( + result.error ?? + translate( + 'auto.components.github.GitHubIssueCommentComposer.082515176a', + 'Failed to add comment' + ) + ) + } + } catch (err) { + if (mountedRef.current) { + toast.error( + err instanceof Error + ? err.message + : translate( + 'auto.components.github.GitHubIssueCommentComposer.082515176a', + 'Failed to add comment' + ) + ) + } + } finally { + if (mountedRef.current) { + setSubmitting(false) + } + } + }, [body, issueNumber, itemType, mountedRef, onCommentAdded, repoId, repoPath]) + + const handleCloseIssue = useCallback( + async (reason: GitHubIssueCloseReason = closeReason) => { + if (!canMutateIssueState || statePending) { + return + } + const previousState = itemState ?? 'open' + setStatePending(true) + applyStatePatch('closed') + try { + await runIssueStateUpdate({ + repoPath, + repoId, + projectOrigin, + number: issueNumber, + updates: { state: 'closed', stateReason: reason } + }) + useAppStore.getState().recordFeatureInteraction('github-tasks') + toast.success( + translate('auto.components.github.GitHubIssueCommentComposer.9f88657c4e', 'Issue closed') + ) + onMutated?.() + } catch (err) { + applyStatePatch(previousState) + toast.error( + err instanceof Error + ? err.message + : translate( + 'auto.components.github.GitHubIssueCommentComposer.e9b7cb7d17', + 'Failed to close issue' + ) + ) + } finally { + if (mountedRef.current) { + setStatePending(false) + } + } + }, + [ + applyStatePatch, + canMutateIssueState, + closeReason, + issueNumber, + itemState, + mountedRef, + onMutated, + projectOrigin, + repoId, + repoPath, + statePending + ] + ) + + const handleReopenIssue = useCallback(async () => { + if (!canReopenIssue || statePending) { + return + } + const previousState = itemState ?? 'closed' + setStatePending(true) + applyStatePatch('open') + try { + await runIssueStateUpdate({ + repoPath, + repoId, + projectOrigin, + number: issueNumber, + updates: { state: 'open' } + }) + useAppStore.getState().recordFeatureInteraction('github-tasks') + toast.success( + translate('auto.components.github.GitHubIssueCommentComposer.bd3b4492a0', 'Issue reopened') + ) + onMutated?.() + } catch (err) { + applyStatePatch(previousState) + toast.error( + err instanceof Error + ? err.message + : translate( + 'auto.components.github.GitHubIssueCommentComposer.f2a8c1d903', + 'Failed to reopen issue' + ) + ) + } finally { + if (mountedRef.current) { + setStatePending(false) + } + } + }, [ + applyStatePatch, + canReopenIssue, + issueNumber, + itemState, + mountedRef, + onMutated, + projectOrigin, + repoId, + repoPath, + statePending + ]) + + const avatar = viewer?.login ? ( + {viewer.login} + ) : ( +
+ ) + + return ( +
+
+ {avatar} +
+

+ {translate( + 'auto.components.github.GitHubIssueCommentComposer.a1b2c3d4e5', + 'Add a comment' + )} +

+ void handleSubmit()} + /> +
+ {canMutateIssueState ? ( + + + { + setCloseReason(reason) + void handleCloseIssue(reason) + }} + /> + + ) : null} + {canReopenIssue ? ( + + ) : null} + +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx index 3c64fbe6f33..df4aaf6236c 100644 --- a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx +++ b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx @@ -2,8 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { EditorContent, useEditor } from '@tiptap/react' import type { Editor } from '@tiptap/react' import Placeholder from '@tiptap/extension-placeholder' -import { ImageIcon } from 'lucide-react' -import { toast } from 'sonner' +import { ImageIcon, Paperclip } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { cn } from '@/lib/utils' @@ -17,6 +16,13 @@ import { } from '@/components/editor/RichMarkdownLinkBubble' import { encodeRawMarkdownHtmlForRichEditor } from '@/components/editor/raw-markdown-html' import { normalizeSoftBreaks } from '@/components/editor/rich-markdown-normalize' +import { GitHubMarkdownComposerPreviewPane } from '@/components/github/github-markdown-composer-preview-pane' +import { + GitHubMarkdownComposerTabbar, + type ComposerTab +} from '@/components/github/github-markdown-composer-tabbar' +import { useImageInput } from '@/components/github/use-image-input' +import type { GitHubOwnerRepo } from '../../../../shared/types' import { translate } from '@/i18n/i18n' type GitHubMarkdownComposerProps = { @@ -28,15 +34,8 @@ type GitHubMarkdownComposerProps = { disabled?: boolean autoFocus?: boolean onSubmitShortcut?: () => void -} - -function isHttpImageUrl(value: string): boolean { - try { - const parsed = new URL(value) - return parsed.protocol === 'https:' || parsed.protocol === 'http:' - } catch { - return false - } + layout?: 'stacked' | 'tabbed' + previewGithubRepo?: GitHubOwnerRepo | null } export function GitHubMarkdownComposer({ @@ -47,7 +46,9 @@ export function GitHubMarkdownComposer({ className, disabled = false, autoFocus = false, - onSubmitShortcut + onSubmitShortcut, + layout = 'stacked', + previewGithubRepo = null }: GitHubMarkdownComposerProps): React.JSX.Element { const rootRef = useRef(null) const editorRef = useRef(null) @@ -57,18 +58,25 @@ export function GitHubMarkdownComposer({ const onSubmitShortcutRef = useRef(onSubmitShortcut) const disabledRef = useRef(disabled) const isEditingLinkRef = useRef(false) - const imageInputOpenRef = useRef(false) + const [activeTab, setActiveTab] = useState('write') const [linkBubble, setLinkBubble] = useState(null) const [isEditingLink, setIsEditingLink] = useState(false) - const [imageInputOpen, setImageInputOpen] = useState(false) - const [imageUrl, setImageUrl] = useState('') - const imageInputRef = useRef(null) + const isTabbed = layout === 'tabbed' + + const { + imageUrl, + imageInputOpen, + imageInputRef, + openImagePicker, + setImageUrl, + setImageInputOpen, + insertImageUrl + } = useImageInput(editorRef, disabledRef, () => setActiveTab('write')) onChangeRef.current = onChange onSubmitShortcutRef.current = onSubmitShortcut disabledRef.current = disabled isEditingLinkRef.current = isEditingLink - imageInputOpenRef.current = imageInputOpen const extensions = useMemo( () => [ @@ -125,7 +133,7 @@ export function GitHubMarkdownComposer({ openLinkEditor() return true } - if (event.key === 'Escape' && imageInputOpenRef.current) { + if (event.key === 'Escape' && imageInputOpen) { event.preventDefault() event.stopPropagation() setImageInputOpen(false) @@ -183,6 +191,23 @@ export function GitHubMarkdownComposer({ if (!editor) { return } + // Why: parent clears to '' after submit; always reset the editor so stale + // draft text never survives a successful comment post. + if (!value.trim()) { + if (editor.getMarkdown().trim()) { + applyingExternalValueRef.current = true + try { + editor.commands.clearContent(true) + normalizeSoftBreaks(editor) + lastSyncedMarkdownRef.current = '' + } finally { + applyingExternalValueRef.current = false + } + } else { + lastSyncedMarkdownRef.current = '' + } + return + } if (value === lastSyncedMarkdownRef.current || value === editor.getMarkdown()) { lastSyncedMarkdownRef.current = value return @@ -200,12 +225,6 @@ export function GitHubMarkdownComposer({ } }, [editor, value]) - useEffect(() => { - if (imageInputOpen) { - requestAnimationFrame(() => imageInputRef.current?.focus()) - } - }, [imageInputOpen]) - const handleLinkSave = useCallback((href: string) => { const editor = editorRef.current if (!editor) { @@ -249,92 +268,107 @@ export function GitHubMarkdownComposer({ } }, [linkBubble?.href]) - const insertImageUrl = useCallback(() => { - const editor = editorRef.current - const trimmed = imageUrl.trim() - if (!editor || !trimmed) { - return - } - if (!isHttpImageUrl(trimmed)) { - toast.error( - translate( - 'auto.components.github.GitHubMarkdownComposer.ec6310b731', - 'Use an http:// or https:// image URL.' - ) - ) - return - } - editor - .chain() - .focus() - .insertContent({ type: 'image', attrs: { src: trimmed } }) - .run() - setImageUrl('') - setImageInputOpen(false) - }, [imageUrl]) + const toolbar = ( + + ) + + const imageInputRow = imageInputOpen ? ( +
{ + event.preventDefault() + insertImageUrl() + }} + > + + setImageUrl(event.target.value)} + onKeyDown={(event) => { + if (isScreenSubmitShortcut(event)) { + event.preventDefault() + event.stopPropagation() + insertImageUrl() + return + } + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + setImageInputOpen(false) + } + }} + placeholder={translate( + 'auto.components.github.GitHubMarkdownComposer.f24783f470', + 'https://...' + )} + disabled={disabled} + className="h-8 min-w-0 text-xs" + /> + + + + ) : null + + const editorPane = ( +
+ +
+ ) + + const previewPane = ( + + ) + + const attachmentFooter = isTabbed ? ( + + ) : null return (
- { - if (!disabledRef.current) { - setImageInputOpen(true) - } - }} - /> - {imageInputOpen ? ( -
{ - event.preventDefault() - insertImageUrl() - }} - > - - setImageUrl(event.target.value)} - onKeyDown={(event) => { - if (isScreenSubmitShortcut(event)) { - event.preventDefault() - event.stopPropagation() - insertImageUrl() - return - } - if (event.key === 'Escape') { - event.preventDefault() - event.stopPropagation() - setImageInputOpen(false) - } - }} - placeholder={translate( - 'auto.components.github.GitHubMarkdownComposer.f24783f470', - 'https://...' - )} - disabled={disabled} - className="h-8 min-w-0 text-xs" - /> - - - - ) : null} -
- -
+ {isTabbed ? ( + + {toolbar} + + ) : ( + toolbar + )} + {imageInputRow} + {isTabbed ? (activeTab === 'write' ? editorPane : previewPane) : editorPane} + {attachmentFooter} {linkBubble ? ( + + +) + +export function GitHubWorkItemAssigneePopoverContent({ + open, + assignees, + selectedLogins, + error, + loading, + onToggleAssignee +}: { + open: boolean + assignees: readonly GitHubAssignableUser[] + selectedLogins: readonly string[] + error: string | null | undefined + loading?: boolean + onToggleAssignee: (login: string) => void +}): React.JSX.Element { + const [query, setQuery] = useState('') + const filteredAssignees = useMemo( + () => filterGitHubWorkItemAssignees(assignees, query), + [assignees, query] + ) + const selectedSet = useMemo(() => new Set(selectedLogins), [selectedLogins]) + + useEffect(() => { + if (!open) { + setQuery('') + } + }, [open]) + + if (error) { + return
{error}
+ } + + const emptyText = loading + ? translate( + 'auto.components.github.GitHubWorkItemAssigneePopoverContent.cddd9b04a7', + 'Loading assignees' + ) + : translate( + 'auto.components.github.GitHubWorkItemAssigneePopoverContent.a00830d3f7', + 'No users' + ) + + return ( + + + + + {emptyText} + + {filteredAssignees.map((user) => { + const isSelected = selectedSet.has(user.login) + return ( + onToggleAssignee(user.login)} + className="flex items-center gap-2 rounded-sm px-2 py-1.5 text-[12px]" + > + + {isSelected ? assigneeCheckIcon : null} + + + {user.login} + {user.name ? ( + + {user.name} + + ) : null} + + + ) + })} + + + ) +} diff --git a/src/renderer/src/components/github/GitHubWorkItemLabelPopoverContent.tsx b/src/renderer/src/components/github/GitHubWorkItemLabelPopoverContent.tsx new file mode 100644 index 00000000000..ff0612e86bb --- /dev/null +++ b/src/renderer/src/components/github/GitHubWorkItemLabelPopoverContent.tsx @@ -0,0 +1,152 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { ExternalLink, Settings } from 'lucide-react' +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList +} from '@/components/ui/command' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { filterGitHubWorkItemLabels } from './github-work-item-label-filter' + +const labelCheckIcon = ( + + + +) + +function GitHubLabelsSettingsLink({ + url, + separated, + onOpen +}: { + url: string | null + separated?: boolean + onOpen?: () => void +}): React.JSX.Element | null { + if (!url) { + return null + } + + return ( +
+ +
+ ) +} + +export function GitHubWorkItemLabelPopoverContent({ + open, + labels, + selectedLabels, + error, + loading, + repositoryLabelsUrl, + onToggleLabel, + onOpenSettingsLink +}: { + open: boolean + labels: readonly string[] + selectedLabels: readonly string[] + error: string | null | undefined + loading?: boolean + repositoryLabelsUrl?: string | null + onToggleLabel: (label: string) => void + onOpenSettingsLink?: () => void +}): React.JSX.Element { + const [query, setQuery] = useState('') + const filteredLabels = useMemo(() => filterGitHubWorkItemLabels(labels, query), [labels, query]) + const selectedSet = useMemo(() => new Set(selectedLabels), [selectedLabels]) + + useEffect(() => { + if (!open) { + setQuery('') + } + }, [open]) + + if (error) { + return
{error}
+ } + + const emptyText = loading + ? translate( + 'auto.components.github.GitHubWorkItemLabelPopoverContent.cddd9b04a7', + 'Loading labels' + ) + : translate('auto.components.github.GitHubWorkItemLabelPopoverContent.de26e2eb06', 'No labels') + + return ( + <> + + + + + {emptyText} + + {filteredLabels.map((label) => { + const isSelected = selectedSet.has(label) + return ( + onToggleLabel(label)} + className="flex items-center gap-2 rounded-sm px-2 py-1.5 text-[12px]" + > + + {isSelected ? labelCheckIcon : null} + + {label} + + ) + })} + + + 0} + onOpen={onOpenSettingsLink} + /> + + ) +} diff --git a/src/renderer/src/components/github/github-issue-close-reasons.tsx b/src/renderer/src/components/github/github-issue-close-reasons.tsx new file mode 100644 index 00000000000..921e62d7f8e --- /dev/null +++ b/src/renderer/src/components/github/github-issue-close-reasons.tsx @@ -0,0 +1,61 @@ +import { Ban, CheckCircle2, Copy } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import type { GitHubIssueCloseReason } from '../../../../shared/types' + +export type CloseIssueReasonOption = { + reason: GitHubIssueCloseReason + label: string + description: string + icon: React.JSX.Element +} + +export const CLOSE_ISSUE_REASONS: CloseIssueReasonOption[] = [ + { + reason: 'completed', + get label() { + return translate( + 'auto.components.github.githubIssueCloseReasons.completed.label', + 'Close as completed' + ) + }, + get description() { + return translate( + 'auto.components.github.githubIssueCloseReasons.completed.description', + 'Done, closed, fixed, resolved' + ) + }, + icon: + }, + { + reason: 'not_planned', + get label() { + return translate( + 'auto.components.github.githubIssueCloseReasons.notPlanned.label', + 'Close as not planned' + ) + }, + get description() { + return translate( + 'auto.components.github.githubIssueCloseReasons.notPlanned.description', + "Won't fix, can't repro, stale" + ) + }, + icon: + }, + { + reason: 'duplicate', + get label() { + return translate( + 'auto.components.github.githubIssueCloseReasons.duplicate.label', + 'Close as duplicate' + ) + }, + get description() { + return translate( + 'auto.components.github.githubIssueCloseReasons.duplicate.description', + 'Duplicate of another issue' + ) + }, + icon: + } +] diff --git a/src/renderer/src/components/github/github-issue-comment-helpers.ts b/src/renderer/src/components/github/github-issue-comment-helpers.ts new file mode 100644 index 00000000000..6bb52ea9b60 --- /dev/null +++ b/src/renderer/src/components/github/github-issue-comment-helpers.ts @@ -0,0 +1,74 @@ +import { useAppStore } from '@/store' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import type { GitHubIssueCloseReason } from '../../../../shared/types' + +export type GitHubIssueCommentProjectOrigin = { + owner: string + repo: string + cacheKey: string + projectItemId: string +} + +export async function runIssueStateUpdate(args: { + repoPath: string + repoId?: string | null + projectOrigin: GitHubIssueCommentProjectOrigin | undefined + number: number + updates: { + state: 'open' | 'closed' + stateReason?: GitHubIssueCloseReason + duplicateOf?: number + } +}): Promise { + if (args.projectOrigin) { + const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const updateArgs = { + owner: args.projectOrigin.owner, + repo: args.projectOrigin.repo, + number: args.number, + updates: args.updates + } + const res = + target.kind === 'environment' + ? await callRuntimeRpc>>( + target, + 'github.project.updateIssueBySlug', + updateArgs, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updateIssueBySlug(updateArgs) + if (!res.ok) { + throw new Error(res.error.message) + } + return + } + const res = await window.api.gh.updateIssue({ + repoPath: args.repoPath, + repoId: args.repoId ?? undefined, + number: args.number, + updates: args.updates + }) + if (!res.ok) { + throw new Error(res.error) + } +} + +export async function addIssueCommentForRepo(args: { + repoId?: string + repoPath: string + number: number + body: string + type?: 'issue' | 'pr' +}): Promise>> { + return window.api.gh.addIssueComment({ + repoPath: args.repoPath, + repoId: args.repoId, + number: args.number, + body: args.body, + type: args.type + }) +} + +export function githubAvatarUrl(login: string): string { + return `https://github.com/${encodeURIComponent(login)}.png?size=64` +} diff --git a/src/renderer/src/components/github/github-markdown-composer-preview-pane.tsx b/src/renderer/src/components/github/github-markdown-composer-preview-pane.tsx new file mode 100644 index 00000000000..06739d438b7 --- /dev/null +++ b/src/renderer/src/components/github/github-markdown-composer-preview-pane.tsx @@ -0,0 +1,44 @@ +import CommentMarkdown from '@/components/sidebar/CommentMarkdown' +import type { GitHubOwnerRepo } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +export function isHttpImageUrl(value: string): boolean { + try { + const parsed = new URL(value) + return parsed.protocol === 'https:' || parsed.protocol === 'http:' + } catch { + return false + } +} + +export function GitHubMarkdownComposerPreviewPane({ + value, + minHeightClassName, + previewGithubRepo +}: { + value: string + minHeightClassName: string + previewGithubRepo: GitHubOwnerRepo | null +}): React.JSX.Element { + return ( +
+ {value.trim() ? ( + + ) : ( +

+ {translate( + 'auto.components.github.GitHubMarkdownComposer.8f1c2d4e6a', + 'Nothing to preview' + )} +

+ )} +
+ ) +} diff --git a/src/renderer/src/components/github/github-markdown-composer-tabbar.tsx b/src/renderer/src/components/github/github-markdown-composer-tabbar.tsx new file mode 100644 index 00000000000..2436cb970a7 --- /dev/null +++ b/src/renderer/src/components/github/github-markdown-composer-tabbar.tsx @@ -0,0 +1,43 @@ +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { ReactNode } from 'react' + +export type ComposerTab = 'write' | 'preview' + +export function GitHubMarkdownComposerTabbar({ + activeTab, + onTabChange, + children +}: { + activeTab: ComposerTab + onTabChange: (tab: ComposerTab) => void + children: ReactNode +}): React.JSX.Element { + return ( +
+
+ + +
+ {activeTab === 'write' ? ( +
{children}
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/github/github-rate-limit-display.tsx b/src/renderer/src/components/github/github-rate-limit-display.tsx index 4321aeeec04..e40fb04c62a 100644 --- a/src/renderer/src/components/github/github-rate-limit-display.tsx +++ b/src/renderer/src/components/github/github-rate-limit-display.tsx @@ -20,27 +20,30 @@ type BucketMeta = { const BUCKETS: BucketMeta[] = [ { key: 'core', - label: translate('auto.components.github.github.rate.limit.display.bb227706a6', 'REST'), - description: translate( - 'auto.components.github.github.rate.limit.display.c392c749a6', - 'REST API' - ) + get label() { + return translate('auto.components.github.github.rate.limit.display.bb227706a6', 'REST') + }, + get description() { + return translate('auto.components.github.github.rate.limit.display.c392c749a6', 'REST API') + } }, { key: 'search', - label: translate('auto.components.github.github.rate.limit.display.c377a4f06a', 'Search'), - description: translate( - 'auto.components.github.github.rate.limit.display.1f2f28a4de', - 'Search API' - ) + get label() { + return translate('auto.components.github.github.rate.limit.display.c377a4f06a', 'Search') + }, + get description() { + return translate('auto.components.github.github.rate.limit.display.1f2f28a4de', 'Search API') + } }, { key: 'graphql', - label: translate('auto.components.github.github.rate.limit.display.1daf0f22a9', 'GraphQL'), - description: translate( - 'auto.components.github.github.rate.limit.display.01f7323e58', - 'GraphQL API' - ) + get label() { + return translate('auto.components.github.github.rate.limit.display.1daf0f22a9', 'GraphQL') + }, + get description() { + return translate('auto.components.github.github.rate.limit.display.01f7323e58', 'GraphQL API') + } } ] diff --git a/src/renderer/src/components/github/github-work-item-assignee-filter.test.ts b/src/renderer/src/components/github/github-work-item-assignee-filter.test.ts new file mode 100644 index 00000000000..eb3e340729f --- /dev/null +++ b/src/renderer/src/components/github/github-work-item-assignee-filter.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { filterGitHubWorkItemAssignees } from './github-work-item-assignee-filter' + +describe('filterGitHubWorkItemAssignees', () => { + const assignees = [ + { login: 'alice', name: 'Alice Smith', avatarUrl: 'https://example.com/alice.png' }, + { login: 'bob', name: 'Bob Jones', avatarUrl: 'https://example.com/bob.png' }, + { login: 'carol', name: null, avatarUrl: 'https://example.com/carol.png' } + ] + + it('returns all assignees when the query is empty', () => { + expect(filterGitHubWorkItemAssignees(assignees, '')).toEqual(assignees) + expect(filterGitHubWorkItemAssignees(assignees, ' ')).toEqual(assignees) + }) + + it('matches logins case-insensitively', () => { + expect(filterGitHubWorkItemAssignees(assignees, 'BOB')).toEqual([assignees[1]]) + }) + + it('matches display names case-insensitively', () => { + expect(filterGitHubWorkItemAssignees(assignees, 'smith')).toEqual([assignees[0]]) + expect(filterGitHubWorkItemAssignees(assignees, 'jones')).toEqual([assignees[1]]) + }) +}) diff --git a/src/renderer/src/components/github/github-work-item-assignee-filter.ts b/src/renderer/src/components/github/github-work-item-assignee-filter.ts new file mode 100644 index 00000000000..6682321f24e --- /dev/null +++ b/src/renderer/src/components/github/github-work-item-assignee-filter.ts @@ -0,0 +1,16 @@ +import type { GitHubAssignableUser } from '../../../../shared/types' + +export function filterGitHubWorkItemAssignees( + assignees: readonly GitHubAssignableUser[], + query: string +): GitHubAssignableUser[] { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) { + return [...assignees] + } + return assignees.filter( + (user) => + user.login.toLowerCase().includes(normalizedQuery) || + (user.name ?? '').toLowerCase().includes(normalizedQuery) + ) +} diff --git a/src/renderer/src/components/github/github-work-item-label-filter.test.ts b/src/renderer/src/components/github/github-work-item-label-filter.test.ts new file mode 100644 index 00000000000..7f1adad477c --- /dev/null +++ b/src/renderer/src/components/github/github-work-item-label-filter.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { filterGitHubWorkItemLabels } from './github-work-item-label-filter' + +describe('filterGitHubWorkItemLabels', () => { + const labels = ['agent-workflow', 'bug', 'documentation', 'duplicate'] + + it('returns all labels when the query is empty', () => { + expect(filterGitHubWorkItemLabels(labels, '')).toEqual(labels) + expect(filterGitHubWorkItemLabels(labels, ' ')).toEqual(labels) + }) + + it('matches labels case-insensitively', () => { + expect(filterGitHubWorkItemLabels(labels, 'BUG')).toEqual(['bug']) + expect(filterGitHubWorkItemLabels(labels, 'Doc')).toEqual(['documentation']) + }) + + it('matches partial label names', () => { + expect(filterGitHubWorkItemLabels(labels, 'agent')).toEqual(['agent-workflow']) + expect(filterGitHubWorkItemLabels(labels, 'dup')).toEqual(['duplicate']) + }) +}) diff --git a/src/renderer/src/components/github/github-work-item-label-filter.ts b/src/renderer/src/components/github/github-work-item-label-filter.ts new file mode 100644 index 00000000000..65f6b27b60c --- /dev/null +++ b/src/renderer/src/components/github/github-work-item-label-filter.ts @@ -0,0 +1,7 @@ +export function filterGitHubWorkItemLabels(labels: readonly string[], query: string): string[] { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) { + return [...labels] + } + return labels.filter((label) => label.toLowerCase().includes(normalizedQuery)) +} diff --git a/src/renderer/src/components/github/use-image-input.ts b/src/renderer/src/components/github/use-image-input.ts new file mode 100644 index 00000000000..ab6d5e11a15 --- /dev/null +++ b/src/renderer/src/components/github/use-image-input.ts @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' +import type { Editor } from '@tiptap/react' +import { isHttpImageUrl } from './github-markdown-composer-preview-pane' +import { translate } from '@/i18n/i18n' + +export function useImageInput( + editorRef: React.MutableRefObject, + disabledRef: React.MutableRefObject, + onOpen?: () => void +): { + imageUrl: string + imageInputOpen: boolean + imageInputRef: React.RefObject + openImagePicker: () => void + setImageUrl: (value: string) => void + setImageInputOpen: (open: boolean) => void + insertImageUrl: () => void +} { + const [imageInputOpen, setImageInputOpen] = useState(false) + const [imageUrl, setImageUrl] = useState('') + const imageInputRef = useRef(null) + + useEffect(() => { + if (imageInputOpen) { + requestAnimationFrame(() => imageInputRef.current?.focus()) + } + }, [imageInputOpen]) + + const insertImageUrl = useCallback(() => { + const editor = editorRef.current + const trimmed = imageUrl.trim() + if (!editor || !trimmed) { + return + } + if (!isHttpImageUrl(trimmed)) { + toast.error( + translate( + 'auto.components.github.GitHubMarkdownComposer.ec6310b731', + 'Use an http:// or https:// image URL.' + ) + ) + return + } + editor + .chain() + .focus() + .insertContent({ type: 'image', attrs: { src: trimmed } }) + .run() + setImageUrl('') + setImageInputOpen(false) + }, [imageUrl, editorRef]) + + const openImagePicker = useCallback(() => { + if (!disabledRef.current) { + setImageInputOpen(true) + onOpen?.() + } + }, [disabledRef, onOpen]) + + return { + imageUrl, + imageInputOpen, + imageInputRef, + openImagePicker, + setImageUrl, + setImageInputOpen, + insertImageUrl + } +} diff --git a/src/renderer/src/components/mobile/MobileHero.tsx b/src/renderer/src/components/mobile/MobileHero.tsx index b786b14ef74..43409635549 100644 --- a/src/renderer/src/components/mobile/MobileHero.tsx +++ b/src/renderer/src/components/mobile/MobileHero.tsx @@ -4,19 +4,12 @@ import type { MobileNetworkInterface } from '../settings/mobile-network-interfac import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { AndroidLogo, IosBrandIcon } from './MobileBrandIcons' export { HeroIntro } from './MobileHeroIntro' -export { HeroPaired } from './MobileHeroPaired' +export { HeroPaired, type PairedDevice } from './MobileHeroPairedDevices' import { translate } from '@/i18n/i18n' export type Platform = 'ios' | 'android' export type StepIndex = 0 | 1 -export type PairedDevice = { - deviceId: string - name: string - pairedAt: number - lastSeenAt: number -} - // Why: header copy needs to refer to the *user's* device by its native name. function getDeviceLabel(): string { const ua = navigator.userAgent diff --git a/src/renderer/src/components/mobile/MobileHeroPairedDevices.tsx b/src/renderer/src/components/mobile/MobileHeroPairedDevices.tsx new file mode 100644 index 00000000000..4202c92ab51 --- /dev/null +++ b/src/renderer/src/components/mobile/MobileHeroPairedDevices.tsx @@ -0,0 +1,84 @@ +import { Smartphone, Trash2 } from 'lucide-react' +import { translate } from '@/i18n/i18n' + +export type PairedDevice = { + deviceId: string + name: string + pairedAt: number + lastSeenAt: number +} + +type HeroPairedProps = { + devices: readonly PairedDevice[] + onPairAnother: () => void + onRevoke: (deviceId: string) => void + revokingDeviceIds: readonly string[] +} + +export function HeroPaired({ + devices, + onPairAnother, + onRevoke, + revokingDeviceIds +}: HeroPairedProps): React.JSX.Element { + return ( +
+
+ + {translate('auto.components.mobile.MobileHero.5410d55d79', 'Orca Mobile')} + +
+

+ {devices.length === 1 + ? translate('auto.components.mobile.MobileHero.051978a785', 'Your phone is paired.') + : translate('auto.components.mobile.MobileHero.d0b52871ce', 'Your phones are paired.')} +

+

+ {translate( + 'auto.components.mobile.MobileHero.266c18c105', + 'Open Orca Mobile to pick up where you left off, or pair another device.' + )} +

+
    + {devices.map((device) => { + const revoking = revokingDeviceIds.includes(device.deviceId) + return ( +
  • +
    + +
    +
    +
    {device.name}
    +
    + {translate('auto.components.mobile.MobileHero.94829abdb1', 'Paired')}{' '} + {new Date(device.pairedAt).toLocaleDateString()} +
    +
    + +
  • + ) + })} +
+
+ + +
+
+ ) +} diff --git a/src/renderer/src/components/mobile/mobile-platform-copy.ts b/src/renderer/src/components/mobile/mobile-platform-copy.ts index e09bbbc1eee..dec968086b0 100644 --- a/src/renderer/src/components/mobile/mobile-platform-copy.ts +++ b/src/renderer/src/components/mobile/mobile-platform-copy.ts @@ -6,18 +6,22 @@ export const PLATFORM_COPY: Record< { description: string; ctaLabel: string; url: string } > = { ios: { - description: translate( - 'auto.components.mobile.mobile.platform.copy.432db52b73', - 'Scan with your iPhone camera to open the App Store.' - ), + get description() { + return translate( + 'auto.components.mobile.mobile.platform.copy.432db52b73', + 'Scan with your iPhone camera to open the App Store.' + ) + }, ctaLabel: 'Open App Store', url: 'https://apps.apple.com/app/orca-ide/id6766130217' }, android: { - description: translate( - 'auto.components.mobile.mobile.platform.copy.2a532d6fd7', - 'Scan with your Android camera to download the latest APK from GitHub Releases.' - ), + get description() { + return translate( + 'auto.components.mobile.mobile.platform.copy.2a532d6fd7', + 'Scan with your Android camera to download the latest APK from GitHub Releases.' + ) + }, ctaLabel: 'Download APK', url: 'https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk' } diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index 8cbd8bd41d2..38ca4ff3348 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -11,13 +11,11 @@ import { GitBranchPlus, GitMerge, GitPullRequest, - Github, - Gitlab, LoaderCircle, Search, - Sparkles, X } from 'lucide-react' +import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command' import { Button } from '@/components/ui/button' @@ -63,29 +61,11 @@ import type { } from '../../../../shared/types' import { resolveSmartWorkspaceCommandValue } from './smart-workspace-command-value' import { translate } from '@/i18n/i18n' - -// Why: GitLab MR list filter — Open / Merged / Closed / All — replaces -// GitHub's search-DSL on the GitLab tab per the agreed scope. -type MrStateFilter = 'opened' | 'merged' | 'closed' | 'all' - -const MR_STATE_FILTERS: { id: MrStateFilter; label: string }[] = [ - { - id: 'opened', - label: translate('auto.components.new.workspace.SmartWorkspaceNameField.622864b52a', 'Open') - }, - { - id: 'merged', - label: translate('auto.components.new.workspace.SmartWorkspaceNameField.2319d87718', 'Merged') - }, - { - id: 'closed', - label: translate('auto.components.new.workspace.SmartWorkspaceNameField.6fad211c66', 'Closed') - }, - { - id: 'all', - label: translate('auto.components.new.workspace.SmartWorkspaceNameField.26824f60dd', 'All') - } -] +import { + getMrStateFilters, + getSmartWorkspaceNameModes, + type MrStateFilter +} from './smart-workspace-localized-options' type RepoOption = ReturnType['repos'][number] @@ -120,47 +100,6 @@ export type SmartWorkspaceNameSelection = { const SEARCH_DEBOUNCE_MS = 200 const RESULT_LIMIT = 12 -const MODES: { - id: SmartNameMode - label: string - Icon: React.ComponentType<{ className?: string }> -}[] = [ - { - id: 'smart', - label: translate('auto.components.new.workspace.SmartWorkspaceNameField.b3c60c2b7c', 'Smart'), - Icon: Sparkles - }, - { - id: 'github', - label: translate('auto.components.new.workspace.SmartWorkspaceNameField.0a180280bd', 'GitHub'), - Icon: Github - }, - { - id: 'linear', - label: translate('auto.components.new.workspace.SmartWorkspaceNameField.7a47af0565', 'Linear'), - Icon: ({ className }: { className?: string }) => ( - - - - ) - }, - { - id: 'gitlab', - label: translate('auto.components.new.workspace.SmartWorkspaceNameField.2cfc6be192', 'GitLab'), - Icon: Gitlab - }, - { - id: 'branches', - label: translate('auto.components.new.workspace.SmartWorkspaceNameField.2e4c7c95fe', 'Branch'), - Icon: GitBranch - }, - { - id: 'text', - label: translate('auto.components.new.workspace.SmartWorkspaceNameField.6f07a18604', 'Name'), - Icon: CaseSensitive - } -] - type RowEntry = SmartWorkspaceSourceRow export default function SmartWorkspaceNameField({ @@ -182,6 +121,9 @@ export default function SmartWorkspaceNameField({ textOnly = false, branchesEnabled = true }: SmartWorkspaceNameFieldProps): React.JSX.Element { + // Why: tab/filter labels use the lightweight translate() helper; subscribing + // here makes them refresh even when language changes don't remount the field. + useTranslation() const { addRepo, checkLinearConnection, @@ -257,25 +199,22 @@ export default function SmartWorkspaceNameField({ ) const gitlabAvailable = availableTaskProviders.includes('gitlab') const linearAvailable = availableTaskProviders.includes('linear') - const availableModes = useMemo( - () => - MODES.filter((item) => { - if (textOnly) { - return item.id === 'text' - } - if (item.id === 'gitlab') { - return gitlabAvailable - } - if (item.id === 'linear') { - return linearAvailable - } - if (item.id === 'branches') { - return branchesEnabled - } - return true - }), - [branchesEnabled, gitlabAvailable, linearAvailable, textOnly] - ) + const availableModes = getSmartWorkspaceNameModes().filter((item) => { + if (textOnly) { + return item.id === 'text' + } + if (item.id === 'gitlab') { + return gitlabAvailable + } + if (item.id === 'linear') { + return linearAvailable + } + if (item.id === 'branches') { + return branchesEnabled + } + return true + }) + const mrStateFilters = getMrStateFilters() const selectedSourceFocusKey = selectedSource ? `${selectedSource.kind}:${selectedSource.label}:${selectedSource.url ?? ''}` @@ -1181,7 +1120,7 @@ export default function SmartWorkspaceNameField({ className="flex shrink-0 items-center gap-1 border-b border-border/40 px-2 py-1.5" onMouseDown={(e) => e.preventDefault()} > - {MR_STATE_FILTERS.map(({ id, label }) => ( + {mrStateFilters.map(({ id, label }) => (
) : null}
- +

- {translate("auto.components.settings.TerminalWindowSection.1d1920dc8a", "Hide the mouse cursor when typing in the terminal.")}

+ {translate( + 'auto.components.settings.TerminalWindowSection.1d1920dc8a', + 'Hide the mouse cursor when typing in the terminal.' + )} +

+ {translate( + 'auto.components.settings.TerminalWindowSection.63f8d9336e', + 'Color Overrides' + )} +
updateSettings({ terminalColorOverrides: undefined })} > - {translate("auto.components.settings.TerminalWindowSection.03c855d15f", "Reset all color overrides")} + {translate( + 'auto.components.settings.TerminalWindowSection.03c855d15f', + 'Reset all color overrides' + )} +
diff --git a/src/renderer/src/components/settings/mcp-config-inspection.ts b/src/renderer/src/components/settings/mcp-config-inspection.ts new file mode 100644 index 00000000000..50d5b59b1c7 --- /dev/null +++ b/src/renderer/src/components/settings/mcp-config-inspection.ts @@ -0,0 +1,94 @@ +import { + getMcpConfigCandidateParentDir, + getMcpConfigParentDirs, + inspectMcpConfigContent, + MCP_CONFIG_CANDIDATES, + selectExistingMcpConfigCandidates, + type McpConfigDirectoryEntry +} from '../../../../shared/mcp-config' +import { joinPath } from '../../lib/path' +import { extractIpcErrorMessage } from '../../lib/ipc-error' +import type { LoadedMcpConfigInspection } from './McpConfigFileRow' + +function isMissingFileError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return /ENOENT|no such file|not found/i.test(message) +} + +export async function loadMcpConfigInspections( + targetRootPath: string, + connectionId: string | undefined +): Promise { + const entriesByRelativeDir = new Map() + const rootEntries = await window.api.fs.readDir({ dirPath: targetRootPath, connectionId }) + entriesByRelativeDir.set('', rootEntries) + + const rootDirectoryNames = new Set( + rootEntries.filter((entry) => entry.isDirectory).map((entry) => entry.name) + ) + const unreadableParentDirMessages = new Map() + await Promise.all( + getMcpConfigParentDirs().map(async (relativeDir) => { + if (!rootDirectoryNames.has(relativeDir)) { + return + } + try { + const entries = await window.api.fs.readDir({ + dirPath: joinPath(targetRootPath, relativeDir), + connectionId + }) + entriesByRelativeDir.set(relativeDir, entries) + } catch (error) { + unreadableParentDirMessages.set( + relativeDir, + extractIpcErrorMessage(error, `Unable to inspect ${relativeDir}.`) + ) + } + }) + ) + + const existingRelativePaths = new Set( + selectExistingMcpConfigCandidates(entriesByRelativeDir).map( + (candidate) => candidate.relativePath + ) + ) + + return Promise.all( + MCP_CONFIG_CANDIDATES.map(async (candidate): Promise => { + const absolutePath = joinPath(targetRootPath, candidate.relativePath) + const parentDirReadError = unreadableParentDirMessages.get( + getMcpConfigCandidateParentDir(candidate) + ) + if (parentDirReadError) { + return { + ...inspectMcpConfigContent(candidate, null), + exists: false, + status: 'invalid', + absolutePath, + readError: parentDirReadError + } + } + + if (!existingRelativePaths.has(candidate.relativePath)) { + return { ...inspectMcpConfigContent(candidate, null), absolutePath } + } + + try { + const result = await window.api.fs.readFile({ filePath: absolutePath, connectionId }) + const inspection = inspectMcpConfigContent(candidate, result.isBinary ? '' : result.content) + return { ...inspection, absolutePath } + } catch (error) { + if (isMissingFileError(error)) { + return { ...inspectMcpConfigContent(candidate, null), absolutePath } + } + return { + ...inspectMcpConfigContent(candidate, null), + exists: false, + status: 'invalid', + absolutePath, + readError: extractIpcErrorMessage(error, 'Unable to read config file.') + } + } + }) + ) +} diff --git a/src/renderer/src/components/settings/mobile-auto-restore-options.ts b/src/renderer/src/components/settings/mobile-auto-restore-options.ts index 4c4c70c3962..f0659d70835 100644 --- a/src/renderer/src/components/settings/mobile-auto-restore-options.ts +++ b/src/renderer/src/components/settings/mobile-auto-restore-options.ts @@ -3,25 +3,33 @@ import { translate } from '@/i18n/i18n' export const AUTO_RESTORE_FIT_OPTIONS: { value: string; label: string; ms: number | null }[] = [ { value: 'indefinite', - label: translate( - 'auto.components.settings.MobilePane.aa1263e881', - 'Keep at phone size (default)' - ), + get label() { + return translate( + 'auto.components.settings.MobilePane.aa1263e881', + 'Keep at phone size (default)' + ) + }, ms: null }, { value: '60s', - label: translate('auto.components.settings.MobilePane.c474aa09d8', 'After 1 minute'), + get label() { + return translate('auto.components.settings.MobilePane.c474aa09d8', 'After 1 minute') + }, ms: 60_000 }, { value: '5m', - label: translate('auto.components.settings.MobilePane.d4ba07d914', 'After 5 minutes'), + get label() { + return translate('auto.components.settings.MobilePane.d4ba07d914', 'After 5 minutes') + }, ms: 5 * 60_000 }, { value: '30m', - label: translate('auto.components.settings.MobilePane.ff865419dc', 'After 30 minutes'), + get label() { + return translate('auto.components.settings.MobilePane.ff865419dc', 'After 30 minutes') + }, ms: 30 * 60_000 } ] diff --git a/src/renderer/src/components/settings/terminal-window-color-groups.tsx b/src/renderer/src/components/settings/terminal-window-color-groups.tsx new file mode 100644 index 00000000000..870349b5440 --- /dev/null +++ b/src/renderer/src/components/settings/terminal-window-color-groups.tsx @@ -0,0 +1,344 @@ +import type { TerminalColorOverrides } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +export const COLOR_OVERRIDE_GROUPS: { + label: string + keys: { key: keyof TerminalColorOverrides; label: string; description: string }[] +}[] = [ + { + get label() { + return translate('auto.components.settings.TerminalWindowSection.cf37ff69f6', 'Base') + }, + keys: [ + { + key: 'foreground', + get label() { + return translate( + 'auto.components.settings.TerminalWindowSection.79f6bfb76e', + 'Foreground' + ) + }, + get description() { + return translate( + 'auto.components.settings.TerminalWindowSection.026a0b8013', + 'Main text color' + ) + } + }, + { + key: 'background', + get label() { + return translate( + 'auto.components.settings.TerminalWindowSection.cc1b2ffeb2', + 'Background' + ) + }, + get description() { + return translate( + 'auto.components.settings.TerminalWindowSection.da64e8f4c1', + 'Terminal background color' + ) + } + }, + { + key: 'cursor', + get label() { + return translate('auto.components.settings.TerminalWindowSection.c9e1fdf42f', 'Cursor') + }, + get description() { + return translate( + 'auto.components.settings.TerminalWindowSection.cd0700762b', + 'Cursor color' + ) + } + }, + { + key: 'cursorAccent', + get label() { + return translate( + 'auto.components.settings.TerminalWindowSection.a2d9f095a7', + 'Cursor Text' + ) + }, + get description() { + return translate( + 'auto.components.settings.TerminalWindowSection.7f4063076c', + 'Color of text under the cursor (block cursor)' + ) + } + }, + { + key: 'selectionBackground', + get label() { + return translate( + 'auto.components.settings.TerminalWindowSection.40c3cfd30a', + 'Selection Background' + ) + }, + get description() { + return translate( + 'auto.components.settings.TerminalWindowSection.74d8555f85', + 'Background color of selected text' + ) + } + }, + { + key: 'selectionForeground', + get label() { + return translate( + 'auto.components.settings.TerminalWindowSection.8b450b5305', + 'Selection Foreground' + ) + }, + get description() { + return translate( + 'auto.components.settings.TerminalWindowSection.b2c0857c49', + 'Text color of selected text' + ) + } + }, + { + key: 'bold', + get label() { + return translate('auto.components.settings.TerminalWindowSection.862e463f7f', 'Bold Text') + }, + get description() { + return translate( + 'auto.components.settings.TerminalWindowSection.fb8c6f1967', + 'Color for bold text. Falls back to the normal color if not set.' + ) + } + } + ] + }, + { + get label() { + return translate('auto.components.settings.TerminalWindowSection.68e9f07de0', 'ANSI Normal') + }, + keys: [ + { + key: 'black', + get label() { + return translate('auto.components.settings.TerminalWindowSection.adfdee23cb', 'Black') + }, + get description() { + return translate( + 'auto.components.settings.TerminalWindowSection.cf4437a2f7', + 'ANSI black color' + ) + } + }, + { + key: 'red', + get label() { + return translate('auto.components.settings.TerminalWindowSection.3a78f30b50', 'Red') + }, + get description() { + return translate( + 'auto.components.settings.TerminalWindowSection.b41270f5ca', + 'ANSI red color' + ) + } + }, + { + key: 'green', + get label() { + return translate('auto.components.settings.TerminalWindowSection.8f2092b315', 'Green') + }, + get description() { + return translate( + 'auto.components.settings.TerminalWindowSection.8a673d4206', + 'ANSI green color' + ) + } + }, + { + key: 'yellow', + get label() { + return translate('auto.components.settings.TerminalWindowSection.bb516de873', 'Yellow') + }, + get description() { + return translate( + 'auto.components.settings.TerminalWindowSection.09c1c6b096', + 'ANSI yellow color' + ) + } + }, + { + key: 'blue', + get label() { + return translate('auto.components.settings.TerminalWindowSection.292a4c7316', 'Blue') + }, + get description() { + return translate( + 'auto.components.settings.TerminalWindowSection.9635a71c51', + 'ANSI blue color' + ) + } + }, + { + key: 'magenta', + get label() { + return translate('auto.components.settings.TerminalWindowSection.d5e92fcd94', 'Magenta') + }, + get description() { + return translate( + 'auto.components.settings.TerminalWindowSection.1705318506', + 'ANSI magenta color' + ) + } + }, + { + key: 'cyan', + get label() { + return translate('auto.components.settings.TerminalWindowSection.fb8bb4eb1f', 'Cyan') + }, + get description() { + return translate( + 'auto.components.settings.TerminalWindowSection.bd4c759327', + 'ANSI cyan color' + ) + } + }, + { + key: 'white', + get label() { + return translate('auto.components.settings.TerminalWindowSection.0cb4459fb8', 'White') + }, + get description() { + return translate( + 'auto.components.settings.TerminalWindowSection.28846b1ca6', + 'ANSI white color' + ) + } + } + ] + }, + { + get label() { + return translate('auto.components.settings.TerminalWindowSection.1be593d3e8', 'ANSI Bright') + }, + keys: [ + { + key: 'brightBlack', + get label() { + return translate( + 'auto.components.settings.TerminalWindowSection.260d69ce9a', + 'Bright Black' + ) + }, + get description() { + return translate( + 'auto.components.settings.TerminalWindowSection.f30c492769', + 'ANSI bright black color' + ) + } + }, + { + key: 'brightRed', + get label() { + return translate( + 'auto.components.settings.TerminalWindowSection.32b1b6acd7', + 'Bright Red' + ) + }, + get description() { + return translate( + 'auto.components.settings.TerminalWindowSection.667de68863', + 'ANSI bright red color' + ) + } + }, + { + key: 'brightGreen', + get label() { + return translate( + 'auto.components.settings.TerminalWindowSection.7dafd57730', + 'Bright Green' + ) + }, + get description() { + return translate( + 'auto.components.settings.TerminalWindowSection.0ffb02f921', + 'ANSI bright green color' + ) + } + }, + { + key: 'brightYellow', + get label() { + return translate( + 'auto.components.settings.TerminalWindowSection.936a326be3', + 'Bright Yellow' + ) + }, + get description() { + return translate( + 'auto.components.settings.TerminalWindowSection.e2ef5f4ab7', + 'ANSI bright yellow color' + ) + } + }, + { + key: 'brightBlue', + get label() { + return translate( + 'auto.components.settings.TerminalWindowSection.66820332fa', + 'Bright Blue' + ) + }, + get description() { + return translate( + 'auto.components.settings.TerminalWindowSection.bef6c0f6bf', + 'ANSI bright blue color' + ) + } + }, + { + key: 'brightMagenta', + get label() { + return translate( + 'auto.components.settings.TerminalWindowSection.e56e7d6ea0', + 'Bright Magenta' + ) + }, + get description() { + return translate( + 'auto.components.settings.TerminalWindowSection.fe4d89ef85', + 'ANSI bright magenta color' + ) + } + }, + { + key: 'brightCyan', + get label() { + return translate( + 'auto.components.settings.TerminalWindowSection.f94adc4113', + 'Bright Cyan' + ) + }, + get description() { + return translate( + 'auto.components.settings.TerminalWindowSection.1601140f03', + 'ANSI bright cyan color' + ) + } + }, + { + key: 'brightWhite', + get label() { + return translate( + 'auto.components.settings.TerminalWindowSection.16948119cb', + 'Bright White' + ) + }, + get description() { + return translate( + 'auto.components.settings.TerminalWindowSection.42e01a6055', + 'ANSI bright white color' + ) + } + } + ] + } +] diff --git a/src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx b/src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx index 5aa81139153..4b592ad1a46 100644 --- a/src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx @@ -3,17 +3,15 @@ import { describe, expect, it, vi } from 'vitest' import { Dialog } from '@/components/ui/dialog' import { TooltipProvider } from '@/components/ui/tooltip' import { CreateStep } from './AddRepoCreateStep' -import type { GitAvailability, RepoKind } from './create-project-defaults' +import type { GitAvailability } from './create-project-defaults' function renderCreateStep({ createName = '', - createKind = 'git', gitAvailability = 'available', createParent = '/Users/alice/orca/projects', parentDefaultPending = false }: { createName?: string - createKind?: RepoKind gitAvailability?: GitAvailability createParent?: string parentDefaultPending?: boolean @@ -24,7 +22,6 @@ function renderCreateStep({ @@ -43,24 +39,31 @@ function renderCreateStep({ } describe('CreateStep', () => { - it('renders the name-first create UI with advanced controls collapsed', () => { + it('renders the conductor-style Git project form without templates or kind selection', () => { const html = renderCreateStep() - expect(html).toContain('Create a new project') - expect(html).toContain('Name') - expect(html).toContain('Git repository in ~/orca/projects') - // The summary card itself is the collapsed disclosure for the uncommon settings. - expect(html).toContain('aria-expanded="false"') + expect(html).toContain('Create project') + expect(html).toContain('Project name') + expect(html).not.toContain('Git repo:') + expect(html).not.toContain('>project-name') + expect(html).toContain('Parent folder') + expect(html).toContain('Browse') + expect(html).not.toContain('Template') expect(html).not.toContain('Project kind') - expect(html).not.toContain('Location') - expect(html).not.toContain('aria-label="Browse server filesystem"') }) - it('shows the Git fallback explanation in the collapsed summary', () => { - const html = renderCreateStep({ createKind: 'folder', gitAvailability: 'unavailable' }) + it('shows the repo name in the helper only after a project name is entered', () => { + const html = renderCreateStep({ createName: 'demo-project' }) - expect(html).toContain('Folder in ~/orca/projects') - expect(html).toContain('Git isn't installed, so a plain folder is the default.') + expect(html).toContain('Git repo:') + expect(html).toContain('demo-project') + }) + + it('requires Git instead of falling back to folder creation', () => { + const html = renderCreateStep({ gitAvailability: 'unavailable' }) + + expect(html).toContain('Git is required to create a project.') + expect(html).toContain('disabled=""') }) it('disables create while an auto-filled parent belongs to a previous target', () => { diff --git a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx index 863b1121238..8fd3e75d196 100644 --- a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx @@ -1,30 +1,17 @@ // Step for AddRepoDialog (orca#763), split out so create-project state stays scoped. -import React, { useCallback, useMemo, useRef, useState } from 'react' -import { ChevronDown, Folder, GitBranch, Loader2 } from 'lucide-react' +import React, { useMemo, useState } from 'react' +import { CornerDownLeft, FolderOpen, Loader2 } from 'lucide-react' import { DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { cn } from '@/lib/utils' -import { - CreateProjectLocationField, - CreateProjectParentBrowser -} from './CreateProjectLocationField' +import { CreateProjectParentBrowser } from './CreateProjectLocationField' import { translate } from '@/i18n/i18n' -import { - formatCreateProjectParentSummary, - joinCreateProjectPath, - type GitAvailability, - type RepoKind -} from './create-project-defaults' - -// ── UI helpers ─────────────────────────────────────────────────────── - -const CREATE_PROJECT_NAME_PLACEHOLDER = 'project-name' +import { getScreenSubmitModifierLabel } from '@/lib/screen-submit-shortcut' +import { formatCreateProjectParentSummary, type GitAvailability } from './create-project-defaults' type CreateStepProps = { createName: string createParent: string - createKind: RepoKind createError: string | null isCreating: boolean defaultParent?: string @@ -35,7 +22,6 @@ type CreateStepProps = { runtimeEnvironmentId?: string | null onNameChange: (value: string) => void onParentChange: (value: string) => void - onKindChange: (kind: RepoKind) => void onPickParent: () => void onCreate: () => void } @@ -43,7 +29,6 @@ type CreateStepProps = { export function CreateStep({ createName, createParent, - createKind, createError, isCreating, defaultParent = '', @@ -54,52 +39,16 @@ export function CreateStep({ runtimeEnvironmentId, onNameChange, onParentChange, - onKindChange, onPickParent, onCreate }: CreateStepProps): React.JSX.Element { - const radioGroupRef = useRef(null) - const radioFocusFrameRef = useRef(null) const [browsingParent, setBrowsingParent] = useState(false) - const [advancedOpen, setAdvancedOpen] = useState(false) - - const cancelRadioFocusFrame = useCallback((): void => { - if (radioFocusFrameRef.current === null) { - return - } - cancelAnimationFrame(radioFocusFrameRef.current) - radioFocusFrameRef.current = null - }, []) - - const setRadioGroupNode = useCallback( - (node: HTMLDivElement | null): void => { - // Why: the queued arrow-key focus is only valid while this radiogroup is mounted. - if (!node) { - cancelRadioFocusFrame() - } - radioGroupRef.current = node - }, - [cancelRadioFocusFrame] - ) - - // Arrow keys cycle selection within the radiogroup (WAI-ARIA radio pattern). - const cycleKind = useCallback(() => { - const next = createKind === 'git' ? 'folder' : 'git' - onKindChange(next) - cancelRadioFocusFrame() - radioFocusFrameRef.current = requestAnimationFrame(() => { - radioFocusFrameRef.current = null - const nextEl = radioGroupRef.current?.querySelector( - `[data-kind="${next}"]` - ) - nextEl?.focus() - }) - }, [cancelRadioFocusFrame, createKind, onKindChange]) const canSubmit = createName.trim().length > 0 && createParent.trim().length > 0 && gitAvailability !== 'checking' && + gitAvailability !== 'unavailable' && !parentDefaultPending && !isCreating const missingLocationLabel = translate( @@ -111,7 +60,7 @@ export function CreateStep({ 'server folder not selected' ) - const summaryParent = useMemo( + const parentSummary = useMemo( () => formatCreateProjectParentSummary({ parent: createParent, @@ -128,14 +77,8 @@ export function CreateStep({ runtimeEnvironmentId ] ) - const targetPathPreview = useMemo(() => { - const name = createName.trim() || CREATE_PROJECT_NAME_PLACEHOLDER - return createParent.trim() ? joinCreateProjectPath(createParent, name) : '' - }, [createName, createParent]) - const kindLabel = - createKind === 'git' - ? translate('auto.components.sidebar.AddRepoCreateStep.11fd2a7db8', 'Git repository') - : translate('auto.components.sidebar.AddRepoCreateStep.038729c107', 'Folder') + const repoNamePreview = createName.trim() + const submitShortcutModifierLabel = getScreenSubmitModifierLabel() const showGitFallback = gitAvailability === 'unavailable' const showGitChecking = gitAvailability === 'checking' const showRuntimeMissingParent = @@ -157,14 +100,14 @@ export function CreateStep({ {translate( - 'auto.components.sidebar.AddRepoCreateStep.c7b9f94456', - 'Create a new project' + 'auto.components.sidebar.AddRepoCreateStep.createProjectTitle', + 'Create project' )} {translate( - 'auto.components.sidebar.AddRepoCreateStep.b100311784', - 'Name it and Orca will create a real project with sensible defaults.' + 'auto.components.sidebar.AddRepoCreateStep.createProjectDescription', + 'Create a local Git repo and first workspace.' )} @@ -173,14 +116,16 @@ export function CreateStep({ (= content size), so a long path inside the Location row would blow out the dialog width even with flex + truncate on the row itself. min-w-0 here caps the grid track at the dialog's max-width. */} -
- {/* Name. Monospaced because it ends up as a directory name. */} -
+
+
+ {repoNamePreview ? ( +

+ {translate( + 'auto.components.sidebar.AddRepoCreateStep.createsGitRepoHelp', + 'Git repo:' + )}{' '} + {repoNamePreview} +

+ ) : null}
- {/* Summary card doubles as the disclosure for the uncommon settings, so the - defaults and the controls to change them live in one place. */} -
- - - {advancedOpen && ( -
- {/* Real radiogroup so screen readers announce the segmented choice. */} -
- - {translate( - 'auto.components.sidebar.AddRepoCreateStep.180e9b5e48', - 'Project kind' - )} - -
- {(['git', 'folder'] as const).map((kind) => { - const selected = createKind === kind - const label = - kind === 'git' - ? translate( - 'auto.components.sidebar.AddRepoCreateStep.11fd2a7db8', - 'Git repository' - ) - : translate( - 'auto.components.sidebar.AddRepoCreateStep.038729c107', - 'Folder' - ) - const Icon = kind === 'git' ? GitBranch : Folder - return ( - - ) - })} -
- {showGitFallback && ( -

- {translate( - 'auto.components.sidebar.AddRepoCreateStep.fe1e616c5b', - "Git isn't installed, so a plain folder is the default." - )} -

- )} -
- - {/* The local picker returns client paths; runtime servers browse host paths via RPC. */} - setBrowsingParent(true)} - /> - - {targetPathPreview && ( -

- {targetPathPreview} -

+ +
+ {showGitChecking ? ( +

+ + {translate( + 'auto.components.sidebar.AddRepoCreateStep.2a762f3b19', + 'Checking Git on this host...' )} -

+

+ ) : showGitFallback ? ( +

+ {translate( + 'auto.components.sidebar.AddRepoCreateStep.gitRequiredError', + 'Git is required to create a project.' + )} +

+ ) : showRuntimeMissingParent ? ( +

+ {translate( + 'auto.components.sidebar.AddRepoCreateStep.c234df77f7', + 'Choose or enter a server parent folder before creating.' + )} +

+ ) : ( +

+ {parentSummary} +

)}
{createError && ( -

+

{createError}

)} - +
+ +
) diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index 5bf06950be3..9d2c17a12b7 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -133,12 +133,10 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const { createName, createParent, - createKind, createError, isCreating, setCreateName, setCreateParent, - setCreateKind, setCreateError, resetCreateState, handlePickParent, @@ -153,14 +151,12 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { createRuntimeParentStatus, createParentDefaultPending, resetCreateDefaultState, - markCreateParentTouched, - markCreateKindTouched + markCreateParentTouched } = useCreateProjectDefaults({ step, activeRuntimeEnvironmentId: settings?.activeRuntimeEnvironmentId, createParent, - setCreateParent, - setCreateKind + setCreateParent }) const { @@ -332,7 +328,6 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { nestedGroupName={nestedGroupName} createName={createName} createParent={createParent} - createKind={createKind} createError={createError} isCreating={isCreating} createDefaultParent={createDefaultParent} @@ -390,11 +385,6 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { setCreateParent(value) setCreateError(null) }} - onCreateKindChange={(kind) => { - markCreateKindTouched() - setCreateKind(kind) - setCreateError(null) - }} onPickCreateParent={() => { void handlePickParent().then((dir) => { if (dir) { diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx index f422bb391f1..b295293c100 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx @@ -53,7 +53,6 @@ function renderStepContent(overrides: Partial): string { nestedGroupName: 'platform', createName: '', createParent: '', - createKind: 'git', createError: null, isCreating: false, createDefaultParent: '', @@ -82,7 +81,6 @@ function renderStepContent(overrides: Partial): string { onImportNestedRepos: vi.fn(), onCreateNameChange: vi.fn(), onCreateParentChange: vi.fn(), - onCreateKindChange: vi.fn(), onPickCreateParent: vi.fn(), onCreate: vi.fn(), ...overrides @@ -129,8 +127,9 @@ describe('AddRepoDialogStepContent nested imports', () => { activeRuntimeEnvironmentId: 'env-1' }) - expect(html).toContain('Create a new project') - expect(html).toContain('server folder not selected') + expect(html).toContain('Create project') + expect(html).toContain('Choose or enter a server parent folder before creating.') + expect(html).toContain('Browse') }) it('offers server browsing for remote clone destinations', () => { diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx index dc09458f42b..baa39686ba7 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx @@ -38,7 +38,6 @@ type AddRepoDialogStepContentProps = { nestedGroupName: string createName: string createParent: string - createKind: 'git' | 'folder' createError: string | null isCreating: boolean createDefaultParent: string @@ -67,7 +66,6 @@ type AddRepoDialogStepContentProps = { onImportNestedRepos: (mode: 'group' | 'separate') => void onCreateNameChange: (name: string) => void onCreateParentChange: (parent: string) => void - onCreateKindChange: (kind: 'git' | 'folder') => void onPickCreateParent: () => void onCreate: () => void } @@ -100,7 +98,6 @@ export function AddRepoDialogStepContent({ nestedGroupName, createName, createParent, - createKind, createError, isCreating, createDefaultParent, @@ -129,7 +126,6 @@ export function AddRepoDialogStepContent({ onImportNestedRepos, onCreateNameChange, onCreateParentChange, - onCreateKindChange, onPickCreateParent, onCreate }: AddRepoDialogStepContentProps): React.JSX.Element | null { @@ -224,7 +220,6 @@ export function AddRepoDialogStepContent({ diff --git a/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx b/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx index ace7b22bc85..62cfae41daa 100644 --- a/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx @@ -131,7 +131,7 @@ describe('AddRepoLocalStartStep', () => { expect(markup).toContain('Browse folder') expect(markup).toContain('Clone from URL') expect(markup).toContain('Remote project') - expect(markup).toContain('Create new project') + expect(markup).toContain('Create project') expect(markup).toContain('Other ways to add') expect(markup).not.toContain('More options') }) @@ -140,7 +140,7 @@ describe('AddRepoLocalStartStep', () => { const titles = getActionTitles(false) expect(titles.primary).toBe('Browse folder') - expect(titles.secondary).toEqual(['Clone from URL', 'Remote project', 'Create new project']) + expect(titles.secondary).toEqual(['Clone from URL', 'Remote project', 'Create project']) }) it('keeps Browse folder primary for SSH-likely users', () => { @@ -149,14 +149,14 @@ describe('AddRepoLocalStartStep', () => { expect(markup).toContain('Browse folder') expect(markup).toContain('Remote project') expect(markup).toContain('Clone from URL') - expect(markup).toContain('Create new project') + expect(markup).toContain('Create project') }) it('orders secondary actions remote-first for SSH-likely users', () => { const titles = getActionTitles(true) expect(titles.primary).toBe('Browse folder') - expect(titles.secondary).toEqual(['Remote project', 'Clone from URL', 'Create new project']) + expect(titles.secondary).toEqual(['Remote project', 'Clone from URL', 'Create project']) }) it('focuses Browse folder when the default Add Project step opens', async () => { @@ -188,7 +188,7 @@ describe('AddRepoLocalStartStep', () => { expect(findButton(container, 'Clone from URL').disabled).toBe(false) expect(findButton(container, 'Remote project').disabled).toBe(false) - expect(findButton(container, 'Create new project').disabled).toBe(false) + expect(findButton(container, 'Create project').disabled).toBe(false) await act(async () => { root.unmount() diff --git a/src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx b/src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx index 3ec00563c1c..7d92e5057cc 100644 --- a/src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx +++ b/src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx @@ -232,6 +232,7 @@ export function FolderWorkspaceComposerDialog({ quickAgent, autoRenameBranchFromWork: settings?.autoRenameBranchFromWork, agentCmdOverrides: settings?.agentCmdOverrides, + isRemote: selectedRepoConnectionId !== null, createFolderWorkspace, onOpenChange }) @@ -246,6 +247,7 @@ export function FolderWorkspaceComposerDialog({ onOpenChange, projectGroup, quickAgent, + selectedRepoConnectionId, settings?.agentCmdOverrides, settings?.autoRenameBranchFromWork, submitting, diff --git a/src/renderer/src/components/sidebar/ProjectOrderManualDefaultNotice.tsx b/src/renderer/src/components/sidebar/ProjectOrderManualDefaultNotice.tsx new file mode 100644 index 00000000000..64abeff0c2d --- /dev/null +++ b/src/renderer/src/components/sidebar/ProjectOrderManualDefaultNotice.tsx @@ -0,0 +1,89 @@ +import React, { useCallback } from 'react' +import { X } from 'lucide-react' +import { useAppStore } from '@/store' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { shouldShowProjectOrderManualDefaultNotice } from './project-order-manual-default-notice-visibility' +import { translate } from '@/i18n/i18n' + +function ProjectOrderManualDefaultNotice(): React.JSX.Element | null { + const persistedUIReady = useAppStore((s) => s.persistedUIReady) + const projectOrderManualDefaultNoticeDismissed = useAppStore( + (s) => s.projectOrderManualDefaultNoticeDismissed + ) + const dismissProjectOrderManualDefaultNotice = useAppStore( + (s) => s.dismissProjectOrderManualDefaultNotice + ) + const groupBy = useAppStore((s) => s.groupBy) + const projectOrderBy = useAppStore((s) => s.projectOrderBy) + const repoCount = useAppStore((s) => s.repos.length) + + const shouldShow = shouldShowProjectOrderManualDefaultNotice({ + persistedUIReady, + projectOrderManualDefaultNoticeDismissed, + groupBy, + projectOrderBy, + repoCount + }) + + const handleDismiss = useCallback(() => { + dismissProjectOrderManualDefaultNotice() + }, [dismissProjectOrderManualDefaultNotice]) + + if (!shouldShow) { + return null + } + + return ( +
+
+
+

+ {translate( + 'auto.components.sidebar.ProjectOrderManualDefaultNotice.a1f4c2d8e0', + 'Manual project order is now default' + )} +

+ + + + + + {translate( + 'auto.components.sidebar.ProjectOrderManualDefaultNotice.822ff300ad', + 'Dismiss' + )} + + +
+

+ {translate( + 'auto.components.sidebar.ProjectOrderManualDefaultNotice.b7e3a91c4f', + 'Drag project headers to reorder, or switch to' + )}{' '} + + {translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.b451c8b162', 'Recent')} + {' '} + {translate( + 'auto.components.sidebar.ProjectOrderManualDefaultNotice.e8c1f4a2b9', + 'in workspace options.' + )} +

+
+
+ ) +} + +export default React.memo(ProjectOrderManualDefaultNotice) diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx index 42b1d6cb843..f456fc25f0f 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx @@ -24,7 +24,10 @@ const SidebarHeader = React.memo(function SidebarHeader({ return (
- + {sidebarTitle}
diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx index 5a02d8724f7..208a747ebe2 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx @@ -17,7 +17,7 @@ import { } from '@/components/ui/dropdown-menu' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import type { AgentActivityDisplayMode, WorktreeCardProperty } from '../../../../shared/types' +import type { AgentActivityDisplayMode } from '../../../../shared/types' import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' import SidebarRepositoryFilterSection from './SidebarRepositoryFilterSection' import SidebarWorkspaceFilterSection from './SidebarWorkspaceFilterSection' @@ -28,55 +28,14 @@ type SidebarWorkspaceOptionsMenuProps = { onMenuOpenChange?: (open: boolean) => void } -const GROUP_BY_OPTIONS = [ - { id: 'none', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.c2c7a45cda", "None") }, - { id: 'workspace-status', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.e029a2d775", "Status") }, - { id: 'pr-status', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.0f9b959b31", "PR") }, - { id: 'repo', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.2170d553cf", "Project") } -] as const - -const CARD_LAYOUT_OPTIONS = [ - { id: 'detailed', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.cc17bd443b", "Detailed") }, - { id: 'compact', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.25105b28cb", "Compact") } -] as const - -const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [ - { id: 'issue', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.91dfc653e8", "GitHub ticket") }, - { id: 'linear-issue', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.ca4d3c522e", "Linear issue") }, - { id: 'pr', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.b8dcc6f321", "PR/MR link") }, - { id: 'comment', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.26c71e536c", "Notes") }, - { id: 'ports', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.b64d8bcca0", "Ports") }, - // Why: toggles the inline "Agent activity" list rendered below each - // workspace card body (see WorktreeCard -> WorktreeCardAgents). Off hides - // the list; there is no alternate surface. - { id: 'inline-agents', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.d7084e8bc8", "Agent activity") } -] - -const AGENT_ACTIVITY_DISPLAY_OPTIONS: { id: AgentActivityDisplayMode; label: string }[] = [ - { id: 'compact', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.25105b28cb", "Compact") }, - { id: 'full', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.2a81e07366", "Full list") } -] - -const SORT_OPTIONS = [ - { id: 'name', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.3728165cdd", "Name"), description: null }, - { - id: 'smart', - label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.503462f2b4", "Agent Activity"), - description: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.b759bb87ee", "Agents that need attention, then most recent activity.") - }, - { id: 'recent', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.b451c8b162", "Recent"), description: null }, - { id: 'repo', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.2170d553cf", "Project"), description: null }, - { - id: 'manual', - label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.7b316bdd51", "Manual"), - description: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.7153d07485", "Drag workspaces to arrange them within each group.") - } -] as const - -const PROJECT_ORDER_OPTIONS = [ - { id: 'manual', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.7b316bdd51", "Manual"), description: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.6664282a7b", "Drag projects to arrange them") }, - { id: 'recent', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.b451c8b162", "Recent"), description: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.af9249c505", "Most recent workspace activity") } -] as const +import { + AGENT_ACTIVITY_DISPLAY_OPTIONS, + CARD_LAYOUT_OPTIONS, + GROUP_BY_OPTIONS, + PROJECT_ORDER_OPTIONS, + PROPERTY_OPTIONS, + SORT_OPTIONS +} from './sidebar-workspace-options-menu-options' const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsMenu({ preserveWorkspaceBoardOpen = false, @@ -148,8 +107,15 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM className="relative text-muted-foreground" aria-label={ hasAnyFilter - ? translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.bc96dbd041", "Workspace options ({{value0}} active)", { value0: activeFilterLabel }) - : translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.9919ae1082", "Workspace options") + ? translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.bc96dbd041', + 'Workspace options ({{value0}} active)', + { value0: activeFilterLabel } + ) + : translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.9919ae1082', + 'Workspace options' + ) } data-workspace-board-preserve-open={preserveWorkspaceBoardOpen ? '' : undefined} > @@ -168,7 +134,16 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM - {hasAnyFilter ? translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.bc96dbd041", "Workspace options ({{value0}})", { value0: activeFilterLabel }) : translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.9919ae1082", "Workspace options")} + {hasAnyFilter + ? translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.bc96dbd041', + 'Workspace options ({{value0}})', + { value0: activeFilterLabel } + ) + : translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.9919ae1082', + 'Workspace options' + )} - {translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.dc0bb670bc", "Group by")} + + {translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.dc0bb670bc', 'Group by')} +
- {translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.7bada3b1ab", "Sort by")} + + {translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.7bada3b1ab', + 'Sort by' + )} + {sortLabel} @@ -250,11 +232,16 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM {/* Why: project order only has a visible effect when grouping by project; hide it in none/status/PR modes to avoid a dead control. */} - {groupBy === "repo" && ( + {groupBy === 'repo' && ( - {translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.09faabd875", "Project order")} + + {translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.09faabd875', + 'Project order' + )} + {projectOrderLabel} @@ -292,7 +279,12 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM - {translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.320b675c9a", "Card layout")} + + {translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.320b675c9a', + 'Card layout' + )} + {cardLayoutLabel} @@ -326,9 +318,19 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM - {translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.ba87080fb7", "Show properties")} - {cardLayout === "compact" ? ( - {translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.3d4b9c4997", "Hover")} + + {translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.ba87080fb7', + 'Show properties' + )} + + {cardLayout === 'compact' ? ( + + {translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.3d4b9c4997', + 'Hover' + )} + ) : visiblePropertyCount > 0 ? ( {visiblePropertyCount} @@ -352,7 +354,11 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM ))} - {translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.95c9754653", "Agent activity layout")} + {translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.95c9754653', + 'Agent activity layout' + )} + diff --git a/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx b/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx index 77e745bc6ad..21fa58d026d 100644 --- a/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx +++ b/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx @@ -24,14 +24,36 @@ type SshDisconnectedDialogProps = { } const STATUS_MESSAGES: Partial> = { - disconnected: 'This remote repository is not connected.', - reconnecting: 'Reconnecting to the remote host...', - 'reconnection-failed': 'Reconnection to the remote host failed.', - error: translate( - 'auto.components.sidebar.SshDisconnectedDialog.376bed88e5', - 'The connection to the remote host encountered an error.' - ), - 'auth-failed': 'Authentication to the remote host failed.' + get disconnected() { + return translate( + 'auto.components.sidebar.SshDisconnectedDialog.disconnected', + 'This remote repository is not connected.' + ) + }, + get reconnecting() { + return translate( + 'auto.components.sidebar.SshDisconnectedDialog.reconnecting', + 'Reconnecting to the remote host...' + ) + }, + get 'reconnection-failed'() { + return translate( + 'auto.components.sidebar.SshDisconnectedDialog.reconnectionFailed', + 'Reconnection to the remote host failed.' + ) + }, + get error() { + return translate( + 'auto.components.sidebar.SshDisconnectedDialog.376bed88e5', + 'The connection to the remote host encountered an error.' + ) + }, + get 'auth-failed'() { + return translate( + 'auto.components.sidebar.SshDisconnectedDialog.authFailed', + 'Authentication to the remote host failed.' + ) + } } function isReconnectable(status: SshConnectionStatus): boolean { @@ -76,9 +98,21 @@ export function SshDisconnectedDialog({ status === 'connecting' || status === 'deploying-relay' || status === 'reconnecting' + const reconnectingMessage = + STATUS_MESSAGES.reconnecting ?? + translate( + 'auto.components.sidebar.SshDisconnectedDialog.reconnecting', + 'Reconnecting to the remote host...' + ) + const disconnectedMessage = + STATUS_MESSAGES.disconnected ?? + translate( + 'auto.components.sidebar.SshDisconnectedDialog.disconnected', + 'This remote repository is not connected.' + ) const message = isConnecting - ? 'Reconnecting to the remote host...' - : (STATUS_MESSAGES[status] ?? 'This remote repository is not connected.') + ? reconnectingMessage + : (STATUS_MESSAGES[status] ?? disconnectedMessage) const showReconnect = isReconnectable(status) useEffect(() => { diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index dbbd52fdf94..92c3dda7493 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -124,6 +124,7 @@ import { SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT, type ScrollToCurrentWorkspaceRevealRequestDetail } from '@/lib/scroll-to-current-workspace-status' +import { getSidebarOrderedRepoHeaderIdsByBucket } from './project-header-drop' import { isRepoHeaderActionTarget, useRepoHeaderDrag } from './project-header-drag' import { buildManualOrderUpdatesForGroupDrop, @@ -890,8 +891,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ) const suppressWorktreeClickUntilRef = useRef(0) const hasProjectGroups = projectGroups.length > 0 - const canReorderRepoHeaders = - groupBy === 'repo' && projectOrderBy === 'manual' && !hasProjectGroups + const canReorderRepoHeaders = groupBy === 'repo' && projectOrderBy === 'manual' + const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup) const lastVisibleRefreshKeyRef = useRef('') const reportVisibleGitHubPRRefreshCandidates = useAppStore( (s) => s.reportVisibleGitHubPRRefreshCandidates @@ -934,13 +935,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp }, [reorderRepos] ) - // Drag is only meaningful when repo headers are using manual order. The - // controller is still constructed for hook order stability when inert. - const repoDrag = useRepoHeaderDrag({ - orderedRepoIds: allRepoIds, - onCommit: commitRepoReorder, - getScrollContainer: () => scrollRef.current - }) const worktreeDragGroups = useMemo(() => getWorktreeDragGroups(rows), [rows]) const worktreeDragUnitGroups = useMemo(() => getWorktreeDragUnitGroups(rows), [rows]) const worktreeLineageDragRows = useMemo( @@ -1050,6 +1044,45 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp [computeWorktreeDropForGroup] ) const renderRows = useMemo(() => buildRenderableRows(rows), [rows]) + const sidebarRepoHeaderIdsByBucket = useMemo( + () => getSidebarOrderedRepoHeaderIdsByBucket(rows), + [rows] + ) + const repoHeaderIndexByRepoId = useMemo(() => { + const map = new Map() + for (const repoIds of sidebarRepoHeaderIdsByBucket.values()) { + repoIds.forEach((repoId, index) => { + map.set(repoId, index) + }) + } + return map + }, [sidebarRepoHeaderIdsByBucket]) + const repoHeaderBucketByRepoId = useMemo(() => { + const map = new Map() + for (const [bucketKey, repoIds] of sidebarRepoHeaderIdsByBucket) { + for (const repoId of repoIds) { + map.set(repoId, bucketKey) + } + } + return map + }, [sidebarRepoHeaderIdsByBucket]) + const commitProjectGroupOrder = useCallback( + (repoId: string, projectGroupId: string | null, order: number) => { + void moveProjectToGroup(repoId, projectGroupId, order) + }, + [moveProjectToGroup] + ) + // Drag is only meaningful when repo headers are using manual order. The + // controller is still constructed for hook order stability when inert. + const repoDrag = useRepoHeaderDrag({ + orderedRepoIds: allRepoIds, + sidebarRepoHeaderIdsByBucket, + repoById: repoMap, + usesProjectGroupOrdering: hasProjectGroups, + onCommitRepoOrder: commitRepoReorder, + onCommitProjectGroupOrder: commitProjectGroupOrder, + getScrollContainer: () => scrollRef.current + }) const firstHeaderIndex = useMemo( () => renderRows.findIndex((row) => row.type === 'header'), [renderRows] @@ -2913,6 +2946,21 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const isRepoHeader = groupBy === 'repo' && row.repo !== undefined const isProjectGroupHeader = groupBy === 'repo' && row.projectGroup !== undefined const projectIdForHeader = isRepoHeader ? row.repo!.id : undefined + const repoHeaderIndex = + projectIdForHeader !== undefined + ? repoHeaderIndexByRepoId.get(projectIdForHeader) + : undefined + const repoHeaderBucketKey = + projectIdForHeader !== undefined + ? repoHeaderBucketByRepoId.get(projectIdForHeader) + : undefined + const isDraggableRepoHeader = Boolean( + canReorderRepoHeaders && + isRepoHeader && + projectIdForHeader && + repoHeaderBucketKey && + (sidebarRepoHeaderIdsByBucket.get(repoHeaderBucketKey)?.length ?? 0) > 1 + ) const isDraggingThis = canReorderRepoHeaders && repoDrag.state.draggingRepoId !== null && @@ -2966,6 +3014,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp data-worktree-virtual-row-key={String(vItem.key)} data-worktree-sticky-header="" data-worktree-sticky-header-active={isActiveStickyHeader ? '' : undefined} + data-worktree-virtual-row-start={vItem.start} data-index={vItem.index} ref={measureVirtualRowElement} className={cn( @@ -2985,17 +3034,24 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ? undefined : { transform: getVirtualRowTransform(vItem.start) } } + onPointerDown={ + isDraggableRepoHeader && projectIdForHeader + ? (event) => repoDrag.onHandlePointerDown(event, projectIdForHeader) + : undefined + } >
repoDrag.onHandlePointerDown(e, projectIdForHeader) - : undefined - } onDragOver={ isPinnedHeader ? handleWorkspacePinDragOver diff --git a/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts b/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts index 259b6b8f3b7..0572b1dc236 100644 --- a/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts +++ b/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts @@ -71,12 +71,12 @@ export function getAddRepoLocalStartActions({ kind: 'create' as const, icon: Plus, title: translate( - 'auto.components.sidebar.add.repo.local.start.actions.c709860596', - 'Create new project' + 'auto.components.sidebar.add.repo.local.start.actions.createProjectTitle', + 'Create project' ), description: translate( - 'auto.components.sidebar.add.repo.local.start.actions.d72789705e', - 'Start from an empty folder' + 'auto.components.sidebar.add.repo.local.start.actions.createGitProjectDescription', + 'Create a local Git repository' ), onClick: onOpenCreateStep } diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 71c4a6d2ccd..0faef5ca7e7 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -32,6 +32,7 @@ type SubmitFolderWorkspaceCreateParams = { quickAgent: TuiAgent | null autoRenameBranchFromWork: boolean | undefined agentCmdOverrides: Record | undefined + isRemote?: boolean createFolderWorkspace: (input: FolderWorkspaceCreateInput) => Promise onOpenChange: (open: boolean) => void } @@ -45,6 +46,7 @@ export async function submitFolderWorkspaceCreate({ quickAgent, autoRenameBranchFromWork, agentCmdOverrides, + isRemote, createFolderWorkspace, onOpenChange }: SubmitFolderWorkspaceCreateParams): Promise { @@ -54,8 +56,10 @@ export async function submitFolderWorkspaceCreate({ nameIsAutoManaged && linkedName ? linkedName : name.trim() || linkedName || `${projectGroup.name} workspace` + // Why: only suggest `orca linear` when the launched terminal can actually + // resolve the CLI; SSH launches get the relay shim, local launches may not. const linearCliAvailable = linkedWorkItem?.linearIdentifier - ? await isOrcaCliAvailableForLaunch({ remote: projectGroup.connectionId != null }) + ? await isOrcaCliAvailableForLaunch({ remote: isRemote ?? projectGroup.connectionId != null }) : false const linkedPromptContext = getLinkedWorkItemPromptContext(linkedWorkItem, { cliAvailable: linearCliAvailable @@ -85,6 +89,7 @@ export async function submitFolderWorkspaceCreate({ if (!workspace) { return } + const startupPlan = quickAgent ? buildAgentStartupPlan({ agent: quickAgent, diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index d51dd2c2924..1a3941ea440 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -3,6 +3,7 @@ import { useAppStore } from '@/store' import { TooltipProvider } from '@/components/ui/tooltip' import { useSidebarResize } from '@/hooks/useSidebarResize' import SidebarHeader from './SidebarHeader' +import ProjectOrderManualDefaultNotice from './ProjectOrderManualDefaultNotice' import SidebarNav from './SidebarNav' import SetupScriptPromptCard from './SetupScriptPromptCard' import WorktreeList from './WorktreeList' @@ -122,6 +123,7 @@ function Sidebar({ {/* Fixed controls */} + + usesProjectGroupOrdering: boolean + onCommitRepoOrder: (orderedIds: string[]) => void + onCommitProjectGroupOrder: (repoId: string, projectGroupId: string | null, order: number) => void +}): void { + const draggedRepo = args.repoById.get(args.session.repoId) + if (!draggedRepo) { + return + } + + const sidebarRepoHeaderIds = args.session.sidebarRepoHeaderIds + const sourceIndex = sidebarRepoHeaderIds.indexOf(args.session.repoId) + if (args.sidebarDropIndex === sourceIndex) { + return + } + + if (args.usesProjectGroupOrdering) { + const siblings = sidebarRepoHeaderIds + .filter((repoId) => repoId !== args.session.repoId) + .map((repoId) => args.repoById.get(repoId)) + .filter((repo): repo is Repo => repo !== undefined) + const siblingDropIndex = mapSidebarProjectHeaderDropIndexToSiblingInsertIndex({ + sidebarDropIndex: args.sidebarDropIndex, + sourceIndex, + siblingCount: siblings.length + }) + if (siblingDropIndex === sourceIndex) { + return + } + const repoOrderRankById = new Map( + args.orderedRepoIds.map((repoId, index) => [repoId, index] as const) + ) + const order = getProjectGroupOrderForSidebarDrop({ + siblings, + dropIndex: siblingDropIndex, + repoOrderRankById + }) + args.onCommitProjectGroupOrder(args.session.repoId, draggedRepo.projectGroupId ?? null, order) + return + } + + const insertAt = mapSidebarRepoDropIndexToAllRepoInsertAt( + args.sidebarDropIndex, + sidebarRepoHeaderIds, + args.orderedRepoIds + ) + const next = applyAllRepoInsertAt(args.orderedRepoIds, args.session.repoId, insertAt) + if (!next) { + return + } + args.onCommitRepoOrder(next) +} diff --git a/src/renderer/src/components/sidebar/project-header-drag-contract.ts b/src/renderer/src/components/sidebar/project-header-drag-contract.ts new file mode 100644 index 00000000000..6d823e8dbf4 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-header-drag-contract.ts @@ -0,0 +1,59 @@ +import type { PointerEvent } from 'react' + +import type { ProjectHeaderDragBucketKey, ProjectHeaderDragRect } from './project-header-drop' +import type { Repo } from '../../../../shared/types' + +export type RepoDragState = { + draggingRepoId: string | null + dropIndex: number | null + dropIndicatorY: number | null +} + +export const INITIAL_REPO_DRAG_STATE: RepoDragState = { + draggingRepoId: null, + dropIndex: null, + dropIndicatorY: null +} + +export type UseRepoHeaderDragArgs = { + orderedRepoIds: string[] + sidebarRepoHeaderIdsByBucket: ReadonlyMap + repoById: ReadonlyMap + usesProjectGroupOrdering: boolean + onCommitRepoOrder: (orderedIds: string[]) => void + onCommitProjectGroupOrder: (repoId: string, projectGroupId: string | null, order: number) => void + getScrollContainer: () => HTMLElement | null +} + +export type RepoHeaderDragController = { + state: RepoDragState + onHandlePointerDown: (event: PointerEvent, repoId: string) => void +} + +export type ProjectHeaderDragSession = { + repoId: string + bucketKey: ProjectHeaderDragBucketKey + sidebarRepoHeaderIds: readonly string[] + pointerId: number + headerRects: ProjectHeaderDragRect[] + handleEl: HTMLElement + startX: number + startY: number + latestPointerY: number + promoted: boolean +} + +export const PROJECT_HEADER_DRAG_THRESHOLD_PX = 4 + +const REPO_HEADER_ACTION_SELECTOR = + '[data-repo-header-action], button, a, input, textarea, select, [contenteditable=""], [contenteditable="true"]' + +export function isRepoHeaderActionTarget( + target: EventTarget | null, + currentTarget: HTMLElement +): boolean { + if (!(target instanceof HTMLElement) || target === currentTarget) { + return false + } + return currentTarget.contains(target) && target.closest(REPO_HEADER_ACTION_SELECTOR) !== null +} diff --git a/src/renderer/src/components/sidebar/project-header-drag-start.ts b/src/renderer/src/components/sidebar/project-header-drag-start.ts new file mode 100644 index 00000000000..f0d96bab4d7 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-header-drag-start.ts @@ -0,0 +1,61 @@ +import type { PointerEvent } from 'react' + +import { + getProjectHeaderDragBucketKey, + measureProjectHeaderDragRects, + type ProjectHeaderDragBucketKey +} from './project-header-drop' +import { + isRepoHeaderActionTarget, + type ProjectHeaderDragSession +} from './project-header-drag-contract' +import type { Repo } from '../../../../shared/types' + +export function createProjectHeaderDragSession(args: { + event: PointerEvent + repoId: string + repoById: ReadonlyMap + sidebarRepoHeaderIdsByBucket: ReadonlyMap + getScrollContainer: () => HTMLElement | null +}): ProjectHeaderDragSession | null { + if (args.event.button !== 0) { + return null + } + if (isRepoHeaderActionTarget(args.event.target, args.event.currentTarget)) { + return null + } + const repo = args.repoById.get(args.repoId) + if (!repo) { + return null + } + const bucketKey = getProjectHeaderDragBucketKey(repo) + const sidebarRepoHeaderIds = args.sidebarRepoHeaderIdsByBucket.get(bucketKey) ?? [] + // Why: a single project in its bucket has nowhere to land, so skip arming + // drag and let the header click toggle collapse instead. + if (sidebarRepoHeaderIds.length <= 1) { + return null + } + const container = args.getScrollContainer() + if (!container) { + return null + } + const handleEl = args.event.currentTarget + try { + handleEl.setPointerCapture(args.event.pointerId) + } catch { + // setPointerCapture can throw if the element is detached; the global + // pointer listeners still fire, so dragging keeps working. + } + return { + repoId: args.repoId, + bucketKey, + sidebarRepoHeaderIds, + pointerId: args.event.pointerId, + headerRects: measureProjectHeaderDragRects(container, bucketKey), + handleEl, + startX: args.event.clientX, + startY: args.event.clientY, + latestPointerY: args.event.clientY, + promoted: false + } +} diff --git a/src/renderer/src/components/sidebar/project-header-drag.ts b/src/renderer/src/components/sidebar/project-header-drag.ts index ac84dc620be..bfc5eb29686 100644 --- a/src/renderer/src/components/sidebar/project-header-drag.ts +++ b/src/renderer/src/components/sidebar/project-header-drag.ts @@ -1,105 +1,68 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { + computeProjectHeaderDropPreview, + measureProjectHeaderDragRects +} from './project-header-drop' +import { commitProjectHeaderDragDrop } from './project-header-drag-commit' +import { + INITIAL_REPO_DRAG_STATE, + PROJECT_HEADER_DRAG_THRESHOLD_PX, + type ProjectHeaderDragSession, + type RepoDragState, + type RepoHeaderDragController, + type UseRepoHeaderDragArgs +} from './project-header-drag-contract' +import { createProjectHeaderDragSession } from './project-header-drag-start' +import { getWorktreeSidebarDragAutoscroll } from './worktree-sidebar-drag-autoscroll' + // Why pointer events instead of HTML5 DnD: rows are absolutely-positioned by // react-virtual and unmount/remount as scroll changes, so DnD enter/leave fire // against stale targets. With pointer events we cache the active set of repo // header positions and compute the drop index from the live pointer Y. -export type RepoDragState = { - draggingRepoId: string | null - // Insertion index in the orderedRepoIds array where the dragged repo would - // land if released now. null while not dragging. - dropIndex: number | null - // Y coordinate (in scrollContainer's local space, i.e. relative to its - // top-left content origin including current scrollTop offset) where the - // insertion bar should be drawn. null while not dragging. - dropIndicatorY: number | null -} - -const INITIAL_STATE: RepoDragState = { - draggingRepoId: null, - dropIndex: null, - dropIndicatorY: null -} - -export type UseRepoHeaderDragArgs = { - orderedRepoIds: string[] - onCommit: (orderedIds: string[]) => void - // Returns the scroll container that hosts the virtualized rows. Bounding - // rects are read from this element so insertion-bar Y values stay correct - // when the sidebar is resized. - getScrollContainer: () => HTMLElement | null -} - -type HeaderRect = { - repoId: string - // top/bottom in scrollContainer-local space (page-coord top minus container - // page-coord top, plus current scrollTop). - top: number - bottom: number -} - -export type RepoHeaderDragController = { - state: RepoDragState - // Call from the repo header's onPointerDown. The drag does NOT start - // immediately — it arms a pending session that promotes to an active drag - // only once the pointer moves past DRAG_THRESHOLD_PX. A pointerup before - // promotion releases without committing, so the surrounding click handler - // still fires and toggles the group's collapsed state. - onHandlePointerDown: (event: React.PointerEvent, repoId: string) => void -} - -// Pixels the pointer must travel before we promote a pending press into a -// real drag. Below this we treat the press as a normal click (toggle group). -const DRAG_THRESHOLD_PX = 4 -const REPO_HEADER_ACTION_SELECTOR = - '[data-repo-header-action], button, a, input, textarea, select, [contenteditable=""], [contenteditable="true"]' - -export function isRepoHeaderActionTarget( - target: EventTarget | null, - currentTarget: HTMLElement -): boolean { - if (!(target instanceof HTMLElement) || target === currentTarget) { - return false - } - return currentTarget.contains(target) && target.closest(REPO_HEADER_ACTION_SELECTOR) !== null -} - export function useRepoHeaderDrag({ orderedRepoIds, - onCommit, + sidebarRepoHeaderIdsByBucket, + repoById, + usesProjectGroupOrdering, + onCommitRepoOrder, + onCommitProjectGroupOrder, getScrollContainer }: UseRepoHeaderDragArgs): RepoHeaderDragController { - const [state, setState] = useState(INITIAL_STATE) - // Tracks whether a press has begun (armed) regardless of promotion. Used - // only to gate window listeners; visible drag state lives in `state`. + const [state, setState] = useState(INITIAL_REPO_DRAG_STATE) const [sessionArmed, setSessionArmed] = useState(false) - // Why: endDrag reads dropIndex on pointerup, but binding the listener with - // dropIndex in deps would re-add window listeners on every pointermove. - // The ref tracks the latest computed value without invalidating the effect. const latestDropIndexRef = useRef(null) latestDropIndexRef.current = state.dropIndex - // Keep callbacks stable: they read from refs so we don't re-bind window - // listeners every render. const orderedIdsRef = useRef(orderedRepoIds) orderedIdsRef.current = orderedRepoIds - const onCommitRef = useRef(onCommit) - onCommitRef.current = onCommit + const sidebarRepoHeaderIdsByBucketRef = useRef(sidebarRepoHeaderIdsByBucket) + sidebarRepoHeaderIdsByBucketRef.current = sidebarRepoHeaderIdsByBucket + const repoByIdRef = useRef(repoById) + repoByIdRef.current = repoById + const usesProjectGroupOrderingRef = useRef(usesProjectGroupOrdering) + usesProjectGroupOrderingRef.current = usesProjectGroupOrdering + const onCommitRepoOrderRef = useRef(onCommitRepoOrder) + onCommitRepoOrderRef.current = onCommitRepoOrder + const onCommitProjectGroupOrderRef = useRef(onCommitProjectGroupOrder) + onCommitProjectGroupOrderRef.current = onCommitProjectGroupOrder const getContainerRef = useRef(getScrollContainer) getContainerRef.current = getScrollContainer + const autoscrollLastFrameTimeRef = useRef(null) + const autoscrollFrameIdRef = useRef(null) - const dragSessionRef = useRef<{ - repoId: string - pointerId: number - headerRects: HeaderRect[] - handleEl: HTMLElement - startX: number - startY: number - // false until the pointer moves past DRAG_THRESHOLD_PX. While false the - // session exists but no drop indicator is shown and pointerup is treated - // as a click rather than a drop. - promoted: boolean - } | null>(null) + const dragSessionRef = useRef(null) + + const refreshHeaderRects = useCallback(() => { + const container = getContainerRef.current() + const session = dragSessionRef.current + if (!container || !session) { + return [] + } + const rects = measureProjectHeaderDragRects(container, session.bucketKey) + session.headerRects = rects + return rects + }, []) const computeDrop = useCallback( (pointerY: number): { dropIndex: number; dropIndicatorY: number } | null => { @@ -109,107 +72,123 @@ export function useRepoHeaderDrag({ return null } const containerRect = container.getBoundingClientRect() - // Translate pointer to container-local coords + scroll. - const localY = pointerY - containerRect.top + container.scrollTop - const rects = session.headerRects - if (rects.length === 0) { - return null - } - // Find the first header whose midpoint is below the pointer. - let insertBefore = rects.length - for (let i = 0; i < rects.length; i++) { - const mid = (rects[i].top + rects[i].bottom) / 2 - if (localY < mid) { - insertBefore = i - break - } - } - // Why anchor to the target header (not midpoint between headers): the - // space between two project group headers is filled with worktree cards, - // so the midpoint falls *inside another repo's content*. Sitting the - // indicator just above the target header keeps it at the visual top of - // where the dragged group would land. - const INDICATOR_GAP_PX = 4 - const rawIndicatorY = - insertBefore >= rects.length - ? rects.at(-1)!.bottom + INDICATOR_GAP_PX - : Math.max(0, rects[insertBefore].top - INDICATOR_GAP_PX) - // Why: while scrolled, the topmost mounted header is pinned flush at the - // container top, so `top - GAP` lands above the overflow clip region and - // the line is painted invisibly. Floor the indicator at the current - // scroll offset so a top-of-list drop stays visible just below the edge. - const indicatorY = Math.max(container.scrollTop, rawIndicatorY) - return { dropIndex: insertBefore, dropIndicatorY: indicatorY } + return computeProjectHeaderDropPreview({ + pointerY, + containerTop: containerRect.top, + scrollTop: container.scrollTop, + rects: session.headerRects, + sidebarRepoHeaderIds: session.sidebarRepoHeaderIds + }) }, [] ) - const endDrag = useCallback((commit: boolean) => { - const session = dragSessionRef.current - if (!session) { - setState(INITIAL_STATE) - setSessionArmed(false) - return + const cancelAutoscroll = useCallback(() => { + if (autoscrollFrameIdRef.current !== null) { + window.cancelAnimationFrame(autoscrollFrameIdRef.current) + autoscrollFrameIdRef.current = null } - try { - session.handleEl.releasePointerCapture(session.pointerId) - } catch { - // capture may already be released (pointercancel, element unmounted) - } - if (session.promoted) { - // After a real drag, the browser still fires a click on the header. - // Swallow exactly one click in capture phase so it doesn't toggle the - // group's collapsed state. Scope to the dragged handle (and ancestors) - // so an unrelated click that races between pointerup and the failsafe - // teardown isn't silently eaten. - const handleEl = session.handleEl - const swallow = (e: MouseEvent): void => { - const target = e.target as Node | null - if (target && handleEl.contains(target)) { - e.stopPropagation() - e.preventDefault() - } - window.removeEventListener('click', swallow, true) - } - window.addEventListener('click', swallow, true) - // Failsafe: if no click ever arrives (e.g. pointercancel), drop the - // listener after a tick so future clicks aren't silenced. - setTimeout(() => window.removeEventListener('click', swallow, true), 0) - } - // Only commit a reorder if the press was promoted into a real drag — - // otherwise the press was effectively a click, and the surrounding - // header onClick handler will toggle collapse. - const finalIndex = - commit && session.promoted && latestDropIndexRef.current !== null - ? latestDropIndexRef.current - : null - dragSessionRef.current = null - setState(INITIAL_STATE) - setSessionArmed(false) - if (finalIndex === null) { - return - } - const ids = orderedIdsRef.current - const fromIndex = ids.indexOf(session.repoId) - if (fromIndex === -1) { - return - } - // Splice fromIndex out, then insert at finalIndex (adjusting if the - // removal shifted indices). - const next = ids.slice() - next.splice(fromIndex, 1) - const insertAt = finalIndex > fromIndex ? finalIndex - 1 : finalIndex - if (insertAt === fromIndex) { - return - } - next.splice(insertAt, 0, session.repoId) - onCommitRef.current(next) + autoscrollLastFrameTimeRef.current = null }, []) - // Window-level listeners while a session is armed — pointer capture on the - // header element ensures the events still fire even if the pointer leaves - // it. The session may be unpromoted (waiting for a movement past the - // threshold to become a real drag) or promoted (drop indicator visible). + const endDrag = useCallback( + (commit: boolean) => { + cancelAutoscroll() + const session = dragSessionRef.current + if (!session) { + setState(INITIAL_REPO_DRAG_STATE) + setSessionArmed(false) + return + } + try { + session.handleEl.releasePointerCapture(session.pointerId) + } catch { + // capture may already be released (pointercancel, element unmounted) + } + if (session.promoted) { + const handleEl = session.handleEl + const swallow = (e: MouseEvent): void => { + const target = e.target as Node | null + if (target && handleEl.contains(target)) { + e.stopPropagation() + e.preventDefault() + } + window.removeEventListener('click', swallow, true) + } + window.addEventListener('click', swallow, true) + setTimeout(() => window.removeEventListener('click', swallow, true), 0) + } + const sidebarDropIndex = + commit && session.promoted && latestDropIndexRef.current !== null + ? latestDropIndexRef.current + : null + dragSessionRef.current = null + setState(INITIAL_REPO_DRAG_STATE) + setSessionArmed(false) + if (sidebarDropIndex === null) { + return + } + + commitProjectHeaderDragDrop({ + session, + sidebarDropIndex, + orderedRepoIds: orderedIdsRef.current, + repoById: repoByIdRef.current, + usesProjectGroupOrdering: usesProjectGroupOrderingRef.current, + onCommitRepoOrder: onCommitRepoOrderRef.current, + onCommitProjectGroupOrder: onCommitProjectGroupOrderRef.current + }) + }, + [cancelAutoscroll] + ) + + const runAutoscrollFrame = useCallback( + (frameTime: number) => { + autoscrollFrameIdRef.current = null + const session = dragSessionRef.current + const container = getContainerRef.current() + if (!session?.promoted || !container) { + cancelAutoscroll() + return + } + + const previousFrameTime = autoscrollLastFrameTimeRef.current ?? frameTime + autoscrollLastFrameTimeRef.current = frameTime + const autoscroll = getWorktreeSidebarDragAutoscroll({ + point: { clientX: 0, clientY: session.latestPointerY }, + containerRect: container.getBoundingClientRect(), + scrollTop: container.scrollTop, + scrollHeight: container.scrollHeight, + clientHeight: container.clientHeight, + elapsedMs: frameTime - previousFrameTime + }) + if (autoscroll) { + container.scrollTop = autoscroll.scrollTop + refreshHeaderRects() + } + + const drop = computeDrop(session.latestPointerY) + if (drop) { + setState((prev) => + prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY + ? prev + : { draggingRepoId: session.repoId, ...drop } + ) + } + + autoscrollFrameIdRef.current = window.requestAnimationFrame(runAutoscrollFrame) + }, + [cancelAutoscroll, computeDrop, refreshHeaderRects] + ) + + const ensureAutoscroll = useCallback(() => { + if (autoscrollFrameIdRef.current !== null) { + return + } + autoscrollLastFrameTimeRef.current = null + autoscrollFrameIdRef.current = window.requestAnimationFrame(runAutoscrollFrame) + }, [runAutoscrollFrame]) + useEffect(() => { if (!sessionArmed) { return @@ -219,24 +198,30 @@ export function useRepoHeaderDrag({ if (!session || e.pointerId !== session.pointerId) { return } + session.latestPointerY = e.clientY if (!session.promoted) { const dx = e.clientX - session.startX const dy = e.clientY - session.startY - if (dx * dx + dy * dy < DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX) { + if ( + dx * dx + dy * dy < + PROJECT_HEADER_DRAG_THRESHOLD_PX * PROJECT_HEADER_DRAG_THRESHOLD_PX + ) { return } session.promoted = true + refreshHeaderRects() setState({ draggingRepoId: session.repoId, dropIndex: null, dropIndicatorY: null }) } + refreshHeaderRects() const drop = computeDrop(e.clientY) - if (!drop) { - return + if (drop) { + setState((prev) => + prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY + ? prev + : { draggingRepoId: session.repoId, ...drop } + ) } - setState((prev) => - prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY - ? prev - : { draggingRepoId: session.repoId, ...drop } - ) + ensureAutoscroll() } const onPointerUp = (e: PointerEvent): void => { const session = dragSessionRef.current @@ -270,13 +255,10 @@ export function useRepoHeaderDrag({ window.removeEventListener('pointercancel', onPointerCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('blur', onBlur) + cancelAutoscroll() } - }, [sessionArmed, computeDrop, endDrag]) + }, [cancelAutoscroll, computeDrop, endDrag, ensureAutoscroll, refreshHeaderRects, sessionArmed]) - // From pointerdown onward (armed session, before/after promotion) force a - // grabbing cursor and disable text selection across the whole window so - // the user gets immediate feedback that the press registered, even before - // they've moved past DRAG_THRESHOLD_PX. useEffect(() => { if (!sessionArmed) { return @@ -294,59 +276,17 @@ export function useRepoHeaderDrag({ const onHandlePointerDown = useCallback( (event: React.PointerEvent, repoId: string) => { - // Only react to primary button. Ignore right/middle clicks. - if (event.button !== 0) { - return - } - // Don't intercept presses from nested action surfaces; Radix triggers - // and disabled wrappers are not always plain button descendants. - if (isRepoHeaderActionTarget(event.target, event.currentTarget)) { - return - } - const container = getContainerRef.current() - if (!container) { - return - } - // Snapshot every repo header's position in scrollContainer-local space. - // Using a snapshot (vs reading the DOM each pointermove) means the drop - // computation does not depend on those rows still being mounted — - // critical because react-virtual will unmount them as the user scrolls. - const containerRect = container.getBoundingClientRect() - const headerEls = container.querySelectorAll('[data-repo-header-id]') - const headerRects: HeaderRect[] = [] - headerEls.forEach((el) => { - const id = el.getAttribute('data-repo-header-id') - if (!id) { - return - } - const rect = el.getBoundingClientRect() - headerRects.push({ - repoId: id, - top: rect.top - containerRect.top + container.scrollTop, - bottom: rect.bottom - containerRect.top + container.scrollTop - }) - }) - headerRects.sort((a, b) => a.top - b.top) - - const handleEl = event.currentTarget - try { - handleEl.setPointerCapture(event.pointerId) - } catch { - // setPointerCapture can throw if the element is detached; the global - // pointer listeners still fire, so dragging keeps working. - } - dragSessionRef.current = { + const session = createProjectHeaderDragSession({ + event, repoId, - pointerId: event.pointerId, - headerRects, - handleEl, - startX: event.clientX, - startY: event.clientY, - promoted: false + repoById: repoByIdRef.current, + sidebarRepoHeaderIdsByBucket: sidebarRepoHeaderIdsByBucketRef.current, + getScrollContainer: getContainerRef.current + }) + if (!session) { + return } - // Don't show drag UI yet. Wait for movement past DRAG_THRESHOLD_PX so a - // simple click on the header still toggles collapse via the surrounding - // onClick handler. + dragSessionRef.current = session setSessionArmed(true) }, [] @@ -354,3 +294,5 @@ export function useRepoHeaderDrag({ return { state, onHandlePointerDown } } + +export { isRepoHeaderActionTarget } from './project-header-drag-contract' diff --git a/src/renderer/src/components/sidebar/project-header-drop.test.ts b/src/renderer/src/components/sidebar/project-header-drop.test.ts new file mode 100644 index 00000000000..1d5901235c7 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-header-drop.test.ts @@ -0,0 +1,191 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' + +import { + applyAllRepoInsertAt, + computeProjectHeaderDropPreview, + getProjectGroupOrderForSidebarDrop, + getProjectHeaderDragBucketKey, + getSidebarOrderedRepoHeaderIdsByBucket, + mapSidebarProjectHeaderDropIndexToSiblingInsertIndex, + mapSidebarRepoDropIndexToAllRepoInsertAt +} from './project-header-drop' +import type { Row } from './worktree-list-groups' +import type { Repo } from '../../../../shared/types' + +describe('getProjectHeaderDragBucketKey', () => { + it('uses ungrouped for repos without a project group', () => { + expect(getProjectHeaderDragBucketKey({ projectGroupId: undefined })).toBe('ungrouped') + }) + + it('scopes grouped repos to their project group bucket', () => { + expect(getProjectHeaderDragBucketKey({ projectGroupId: 'group-a' })).toBe('group:group-a') + }) +}) + +describe('getSidebarOrderedRepoHeaderIdsByBucket', () => { + it('groups repo headers by project group membership', () => { + const rows = [ + { + type: 'header', + key: 'repo:a', + label: 'A', + count: 1, + tone: 'tone', + repo: { id: 'a', projectGroupId: 'group-a' } + }, + { + type: 'header', + key: 'repo:b', + label: 'B', + count: 1, + tone: 'tone', + repo: { id: 'b' } + } + ] as Row[] + + expect(getSidebarOrderedRepoHeaderIdsByBucket(rows)).toEqual( + new Map([ + ['group:group-a', ['a']], + ['ungrouped', ['b']] + ]) + ) + }) +}) + +describe('mapSidebarRepoDropIndexToAllRepoInsertAt', () => { + const sidebar = ['a', 'b', 'c'] + + it('maps sidebar start drops onto the first visible repo in the full list', () => { + expect(mapSidebarRepoDropIndexToAllRepoInsertAt(0, sidebar, ['hidden', 'a', 'b', 'c'])).toBe(1) + }) + + it('maps sidebar end drops onto the slot after the last visible repo', () => { + expect(mapSidebarRepoDropIndexToAllRepoInsertAt(3, sidebar, ['a', 'hidden', 'b', 'c'])).toBe(4) + }) + + it('maps middle sidebar drops onto the target repo id in the full list', () => { + expect(mapSidebarRepoDropIndexToAllRepoInsertAt(2, sidebar, ['a', 'hidden', 'b', 'c'])).toBe(3) + }) +}) + +describe('mapSidebarProjectHeaderDropIndexToSiblingInsertIndex', () => { + it('keeps upward drops at the same target index after removing the source', () => { + expect( + mapSidebarProjectHeaderDropIndexToSiblingInsertIndex({ + sidebarDropIndex: 0, + sourceIndex: 2, + siblingCount: 2 + }) + ).toBe(0) + }) + + it('shifts downward drops because the source header is removed first', () => { + expect( + mapSidebarProjectHeaderDropIndexToSiblingInsertIndex({ + sidebarDropIndex: 3, + sourceIndex: 0, + siblingCount: 2 + }) + ).toBe(2) + }) + + it('maps a drop immediately after the source back to the original slot', () => { + expect( + mapSidebarProjectHeaderDropIndexToSiblingInsertIndex({ + sidebarDropIndex: 2, + sourceIndex: 1, + siblingCount: 2 + }) + ).toBe(1) + }) +}) + +describe('computeProjectHeaderDropPreview', () => { + it('uses row-model header indices instead of mounted subset order', () => { + const preview = computeProjectHeaderDropPreview({ + pointerY: 105, + containerTop: 0, + scrollTop: 0, + sidebarRepoHeaderIds: ['a', 'b', 'c', 'd', 'e'], + rects: [ + { repoId: 'b', bucketKey: 'ungrouped', headerIndex: 1, top: 100, bottom: 128 }, + { repoId: 'c', bucketKey: 'ungrouped', headerIndex: 2, top: 200, bottom: 228 }, + { repoId: 'd', bucketKey: 'ungrouped', headerIndex: 3, top: 300, bottom: 328 } + ] + }) + + expect(preview).toEqual({ dropIndex: 1, dropIndicatorY: 96 }) + }) + + it('supports boundary drops at the end of the full sidebar list', () => { + const preview = computeProjectHeaderDropPreview({ + pointerY: 360, + containerTop: 0, + scrollTop: 0, + sidebarRepoHeaderIds: ['a', 'b', 'c'], + rects: [{ repoId: 'c', bucketKey: 'ungrouped', headerIndex: 2, top: 300, bottom: 328 }] + }) + + expect(preview).toEqual({ dropIndex: 3, dropIndicatorY: 331 }) + }) +}) + +describe('applyAllRepoInsertAt', () => { + it('reorders repos using a full-list insertion index', () => { + expect(applyAllRepoInsertAt(['hidden', 'a', 'b', 'c'], 'c', 1)).toEqual([ + 'hidden', + 'c', + 'a', + 'b' + ]) + }) + + it('returns null for no-op reorders', () => { + expect(applyAllRepoInsertAt(['a', 'b', 'c'], 'b', 2)).toBeNull() + }) +}) + +describe('getProjectGroupOrderForSidebarDrop', () => { + const repo = (id: string, projectGroupOrder?: number): Repo => + ({ + id, + path: `/${id}`, + displayName: id, + badgeColor: '#000', + addedAt: 0, + projectGroupOrder + }) as Repo + + it('uses a midpoint between sibling orders when there is room', () => { + expect( + getProjectGroupOrderForSidebarDrop({ + siblings: [repo('a', 0), repo('b', 10)], + dropIndex: 1 + }) + ).toBe(5) + }) + + it('uses manual repo rank as the fallback for missing sibling orders', () => { + expect( + getProjectGroupOrderForSidebarDrop({ + siblings: [repo('a'), repo('c')], + dropIndex: 1, + repoOrderRankById: new Map([ + ['a', 0], + ['b', 1], + ['c', 2] + ]) + }) + ).toBe(1000) + }) + + it('keeps a deterministic finite anchor when sibling orders collide', () => { + expect( + getProjectGroupOrderForSidebarDrop({ + siblings: [repo('a', 0), repo('b', 0)], + dropIndex: 1 + }) + ).toBe(1) + }) +}) diff --git a/src/renderer/src/components/sidebar/project-header-drop.ts b/src/renderer/src/components/sidebar/project-header-drop.ts new file mode 100644 index 00000000000..eb8bce34723 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-header-drop.ts @@ -0,0 +1,250 @@ +import { getWorktreeSidebarBoundaryDrop } from './worktree-sidebar-drag-autoscroll' +import type { Row } from './worktree-list-groups' +import type { Repo } from '../../../../shared/types' + +export type ProjectHeaderDragBucketKey = string + +export type ProjectHeaderDragRect = { + repoId: string + bucketKey: ProjectHeaderDragBucketKey + // Index among sibling repo headers in the drag bucket (from the row model), + // not the mounted subset. Virtualized rows unmount off-screen headers, so + // loop index over mounted rects would map drops to the wrong persisted order. + headerIndex: number + top: number + bottom: number +} + +export type ProjectHeaderDropPreview = { + dropIndex: number + dropIndicatorY: number +} + +const INDICATOR_GAP_PX = 4 + +export function getProjectHeaderDragBucketKey( + repo: Pick +): ProjectHeaderDragBucketKey { + return repo.projectGroupId ? `group:${repo.projectGroupId}` : 'ungrouped' +} + +export function getSidebarOrderedRepoHeaderIds(rows: readonly Row[]): string[] { + const ids: string[] = [] + for (const row of rows) { + if (row.type === 'header' && row.repo) { + ids.push(row.repo.id) + } + } + return ids +} + +export function getSidebarOrderedRepoHeaderIdsByBucket( + rows: readonly Row[] +): Map { + const buckets = new Map() + for (const row of rows) { + if (row.type !== 'header' || !row.repo) { + continue + } + const bucketKey = getProjectHeaderDragBucketKey(row.repo) + const list = buckets.get(bucketKey) ?? [] + list.push(row.repo.id) + buckets.set(bucketKey, list) + } + return buckets +} + +export function getProjectGroupOrderForSidebarDrop(args: { + siblings: readonly Repo[] + dropIndex: number + repoOrderRankById?: ReadonlyMap +}): number { + const ordered = args.siblings.slice() + if (ordered.length === 0) { + return 0 + } + const getEffectiveOrder = (repo: Repo | undefined, fallbackIndex: number): number | undefined => { + if (!repo) { + return undefined + } + const order = repo.projectGroupOrder + if (typeof order === 'number' && Number.isFinite(order)) { + return order + } + const repoRank = args.repoOrderRankById?.get(repo.id) + return (repoRank ?? fallbackIndex) * 1000 + } + const before = getEffectiveOrder(ordered[args.dropIndex - 1], args.dropIndex - 1) + const after = getEffectiveOrder(ordered[args.dropIndex], args.dropIndex) + if (before === undefined && after === undefined) { + return 0 + } + if (before === undefined) { + return after !== undefined ? after - 1 : 0 + } + if (after === undefined) { + return before + 1 + } + if (after > before) { + return before + (after - before) / 2 + } + // Why: duplicate legacy ranks leave no numeric slot between neighbors; choose + // a deterministic finite value so the next drag has a persisted anchor. + return before + 1 +} + +export function mapSidebarProjectHeaderDropIndexToSiblingInsertIndex(args: { + sidebarDropIndex: number + sourceIndex: number + siblingCount: number +}): number { + // Why: sidebar drop indices include the dragged header, but group-order ranks + // are computed against the sibling list after that header is removed. + const adjustedDropIndex = + args.sourceIndex >= 0 && args.sidebarDropIndex > args.sourceIndex + ? args.sidebarDropIndex - 1 + : args.sidebarDropIndex + return Math.max(0, Math.min(args.siblingCount, adjustedDropIndex)) +} + +function getVirtualRowStart(virtualRow: HTMLElement | null): number | null { + if (!virtualRow) { + return null + } + const rawStart = virtualRow.getAttribute('data-worktree-virtual-row-start') + if (rawStart === null) { + return null + } + const start = Number(rawStart) + return Number.isFinite(start) ? start : null +} + +export function measureProjectHeaderDragRects( + container: HTMLElement, + bucketKey?: ProjectHeaderDragBucketKey +): ProjectHeaderDragRect[] { + const containerRect = container.getBoundingClientRect() + const rects: ProjectHeaderDragRect[] = [] + container.querySelectorAll('[data-repo-header-id]').forEach((element) => { + const repoId = element.getAttribute('data-repo-header-id') + const elementBucketKey = element.getAttribute('data-repo-header-bucket') + const rawHeaderIndex = element.getAttribute('data-repo-header-index') + const headerIndex = rawHeaderIndex === null ? Number.NaN : Number(rawHeaderIndex) + if (!repoId || !elementBucketKey || !Number.isFinite(headerIndex)) { + return + } + if (bucketKey !== undefined && elementBucketKey !== bucketKey) { + return + } + const rect = element.getBoundingClientRect() + const virtualRow = element.closest('[data-worktree-virtual-row]') + const virtualRowStart = getVirtualRowStart(virtualRow) + const top = + virtualRow && virtualRowStart !== null + ? virtualRowStart + rect.top - virtualRow.getBoundingClientRect().top + : rect.top - containerRect.top + container.scrollTop + rects.push({ + repoId, + bucketKey: elementBucketKey, + headerIndex, + top, + bottom: top + rect.height + }) + }) + rects.sort((left, right) => left.top - right.top) + return rects +} + +export function mapSidebarRepoDropIndexToAllRepoInsertAt( + sidebarDropIndex: number, + sidebarRepoHeaderIds: readonly string[], + allRepoIds: readonly string[] +): number { + if (sidebarRepoHeaderIds.length === 0) { + return 0 + } + if (sidebarDropIndex <= 0) { + return allRepoIds.indexOf(sidebarRepoHeaderIds[0]!) + } + if (sidebarDropIndex >= sidebarRepoHeaderIds.length) { + const lastId = sidebarRepoHeaderIds.at(-1)! + return allRepoIds.indexOf(lastId) + 1 + } + return allRepoIds.indexOf(sidebarRepoHeaderIds[sidebarDropIndex]!) +} + +export function computeProjectHeaderDropPreview(args: { + pointerY: number + containerTop: number + scrollTop: number + rects: readonly ProjectHeaderDragRect[] + sidebarRepoHeaderIds: readonly string[] +}): ProjectHeaderDropPreview | null { + const { rects, sidebarRepoHeaderIds } = args + if (rects.length === 0 || sidebarRepoHeaderIds.length === 0) { + return null + } + + const localY = args.pointerY - args.containerTop + args.scrollTop + const first = rects[0]! + const last = rects.at(-1)! + const boundaryDrop = getWorktreeSidebarBoundaryDrop({ + localY, + firstRect: { + worktreeId: first.repoId, + groupIndex: first.headerIndex, + top: first.top, + bottom: first.bottom + }, + lastRect: { + worktreeId: last.repoId, + groupIndex: last.headerIndex, + top: last.top, + bottom: last.bottom + }, + sourceGroupSize: sidebarRepoHeaderIds.length + }) + if (boundaryDrop.kind === 'outside') { + return null + } + + let dropIndex = last.headerIndex + 1 + let indicatorY = last.bottom + INDICATOR_GAP_PX + if (boundaryDrop.kind === 'drop') { + dropIndex = boundaryDrop.dropIndex + indicatorY = boundaryDrop.indicatorY + } else { + for (const rect of rects) { + const mid = (rect.top + rect.bottom) / 2 + if (localY < mid) { + dropIndex = rect.headerIndex + indicatorY = Math.max(0, rect.top - INDICATOR_GAP_PX) + break + } + } + } + + return { + dropIndex, + dropIndicatorY: Math.max(args.scrollTop, indicatorY) + } +} + +export function applyAllRepoInsertAt( + allRepoIds: readonly string[], + draggedRepoId: string, + insertAt: number +): string[] | null { + const fromIndex = allRepoIds.indexOf(draggedRepoId) + if (fromIndex === -1 || insertAt < 0 || insertAt > allRepoIds.length) { + return null + } + const next = allRepoIds.slice() + next.splice(fromIndex, 1) + const adjustedInsertAt = insertAt > fromIndex ? insertAt - 1 : insertAt + if (adjustedInsertAt === fromIndex) { + return null + } + next.splice(adjustedInsertAt, 0, draggedRepoId) + return next +} diff --git a/src/renderer/src/components/sidebar/project-order-manual-default-notice-visibility.test.ts b/src/renderer/src/components/sidebar/project-order-manual-default-notice-visibility.test.ts new file mode 100644 index 00000000000..ee3a3715d5b --- /dev/null +++ b/src/renderer/src/components/sidebar/project-order-manual-default-notice-visibility.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { resolveProjectOrderManualDefaultNoticeDismissed } from '../../../../shared/project-order-manual-default-notice' +import { shouldShowProjectOrderManualDefaultNotice } from './project-order-manual-default-notice-visibility' + +describe('resolveProjectOrderManualDefaultNoticeDismissed', () => { + it('keeps an explicit dismissal', () => { + expect( + resolveProjectOrderManualDefaultNoticeDismissed({ + rawDismissed: true, + rawProjectOrderBy: undefined, + isExistingProfile: true + }) + ).toBe(true) + }) + + it('hides the notice for brand-new profiles', () => { + expect( + resolveProjectOrderManualDefaultNoticeDismissed({ + rawDismissed: undefined, + rawProjectOrderBy: undefined, + isExistingProfile: false + }) + ).toBe(true) + }) + + it('hides the notice when recent ordering was already explicit', () => { + expect( + resolveProjectOrderManualDefaultNoticeDismissed({ + rawDismissed: undefined, + rawProjectOrderBy: 'recent', + isExistingProfile: true + }) + ).toBe(true) + }) + + it('shows the notice for upgraded profiles without an explicit project order', () => { + expect( + resolveProjectOrderManualDefaultNoticeDismissed({ + rawDismissed: undefined, + rawProjectOrderBy: undefined, + isExistingProfile: true + }) + ).toBe(false) + }) +}) + +describe('shouldShowProjectOrderManualDefaultNotice', () => { + it('shows only when project grouping is active and repos exist', () => { + expect( + shouldShowProjectOrderManualDefaultNotice({ + persistedUIReady: true, + projectOrderManualDefaultNoticeDismissed: false, + groupBy: 'repo', + projectOrderBy: 'manual', + repoCount: 2 + }) + ).toBe(true) + expect( + shouldShowProjectOrderManualDefaultNotice({ + persistedUIReady: true, + projectOrderManualDefaultNoticeDismissed: false, + groupBy: 'none', + projectOrderBy: 'manual', + repoCount: 2 + }) + ).toBe(false) + expect( + shouldShowProjectOrderManualDefaultNotice({ + persistedUIReady: true, + projectOrderManualDefaultNoticeDismissed: false, + groupBy: 'repo', + projectOrderBy: 'manual', + repoCount: 0 + }) + ).toBe(false) + + expect( + shouldShowProjectOrderManualDefaultNotice({ + persistedUIReady: true, + projectOrderManualDefaultNoticeDismissed: false, + groupBy: 'repo', + projectOrderBy: 'recent', + repoCount: 2 + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/sidebar/project-order-manual-default-notice-visibility.ts b/src/renderer/src/components/sidebar/project-order-manual-default-notice-visibility.ts new file mode 100644 index 00000000000..491f436ed5a --- /dev/null +++ b/src/renderer/src/components/sidebar/project-order-manual-default-notice-visibility.ts @@ -0,0 +1,15 @@ +export function shouldShowProjectOrderManualDefaultNotice(args: { + persistedUIReady: boolean + projectOrderManualDefaultNoticeDismissed: boolean + groupBy: 'none' | 'workspace-status' | 'repo' | 'pr-status' + projectOrderBy: 'manual' | 'recent' + repoCount: number +}): boolean { + return ( + args.persistedUIReady && + !args.projectOrderManualDefaultNoticeDismissed && + args.groupBy === 'repo' && + args.projectOrderBy === 'manual' && + args.repoCount > 0 + ) +} diff --git a/src/renderer/src/components/sidebar/sidebar-workspace-options-menu-options.ts b/src/renderer/src/components/sidebar/sidebar-workspace-options-menu-options.ts new file mode 100644 index 00000000000..52c985120db --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-workspace-options-menu-options.ts @@ -0,0 +1,194 @@ +import type { AgentActivityDisplayMode, WorktreeCardProperty } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +export const GROUP_BY_OPTIONS = [ + { + id: 'none', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.c2c7a45cda', 'None') + } + }, + { + id: 'workspace-status', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.e029a2d775', 'Status') + } + }, + { + id: 'pr-status', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.0f9b959b31', 'PR') + } + }, + { + id: 'repo', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.2170d553cf', 'Project') + } + } +] as const + +export const CARD_LAYOUT_OPTIONS = [ + { + id: 'detailed', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.cc17bd443b', 'Detailed') + } + }, + { + id: 'compact', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.25105b28cb', 'Compact') + } + } +] as const + +export const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [ + { + id: 'issue', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.91dfc653e8', + 'GitHub ticket' + ) + } + }, + { + id: 'linear-issue', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.ca4d3c522e', + 'Linear issue' + ) + } + }, + { + id: 'pr', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.b8dcc6f321', + 'PR/MR link' + ) + } + }, + { + id: 'comment', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.26c71e536c', 'Notes') + } + }, + { + id: 'ports', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.b64d8bcca0', 'Ports') + } + }, + // Why: toggles the inline "Agent activity" list rendered below each + // workspace card body (see WorktreeCard -> WorktreeCardAgents). Off hides + // the list; there is no alternate surface. + { + id: 'inline-agents', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.d7084e8bc8', + 'Agent activity' + ) + } + } +] + +export const AGENT_ACTIVITY_DISPLAY_OPTIONS: { id: AgentActivityDisplayMode; label: string }[] = [ + { + id: 'compact', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.25105b28cb', 'Compact') + } + }, + { + id: 'full', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.2a81e07366', + 'Full list' + ) + } + } +] + +export const SORT_OPTIONS = [ + { + id: 'name', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.3728165cdd', 'Name') + }, + description: null + }, + { + id: 'smart', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.503462f2b4', + 'Agent Activity' + ) + }, + get description() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.b759bb87ee', + 'Agents that need attention, then most recent activity.' + ) + } + }, + { + id: 'recent', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.b451c8b162', 'Recent') + }, + description: null + }, + { + id: 'repo', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.2170d553cf', 'Project') + }, + description: null + }, + { + id: 'manual', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.7b316bdd51', 'Manual') + }, + get description() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.7153d07485', + 'Drag workspaces to arrange them within each group.' + ) + } + } +] as const + +export const PROJECT_ORDER_OPTIONS = [ + { + id: 'manual', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.7b316bdd51', 'Manual') + }, + get description() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.6664282a7b', + 'Drag projects to arrange them' + ) + } + }, + { + id: 'recent', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.b451c8b162', 'Recent') + }, + get description() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.af9249c505', + 'Most recent workspace activity' + ) + } + } +] as const diff --git a/src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts b/src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts index 3508458daa4..941362ca5e8 100644 --- a/src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts +++ b/src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts @@ -63,16 +63,14 @@ function useHarness(overrides: Partial { @@ -93,16 +91,15 @@ describe('useCreateProjectDefaults', () => { mocks.getDefaultCreateProjectParent.mockResolvedValue('/Users/alice/orca/projects') }) - it('auto-fills the local default parent and defaults to git when available', async () => { + it('auto-fills the local default parent and records Git availability', async () => { mocks.isGitAvailable.mockResolvedValue(true) - const { setCreateParent, setCreateKind } = useHarness() + const { setCreateParent } = useHarness() await flushAsync() expect(setCreateParent).toHaveBeenCalledWith('/Users/alice/orca/projects') expect(mocks.stateValues[DEFAULT_PARENT_STATE]).toBe('/Users/alice/orca/projects') expect(mocks.stateValues[GIT_AVAILABILITY_STATE]).toBe('available') - expect(setCreateKind).toHaveBeenCalledWith('git') expect(mocks.getDefaultCreateProjectParent).toHaveBeenCalled() expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() }) @@ -128,24 +125,22 @@ describe('useCreateProjectDefaults', () => { expect(mocks.stateValues[DEFAULT_PARENT_STATE]).toBe('/Users/alice/orca/projects') }) - it('defaults to folder with a visible fallback when Git is unavailable', async () => { + it('records unavailable Git without changing project kind', async () => { mocks.isGitAvailable.mockResolvedValue(false) - const { setCreateKind } = useHarness() + useHarness() await flushAsync() expect(mocks.stateValues[GIT_AVAILABILITY_STATE]).toBe('unavailable') - expect(setCreateKind).toHaveBeenCalledWith('folder') }) - it('reports unknown availability and keeps the kind when the Git probe fails', async () => { + it('reports unknown availability when the Git probe fails', async () => { mocks.isGitAvailable.mockRejectedValue(new Error('probe failed')) - const { setCreateKind } = useHarness() + useHarness() await flushAsync() expect(mocks.stateValues[GIT_AVAILABILITY_STATE]).toBe('unknown') - expect(setCreateKind).not.toHaveBeenCalled() }) it('does not overwrite a parent the user already chose', async () => { @@ -161,7 +156,7 @@ describe('useCreateProjectDefaults', () => { mocks.browseRuntimeServerDirectory.mockResolvedValue({ resolvedPath: '/home/alice' }) mocks.callRuntimeRpc.mockResolvedValue({ available: true }) - const { setCreateParent, setCreateKind } = useHarness({ activeRuntimeEnvironmentId: 'env-1' }) + const { setCreateParent } = useHarness({ activeRuntimeEnvironmentId: 'env-1' }) await flushAsync() expect(mocks.browseRuntimeServerDirectory).toHaveBeenCalledWith('env-1', '~') @@ -176,7 +171,6 @@ describe('useCreateProjectDefaults', () => { { timeoutMs: 3000 } ) expect(mocks.isGitAvailable).not.toHaveBeenCalled() - expect(setCreateKind).toHaveBeenCalledWith('git') }) it('replaces an untouched local default when switching to a runtime target', async () => { @@ -249,11 +243,10 @@ describe('useCreateProjectDefaults', () => { }) it('does nothing outside the create step', async () => { - const { setCreateParent, setCreateKind } = useHarness({ step: 'add' }) + const { setCreateParent } = useHarness({ step: 'add' }) await flushAsync() expect(setCreateParent).not.toHaveBeenCalled() - expect(setCreateKind).not.toHaveBeenCalled() expect(mocks.isGitAvailable).not.toHaveBeenCalled() expect(mocks.browseRuntimeServerDirectory).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/sidebar/useCreateProjectDefaults.ts b/src/renderer/src/components/sidebar/useCreateProjectDefaults.ts index 9c94cba1629..f93e61ee3d1 100644 --- a/src/renderer/src/components/sidebar/useCreateProjectDefaults.ts +++ b/src/renderer/src/components/sidebar/useCreateProjectDefaults.ts @@ -5,11 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { browseRuntimeServerDirectory } from '@/runtime/runtime-server-directory-browser' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import type { AddRepoDialogStep } from './add-repo-dialog-types' -import { - getDefaultCreateProjectParent, - type GitAvailability, - type RepoKind -} from './create-project-defaults' +import { getDefaultCreateProjectParent, type GitAvailability } from './create-project-defaults' const LOCAL_GIT_AVAILABILITY_TIMEOUT_MS = 1500 const RUNTIME_GIT_AVAILABILITY_TIMEOUT_MS = 3000 @@ -51,14 +47,12 @@ export function useCreateProjectDefaults({ step, activeRuntimeEnvironmentId, createParent, - setCreateParent, - setCreateKind + setCreateParent }: { step: AddRepoDialogStep activeRuntimeEnvironmentId: string | null | undefined createParent: string setCreateParent: (value: string) => void - setCreateKind: (kind: RepoKind) => void }): { createDefaultParent: string createGitAvailability: GitAvailability @@ -66,7 +60,6 @@ export function useCreateProjectDefaults({ createParentDefaultPending: boolean resetCreateDefaultState: () => void markCreateParentTouched: (value?: string) => void - markCreateKindTouched: () => void } { const [createDefaultParent, setCreateDefaultParent] = useState('') const [createGitAvailability, setCreateGitAvailability] = useState('unknown') @@ -76,7 +69,6 @@ export function useCreateProjectDefaults({ const autoFilledCreateParentRef = useRef(null) const createParentProvenanceRef = useRef(null) const createParentTouchedRef = useRef(false) - const createKindTouchedRef = useRef(false) const createParentDefaultGenRef = useRef(0) const createGitProbeGenRef = useRef(0) const activeCreateParentRuntimeEnvironmentId = activeRuntimeEnvironmentId?.trim() || null @@ -96,7 +88,6 @@ export function useCreateProjectDefaults({ autoFilledCreateParentRef.current = null createParentProvenanceRef.current = null createParentTouchedRef.current = false - createKindTouchedRef.current = false setCreateDefaultParent('') setCreateGitAvailability('unknown') setCreateRuntimeParentStatus('idle') @@ -114,9 +105,6 @@ export function useCreateProjectDefaults({ }, [activeCreateParentRuntimeEnvironmentId, createParent] ) - const markCreateKindTouched = useCallback(() => { - createKindTouchedRef.current = true - }, []) const createParentDefaultPending = step === 'create' && @@ -282,10 +270,6 @@ export function useCreateProjectDefaults({ return } setCreateGitAvailability(available ? 'available' : 'unavailable') - if (createKindTouchedRef.current) { - return - } - setCreateKind(available ? 'git' : 'folder') }) .catch(() => { if (gen !== createGitProbeGenRef.current) { @@ -293,7 +277,7 @@ export function useCreateProjectDefaults({ } setCreateGitAvailability('unknown') }) - }, [activeRuntimeEnvironmentId, setCreateKind, step]) + }, [activeRuntimeEnvironmentId, step]) return { createDefaultParent, @@ -301,7 +285,6 @@ export function useCreateProjectDefaults({ createRuntimeParentStatus, createParentDefaultPending: createParentPending, resetCreateDefaultState, - markCreateParentTouched, - markCreateKindTouched + markCreateParentTouched } } diff --git a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts index 4afc7041c91..78a26a3848c 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts @@ -89,7 +89,7 @@ describe('useCreateRepo default-checkout handoff', () => { vi.clearAllMocks() mocks.stateIndex = 0 mocks.stateSetters = [] - mocks.stateValues = ['created', '/projects', 'git', null, false] + mocks.stateValues = ['created', '/projects', null, false] mocks.storeState.repos = [] mocks.storeState.worktreesByRepo = {} mocks.storeState.settings.activeRuntimeEnvironmentId = null @@ -163,11 +163,11 @@ describe('useCreateRepo default-checkout handoff', () => { ) }) - it('marks onboarding folder progress when a created folder project opens', async () => { + it('uses the folder completion path if IPC returns a folder project', async () => { const repo = makeRepo({ kind: 'folder' }) const worktree = { id: `${repo.id}::/projects/created` } const closeModal = vi.fn() - mocks.stateValues = ['created', '/projects', 'folder', null, false] + mocks.stateValues = ['created', '/projects', null, false] mocks.createRepo.mockResolvedValue({ repo }) mocks.fetchWorktrees.mockImplementation(async (repoId: string) => { mocks.storeState.worktreesByRepo = { [repoId]: [worktree] } @@ -181,7 +181,7 @@ describe('useCreateRepo default-checkout handoff', () => { expect(mocks.createRepo).toHaveBeenCalledWith({ parentPath: '/projects', name: 'created', - kind: 'folder' + kind: 'git' }) expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id) expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith(worktree.id, { diff --git a/src/renderer/src/components/sidebar/useCreateRepo.ts b/src/renderer/src/components/sidebar/useCreateRepo.ts index bb12da03eb7..9d9aa95e418 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.ts @@ -10,7 +10,6 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl import { isGitRepoKind } from '../../../../shared/repo-kind' import type { Repo } from '../../../../shared/types' import { translate } from '@/i18n/i18n' -import type { RepoKind } from './create-project-defaults' export function useCreateRepo( fetchWorktrees: ( @@ -22,7 +21,6 @@ export function useCreateRepo( ) { const [createName, setCreateName] = useState('') const [createParent, setCreateParent] = useState('') - const [createKind, setCreateKind] = useState('git') const [createError, setCreateError] = useState(null) const [isCreating, setIsCreating] = useState(false) const mountedRef = useMountedRef() @@ -36,7 +34,6 @@ export function useCreateRepo( createGenRef.current++ setCreateName('') setCreateParent('') - setCreateKind('git') setCreateError(null) setIsCreating(false) }, []) @@ -82,14 +79,14 @@ export function useCreateRepo( { parentPath, name, - kind: createKind + kind: 'git' }, { timeoutMs: 60_000 } ) : await window.api.repos.create({ parentPath, name, - kind: createKind + kind: 'git' }) // Why: if the user closed the dialog or clicked Back mid-create, // createGenRef was bumped by resetCreateState. Ignore stale results. @@ -170,17 +167,15 @@ export function useCreateRepo( setIsCreating(false) } } - }, [createName, createParent, createKind, fetchWorktrees, mountedRef, closeModal, onGitRepoReady]) + }, [createName, createParent, fetchWorktrees, mountedRef, closeModal, onGitRepoReady]) return { createName, createParent, - createKind, createError, isCreating, setCreateName, setCreateParent, - setCreateKind, setCreateError, resetCreateState, handlePickParent, diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index e3abac92b3b..6726a397a2d 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -1185,6 +1185,77 @@ describe('project groups', () => { ]) }) + it('keeps missing projectGroupOrder siblings in manual fallback slots', () => { + const group: ProjectGroup = { + id: 'group-1', + name: 'Platform', + parentPath: '/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const repoA: Repo = { + ...repo, + id: 'repo-a', + displayName: 'alpha', + projectGroupId: group.id + } + const repoB: Repo = { + ...repo, + id: 'repo-b', + displayName: 'beta', + projectGroupId: group.id, + projectGroupOrder: 1000 + } + const repoC: Repo = { + ...repo, + id: 'repo-c', + displayName: 'gamma', + projectGroupId: group.id + } + const repoMap = new Map([ + [repoA.id, repoA], + [repoB.id, repoB], + [repoC.id, repoC] + ]) + const repoOrder = new Map([ + [repoA.id, 0], + [repoB.id, 1], + [repoC.id, 2] + ]) + + const rows = buildRows( + 'repo', + [ + { ...worktree, id: 'wt-a', repoId: repoA.id }, + { ...worktree, id: 'wt-b', repoId: repoB.id }, + { ...worktree, id: 'wt-c', repoId: repoC.id } + ], + repoMap, + null, + new Set(), + repoOrder, + undefined, + 'manual', + undefined, + undefined, + false, + undefined, + [group] + ) + + expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ + 'project-group:group-1', + 'repo:repo-a', + 'repo:repo-b', + 'repo:repo-c' + ]) + }) + it('orders repos inside a Project Group by activity in recent mode, keeping tabOrder', () => { const groupA: ProjectGroup = { id: 'group-a', diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 9312f06859a..43a7e5397f4 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -128,22 +128,30 @@ export const PR_GROUP_META: Record< } > = { done: { - label: translate('auto.components.sidebar.worktree.list.groups.5076efc3d2', 'Done'), + get label() { + return translate('auto.components.sidebar.worktree.list.groups.5076efc3d2', 'Done') + }, icon: ConductorDoneIcon, tone: 'text-[#c7a594]' }, 'in-review': { - label: translate('auto.components.sidebar.worktree.list.groups.6798dc7c94', 'In review'), + get label() { + return translate('auto.components.sidebar.worktree.list.groups.6798dc7c94', 'In review') + }, icon: ConductorReviewIcon, tone: 'text-[#16a34a]' }, 'in-progress': { - label: translate('auto.components.sidebar.worktree.list.groups.7c2f009786', 'In progress'), + get label() { + return translate('auto.components.sidebar.worktree.list.groups.7c2f009786', 'In progress') + }, icon: ConductorProgressIcon, tone: 'text-[#d4a300]' }, closed: { - label: translate('auto.components.sidebar.worktree.list.groups.682ed5d551', 'Closed'), + get label() { + return translate('auto.components.sidebar.worktree.list.groups.682ed5d551', 'Closed') + }, icon: CircleX, tone: 'text-zinc-600 dark:text-zinc-300' } @@ -161,7 +169,9 @@ export function getProjectGroupHeaderKey(groupId: string | null): string { export const PINNED_GROUP_KEY = 'pinned' export const PINNED_GROUP_META = { - label: translate('auto.components.sidebar.worktree.list.groups.4aeefc5996', 'Pinned'), + get label() { + return translate('auto.components.sidebar.worktree.list.groups.4aeefc5996', 'Pinned') + }, tone: 'text-foreground', icon: Pin } as const @@ -169,7 +179,9 @@ export const PINNED_GROUP_META = { export const ALL_GROUP_KEY = 'all' export const ALL_GROUP_META = { - label: translate('auto.components.sidebar.worktree.list.groups.0ed04075b8', 'All'), + get label() { + return translate('auto.components.sidebar.worktree.list.groups.0ed04075b8', 'All') + }, tone: 'text-foreground', icon: List } as const @@ -796,20 +808,27 @@ export function buildRows( compareRecentRank(recentRankForEntry(left), recentRankForEntry(right)) ) } - // Manual: within a Project Group, projects order by their per-group rank - // (projectGroupOrder), not the global repoOrder. + const manualFallbackRank = new Map( + entries.map((entry) => [entry[0], manualRankForEntry(entry, repoOrder)]) + ) + // Why: legacy grouped projects may not have projectGroupOrder yet. Falling + // back to manual rank keeps one-project drag writes able to land between + // old siblings instead of any finite order jumping ahead of all missing ones. return [...entries].sort((left, right) => { const leftOrder = left[1].repo?.projectGroupOrder const rightOrder = right[1].repo?.projectGroupOrder const leftRank = typeof leftOrder === 'number' && Number.isFinite(leftOrder) ? leftOrder - : Number.POSITIVE_INFINITY + : (manualFallbackRank.get(left[0]) ?? Number.POSITIVE_INFINITY) * 1000 const rightRank = typeof rightOrder === 'number' && Number.isFinite(rightOrder) ? rightOrder - : Number.POSITIVE_INFINITY - return leftRank - rightRank + : (manualFallbackRank.get(right[0]) ?? Number.POSITIVE_INFINITY) * 1000 + if (leftRank !== rightRank) { + return leftRank - rightRank + } + return left[1].label.localeCompare(right[1].label) }) } diff --git a/src/renderer/src/components/stats/ClaudeUsageDetails.tsx b/src/renderer/src/components/stats/ClaudeUsageDetails.tsx new file mode 100644 index 00000000000..fc16182b759 --- /dev/null +++ b/src/renderer/src/components/stats/ClaudeUsageDetails.tsx @@ -0,0 +1,63 @@ +import type { + ClaudeUsageBreakdownRow, + ClaudeUsageDailyPoint, + ClaudeUsageSessionRow, + ClaudeUsageSummary +} from '../../../../shared/claude-usage-types' +import { ClaudeUsageDailyChart } from './ClaudeUsageDailyChart' +import { ClaudeUsageRecentSessionsTable } from './ClaudeUsageRecentSessionsTable' +import { UsageBreakdownSection } from './UsageBreakdownSection' +import { translate } from '@/i18n/i18n' + +type ClaudeUsageDetailsProps = { + daily: ClaudeUsageDailyPoint[] + modelBreakdown: ClaudeUsageBreakdownRow[] + projectBreakdown: ClaudeUsageBreakdownRow[] + recentSessions: ClaudeUsageSessionRow[] + summary: ClaudeUsageSummary | null | undefined +} + +export function ClaudeUsageDetails({ + daily, + modelBreakdown, + projectBreakdown, + recentSessions, + summary +}: ClaudeUsageDetailsProps): React.JSX.Element { + return ( + <> + + +
+ ({ + key: row.key, + label: row.label, + tokens: row.inputTokens + row.outputTokens, + sessions: row.sessions, + eventsOrTurns: row.turns + }))} + eventsOrTurns="turns" + /> + ({ + key: row.key, + label: row.label, + tokens: row.inputTokens + row.outputTokens, + sessions: row.sessions, + eventsOrTurns: row.turns + }))} + eventsOrTurns="turns" + /> +
+ + + + ) +} diff --git a/src/renderer/src/components/stats/ClaudeUsagePane.tsx b/src/renderer/src/components/stats/ClaudeUsagePane.tsx index ec76a95600f..946104bbadd 100644 --- a/src/renderer/src/components/stats/ClaudeUsagePane.tsx +++ b/src/renderer/src/components/stats/ClaudeUsagePane.tsx @@ -10,9 +10,8 @@ import { } from 'lucide-react' import type { ClaudeUsageRange, ClaudeUsageScope } from '../../../../shared/claude-usage-types' import { useAppStore } from '../../store' -import { ClaudeUsageDailyChart } from './ClaudeUsageDailyChart' +import { ClaudeUsageDetails } from './ClaudeUsageDetails' import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState' -import { ClaudeUsageRecentSessionsTable } from './ClaudeUsageRecentSessionsTable' import { ShareUsageButton } from './ShareUsageButton' import { StatCard } from './StatCard' import { UsagePaneFilterControls } from './UsagePaneFilterControls' @@ -24,18 +23,30 @@ const RANGE_OPTIONS: ClaudeUsageRange[] = ['7d', '30d', '90d', 'all'] const SCOPE_OPTIONS: { value: ClaudeUsageScope; label: string }[] = [ { value: 'orca', - label: translate('auto.components.stats.ClaudeUsagePane.4f8368c272', 'Orca worktrees only') + get label() { + return translate('auto.components.stats.ClaudeUsagePane.4f8368c272', 'Orca worktrees only') + } }, { value: 'all', - label: translate('auto.components.stats.ClaudeUsagePane.5ce4842c2c', 'All local Claude usage') + get label() { + return translate('auto.components.stats.ClaudeUsagePane.5ce4842c2c', 'All local Claude usage') + } } ] const RANGE_LABELS: Record = { - '7d': 'Last 7 days', - '30d': 'Last 30 days', - '90d': 'Last 90 days', - all: 'All time' + get '7d'() { + return translate('auto.components.stats.ClaudeUsagePane.rangeLast7Days', 'Last 7 days') + }, + get '30d'() { + return translate('auto.components.stats.ClaudeUsagePane.rangeLast30Days', 'Last 30 days') + }, + get '90d'() { + return translate('auto.components.stats.ClaudeUsagePane.rangeLast90Days', 'Last 90 days') + }, + get all() { + return translate('auto.components.stats.ClaudeUsagePane.rangeAllTime', 'All time') + } } export function ClaudeUsagePane(): React.JSX.Element { @@ -222,73 +233,13 @@ export function ClaudeUsagePane(): React.JSX.Element { )}

- - -
-
-
-

- {translate('auto.components.stats.ClaudeUsagePane.0f394c24e3', 'By model')} -

-

- {translate('auto.components.stats.ClaudeUsagePane.c3fdbc5474', 'Top model:')}{' '} - {summary?.topModel ?? - translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')} -

-
-
- {modelBreakdown.slice(0, 5).map((row) => ( -
-
- {row.label} - - {formatTokens(row.inputTokens + row.outputTokens)} - -
-
- {row.sessions}{' '} - {translate('auto.components.stats.ClaudeUsagePane.02a046792e', 'sessions •')}{' '} - {row.turns}{' '} - {translate('auto.components.stats.ClaudeUsagePane.32176e1d44', 'turns')} -
-
- ))} -
-
- -
-
-

- {translate('auto.components.stats.ClaudeUsagePane.7dc9e5613b', 'By project')} -

-

- {translate('auto.components.stats.ClaudeUsagePane.f97435845c', 'Top project:')}{' '} - {summary?.topProject ?? - translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')} -

-
-
- {projectBreakdown.slice(0, 5).map((row) => ( -
-
- {row.label} - - {formatTokens(row.inputTokens + row.outputTokens)} - -
-
- {row.sessions}{' '} - {translate('auto.components.stats.ClaudeUsagePane.02a046792e', 'sessions •')}{' '} - {row.turns}{' '} - {translate('auto.components.stats.ClaudeUsagePane.32176e1d44', 'turns')} -
-
- ))} -
-
-
- - + )}
diff --git a/src/renderer/src/components/stats/CodexUsageDetails.tsx b/src/renderer/src/components/stats/CodexUsageDetails.tsx new file mode 100644 index 00000000000..7e7a46fd17e --- /dev/null +++ b/src/renderer/src/components/stats/CodexUsageDetails.tsx @@ -0,0 +1,64 @@ +import type { + CodexUsageBreakdownRow, + CodexUsageDailyPoint, + CodexUsageSessionRow, + CodexUsageSummary +} from '../../../../shared/codex-usage-types' +import { CodexUsageDailyChart } from './CodexUsageDailyChart' +import { CodexUsageRecentSessionsTable } from './CodexUsageRecentSessionsTable' +import { UsageBreakdownSection } from './UsageBreakdownSection' +import { translate } from '@/i18n/i18n' + +type CodexUsageDetailsProps = { + daily: CodexUsageDailyPoint[] + modelBreakdown: CodexUsageBreakdownRow[] + projectBreakdown: CodexUsageBreakdownRow[] + recentSessions: CodexUsageSessionRow[] + summary: CodexUsageSummary | null | undefined +} + +export function CodexUsageDetails({ + daily, + modelBreakdown, + projectBreakdown, + recentSessions, + summary +}: CodexUsageDetailsProps): React.JSX.Element { + return ( + <> + + +
+ ({ + key: row.key, + label: row.label, + tokens: row.totalTokens, + sessions: row.sessions, + eventsOrTurns: row.events, + hasInferredPricing: row.hasInferredPricing + }))} + eventsOrTurns="events" + /> + ({ + key: row.key, + label: row.label, + tokens: row.totalTokens, + sessions: row.sessions, + eventsOrTurns: row.events + }))} + eventsOrTurns="events" + /> +
+ + + + ) +} diff --git a/src/renderer/src/components/stats/CodexUsagePane.tsx b/src/renderer/src/components/stats/CodexUsagePane.tsx index 31b23ca2ee9..255d9334030 100644 --- a/src/renderer/src/components/stats/CodexUsagePane.tsx +++ b/src/renderer/src/components/stats/CodexUsagePane.tsx @@ -3,8 +3,7 @@ import { Activity, Brain, Coins, DatabaseZap, FolderKanban, Sparkles } from 'luc import type { CodexUsageRange, CodexUsageScope } from '../../../../shared/codex-usage-types' import { useAppStore } from '../../store' import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState' -import { CodexUsageDailyChart } from './CodexUsageDailyChart' -import { CodexUsageRecentSessionsTable } from './CodexUsageRecentSessionsTable' +import { CodexUsageDetails } from './CodexUsageDetails' import { ShareUsageButton } from './ShareUsageButton' import { StatCard } from './StatCard' import { UsagePaneFilterControls } from './UsagePaneFilterControls' @@ -16,18 +15,30 @@ const RANGE_OPTIONS: CodexUsageRange[] = ['7d', '30d', '90d', 'all'] const SCOPE_OPTIONS: { value: CodexUsageScope; label: string }[] = [ { value: 'orca', - label: translate('auto.components.stats.CodexUsagePane.201766b754', 'Orca worktrees only') + get label() { + return translate('auto.components.stats.CodexUsagePane.201766b754', 'Orca worktrees only') + } }, { value: 'all', - label: translate('auto.components.stats.CodexUsagePane.4fe8820098', 'All local Codex usage') + get label() { + return translate('auto.components.stats.CodexUsagePane.4fe8820098', 'All local Codex usage') + } } ] const RANGE_LABELS: Record = { - '7d': 'Last 7 days', - '30d': 'Last 30 days', - '90d': 'Last 90 days', - all: 'All time' + get '7d'() { + return translate('auto.components.stats.CodexUsagePane.rangeLast7Days', 'Last 7 days') + }, + get '30d'() { + return translate('auto.components.stats.CodexUsagePane.rangeLast30Days', 'Last 30 days') + }, + get '90d'() { + return translate('auto.components.stats.CodexUsagePane.rangeLast90Days', 'Last 90 days') + }, + get all() { + return translate('auto.components.stats.CodexUsagePane.rangeAllTime', 'All time') + } } export function CodexUsagePane(): React.JSX.Element { @@ -196,76 +207,13 @@ export function CodexUsagePane(): React.JSX.Element { )}

- - -
-
-
-

- {translate('auto.components.stats.CodexUsagePane.5a0d1d69cd', 'By model')} -

-

- {translate('auto.components.stats.CodexUsagePane.95d2d89285', 'Top model:')}{' '} - {summary?.topModel ?? - translate('auto.components.stats.CodexUsagePane.ae255c3dba', 'n/a')} -

-
-
- {modelBreakdown.slice(0, 5).map((row) => ( -
-
- {row.label} - - {formatTokens(row.totalTokens)} - -
-
- {row.sessions}{' '} - {translate('auto.components.stats.CodexUsagePane.bf1bf2f674', 'sessions •')}{' '} - {row.events}{' '} - {translate('auto.components.stats.CodexUsagePane.79a69522a5', 'events')} - {row.hasInferredPricing - ? ` ${translate('auto.components.stats.CodexUsagePane.247c93ca92', '• inferred pricing')}` - : ''} -
-
- ))} -
-
- -
-
-

- {translate('auto.components.stats.CodexUsagePane.b98718aaab', 'By project')} -

-

- {translate('auto.components.stats.CodexUsagePane.829ee743f2', 'Top project:')}{' '} - {summary?.topProject ?? - translate('auto.components.stats.CodexUsagePane.ae255c3dba', 'n/a')} -

-
-
- {projectBreakdown.slice(0, 5).map((row) => ( -
-
- {row.label} - - {formatTokens(row.totalTokens)} - -
-
- {row.sessions}{' '} - {translate('auto.components.stats.CodexUsagePane.bf1bf2f674', 'sessions •')}{' '} - {row.events}{' '} - {translate('auto.components.stats.CodexUsagePane.79a69522a5', 'events')} -
-
- ))} -
-
-
- - + )}
diff --git a/src/renderer/src/components/stats/OpenCodeUsageDetails.tsx b/src/renderer/src/components/stats/OpenCodeUsageDetails.tsx new file mode 100644 index 00000000000..b3b58733d78 --- /dev/null +++ b/src/renderer/src/components/stats/OpenCodeUsageDetails.tsx @@ -0,0 +1,64 @@ +import type { + OpenCodeUsageBreakdownRow, + OpenCodeUsageDailyPoint, + OpenCodeUsageSessionRow, + OpenCodeUsageSummary +} from '../../../../shared/opencode-usage-types' +import { CodexUsageDailyChart } from './CodexUsageDailyChart' +import { OpenCodeUsageRecentSessionsTable } from './OpenCodeUsageRecentSessionsTable' +import { UsageBreakdownSection } from './UsageBreakdownSection' +import { translate } from '@/i18n/i18n' + +type OpenCodeUsageDetailsProps = { + daily: OpenCodeUsageDailyPoint[] + modelBreakdown: OpenCodeUsageBreakdownRow[] + projectBreakdown: OpenCodeUsageBreakdownRow[] + recentSessions: OpenCodeUsageSessionRow[] + summary: OpenCodeUsageSummary | null | undefined +} + +export function OpenCodeUsageDetails({ + daily, + modelBreakdown, + projectBreakdown, + recentSessions, + summary +}: OpenCodeUsageDetailsProps): React.JSX.Element { + return ( + <> + + +
+ ({ + key: row.key, + label: row.label, + tokens: row.totalTokens, + sessions: row.sessions, + eventsOrTurns: row.events, + estimatedCostUsd: row.estimatedCostUsd + }))} + eventsOrTurns="events" + /> + ({ + key: row.key, + label: row.label, + tokens: row.totalTokens, + sessions: row.sessions, + eventsOrTurns: row.events + }))} + eventsOrTurns="events" + /> +
+ + + + ) +} diff --git a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx index 26857a541e7..b23689fea03 100644 --- a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx +++ b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx @@ -6,8 +6,7 @@ import type { } from '../../../../shared/opencode-usage-types' import { useAppStore } from '../../store' import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState' -import { CodexUsageDailyChart } from './CodexUsageDailyChart' -import { OpenCodeUsageRecentSessionsTable } from './OpenCodeUsageRecentSessionsTable' +import { OpenCodeUsageDetails } from './OpenCodeUsageDetails' import { StatCard } from './StatCard' import { UsagePaneFilterControls } from './UsagePaneFilterControls' import { UsageTrackingDisabledCard } from './UsageTrackingDisabledCard' @@ -18,21 +17,33 @@ const RANGE_OPTIONS: OpenCodeUsageRange[] = ['7d', '30d', '90d', 'all'] const SCOPE_OPTIONS: { value: OpenCodeUsageScope; label: string }[] = [ { value: 'orca', - label: translate('auto.components.stats.OpenCodeUsagePane.e04c58327c', 'Orca worktrees only') + get label() { + return translate('auto.components.stats.OpenCodeUsagePane.e04c58327c', 'Orca worktrees only') + } }, { value: 'all', - label: translate( - 'auto.components.stats.OpenCodeUsagePane.144a6050e9', - 'All local OpenCode usage' - ) + get label() { + return translate( + 'auto.components.stats.OpenCodeUsagePane.144a6050e9', + 'All local OpenCode usage' + ) + } } ] const RANGE_LABELS: Record = { - '7d': 'Last 7 days', - '30d': 'Last 30 days', - '90d': 'Last 90 days', - all: 'All time' + get '7d'() { + return translate('auto.components.stats.OpenCodeUsagePane.rangeLast7Days', 'Last 7 days') + }, + get '30d'() { + return translate('auto.components.stats.OpenCodeUsagePane.rangeLast30Days', 'Last 30 days') + }, + get '90d'() { + return translate('auto.components.stats.OpenCodeUsagePane.rangeLast90Days', 'Last 90 days') + }, + get all() { + return translate('auto.components.stats.OpenCodeUsagePane.rangeAllTime', 'All time') + } } export function OpenCodeUsagePane(): React.JSX.Element { @@ -214,82 +225,13 @@ export function OpenCodeUsagePane(): React.JSX.Element { )}

- - -
-
-
-

- {translate('auto.components.stats.OpenCodeUsagePane.040c044d39', 'By model')} -

-

- {translate('auto.components.stats.OpenCodeUsagePane.a15206a63a', 'Top model:')}{' '} - {summary?.topModel ?? - translate('auto.components.stats.OpenCodeUsagePane.8095a63426', 'n/a')} -

-
-
- {modelBreakdown.slice(0, 5).map((row) => ( -
-
- {row.label} - - {formatTokens(row.totalTokens)} - -
-
- {row.sessions}{' '} - {translate( - 'auto.components.stats.OpenCodeUsagePane.bc0cb89901', - 'sessions •' - )}{' '} - {row.events}{' '} - {translate('auto.components.stats.OpenCodeUsagePane.1e5d410df0', 'events')} - {row.estimatedCostUsd !== null - ? ` • ${formatCost(row.estimatedCostUsd)}` - : ''} -
-
- ))} -
-
- -
-
-

- {translate('auto.components.stats.OpenCodeUsagePane.0f0a1684bb', 'By project')} -

-

- {translate('auto.components.stats.OpenCodeUsagePane.048ffe4d65', 'Top project:')}{' '} - {summary?.topProject ?? - translate('auto.components.stats.OpenCodeUsagePane.8095a63426', 'n/a')} -

-
-
- {projectBreakdown.slice(0, 5).map((row) => ( -
-
- {row.label} - - {formatTokens(row.totalTokens)} - -
-
- {row.sessions}{' '} - {translate( - 'auto.components.stats.OpenCodeUsagePane.bc0cb89901', - 'sessions •' - )}{' '} - {row.events}{' '} - {translate('auto.components.stats.OpenCodeUsagePane.1e5d410df0', 'events')} -
-
- ))} -
-
-
- - + )}
diff --git a/src/renderer/src/components/stats/StatsPane.tsx b/src/renderer/src/components/stats/StatsPane.tsx index 78805da66c3..5746d00f8a4 100644 --- a/src/renderer/src/components/stats/StatsPane.tsx +++ b/src/renderer/src/components/stats/StatsPane.tsx @@ -48,10 +48,30 @@ function formatTrackingSince(timestamp: number | null): string { type UsageTab = 'overview' | 'claude' | 'codex' | 'opencode' const USAGE_ANALYTICS_OPTIONS = [ - { id: 'overview', label: translate('auto.components.stats.StatsPane.b2cf4310ce', 'Overview') }, - { id: 'claude', label: translate('auto.components.stats.StatsPane.85457c02fe', 'Claude') }, - { id: 'codex', label: translate('auto.components.stats.StatsPane.7d26110cea', 'Codex') }, - { id: 'opencode', label: translate('auto.components.stats.StatsPane.1e696db2f6', 'OpenCode') } + { + id: 'overview', + get label() { + return translate('auto.components.stats.StatsPane.b2cf4310ce', 'Overview') + } + }, + { + id: 'claude', + get label() { + return translate('auto.components.stats.StatsPane.85457c02fe', 'Claude') + } + }, + { + id: 'codex', + get label() { + return translate('auto.components.stats.StatsPane.7d26110cea', 'Codex') + } + }, + { + id: 'opencode', + get label() { + return translate('auto.components.stats.StatsPane.1e696db2f6', 'OpenCode') + } + } ] as const satisfies readonly { id: UsageTab; label: string }[] function UsageAnalyticsOptionIcon({ tab }: { tab: UsageTab }): React.JSX.Element { diff --git a/src/renderer/src/components/stats/UsageBreakdownSection.tsx b/src/renderer/src/components/stats/UsageBreakdownSection.tsx new file mode 100644 index 00000000000..318144813f2 --- /dev/null +++ b/src/renderer/src/components/stats/UsageBreakdownSection.tsx @@ -0,0 +1,67 @@ +import { translate } from '@/i18n/i18n' +import { formatCost, formatTokens } from './usage-formatters' + +export type UsageBreakdownRow = { + key: string + label: string + tokens: number + sessions: number + eventsOrTurns: number + hasInferredPricing?: boolean + estimatedCostUsd?: number | null +} + +type UsageBreakdownSectionProps = { + title: string + topLabel: string + topValue: string | null | undefined + rows: UsageBreakdownRow[] + eventsOrTurns: 'events' | 'turns' +} + +export function UsageBreakdownSection({ + title, + topLabel, + topValue, + rows, + eventsOrTurns +}: UsageBreakdownSectionProps): React.JSX.Element { + const eventsOrTurnsKey = + eventsOrTurns === 'turns' + ? 'auto.components.stats.UsageBreakdownSection.32176e1d44' + : 'auto.components.stats.UsageBreakdownSection.79a69522a5' + const eventsOrTurnsLabel = eventsOrTurns === 'turns' ? 'turns' : 'events' + const sessionsKey = 'auto.components.stats.UsageBreakdownSection.02a046792e' + + return ( +
+
+

{title}

+

+ {topLabel}{' '} + {topValue ?? translate('auto.components.stats.UsageBreakdownSection.7765a4c3e1', 'n/a')} +

+
+
+ {rows.slice(0, 5).map((row) => ( +
+
+ {row.label} + {formatTokens(row.tokens)} +
+
+ {row.sessions} {translate(sessionsKey, 'sessions •')} {row.eventsOrTurns}{' '} + {translate(eventsOrTurnsKey, eventsOrTurnsLabel)} + {row.hasInferredPricing + ? ` ${translate('auto.components.stats.UsageBreakdownSection.247c93ca92', '• inferred pricing')}` + : ''} + {row.estimatedCostUsd !== null && row.estimatedCostUsd !== undefined + ? ` • ${formatCost(row.estimatedCostUsd)}` + : ''} +
+
+ ))} +
+
+ ) +} diff --git a/src/renderer/src/components/stats/UsageSessionsTable.tsx b/src/renderer/src/components/stats/UsageSessionsTable.tsx new file mode 100644 index 00000000000..c1401f002ca --- /dev/null +++ b/src/renderer/src/components/stats/UsageSessionsTable.tsx @@ -0,0 +1,105 @@ +import { translate } from '@/i18n/i18n' +import { formatTokens } from './usage-formatters' + +export type UsageSessionRow = { + sessionId: string + lastActiveAt: string + projectLabel: string + model: string | null + events?: number + turns?: number + inputTokens: number + outputTokens: number + cacheTokens?: number + totalTokens?: number + hasInferredPricing?: boolean +} + +function formatSessionTime(timestamp: string): string { + const parsed = new Date(timestamp) + if (Number.isNaN(parsed.getTime())) { + return timestamp + } + return parsed.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }) +} + +type UsageSessionsTableProps = { + sessions: UsageSessionRow[] + eventsColumn?: 'events' | 'turns' + tokensColumn?: 'cache' | 'total' +} + +export function UsageSessionsTable({ + sessions, + eventsColumn = 'events', + tokensColumn = 'total' +}: UsageSessionsTableProps): React.JSX.Element { + const eventsLabel = + eventsColumn === 'turns' + ? translate('auto.components.stats.UsageSessionsTable.1afc25eb06', 'Turns') + : translate('auto.components.stats.UsageSessionsTable.0f03975d59', 'Events') + const tokensLabel = + tokensColumn === 'cache' + ? translate('auto.components.stats.UsageSessionsTable.21ea00bfa8', 'Cache') + : translate('auto.components.stats.UsageSessionsTable.e0b988599d', 'Total') + + return ( +
+ + + + + + + + + + + + + + {sessions.map((row) => ( + + + + + + + + + + ))} + +
+ {translate('auto.components.stats.UsageSessionsTable.01476891c7', 'Last active')} + + {translate('auto.components.stats.UsageSessionsTable.c17bed0416', 'Project')} + + {translate('auto.components.stats.UsageSessionsTable.f6a2c8d019', 'Model')} + {eventsLabel} + {translate('auto.components.stats.UsageSessionsTable.faf3444859', 'Input')} + + {translate('auto.components.stats.UsageSessionsTable.a8b7487ff7', 'Output')} + {tokensLabel}
+ {formatSessionTime(row.lastActiveAt)} + {row.projectLabel} + {row.model ?? + translate('auto.components.stats.UsageSessionsTable.cfe2282ffa', 'Unknown')} + {row.hasInferredPricing ? ' *' : ''} + + {eventsColumn === 'turns' ? row.turns : row.events} + {formatTokens(row.inputTokens)}{formatTokens(row.outputTokens)} + {formatTokens( + tokensColumn === 'cache' + ? (row.cacheTokens ?? 0) + : (row.totalTokens ?? row.inputTokens + row.outputTokens) + )} +
+
+ ) +} diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index 9aca62ba847..00b1678c954 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -11,43 +11,63 @@ import { translate } from '@/i18n/i18n' const TAB_COLORS = [ { - label: translate('auto.components.tab.bar.SortableTabContextMenu.20baa43c05', 'None'), + get label() { + return translate('auto.components.tab.bar.SortableTabContextMenu.20baa43c05', 'None') + }, value: null }, { - label: translate('auto.components.tab.bar.SortableTabContextMenu.cb3eadefd2', 'Blue'), + get label() { + return translate('auto.components.tab.bar.SortableTabContextMenu.cb3eadefd2', 'Blue') + }, value: '#3b82f6' }, { - label: translate('auto.components.tab.bar.SortableTabContextMenu.c2d8b0991f', 'Purple'), + get label() { + return translate('auto.components.tab.bar.SortableTabContextMenu.c2d8b0991f', 'Purple') + }, value: '#a855f7' }, { - label: translate('auto.components.tab.bar.SortableTabContextMenu.03cf6dab1a', 'Pink'), + get label() { + return translate('auto.components.tab.bar.SortableTabContextMenu.03cf6dab1a', 'Pink') + }, value: '#ec4899' }, { - label: translate('auto.components.tab.bar.SortableTabContextMenu.620aec6729', 'Red'), + get label() { + return translate('auto.components.tab.bar.SortableTabContextMenu.620aec6729', 'Red') + }, value: '#ef4444' }, { - label: translate('auto.components.tab.bar.SortableTabContextMenu.a47629b3cf', 'Orange'), + get label() { + return translate('auto.components.tab.bar.SortableTabContextMenu.a47629b3cf', 'Orange') + }, value: '#f97316' }, { - label: translate('auto.components.tab.bar.SortableTabContextMenu.69682e2ce4', 'Yellow'), + get label() { + return translate('auto.components.tab.bar.SortableTabContextMenu.69682e2ce4', 'Yellow') + }, value: '#eab308' }, { - label: translate('auto.components.tab.bar.SortableTabContextMenu.be905e9b0a', 'Green'), + get label() { + return translate('auto.components.tab.bar.SortableTabContextMenu.be905e9b0a', 'Green') + }, value: '#22c55e' }, { - label: translate('auto.components.tab.bar.SortableTabContextMenu.845576bed1', 'Teal'), + get label() { + return translate('auto.components.tab.bar.SortableTabContextMenu.845576bed1', 'Teal') + }, value: '#14b8a6' }, { - label: translate('auto.components.tab.bar.SortableTabContextMenu.7703990447', 'Gray'), + get label() { + return translate('auto.components.tab.bar.SortableTabContextMenu.7703990447', 'Gray') + }, value: '#9ca3af' } ] as const diff --git a/src/renderer/src/components/terminal-pane/MobileDriverOverlay.test.tsx b/src/renderer/src/components/terminal-pane/MobileDriverOverlay.test.tsx index f9f5ea9ba31..81db8cc0be6 100644 --- a/src/renderer/src/components/terminal-pane/MobileDriverOverlay.test.tsx +++ b/src/renderer/src/components/terminal-pane/MobileDriverOverlay.test.tsx @@ -4,7 +4,10 @@ import { MobileDriverOverlay } from './MobileDriverOverlay' type OverlayProps = { actionPending: boolean + allActionLabel?: string + allActionPending?: boolean onAction: () => void | Promise + onAllAction?: () => void | Promise rootRef: (node: HTMLDivElement | null) => void } @@ -53,13 +56,17 @@ vi.mock('react', async () => { } }) -function renderOverlay(onAction: () => void | Promise): OverlayElement { +function renderOverlay( + onAction: () => void | Promise, + onAllAction?: () => void | Promise +): OverlayElement { hookRuntime.stateIndex = 0 hookRuntime.refIndex = 0 return MobileDriverOverlay({ driver: { kind: 'mobile', clientId: 'phone-1' } as never, hasFitOverride: false, - onAction + onAction, + onAllAction }) as OverlayElement } @@ -93,4 +100,18 @@ describe('MobileDriverOverlay', () => { expect(overlay.props.actionPending).toBe(false) }) + + it('exposes an all-terminals restore action when provided', async () => { + const onAction = vi.fn() + const onAllAction = vi.fn() + + const overlay = renderOverlay(onAction, onAllAction) + + expect(overlay.props.allActionLabel).toBe('Resize all terminals') + expect(overlay.props.allActionPending).toBe(false) + await overlay.props.onAllAction?.() + + expect(onAction).not.toHaveBeenCalled() + expect(onAllAction).toHaveBeenCalledOnce() + }) }) diff --git a/src/renderer/src/components/terminal-pane/MobileDriverOverlay.tsx b/src/renderer/src/components/terminal-pane/MobileDriverOverlay.tsx index 80e950dd863..2fac5e8c65d 100644 --- a/src/renderer/src/components/terminal-pane/MobileDriverOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/MobileDriverOverlay.tsx @@ -13,6 +13,7 @@ type Props = { driver: DriverState hasFitOverride: boolean onAction: () => void | Promise + onAllAction?: () => void | Promise /** Identifier class on the rendered root, used by e2e selectors. */ rootClassName?: string } @@ -24,6 +25,7 @@ export function MobileDriverOverlay({ driver, hasFitOverride, onAction, + onAllAction, rootClassName }: Props): ReactElement | null { const isMobileDriving = driver.kind === 'mobile' @@ -34,14 +36,16 @@ export function MobileDriverOverlay({ createMobileDriverOverlayCollapseState(driverClientId) ) const [actionPending, setActionPending] = useState(false) + const [allActionPending, setAllActionPending] = useState(false) const mountedRef = useRef(false) const setOverlayRootRef = useCallback((node: HTMLDivElement | null): void => { mountedRef.current = node !== null if (node) { - // Why: take-back can resolve after the overlay renders null; a later - // mobile session must not inherit the stale disabled state. + // Why: take-back/restore can resolve after the overlay renders null; a + // later mobile session must not inherit stale disabled state. setActionPending(false) + setAllActionPending(false) } }, []) @@ -57,7 +61,7 @@ export function MobileDriverOverlay({ } const handleAction = async (): Promise => { - if (actionPending) { + if (actionPending || allActionPending) { return } setActionPending(true) @@ -70,6 +74,20 @@ export function MobileDriverOverlay({ } } + const handleAllAction = async (): Promise => { + if (!onAllAction || actionPending || allActionPending) { + return + } + setAllActionPending(true) + try { + await onAllAction() + } finally { + if (mountedRef.current) { + setAllActionPending(false) + } + } + } + if (isHeldAtPhoneFit) { return ( setCollapseState({ driverClientId, collapsed: true })} tone="driving" rootRef={setOverlayRootRef} @@ -126,7 +156,10 @@ type LoudOverlayProps = { body: string actionLabel: string actionPending: boolean + allActionLabel?: string + allActionPending?: boolean onAction: () => void | Promise + onAllAction?: () => void | Promise onCollapse?: () => void tone: 'driving' | 'held' rootRef?: (node: HTMLDivElement | null) => void @@ -139,7 +172,10 @@ function LoudOverlay({ body, actionLabel, actionPending, + allActionLabel, + allActionPending = false, onAction, + onAllAction, onCollapse, tone, rootRef: outerRootRef, @@ -197,7 +233,7 @@ function LoudOverlay({
{body}
-
+
{onCollapse && ( )} + {onAllAction && allActionLabel ? ( + + ) : null} {/* Focus is moved to this button only when no user input is active; see effect above. */} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index fa7a8502de3..5a52b8baa9b 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -46,8 +46,13 @@ import type { PreparedAgentSessionFork } from './terminal-agent-session-fork' import { useNotificationDispatch } from './use-notification-dispatch' import { connectPanePty } from './pty-connection' import { shouldPreserveTerminalScrollbackBuffers } from '../../../../shared/workspace-session-terminal-buffers' -import { getFitOverrideForPty, onOverrideChange } from '@/lib/pane-manager/mobile-fit-overrides' import { + getAllOverrides, + getFitOverrideForPty, + onOverrideChange +} from '@/lib/pane-manager/mobile-fit-overrides' +import { + getAllDrivers, getDriverForPty, isPtyLocked, onDriverChange @@ -56,11 +61,6 @@ import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution import { safeFit } from '@/lib/pane-manager/pane-tree-ops' import { captureTerminalShutdownLayout } from './terminal-shutdown-layout-capture' import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' -import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' -import { - getRemoteRuntimePtyEnvironmentId, - getRemoteRuntimeTerminalHandle -} from '@/runtime/runtime-terminal-stream' import { closeWebRuntimeTerminal } from '@/runtime/web-runtime-session' import { isPrimarySelectionEnabled, readPrimarySelectionText } from '@/lib/primary-selection' import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' @@ -85,6 +85,7 @@ import { import { keybindingMatchesAction } from '../../../../shared/keybindings' import { pasteTerminalClipboard } from './terminal-clipboard-paste' import { scheduleImagePasteWebglAtlasRecovery } from './terminal-webgl-paste-recovery' +import { restoreTerminalFitToDesktop, restoreTerminalFitsToDesktop } from './terminal-fit-restore' // Why: registry lives in a leaf module so the store slice can import it // without re-entering the `slice → TerminalPane → store → slice` cycle @@ -1687,6 +1688,46 @@ export default function TerminalPane({ rightClickToPaste }) + const getMobileOwnedTerminalPtyIds = useCallback((): string[] => { + const ptyIds = new Set(getAllOverrides().keys()) + for (const [ptyId, driver] of getAllDrivers()) { + if (driver.kind === 'mobile') { + ptyIds.add(ptyId) + } + } + return [...ptyIds] + }, []) + + const restorePaneTerminalFit = useCallback(async (pane: ManagedPane): Promise => { + // Why: local and remote runtime PTYs use different transports, but the + // desktop reclaim button should have one visible recovery behavior. + const id = paneTransportsRef.current.get(pane.id)?.getPtyId() + if (!id) { + return + } + const restored = await restoreTerminalFitToDesktop(id, settingsRef.current ?? undefined) + if (restored) { + // Why: after the overlay unmounts, focus would otherwise stay on the + // removed button/body instead of the terminal the user just reclaimed. + pane.terminal.focus() + } + }, []) + + const restoreAllTerminalFits = useCallback( + async (focusPane: ManagedPane): Promise => { + // Why: a mobile session can leave multiple PTYs held at phone size; bulk + // restore follows the same reclaim path as the per-pane button. + const restored = await restoreTerminalFitsToDesktop( + getMobileOwnedTerminalPtyIds(), + settingsRef.current ?? undefined + ) + if (restored) { + focusPane.terminal.focus() + } + }, + [getMobileOwnedTerminalPtyIds] + ) + const terminalShouldHandleMiddleClick = useCallback( (target: EventTarget | null): target is Node => { if (!(target instanceof Element)) { @@ -2028,8 +2069,8 @@ export default function TerminalPane({ // input paused (docs/mobile-presence-lock.md). (2) No mobile driver // but a phone-fit override is still in place → indefinite hold // (docs/mobile-fit-hold.md). MobileDriverOverlay owns the visual - // treatment and collapse-to-chip state; both branches share a - // single IPC route through restoreTerminalFit. + // treatment and collapse-to-chip state; both branches share the + // same local/remote desktop-restore route. const driver = getDriverForPty(ptyId) const isMobileDriving = driver.kind === 'mobile' const hasFitOverride = getFitOverrideForPty(ptyId) !== null @@ -2042,38 +2083,8 @@ export default function TerminalPane({ driver={driver} hasFitOverride={hasFitOverride} rootClassName="mobile-driver-banner" - onAction={async () => { - // Why: same restore intent has two transports. Remote-runtime PTYs - // must call the environment RPC; local PTYs use the Electron IPC - // handler. Both resolve active-mobile and held-no-subscriber states. - const transport = paneTransportsRef.current.get(pane.id) - const id = transport?.getPtyId() - if (!id) { - return - } - const remoteHandle = getRemoteRuntimeTerminalHandle(id) - const environmentId = - getRemoteRuntimePtyEnvironmentId(id) ?? - settingsRef.current?.activeRuntimeEnvironmentId ?? - null - const result = - remoteHandle && environmentId - ? await callRuntimeRpc<{ restored: boolean }>( - { kind: 'environment', environmentId }, - 'terminal.restoreFit', - { terminal: remoteHandle }, - { timeoutMs: 15_000 } - ).catch(() => ({ restored: false })) - : await window.api.runtime - .restoreTerminalFit(id) - .catch(() => ({ restored: false })) - if (result.restored) { - // Why: after the overlay unmounts, focus would otherwise stay on - // the removed button/body instead of the terminal the user just - // reclaimed. - pane.terminal.focus() - } - }} + onAction={() => restorePaneTerminalFit(pane)} + onAllAction={() => restoreAllTerminalFits(pane)} />, pane.container, `mobile-driver-banner-${pane.id}` diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 6ff87b1286a..bdcd2ce2c8d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -80,6 +80,8 @@ type StoreState = { consumePendingSnapshot: ReturnType runtimePaneTitlesByTabId: Record> agentStatusByPaneKey: Record + sleepingAgentSessionsByPaneKey: Record + clearSleepingAgentSession: ReturnType markWorktreeUnread: ReturnType observeTerminalGitHubPullRequestLink: ReturnType setAgentStatus: ReturnType @@ -471,6 +473,10 @@ describe('connectPanePty', () => { consumePendingSnapshot: vi.fn(() => null), runtimePaneTitlesByTabId: {}, agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {}, + clearSleepingAgentSession: vi.fn((paneKey: string) => { + delete mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] + }), markWorktreeUnread: vi.fn(), observeTerminalGitHubPullRequestLink: vi.fn(), setAgentStatus: vi.fn((paneKey: string, payload: Record) => { @@ -3082,6 +3088,110 @@ describe('connectPanePty', () => { ) }) + it('resumes from the quit-captured sleeping record when cold-restoring after an app restart', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('fresh-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: 'fresh-pty', + coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } + } + } + return 'fresh-pty' + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + // Why: after an app restart agentStatusByPaneKey is empty — the persisted + // sleeping record is the only source of the provider session id (#5232). + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] + }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: {} + }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(pane.terminal.write).toHaveBeenCalledWith('cold-payload', expect.any(Function)) + expect(transport.sendInput).toHaveBeenCalledWith( + "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'\r" + ) + // Why: consuming the record prevents a later worktree activation from + // launching a duplicate resume tab for the same session. + expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) + }) + + it('does not consume the sleeping record when daemon reattach returns a live snapshot', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: 'live-snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(transport.sendInput).not.toHaveBeenCalled() + expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() + }) + it('does not resume the provider session when daemon reattach returns a live snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('tab-pty') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index f9caa5d9085..3a0429622f2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -1796,31 +1796,39 @@ export function connectPanePty( if (pendingStartupCommand) { return false } - const entry = useAppStore.getState().agentStatusByPaneKey[cacheKey] - if (!entry || entry.state === 'done' || !isResumableTuiAgent(entry.agentType)) { + const state = useAppStore.getState() + const entry = state.agentStatusByPaneKey[cacheKey] + // Why: agentStatusByPaneKey is in-memory only. After an app restart, the + // quit-captured sleeping record is the only surviving source of this + // pane's provider session id (#5232). Live entries win when present — + // they are fresher and setAgentStatus already cleared the record. + const sleepingRecord = entry ? null : state.sleepingAgentSessionsByPaneKey[cacheKey] + const agentType = entry?.agentType ?? sleepingRecord?.agent + const agentState = entry?.state ?? sleepingRecord?.state + const rawProviderSession = entry?.providerSession ?? sleepingRecord?.providerSession + if (!agentType || agentState === 'done' || !isResumableTuiAgent(agentType)) { return false } - const providerSession = normalizeAgentProviderSession(entry.providerSession) + const providerSession = normalizeAgentProviderSession(rawProviderSession) if (!providerSession) { return false } const startupPlan = buildAgentResumeStartupPlan({ - agent: entry.agentType, + agent: agentType, providerSession, - cmdOverrides: useAppStore.getState().settings?.agentCmdOverrides ?? {}, - agentArgs: resolveTuiAgentLaunchArgs( - entry.agentType, - useAppStore.getState().settings?.agentDefaultArgs - ), - agentEnv: resolveTuiAgentLaunchEnv( - entry.agentType, - useAppStore.getState().settings?.agentDefaultEnv - ), + cmdOverrides: state.settings?.agentCmdOverrides ?? {}, + agentArgs: resolveTuiAgentLaunchArgs(agentType, state.settings?.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(agentType, state.settings?.agentDefaultEnv), platform: getColdRestoreAgentResumePlatform() }) if (!startupPlan) { return false } + if (sleepingRecord) { + // Why: the record is one-shot — consuming it here prevents a later + // worktree activation from launching a duplicate resume tab. + useAppStore.getState().clearSleepingAgentSession(cacheKey) + } // Why: cold restore means the PTY process is gone but the agent provider // session is still resumable, so the replacement shell must launch it. pendingStartupCommand = startupPlan.launchCommand diff --git a/src/renderer/src/components/terminal-pane/terminal-fit-restore.test.ts b/src/renderer/src/components/terminal-pane/terminal-fit-restore.test.ts new file mode 100644 index 00000000000..07f030faab6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-fit-restore.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { + getRemoteRuntimePtyEnvironmentId, + getRemoteRuntimeTerminalHandle +} from '@/runtime/runtime-terminal-stream' +import { restoreTerminalFitToDesktop, restoreTerminalFitsToDesktop } from './terminal-fit-restore' + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: vi.fn() +})) + +vi.mock('@/runtime/runtime-terminal-stream', () => ({ + getRemoteRuntimePtyEnvironmentId: vi.fn(), + getRemoteRuntimeTerminalHandle: vi.fn() +})) + +const restoreTerminalFit = vi.fn() + +describe('terminal-fit-restore', () => { + beforeEach(() => { + restoreTerminalFit.mockReset() + vi.mocked(callRuntimeRpc).mockReset() + vi.mocked(getRemoteRuntimePtyEnvironmentId).mockReset() + vi.mocked(getRemoteRuntimeTerminalHandle).mockReset() + vi.stubGlobal('window', { + api: { + runtime: { + restoreTerminalFit + } + } + }) + }) + + it('restores local terminals through desktop IPC', async () => { + vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue(null) + restoreTerminalFit.mockResolvedValue({ restored: true }) + + await expect( + restoreTerminalFitToDesktop('pty-local', { activeRuntimeEnvironmentId: 'env-unused' }) + ).resolves.toBe(true) + + expect(restoreTerminalFit).toHaveBeenCalledWith('pty-local') + expect(callRuntimeRpc).not.toHaveBeenCalled() + }) + + it('restores remote terminals through the environment runtime RPC', async () => { + vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue('terminal-one') + vi.mocked(getRemoteRuntimePtyEnvironmentId).mockReturnValue('env-one') + vi.mocked(callRuntimeRpc).mockResolvedValue({ restored: true }) + + await expect(restoreTerminalFitToDesktop('remote:pty-1', undefined)).resolves.toBe(true) + + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-one' }, + 'terminal.restoreFit', + { terminal: 'terminal-one' }, + { timeoutMs: 15_000 } + ) + expect(restoreTerminalFit).not.toHaveBeenCalled() + }) + + it('uses the active runtime environment when the remote PTY has no encoded environment', async () => { + vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue('terminal-two') + vi.mocked(getRemoteRuntimePtyEnvironmentId).mockReturnValue(null) + vi.mocked(callRuntimeRpc).mockResolvedValue({ restored: true }) + + await expect( + restoreTerminalFitToDesktop('remote:pty-2', { activeRuntimeEnvironmentId: 'env-active' }) + ).resolves.toBe(true) + + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-active' }, + 'terminal.restoreFit', + { terminal: 'terminal-two' }, + { timeoutMs: 15_000 } + ) + }) + + it('deduplicates bulk restore PTYs and succeeds when any restore succeeds', async () => { + vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue(null) + restoreTerminalFit.mockImplementation(async (ptyId: string) => ({ + restored: ptyId === 'pty-2' + })) + + await expect( + restoreTerminalFitsToDesktop(['pty-1', 'pty-1', 'pty-2'], undefined) + ).resolves.toBe(true) + + expect(restoreTerminalFit).toHaveBeenCalledTimes(2) + expect(restoreTerminalFit).toHaveBeenNthCalledWith(1, 'pty-1') + expect(restoreTerminalFit).toHaveBeenNthCalledWith(2, 'pty-2') + }) + + it('treats failed local restore transport as not restored', async () => { + vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue(null) + restoreTerminalFit.mockRejectedValue(new Error('restore failed')) + + await expect(restoreTerminalFitToDesktop('pty-local', undefined)).resolves.toBe(false) + }) + + it('treats failed remote RPC restore transport as not restored', async () => { + vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue('terminal-fail') + vi.mocked(getRemoteRuntimePtyEnvironmentId).mockReturnValue('env-fail') + vi.mocked(callRuntimeRpc).mockRejectedValue(new Error('RPC failed')) + + await expect(restoreTerminalFitToDesktop('remote:pty-fail', undefined)).resolves.toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-fit-restore.ts b/src/renderer/src/components/terminal-pane/terminal-fit-restore.ts new file mode 100644 index 00000000000..bdf152a35b9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-fit-restore.ts @@ -0,0 +1,44 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { + getRemoteRuntimePtyEnvironmentId, + getRemoteRuntimeTerminalHandle +} from '@/runtime/runtime-terminal-stream' + +type TerminalFitRestoreSettings = Pick | undefined + +const restoreFailedResult = (): { restored: boolean } => { + // Why: terminal fit restore is best-effort when mobile/remote transports disappear. + return { restored: false } +} + +export async function restoreTerminalFitToDesktop( + ptyId: string, + settings: TerminalFitRestoreSettings +): Promise { + const remoteHandle = getRemoteRuntimeTerminalHandle(ptyId) + const environmentId = + getRemoteRuntimePtyEnvironmentId(ptyId) ?? settings?.activeRuntimeEnvironmentId ?? null + const result = + remoteHandle && environmentId + ? await callRuntimeRpc<{ restored: boolean }>( + { kind: 'environment', environmentId }, + 'terminal.restoreFit', + { terminal: remoteHandle }, + { timeoutMs: 15_000 } + ).catch(restoreFailedResult) + : await window.api.runtime.restoreTerminalFit(ptyId).catch(restoreFailedResult) + + return result.restored +} + +export async function restoreTerminalFitsToDesktop( + ptyIds: Iterable, + settings: TerminalFitRestoreSettings +): Promise { + const uniquePtyIds = [...new Set(ptyIds)] + const results = await Promise.all( + uniquePtyIds.map((ptyId) => restoreTerminalFitToDesktop(ptyId, settings)) + ) + return results.some(Boolean) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 69a25458cae..696df2f5365 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -464,21 +464,32 @@ } } }, - "folder": { - "workspace": { - "path": { - "status": { - "folderNotFound": "Folder not found", - "pathNotFolder": "Path is not a folder", - "cannotDetermineConnection": "Cannot determine connection", - "cannotCheckFolder": "Cannot check folder", - "missingDescription": "Orca cannot find {{value0}}. Remove and re-import this folder workspace.", - "notDirectoryDescription": "{{value0}} exists, but it is not a folder.", - "ambiguousConnectionDescription": "Orca cannot tell which SSH connection owns this folder scope.", - "unavailableDescription": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.", - "createMissingDescription": "Orca cannot find {{value0}}. Remove and re-import the folder.", - "createFailed": "Failed to create folder workspace" - } + "folderWorkspacePathStatus": { + "title": { + "missing": "Folder not found", + "notDirectory": "Path is not a folder", + "ambiguousConnection": "Cannot determine connection", + "unavailable": "Cannot check folder" + }, + "description": { + "missing": "Orca cannot find {{path}}. Remove and re-import this folder workspace.", + "notDirectory": "{{path}} exists, but it is not a folder.", + "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", + "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + }, + "createError": { + "title": { + "missing": "Folder not found", + "notDirectory": "Path is not a folder", + "ambiguousConnection": "Cannot determine connection", + "unavailable": "Cannot check folder", + "generic": "Failed to create folder workspace" + }, + "description": { + "missing": "Orca cannot find {{path}}. Remove and re-import the folder.", + "notDirectory": "{{path}} exists, but it is not a folder.", + "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", + "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." } } } @@ -1018,7 +1029,15 @@ "f660aa1454": "Connecting", "7711ad5122": "Local setup command", "e5db1b0419": "Combined setup command", - "addProjectBeforeWorkspace": "Add a project before creating a workspace." + "addProjectBeforeWorkspace": "Add a project before creating a workspace.", + "sshNotConnected": "SSH not connected", + "connectingSsh": "Connecting SSH...", + "sshAuthenticationFailed": "SSH authentication failed", + "preparingSshConnection": "Preparing SSH connection...", + "connected": "Connected", + "reconnectingSsh": "Reconnecting SSH...", + "sshReconnectionFailed": "SSH reconnection failed", + "notConnected": "Not connected" }, "NewWorkspaceComposerModal": { "fa90f739a5": "Choose the project, workspace name, and agent before creating the workspace." @@ -1793,7 +1812,11 @@ "015b4e607d": "Cancel", "e3bd59143c": "Insert", "f24783f470": "https://...", - "ec6310b731": "Use an http:// or https:// image URL." + "ec6310b731": "Use an http:// or https:// image URL.", + "b7e4a1c902": "Paste, drop, or click to add files", + "8f1c2d4e6a": "Nothing to preview", + "c91f0a2b14": "Write", + "d82b1e3f05": "Preview" }, "IssueSourceSelector": { "d6aeb2012b": "Showing issues from", @@ -1867,6 +1890,47 @@ } } } + }, + "CloseReasonDropdown": { + "e1f2a3b4c5": "Choose close reason" + }, + "GitHubIssueCommentComposer": { + "082515176a": "Failed to add comment", + "9f88657c4e": "Issue closed", + "e9b7cb7d17": "Failed to close issue", + "bd3b4492a0": "Issue reopened", + "f2a8c1d903": "Failed to reopen issue", + "a1b2c3d4e5": "Add a comment", + "c5c117270e": "Add your comment here, be kind", + "f6a7b8c9d0": "Close issue", + "b1c2d3e4f5": "Reopen issue", + "0a73f59e85": "Send comment", + "bf43425540": "Comment" + }, + "GitHubWorkItemAssigneePopoverContent": { + "cddd9b04a7": "Loading assignees", + "a00830d3f7": "No users", + "4f8b6f2c1d": "Filter assignees..." + }, + "GitHubWorkItemLabelPopoverContent": { + "2aa9acdf34": "Edit labels on GitHub", + "cddd9b04a7": "Loading labels", + "de26e2eb06": "No labels", + "8b0d52ee3a": "Filter labels..." + }, + "githubIssueCloseReasons": { + "completed": { + "label": "Close as completed", + "description": "Done, closed, fixed, resolved" + }, + "notPlanned": { + "label": "Close as not planned", + "description": "Won't fix, can't repro, stale" + }, + "duplicate": { + "label": "Close as duplicate", + "description": "Duplicate of another issue" + } } }, "linear": { @@ -2142,7 +2206,8 @@ "c44659e09f": "Mobile driving", "7cffad954c": "Collapse", "3eed73394f": "Your keyboard is paused", - "faa367dc74": "This terminal is sized for your mobile app" + "faa367dc74": "This terminal is sized for your mobile app", + "54f7d6f69d": "Resize all terminals" }, "TerminalAgentSessionForkDialog": { "17fc841e59": "Copy context", @@ -2768,7 +2833,11 @@ "4f8368c272": "Orca worktrees only", "cfe2282ffa": "Unknown", "7765a4c3e1": "n/a", - "2d41fd45c6": " • Last scan error: {{value0}}" + "2d41fd45c6": " • Last scan error: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "CodexUsageDailyChart": { "1e6f62d7e3": "Reasoning", @@ -2817,7 +2886,11 @@ "bf6cf2d4dd": "Unknown", "ae255c3dba": "n/a", "247c93ca92": "• inferred pricing", - "8a6655f7a2": " • Last scan error: {{value0}}" + "8a6655f7a2": " • Last scan error: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "OpenCodeUsagePane": { "349f7c3f5c": "Total", @@ -2856,7 +2929,11 @@ "e04c58327c": "Orca worktrees only", "362231082f": "Unknown", "8095a63426": "n/a", - "6cc7782458": " • Last scan error: {{value0}}" + "6cc7782458": " • Last scan error: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "ShareUsageButton": { "7d6b25323d": "Share on X", @@ -2975,6 +3052,22 @@ "d9a4b3e2f1c5": "events" } } + }, + "UsageBreakdownSection": { + "7765a4c3e1": "n/a", + "247c93ca92": "• inferred pricing" + }, + "UsageSessionsTable": { + "1afc25eb06": "Turns", + "0f03975d59": "Events", + "21ea00bfa8": "Cache", + "e0b988599d": "Total", + "01476891c7": "Last active", + "c17bed0416": "Project", + "f6a2c8d019": "Model", + "faf3444859": "Input", + "a8b7487ff7": "Output", + "cfe2282ffa": "Unknown" } }, "sparse": { @@ -3079,7 +3172,15 @@ "2c12db1511": "Project already added", "875dda0995": "Enter a server parent path.", "45b7c26034": "Create project", - "85085d74d2": "Creating…" + "85085d74d2": "Creating…", + "createProjectTitle": "Create project", + "createProjectDescription": "Create a local Git repo and first workspace.", + "projectNameLabel": "Project name", + "createsGitRepoHelp": "Git repo:", + "parentFolderLabel": "Parent folder", + "browseParentFolder": "Browse", + "gitRequiredError": "Git is required to create a project.", + "createAction": "Create" }, "AddRepoNestedImportStep": { "496f68cf8c": "Scanning repositories. Click to stop.", @@ -3456,7 +3557,11 @@ "376bed88e5": "The connection to the remote host encountered an error.", "4afcca1d24": "Reconnect", "11552bf786": "SSH Disconnected", - "cb5938ae79": "Reconnecting..." + "cb5938ae79": "Reconnecting...", + "disconnected": "This remote repository is not connected.", + "reconnecting": "Reconnecting to the remote host...", + "reconnectionFailed": "Reconnection to the remote host failed.", + "authFailed": "Authentication to the remote host failed." }, "SshTargetRow": { "4677394048": "Connecting…", @@ -3680,7 +3785,9 @@ "a6c20dca96": "Open a project from an SSH target", "3d162cc76f": "Remote project", "fb4fc5380e": "Local project, Git repo, or folder with many repos", - "2281fdc8c7": "Browse folder" + "2281fdc8c7": "Browse folder", + "createProjectTitle": "Create project", + "createGitProjectDescription": "Create a local Git repository" } } } @@ -3851,6 +3958,12 @@ "create": "Create workspace", "sourceProject": "Task Source", "chooseSourceProject": "Choose task source" + }, + "ProjectOrderManualDefaultNotice": { + "a1f4c2d8e0": "Manual project order is now default", + "822ff300ad": "Dismiss", + "b7e3a91c4f": "Drag project headers to reorder, or switch to", + "e8c1f4a2b9": "in workspace options." } }, "shared": { @@ -7538,7 +7651,9 @@ "f273f2271c": "Started the agent. Marked {{value0}} resolved, skipped {{value1}}, failed {{value2}}.", "aa95b81a3a": "Started the agent. Marked {{value0}} resolved, skipped {{value1}}, failed {{value2}}.", "495b2f8c4b": "Started the agent, but could not mark the selected comments resolved.", - "3c3ad3a1d2": "Started the agent. No selected comments can be marked resolved on the host." + "3c3ad3a1d2": "Started the agent. No selected comments can be marked resolved on the host.", + "b4f3ec62a1": "More {{value0}} actions", + "a9d7c128e4": "unlink {{value0}}" }, "CreatePullRequestDialog": { "2bc1b4345e": "Cancel", @@ -8926,7 +9041,11 @@ "3c4adfd821": "fix login race condition", "56a0271428": "Isolated workspaces", "ef737dcee1": "GitHub & Linear tasks", - "ac51c061e2": "codex" + "ac51c061e2": "codex", + "47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.", + "70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.", + "f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.", + "5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents." }, "FeatureWallBody": { "25ec5356d6": "Setup" @@ -8999,7 +9118,9 @@ "6e3f5223c5": "Explorer", "ab2901bce6": "Checks", "d7f80060ca": "Source Control", - "8e715588e4": "Search" + "8e715588e4": "Search", + "a6c8b9e32f": "Checks passed", + "f4d5e1a7b2": "3 checks" }, "ReviewShipAnimatedVisual": { "4d99496b8c": "Create PR", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 2d30db169d5..ab3aa94ca94 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -464,21 +464,32 @@ } } }, - "folder": { - "workspace": { - "path": { - "status": { - "folderNotFound": "Folder not found", - "pathNotFolder": "Path is not a folder", - "cannotDetermineConnection": "Cannot determine connection", - "cannotCheckFolder": "Cannot check folder", - "missingDescription": "Orca cannot find {{value0}}. Remove and re-import this folder workspace.", - "notDirectoryDescription": "{{value0}} exists, but it is not a folder.", - "ambiguousConnectionDescription": "Orca cannot tell which SSH connection owns this folder scope.", - "unavailableDescription": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.", - "createMissingDescription": "Orca cannot find {{value0}}. Remove and re-import the folder.", - "createFailed": "Failed to create folder workspace" - } + "folderWorkspacePathStatus": { + "title": { + "missing": "Carpeta no encontrada", + "notDirectory": "La ruta no es una carpeta", + "ambiguousConnection": "No se puede determinar la conexión", + "unavailable": "No se puede comprobar la carpeta" + }, + "description": { + "missing": "Orca no puede encontrar {{path}}. Elimina y vuelve a importar este espacio de trabajo de carpeta.", + "notDirectory": "{{path}} existe, pero no es una carpeta.", + "ambiguousConnection": "Orca no puede determinar qué conexión SSH pertenece a este ámbito de carpeta.", + "unavailable": "Orca no puede verificar esta carpeta ahora mismo. Revisa el runtime o la conexión SSH e inténtalo de nuevo." + }, + "createError": { + "title": { + "missing": "Carpeta no encontrada", + "notDirectory": "La ruta no es una carpeta", + "ambiguousConnection": "No se puede determinar la conexión", + "unavailable": "No se puede comprobar la carpeta", + "generic": "No se pudo crear el espacio de trabajo de carpeta" + }, + "description": { + "missing": "Orca no puede encontrar {{path}}. Elimina y vuelve a importar la carpeta.", + "notDirectory": "{{path}} existe, pero no es una carpeta.", + "ambiguousConnection": "Orca no puede determinar qué conexión SSH pertenece a este ámbito de carpeta.", + "unavailable": "Orca no puede verificar esta carpeta ahora mismo. Revisa el runtime o la conexión SSH e inténtalo de nuevo." } } } @@ -1018,7 +1029,15 @@ "f660aa1454": "Conectando", "7711ad5122": "Comando de configuración local", "e5db1b0419": "Comando de configuración combinado", - "addProjectBeforeWorkspace": "Agregue un proyecto antes de crear un espacio de trabajo." + "addProjectBeforeWorkspace": "Agregue un proyecto antes de crear un espacio de trabajo.", + "sshNotConnected": "SSH not connected", + "connectingSsh": "Connecting SSH...", + "sshAuthenticationFailed": "SSH authentication failed", + "preparingSshConnection": "Preparing SSH connection...", + "connected": "Connected", + "reconnectingSsh": "Reconnecting SSH...", + "sshReconnectionFailed": "SSH reconnection failed", + "notConnected": "Not connected" }, "NewWorkspaceComposerModal": { "fa90f739a5": "Elija el proyecto, el nombre del espacio de trabajo y el agente antes de crear el espacio de trabajo." @@ -1793,7 +1812,11 @@ "015b4e607d": "Cancelar", "e3bd59143c": "Insertar", "f24783f470": "https://...", - "ec6310b731": "Utilice una URL de imagen http:// o https://." + "ec6310b731": "Utilice una URL de imagen http:// o https://.", + "b7e4a1c902": "Paste, drop, or click to add files", + "8f1c2d4e6a": "Nothing to preview", + "c91f0a2b14": "Write", + "d82b1e3f05": "Preview" }, "IssueSourceSelector": { "d6aeb2012b": "Mostrando problemas de", @@ -1867,6 +1890,47 @@ } } } + }, + "CloseReasonDropdown": { + "e1f2a3b4c5": "Choose close reason" + }, + "GitHubIssueCommentComposer": { + "082515176a": "Failed to add comment", + "9f88657c4e": "Issue closed", + "e9b7cb7d17": "Failed to close issue", + "bd3b4492a0": "Issue reopened", + "f2a8c1d903": "Failed to reopen issue", + "a1b2c3d4e5": "Add a comment", + "c5c117270e": "Add your comment here, be kind", + "f6a7b8c9d0": "Close issue", + "b1c2d3e4f5": "Reopen issue", + "0a73f59e85": "Send comment", + "bf43425540": "Comment" + }, + "GitHubWorkItemAssigneePopoverContent": { + "cddd9b04a7": "Loading assignees", + "a00830d3f7": "No users", + "4f8b6f2c1d": "Filter assignees..." + }, + "GitHubWorkItemLabelPopoverContent": { + "2aa9acdf34": "Edit labels on GitHub", + "cddd9b04a7": "Loading labels", + "de26e2eb06": "No labels", + "8b0d52ee3a": "Filter labels..." + }, + "githubIssueCloseReasons": { + "completed": { + "label": "Close as completed", + "description": "Done, closed, fixed, resolved" + }, + "notPlanned": { + "label": "Close as not planned", + "description": "Won't fix, can't repro, stale" + }, + "duplicate": { + "label": "Close as duplicate", + "description": "Duplicate of another issue" + } } }, "linear": { @@ -2142,7 +2206,8 @@ "c44659e09f": "conducción móvil", "7cffad954c": "Colapsar", "3eed73394f": "Tu teclado está en pausa", - "faa367dc74": "Este terminal tiene el tamaño adecuado para tu aplicación móvil" + "faa367dc74": "Este terminal tiene el tamaño adecuado para tu aplicación móvil", + "54f7d6f69d": "Cambiar tamaño de todas las terminales" }, "TerminalAgentSessionForkDialog": { "17fc841e59": "Copiar contexto", @@ -2768,7 +2833,11 @@ "4f8368c272": "Solo árboles de trabajo Orca", "cfe2282ffa": "Desconocido", "7765a4c3e1": "n / A", - "2d41fd45c6": "• Error del último análisis: {{value0}}" + "2d41fd45c6": "• Error del último análisis: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "CodexUsageDailyChart": { "1e6f62d7e3": "Razonamiento", @@ -2817,7 +2886,11 @@ "bf6cf2d4dd": "Desconocido", "ae255c3dba": "n / A", "247c93ca92": "• precios inferidos", - "8a6655f7a2": "• Error del último análisis: {{value0}}" + "8a6655f7a2": "• Error del último análisis: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "OpenCodeUsagePane": { "349f7c3f5c": "Total", @@ -2856,7 +2929,11 @@ "e04c58327c": "Solo árboles de trabajo Orca", "362231082f": "Desconocido", "8095a63426": "n / A", - "6cc7782458": "• Error del último análisis: {{value0}}" + "6cc7782458": "• Error del último análisis: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "ShareUsageButton": { "7d6b25323d": "Compartir en X", @@ -2975,6 +3052,22 @@ "d9a4b3e2f1c5": "eventos" } } + }, + "UsageBreakdownSection": { + "7765a4c3e1": "n/a", + "247c93ca92": "• inferred pricing" + }, + "UsageSessionsTable": { + "1afc25eb06": "Turns", + "0f03975d59": "Events", + "21ea00bfa8": "Cache", + "e0b988599d": "Total", + "01476891c7": "Last active", + "c17bed0416": "Project", + "f6a2c8d019": "Model", + "faf3444859": "Input", + "a8b7487ff7": "Output", + "cfe2282ffa": "Unknown" } }, "sparse": { @@ -3079,7 +3172,15 @@ "fe1e616c5b": "Git no está instalado, así que una carpeta simple será el valor predeterminado.", "c234df77f7": "Elige o introduce una carpeta principal del servidor antes de crear.", "3a13f6e88b": "ubicación no seleccionada", - "6ed14c0281": "carpeta del servidor no seleccionada" + "6ed14c0281": "carpeta del servidor no seleccionada", + "createProjectTitle": "Crear proyecto", + "createProjectDescription": "Crea un repositorio Git local y el primer espacio de trabajo.", + "projectNameLabel": "Nombre del proyecto", + "createsGitRepoHelp": "Repositorio Git:", + "parentFolderLabel": "Carpeta principal", + "browseParentFolder": "Explorar", + "gitRequiredError": "Git es necesario para crear un proyecto.", + "createAction": "Crear" }, "AddRepoNestedImportStep": { "496f68cf8c": "Escaneo de repositorios. Haga clic para detener.", @@ -3456,7 +3557,11 @@ "376bed88e5": "La conexión al host remoto encontró un error.", "4afcca1d24": "Reconectar", "11552bf786": "SSH desconectado", - "cb5938ae79": "Reconectando..." + "cb5938ae79": "Reconectando...", + "disconnected": "This remote repository is not connected.", + "reconnecting": "Reconnecting to the remote host...", + "reconnectionFailed": "Reconnection to the remote host failed.", + "authFailed": "Authentication to the remote host failed." }, "SshTargetRow": { "4677394048": "Conectando…", @@ -3680,7 +3785,9 @@ "a6c20dca96": "Abrir un proyecto desde un destino SSH", "3d162cc76f": "Proyecto remoto", "fb4fc5380e": "Proyecto local, repo de Git o carpeta con muchos repos", - "2281fdc8c7": "Explorar carpeta" + "2281fdc8c7": "Explorar carpeta", + "createProjectTitle": "Crear proyecto", + "createGitProjectDescription": "Crear un repositorio Git local" } } } @@ -3851,6 +3958,12 @@ "create": "Create workspace", "sourceProject": "Task Source", "chooseSourceProject": "Choose task source" + }, + "ProjectOrderManualDefaultNotice": { + "a1f4c2d8e0": "Manual project order is now default", + "822ff300ad": "Dismiss", + "b7e3a91c4f": "Drag project headers to reorder, or switch to", + "e8c1f4a2b9": "in workspace options." } }, "shared": { @@ -7538,7 +7651,9 @@ "f273f2271c": "Agente iniciado. Marcados {{value0}} como resueltos, omitidos {{value1}}, con error {{value2}}.", "aa95b81a3a": "Agente iniciado. Marcados {{value0}} como resueltos, omitidos {{value1}}, con error {{value2}}.", "495b2f8c4b": "Agente iniciado, pero no se pudieron marcar los comentarios seleccionados como resueltos.", - "3c3ad3a1d2": "Agente iniciado. Ningún comentario seleccionado se puede marcar como resuelto en el host." + "3c3ad3a1d2": "Agente iniciado. Ningún comentario seleccionado se puede marcar como resuelto en el host.", + "b4f3ec62a1": "More {{value0}} actions", + "a9d7c128e4": "unlink {{value0}}" }, "CreatePullRequestDialog": { "2bc1b4345e": "Cancelar", @@ -8926,7 +9041,11 @@ "3c4adfd821": "arreglar la condición de carrera de inicio de sesión", "56a0271428": "Espacios de trabajo aislados", "ef737dcee1": "GitHub y tareas Lineares", - "ac51c061e2": "codex" + "ac51c061e2": "codex", + "47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.", + "70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.", + "f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.", + "5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents." }, "FeatureWallBody": { "25ec5356d6": "Configuración" @@ -8999,7 +9118,9 @@ "6e3f5223c5": "Explorador", "ab2901bce6": "cheques", "d7f80060ca": "Control de fuente", - "8e715588e4": "Buscar" + "8e715588e4": "Buscar", + "a6c8b9e32f": "Checks passed", + "f4d5e1a7b2": "3 checks" }, "ReviewShipAnimatedVisual": { "4d99496b8c": "Crear relaciones públicas", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c9942ea26bc..d596417e644 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -464,21 +464,32 @@ } } }, - "folder": { - "workspace": { - "path": { - "status": { - "folderNotFound": "Folder not found", - "pathNotFolder": "Path is not a folder", - "cannotDetermineConnection": "Cannot determine connection", - "cannotCheckFolder": "Cannot check folder", - "missingDescription": "Orca cannot find {{value0}}. Remove and re-import this folder workspace.", - "notDirectoryDescription": "{{value0}} exists, but it is not a folder.", - "ambiguousConnectionDescription": "Orca cannot tell which SSH connection owns this folder scope.", - "unavailableDescription": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.", - "createMissingDescription": "Orca cannot find {{value0}}. Remove and re-import the folder.", - "createFailed": "Failed to create folder workspace" - } + "folderWorkspacePathStatus": { + "title": { + "missing": "フォルダーが見つかりません", + "notDirectory": "パスはフォルダーではありません", + "ambiguousConnection": "接続を特定できません", + "unavailable": "フォルダーを確認できません" + }, + "description": { + "missing": "Orca は {{path}} を見つけられません。このフォルダーワークスペースを削除して再インポートしてください。", + "notDirectory": "{{path}} は存在しますが、フォルダーではありません。", + "ambiguousConnection": "Orca はこのフォルダースコープをどの SSH 接続が所有しているか判別できません。", + "unavailable": "Orca は現在このフォルダーを確認できません。ランタイムまたは SSH 接続を確認して再試行してください。" + }, + "createError": { + "title": { + "missing": "フォルダーが見つかりません", + "notDirectory": "パスはフォルダーではありません", + "ambiguousConnection": "接続を特定できません", + "unavailable": "フォルダーを確認できません", + "generic": "フォルダーワークスペースの作成に失敗しました" + }, + "description": { + "missing": "Orca は {{path}} を見つけられません。フォルダーを削除して再インポートしてください。", + "notDirectory": "{{path}} は存在しますが、フォルダーではありません。", + "ambiguousConnection": "Orca はこのフォルダースコープをどの SSH 接続が所有しているか判別できません。", + "unavailable": "Orca は現在このフォルダーを確認できません。ランタイムまたは SSH 接続を確認して再試行してください。" } } } @@ -1018,7 +1029,15 @@ "f660aa1454": "接続中", "7711ad5122": "ローカルセットアップコマンド", "e5db1b0419": "組み合わせセットアップコマンド", - "addProjectBeforeWorkspace": "ワークスペースを作成する前にプロジェクトを追加します。" + "addProjectBeforeWorkspace": "ワークスペースを作成する前にプロジェクトを追加します。", + "sshNotConnected": "SSH not connected", + "connectingSsh": "Connecting SSH...", + "sshAuthenticationFailed": "SSH authentication failed", + "preparingSshConnection": "Preparing SSH connection...", + "connected": "Connected", + "reconnectingSsh": "Reconnecting SSH...", + "sshReconnectionFailed": "SSH reconnection failed", + "notConnected": "Not connected" }, "NewWorkspaceComposerModal": { "fa90f739a5": "ワークスペースを作成する前に、プロジェクト、ワークスペース名、および agent を選択します。" @@ -1793,7 +1812,11 @@ "015b4e607d": "キャンセル", "e3bd59143c": "入れる", "f24783f470": "https://...", - "ec6310b731": "http:// または https:// 画像 URL を使用します。" + "ec6310b731": "http:// または https:// 画像 URL を使用します。", + "b7e4a1c902": "Paste, drop, or click to add files", + "8f1c2d4e6a": "Nothing to preview", + "c91f0a2b14": "Write", + "d82b1e3f05": "Preview" }, "IssueSourceSelector": { "d6aeb2012b": "からのイシューを表示しています", @@ -1867,6 +1890,47 @@ } } } + }, + "CloseReasonDropdown": { + "e1f2a3b4c5": "Choose close reason" + }, + "GitHubIssueCommentComposer": { + "082515176a": "Failed to add comment", + "9f88657c4e": "Issue closed", + "e9b7cb7d17": "Failed to close issue", + "bd3b4492a0": "Issue reopened", + "f2a8c1d903": "Failed to reopen issue", + "a1b2c3d4e5": "Add a comment", + "c5c117270e": "Add your comment here, be kind", + "f6a7b8c9d0": "Close issue", + "b1c2d3e4f5": "Reopen issue", + "0a73f59e85": "Send comment", + "bf43425540": "Comment" + }, + "GitHubWorkItemAssigneePopoverContent": { + "cddd9b04a7": "Loading assignees", + "a00830d3f7": "No users", + "4f8b6f2c1d": "Filter assignees..." + }, + "GitHubWorkItemLabelPopoverContent": { + "2aa9acdf34": "Edit labels on GitHub", + "cddd9b04a7": "Loading labels", + "de26e2eb06": "No labels", + "8b0d52ee3a": "Filter labels..." + }, + "githubIssueCloseReasons": { + "completed": { + "label": "Close as completed", + "description": "Done, closed, fixed, resolved" + }, + "notPlanned": { + "label": "Close as not planned", + "description": "Won't fix, can't repro, stale" + }, + "duplicate": { + "label": "Close as duplicate", + "description": "Duplicate of another issue" + } } }, "linear": { @@ -2142,7 +2206,8 @@ "c44659e09f": "モバイルで操作中", "7cffad954c": "折りたたむ", "3eed73394f": "キーボードが一時停止しています", - "faa367dc74": "この terminal はモバイル アプリに合わせたサイズになっています" + "faa367dc74": "この terminal はモバイル アプリに合わせたサイズになっています", + "54f7d6f69d": "すべての terminal のサイズを変更" }, "TerminalAgentSessionForkDialog": { "17fc841e59": "コンテキストをコピーする", @@ -2768,7 +2833,11 @@ "4f8368c272": "Orca ワークツリーのみ", "cfe2282ffa": "未知", "7765a4c3e1": "該当なし", - "2d41fd45c6": "• 最終スキャン エラー: {{value0}}" + "2d41fd45c6": "• 最終スキャン エラー: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "CodexUsageDailyChart": { "1e6f62d7e3": "推論", @@ -2817,7 +2886,11 @@ "bf6cf2d4dd": "未知", "ae255c3dba": "該当なし", "247c93ca92": "• 推定価格", - "8a6655f7a2": "• 最終スキャン エラー: {{value0}}" + "8a6655f7a2": "• 最終スキャン エラー: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "OpenCodeUsagePane": { "349f7c3f5c": "合計", @@ -2856,7 +2929,11 @@ "e04c58327c": "Orca ワークツリーのみ", "362231082f": "未知", "8095a63426": "該当なし", - "6cc7782458": "• 最終スキャン エラー: {{value0}}" + "6cc7782458": "• 最終スキャン エラー: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "ShareUsageButton": { "7d6b25323d": "Xで共有する", @@ -2975,6 +3052,22 @@ "d9a4b3e2f1c5": "イベント" } } + }, + "UsageBreakdownSection": { + "7765a4c3e1": "n/a", + "247c93ca92": "• inferred pricing" + }, + "UsageSessionsTable": { + "1afc25eb06": "Turns", + "0f03975d59": "Events", + "21ea00bfa8": "Cache", + "e0b988599d": "Total", + "01476891c7": "Last active", + "c17bed0416": "Project", + "f6a2c8d019": "Model", + "faf3444859": "Input", + "a8b7487ff7": "Output", + "cfe2282ffa": "Unknown" } }, "sparse": { @@ -3060,7 +3153,15 @@ "fe1e616c5b": "Git がインストールされていないため、通常のフォルダが既定になります。", "c234df77f7": "作成する前に、サーバーの親フォルダを選択または入力してください。", "3a13f6e88b": "場所が選択されていません", - "6ed14c0281": "サーバーフォルダが選択されていません" + "6ed14c0281": "サーバーフォルダが選択されていません", + "createProjectTitle": "プロジェクトの作成", + "createProjectDescription": "ローカルの Git リポジトリと最初のワークスペースを作成します。", + "projectNameLabel": "プロジェクト名", + "createsGitRepoHelp": "Git リポジトリ:", + "parentFolderLabel": "親フォルダ", + "browseParentFolder": "参照", + "gitRequiredError": "プロジェクトを作成するには Git が必要です。", + "createAction": "作成" }, "AddRepoNestedImportStep": { "496f68cf8c": "リポジトリをスキャンしています。クリックして停止します。", @@ -3437,7 +3538,11 @@ "376bed88e5": "リモート ホストへの接続でエラーが発生しました。", "4afcca1d24": "再接続", "11552bf786": "SSHが切断されました", - "cb5938ae79": "再接続中..." + "cb5938ae79": "再接続中...", + "disconnected": "This remote repository is not connected.", + "reconnecting": "Reconnecting to the remote host...", + "reconnectionFailed": "Reconnection to the remote host failed.", + "authFailed": "Authentication to the remote host failed." }, "SshTargetRow": { "4677394048": "接続中…", @@ -3661,7 +3766,9 @@ "a6c20dca96": "SSH ターゲットからプロジェクトを開く", "3d162cc76f": "リモートプロジェクト", "fb4fc5380e": "ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー", - "2281fdc8c7": "フォルダを参照する" + "2281fdc8c7": "フォルダを参照する", + "createProjectTitle": "プロジェクトの作成", + "createGitProjectDescription": "ローカルの Git リポジトリを作成" } } } @@ -3851,6 +3958,12 @@ "create": "Create workspace", "sourceProject": "Task Source", "chooseSourceProject": "Choose task source" + }, + "ProjectOrderManualDefaultNotice": { + "a1f4c2d8e0": "Manual project order is now default", + "822ff300ad": "Dismiss", + "b7e3a91c4f": "Drag project headers to reorder, or switch to", + "e8c1f4a2b9": "in workspace options." } }, "shared": { @@ -7538,7 +7651,9 @@ "f273f2271c": "agent を開始しました。{{value0}} 件を解決済みにし、{{value1}} 件をスキップ、{{value2}} 件が失敗しました。", "aa95b81a3a": "agent を開始しました。{{value0}} 件を解決済みにし、{{value1}} 件をスキップ、{{value2}} 件が失敗しました。", "495b2f8c4b": "agent を開始しましたが、選択したコメントを解決済みにできませんでした。", - "3c3ad3a1d2": "agent を開始しました。選択したコメントにホスト上で解決済みにできるものはありません。" + "3c3ad3a1d2": "agent を開始しました。選択したコメントにホスト上で解決済みにできるものはありません。", + "b4f3ec62a1": "More {{value0}} actions", + "a9d7c128e4": "unlink {{value0}}" }, "CreatePullRequestDialog": { "2bc1b4345e": "キャンセル", @@ -8926,7 +9041,11 @@ "3c4adfd821": "ログイン競合状態を修正", "56a0271428": "隔離されたワークスペース", "ef737dcee1": "GitHub とLinearタスク", - "ac51c061e2": "codex" + "ac51c061e2": "codex", + "47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.", + "70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.", + "f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.", + "5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents." }, "FeatureWallBody": { "25ec5356d6": "設定" @@ -8999,7 +9118,9 @@ "6e3f5223c5": "エクスプローラ", "ab2901bce6": "チェック", "d7f80060ca": "ソース管理", - "8e715588e4": "検索" + "8e715588e4": "検索", + "a6c8b9e32f": "Checks passed", + "f4d5e1a7b2": "3 checks" }, "ReviewShipAnimatedVisual": { "4d99496b8c": "PRを作成する", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3b5cc43a8d0..75c4aa19a52 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -464,21 +464,32 @@ } } }, - "folder": { - "workspace": { - "path": { - "status": { - "folderNotFound": "Folder not found", - "pathNotFolder": "Path is not a folder", - "cannotDetermineConnection": "Cannot determine connection", - "cannotCheckFolder": "Cannot check folder", - "missingDescription": "Orca cannot find {{value0}}. Remove and re-import this folder workspace.", - "notDirectoryDescription": "{{value0}} exists, but it is not a folder.", - "ambiguousConnectionDescription": "Orca cannot tell which SSH connection owns this folder scope.", - "unavailableDescription": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.", - "createMissingDescription": "Orca cannot find {{value0}}. Remove and re-import the folder.", - "createFailed": "Failed to create folder workspace" - } + "folderWorkspacePathStatus": { + "title": { + "missing": "폴더를 찾을 수 없습니다", + "notDirectory": "경로가 폴더가 아닙니다", + "ambiguousConnection": "연결을 확인할 수 없습니다", + "unavailable": "폴더를 확인할 수 없습니다" + }, + "description": { + "missing": "Orca가 {{path}}을(를) 찾을 수 없습니다. 이 폴더 워크스페이스를 제거하고 다시 가져오세요.", + "notDirectory": "{{path}}이(가) 존재하지만 폴더가 아닙니다.", + "ambiguousConnection": "Orca가 이 폴더 범위를 소유한 SSH 연결을 확인할 수 없습니다.", + "unavailable": "Orca가 지금 이 폴더를 확인할 수 없습니다. 런타임 또는 SSH 연결을 확인한 후 다시 시도하세요." + }, + "createError": { + "title": { + "missing": "폴더를 찾을 수 없습니다", + "notDirectory": "경로가 폴더가 아닙니다", + "ambiguousConnection": "연결을 확인할 수 없습니다", + "unavailable": "폴더를 확인할 수 없습니다", + "generic": "폴더 워크스페이스를 만들지 못했습니다" + }, + "description": { + "missing": "Orca가 {{path}}을(를) 찾을 수 없습니다. 폴더를 제거하고 다시 가져오세요.", + "notDirectory": "{{path}}이(가) 존재하지만 폴더가 아닙니다.", + "ambiguousConnection": "Orca가 이 폴더 범위를 소유한 SSH 연결을 확인할 수 없습니다.", + "unavailable": "Orca가 지금 이 폴더를 확인할 수 없습니다. 런타임 또는 SSH 연결을 확인한 후 다시 시도하세요." } } } @@ -1018,7 +1029,15 @@ "f660aa1454": "연결 중", "7711ad5122": "로컬 설정 명령", "e5db1b0419": "결합된 설정 명령", - "addProjectBeforeWorkspace": "워크스페이스를 만들기 전에 프로젝트를 추가하세요." + "addProjectBeforeWorkspace": "워크스페이스를 만들기 전에 프로젝트를 추가하세요.", + "sshNotConnected": "SSH not connected", + "connectingSsh": "Connecting SSH...", + "sshAuthenticationFailed": "SSH authentication failed", + "preparingSshConnection": "Preparing SSH connection...", + "connected": "Connected", + "reconnectingSsh": "Reconnecting SSH...", + "sshReconnectionFailed": "SSH reconnection failed", + "notConnected": "Not connected" }, "NewWorkspaceComposerModal": { "fa90f739a5": "워크스페이스를 생성하기 전에 프로젝트, 워크스페이스 이름, agent를 선택하세요." @@ -1793,7 +1812,11 @@ "015b4e607d": "취소", "e3bd59143c": "끼워 넣다", "f24783f470": "https://...", - "ec6310b731": "http:// 또는 https:// 이미지 URL을 사용하세요." + "ec6310b731": "http:// 또는 https:// 이미지 URL을 사용하세요.", + "b7e4a1c902": "Paste, drop, or click to add files", + "8f1c2d4e6a": "Nothing to preview", + "c91f0a2b14": "Write", + "d82b1e3f05": "Preview" }, "IssueSourceSelector": { "d6aeb2012b": "다음에서 이슈 표시", @@ -1867,6 +1890,47 @@ } } } + }, + "CloseReasonDropdown": { + "e1f2a3b4c5": "Choose close reason" + }, + "GitHubIssueCommentComposer": { + "082515176a": "Failed to add comment", + "9f88657c4e": "Issue closed", + "e9b7cb7d17": "Failed to close issue", + "bd3b4492a0": "Issue reopened", + "f2a8c1d903": "Failed to reopen issue", + "a1b2c3d4e5": "Add a comment", + "c5c117270e": "Add your comment here, be kind", + "f6a7b8c9d0": "Close issue", + "b1c2d3e4f5": "Reopen issue", + "0a73f59e85": "Send comment", + "bf43425540": "Comment" + }, + "GitHubWorkItemAssigneePopoverContent": { + "cddd9b04a7": "Loading assignees", + "a00830d3f7": "No users", + "4f8b6f2c1d": "Filter assignees..." + }, + "GitHubWorkItemLabelPopoverContent": { + "2aa9acdf34": "Edit labels on GitHub", + "cddd9b04a7": "Loading labels", + "de26e2eb06": "No labels", + "8b0d52ee3a": "Filter labels..." + }, + "githubIssueCloseReasons": { + "completed": { + "label": "Close as completed", + "description": "Done, closed, fixed, resolved" + }, + "notPlanned": { + "label": "Close as not planned", + "description": "Won't fix, can't repro, stale" + }, + "duplicate": { + "label": "Close as duplicate", + "description": "Duplicate of another issue" + } } }, "linear": { @@ -2142,7 +2206,8 @@ "c44659e09f": "모바일 원격 조작", "7cffad954c": "접기", "3eed73394f": "키보드가 일시중지되었습니다.", - "faa367dc74": "이 terminal은 모바일 앱에 맞게 크기가 조정됩니다." + "faa367dc74": "이 terminal은 모바일 앱에 맞게 크기가 조정됩니다.", + "54f7d6f69d": "모든 terminal 크기 조정" }, "TerminalAgentSessionForkDialog": { "17fc841e59": "컨텍스트 복사", @@ -2768,7 +2833,11 @@ "4f8368c272": "Orca 작업 트리만 해당", "cfe2282ffa": "알려지지 않은", "7765a4c3e1": "해당 없음", - "2d41fd45c6": "• 마지막 스캔 오류: {{value0}}" + "2d41fd45c6": "• 마지막 스캔 오류: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "CodexUsageDailyChart": { "1e6f62d7e3": "추리", @@ -2817,7 +2886,11 @@ "bf6cf2d4dd": "알려지지 않은", "ae255c3dba": "해당 없음", "247c93ca92": "• 추론된 가격", - "8a6655f7a2": "• 마지막 스캔 오류: {{value0}}" + "8a6655f7a2": "• 마지막 스캔 오류: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "OpenCodeUsagePane": { "349f7c3f5c": "총", @@ -2856,7 +2929,11 @@ "e04c58327c": "Orca 작업 트리만 해당", "362231082f": "알려지지 않은", "8095a63426": "해당 없음", - "6cc7782458": "• 마지막 스캔 오류: {{value0}}" + "6cc7782458": "• 마지막 스캔 오류: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "ShareUsageButton": { "7d6b25323d": "X에 공유", @@ -2975,6 +3052,22 @@ "d9a4b3e2f1c5": "이벤트" } } + }, + "UsageBreakdownSection": { + "7765a4c3e1": "n/a", + "247c93ca92": "• inferred pricing" + }, + "UsageSessionsTable": { + "1afc25eb06": "Turns", + "0f03975d59": "Events", + "21ea00bfa8": "Cache", + "e0b988599d": "Total", + "01476891c7": "Last active", + "c17bed0416": "Project", + "f6a2c8d019": "Model", + "faf3444859": "Input", + "a8b7487ff7": "Output", + "cfe2282ffa": "Unknown" } }, "sparse": { @@ -3060,7 +3153,15 @@ "fe1e616c5b": "Git이 설치되어 있지 않아 일반 폴더가 기본값입니다.", "c234df77f7": "만들기 전에 서버 상위 폴더를 선택하거나 입력하세요.", "3a13f6e88b": "위치가 선택되지 않음", - "6ed14c0281": "서버 폴더가 선택되지 않음" + "6ed14c0281": "서버 폴더가 선택되지 않음", + "createProjectTitle": "프로젝트 생성", + "createProjectDescription": "로컬 Git 저장소와 첫 번째 작업 공간을 만듭니다.", + "projectNameLabel": "프로젝트 이름", + "createsGitRepoHelp": "Git 저장소:", + "parentFolderLabel": "상위 폴더", + "browseParentFolder": "찾아보기", + "gitRequiredError": "프로젝트를 만들려면 Git이 필요합니다.", + "createAction": "생성" }, "AddRepoNestedImportStep": { "496f68cf8c": "저장소를 스캔하는 중입니다. 중지하려면 클릭하세요.", @@ -3437,7 +3538,11 @@ "376bed88e5": "원격 호스트 연결에 오류가 발생했습니다.", "4afcca1d24": "다시 연결", "11552bf786": "SSH 연결 끊김", - "cb5938ae79": "다시 연결하는 중..." + "cb5938ae79": "다시 연결하는 중...", + "disconnected": "This remote repository is not connected.", + "reconnecting": "Reconnecting to the remote host...", + "reconnectionFailed": "Reconnection to the remote host failed.", + "authFailed": "Authentication to the remote host failed." }, "SshTargetRow": { "4677394048": "연결 중…", @@ -3661,7 +3766,9 @@ "a6c20dca96": "SSH 대상에서 프로젝트 열기", "3d162cc76f": "원격 프로젝트", "fb4fc5380e": "로컬 프로젝트, Git repo 또는 repos가 많은 폴더", - "2281fdc8c7": "폴더 찾아보기" + "2281fdc8c7": "폴더 찾아보기", + "createProjectTitle": "프로젝트 생성", + "createGitProjectDescription": "로컬 Git 저장소 생성" } } } @@ -3851,6 +3958,12 @@ "create": "워크스페이스 만들기", "sourceProject": "Task Source", "chooseSourceProject": "Choose task source" + }, + "ProjectOrderManualDefaultNotice": { + "a1f4c2d8e0": "Manual project order is now default", + "822ff300ad": "Dismiss", + "b7e3a91c4f": "Drag project headers to reorder, or switch to", + "e8c1f4a2b9": "in workspace options." } }, "shared": { @@ -7538,7 +7651,9 @@ "f273f2271c": "agent를 시작했습니다. {{value0}}개 해결됨으로 표시, {{value1}}개 건너뜀, {{value2}}개 실패.", "aa95b81a3a": "agent를 시작했습니다. {{value0}}개 해결됨으로 표시, {{value1}}개 건너뜀, {{value2}}개 실패.", "495b2f8c4b": "agent를 시작했지만 선택한 댓글을 해결됨으로 표시할 수 없습니다.", - "3c3ad3a1d2": "agent를 시작했습니다. 호스트에서 해결됨으로 표시할 수 있는 선택된 댓글이 없습니다." + "3c3ad3a1d2": "agent를 시작했습니다. 호스트에서 해결됨으로 표시할 수 있는 선택된 댓글이 없습니다.", + "b4f3ec62a1": "More {{value0}} actions", + "a9d7c128e4": "unlink {{value0}}" }, "CreatePullRequestDialog": { "2bc1b4345e": "취소", @@ -8926,7 +9041,11 @@ "3c4adfd821": "로그인 경쟁 조건 수정", "56a0271428": "격리된 워크스페이스", "ef737dcee1": "GitHub 및 Linear 작업", - "ac51c061e2": "codex" + "ac51c061e2": "codex", + "47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.", + "70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.", + "f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.", + "5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents." }, "FeatureWallBody": { "25ec5356d6": "설정" @@ -8999,7 +9118,9 @@ "6e3f5223c5": "탐침", "ab2901bce6": "검사", "d7f80060ca": "소스 제어", - "8e715588e4": "검색" + "8e715588e4": "검색", + "a6c8b9e32f": "Checks passed", + "f4d5e1a7b2": "3 checks" }, "ReviewShipAnimatedVisual": { "4d99496b8c": "PR 작성", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 66ffc2376b4..70b8601138b 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -464,21 +464,32 @@ } } }, - "folder": { - "workspace": { - "path": { - "status": { - "folderNotFound": "Folder not found", - "pathNotFolder": "Path is not a folder", - "cannotDetermineConnection": "Cannot determine connection", - "cannotCheckFolder": "Cannot check folder", - "missingDescription": "Orca cannot find {{value0}}. Remove and re-import this folder workspace.", - "notDirectoryDescription": "{{value0}} exists, but it is not a folder.", - "ambiguousConnectionDescription": "Orca cannot tell which SSH connection owns this folder scope.", - "unavailableDescription": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.", - "createMissingDescription": "Orca cannot find {{value0}}. Remove and re-import the folder.", - "createFailed": "Failed to create folder workspace" - } + "folderWorkspacePathStatus": { + "title": { + "missing": "未找到文件夹", + "notDirectory": "路径不是文件夹", + "ambiguousConnection": "无法确定连接", + "unavailable": "无法检查文件夹" + }, + "description": { + "missing": "Orca 找不到 {{path}}。请移除并重新导入此文件夹工作区。", + "notDirectory": "{{path}} 存在,但它不是文件夹。", + "ambiguousConnection": "Orca 无法判断哪个 SSH 连接拥有此文件夹范围。", + "unavailable": "Orca 现在无法验证此文件夹。请检查运行时或 SSH 连接,然后重试。" + }, + "createError": { + "title": { + "missing": "未找到文件夹", + "notDirectory": "路径不是文件夹", + "ambiguousConnection": "无法确定连接", + "unavailable": "无法检查文件夹", + "generic": "创建文件夹工作区失败" + }, + "description": { + "missing": "Orca 找不到 {{path}}。请移除并重新导入该文件夹。", + "notDirectory": "{{path}} 存在,但它不是文件夹。", + "ambiguousConnection": "Orca 无法判断哪个 SSH 连接拥有此文件夹范围。", + "unavailable": "Orca 现在无法验证此文件夹。请检查运行时或 SSH 连接,然后重试。" } } } @@ -1018,7 +1029,15 @@ "f660aa1454": "连接中", "7711ad5122": "本地设置命令", "e5db1b0419": "组合设置命令", - "addProjectBeforeWorkspace": "在创建工作区之前添加项目。" + "addProjectBeforeWorkspace": "在创建工作区之前添加项目。", + "sshNotConnected": "SSH not connected", + "connectingSsh": "Connecting SSH...", + "sshAuthenticationFailed": "SSH authentication failed", + "preparingSshConnection": "Preparing SSH connection...", + "connected": "Connected", + "reconnectingSsh": "Reconnecting SSH...", + "sshReconnectionFailed": "SSH reconnection failed", + "notConnected": "Not connected" }, "NewWorkspaceComposerModal": { "fa90f739a5": "创建工作区之前选择项目、工作区名称和 Agent。" @@ -1793,7 +1812,11 @@ "015b4e607d": "取消", "e3bd59143c": "插入", "f24783f470": "https://...", - "ec6310b731": "使用 http:// 或 https:// 图片 URL。" + "ec6310b731": "使用 http:// 或 https:// 图片 URL。", + "b7e4a1c902": "Paste, drop, or click to add files", + "8f1c2d4e6a": "Nothing to preview", + "c91f0a2b14": "Write", + "d82b1e3f05": "Preview" }, "IssueSourceSelector": { "d6aeb2012b": "显示议题来自", @@ -1867,6 +1890,47 @@ } } } + }, + "CloseReasonDropdown": { + "e1f2a3b4c5": "Choose close reason" + }, + "GitHubIssueCommentComposer": { + "082515176a": "Failed to add comment", + "9f88657c4e": "Issue closed", + "e9b7cb7d17": "Failed to close issue", + "bd3b4492a0": "Issue reopened", + "f2a8c1d903": "Failed to reopen issue", + "a1b2c3d4e5": "Add a comment", + "c5c117270e": "Add your comment here, be kind", + "f6a7b8c9d0": "Close issue", + "b1c2d3e4f5": "Reopen issue", + "0a73f59e85": "Send comment", + "bf43425540": "Comment" + }, + "GitHubWorkItemAssigneePopoverContent": { + "cddd9b04a7": "Loading assignees", + "a00830d3f7": "No users", + "4f8b6f2c1d": "Filter assignees..." + }, + "GitHubWorkItemLabelPopoverContent": { + "2aa9acdf34": "Edit labels on GitHub", + "cddd9b04a7": "Loading labels", + "de26e2eb06": "No labels", + "8b0d52ee3a": "Filter labels..." + }, + "githubIssueCloseReasons": { + "completed": { + "label": "Close as completed", + "description": "Done, closed, fixed, resolved" + }, + "notPlanned": { + "label": "Close as not planned", + "description": "Won't fix, can't repro, stale" + }, + "duplicate": { + "label": "Close as duplicate", + "description": "Duplicate of another issue" + } } }, "linear": { @@ -2142,7 +2206,8 @@ "c44659e09f": "手机驾驶", "7cffad954c": "折叠", "3eed73394f": "您的键盘已暂停", - "faa367dc74": "该 terminal 的尺寸适合您的手机应用程序" + "faa367dc74": "该 terminal 的尺寸适合您的手机应用程序", + "54f7d6f69d": "调整所有 terminal 尺寸" }, "TerminalAgentSessionForkDialog": { "17fc841e59": "复制上下文", @@ -2768,7 +2833,11 @@ "4f8368c272": "仅 Orca 工作树", "cfe2282ffa": "未知", "7765a4c3e1": "不适用", - "2d41fd45c6": "• 上次扫描错误:{{value0}}" + "2d41fd45c6": "• 上次扫描错误:{{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "CodexUsageDailyChart": { "1e6f62d7e3": "推理", @@ -2817,7 +2886,11 @@ "bf6cf2d4dd": "未知", "ae255c3dba": "不适用", "247c93ca92": "• 推断定价", - "8a6655f7a2": "• 上次扫描错误:{{value0}}" + "8a6655f7a2": "• 上次扫描错误:{{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "OpenCodeUsagePane": { "349f7c3f5c": "全部的", @@ -2856,7 +2929,11 @@ "e04c58327c": "仅 Orca 工作树", "362231082f": "未知", "8095a63426": "不适用", - "6cc7782458": "• 上次扫描错误:{{value0}}" + "6cc7782458": "• 上次扫描错误:{{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "ShareUsageButton": { "7d6b25323d": "分享到 X", @@ -2975,6 +3052,22 @@ "d9a4b3e2f1c5": "事件" } } + }, + "UsageBreakdownSection": { + "7765a4c3e1": "n/a", + "247c93ca92": "• inferred pricing" + }, + "UsageSessionsTable": { + "1afc25eb06": "Turns", + "0f03975d59": "Events", + "21ea00bfa8": "Cache", + "e0b988599d": "Total", + "01476891c7": "Last active", + "c17bed0416": "Project", + "f6a2c8d019": "Model", + "faf3444859": "Input", + "a8b7487ff7": "Output", + "cfe2282ffa": "Unknown" } }, "sparse": { @@ -3060,7 +3153,15 @@ "fe1e616c5b": "未安装 Git,因此默认创建普通文件夹。", "c234df77f7": "创建前请选择或输入服务器父文件夹。", "3a13f6e88b": "未选择位置", - "6ed14c0281": "未选择服务器文件夹" + "6ed14c0281": "未选择服务器文件夹", + "createProjectTitle": "创建项目", + "createProjectDescription": "创建本地 Git 存储库和第一个工作区。", + "projectNameLabel": "项目名称", + "createsGitRepoHelp": "Git 存储库:", + "parentFolderLabel": "父文件夹", + "browseParentFolder": "浏览", + "gitRequiredError": "创建项目需要 Git。", + "createAction": "创建" }, "AddRepoNestedImportStep": { "496f68cf8c": "扫描存储库。单击停止。", @@ -3437,7 +3538,11 @@ "376bed88e5": "与远程主机的连接遇到错误。", "4afcca1d24": "重新连接", "11552bf786": "SSH 已断开", - "cb5938ae79": "正在重新连接..." + "cb5938ae79": "正在重新连接...", + "disconnected": "This remote repository is not connected.", + "reconnecting": "Reconnecting to the remote host...", + "reconnectionFailed": "Reconnection to the remote host failed.", + "authFailed": "Authentication to the remote host failed." }, "SshTargetRow": { "4677394048": "正在连接…", @@ -3661,7 +3766,9 @@ "a6c20dca96": "从 SSH 目标打开项目", "3d162cc76f": "远程项目", "fb4fc5380e": "本地项目、Git repo 或包含多个 repos 的文件夹", - "2281fdc8c7": "浏览文件夹" + "2281fdc8c7": "浏览文件夹", + "createProjectTitle": "创建项目", + "createGitProjectDescription": "创建本地 Git 存储库" } } } @@ -3851,6 +3958,12 @@ "create": "创建工作区", "sourceProject": "Task Source", "chooseSourceProject": "Choose task source" + }, + "ProjectOrderManualDefaultNotice": { + "a1f4c2d8e0": "Manual project order is now default", + "822ff300ad": "Dismiss", + "b7e3a91c4f": "Drag project headers to reorder, or switch to", + "e8c1f4a2b9": "in workspace options." } }, "shared": { @@ -7538,7 +7651,9 @@ "f273f2271c": "已启动 agent。已标记 {{value0}} 个为已解决,跳过 {{value1}} 个,失败 {{value2}} 个。", "aa95b81a3a": "已启动 agent。已标记 {{value0}} 个为已解决,跳过 {{value1}} 个,失败 {{value2}} 个。", "495b2f8c4b": "已启动 agent,但无法将选中的评论标记为已解决。", - "3c3ad3a1d2": "已启动 agent。选中的评论中没有可在托管平台上标记为已解决的评论。" + "3c3ad3a1d2": "已启动 agent。选中的评论中没有可在托管平台上标记为已解决的评论。", + "b4f3ec62a1": "More {{value0}} actions", + "a9d7c128e4": "unlink {{value0}}" }, "CreatePullRequestDialog": { "2bc1b4345e": "取消", @@ -8926,7 +9041,11 @@ "3c4adfd821": "修复登录竞争条件", "56a0271428": "独立的工作区", "ef737dcee1": "GitHub 和 Linear 任务", - "ac51c061e2": "codex" + "ac51c061e2": "codex", + "47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.", + "70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.", + "f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.", + "5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents." }, "FeatureWallBody": { "25ec5356d6": "设置" @@ -8999,7 +9118,9 @@ "6e3f5223c5": "探险家", "ab2901bce6": "检查项", "d7f80060ca": "源代码控制", - "8e715588e4": "搜索" + "8e715588e4": "搜索", + "a6c8b9e32f": "Checks passed", + "f4d5e1a7b2": "3 checks" }, "ReviewShipAnimatedVisual": { "4d99496b8c": "创建PR", diff --git a/src/renderer/src/i18n/no-top-level-translate.test.ts b/src/renderer/src/i18n/no-top-level-translate.test.ts new file mode 100644 index 00000000000..65d6a702db0 --- /dev/null +++ b/src/renderer/src/i18n/no-top-level-translate.test.ts @@ -0,0 +1,82 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { relative, resolve } from 'node:path' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' + +const RENDERER_ROOT = resolve('src/renderer/src') + +const FUNCTION_KINDS = new Set([ + ts.SyntaxKind.FunctionDeclaration, + ts.SyntaxKind.FunctionExpression, + ts.SyntaxKind.ArrowFunction, + ts.SyntaxKind.MethodDeclaration, + ts.SyntaxKind.Constructor, + ts.SyntaxKind.GetAccessor, + ts.SyntaxKind.SetAccessor +]) + +function collectSourceFiles(dir: string, files: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const filePath = resolve(dir, name) + const stat = statSync(filePath) + if (stat.isDirectory()) { + collectSourceFiles(filePath, files) + } else if ( + /\.(ts|tsx)$/.test(name) && + !/\.test\.(ts|tsx)$/.test(name) && + !filePath.includes('/i18n/locales/') + ) { + files.push(filePath) + } + } + return files +} + +function isInsideFunction(node: ts.Node): boolean { + let parent = node.parent + while (parent && parent.kind !== ts.SyntaxKind.SourceFile) { + if (FUNCTION_KINDS.has(parent.kind)) { + return true + } + parent = parent.parent + } + return false +} + +describe('i18n import-time safety', () => { + it('does not evaluate translate() at module load time', () => { + const violations: string[] = [] + + for (const filePath of collectSourceFiles(RENDERER_ROOT)) { + const source = readFileSync(filePath, 'utf8') + if (!source.includes('translate(')) { + continue + } + const sourceFile = ts.createSourceFile( + filePath, + source, + ts.ScriptTarget.Latest, + true, + filePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS + ) + + function visit(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + node.expression.getText(sourceFile) === 'translate' && + !isInsideFunction(node) + ) { + const { line, character } = sourceFile.getLineAndCharacterOfPosition( + node.getStart(sourceFile) + ) + violations.push(`${relative(process.cwd(), filePath)}:${line + 1}:${character + 1}`) + } + ts.forEachChild(node, visit) + } + + visit(sourceFile) + } + + expect(violations).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/folder-workspace-path-status.ts b/src/renderer/src/lib/folder-workspace-path-status.ts index 8326229a74b..51f2807d6cc 100644 --- a/src/renderer/src/lib/folder-workspace-path-status.ts +++ b/src/renderer/src/lib/folder-workspace-path-status.ts @@ -1,6 +1,6 @@ +import { translate } from '@/i18n/i18n' import type { FolderWorkspacePathStatus } from '../../../shared/folder-workspace-path-status' import { blocksFolderWorkspaceActivation } from '../../../shared/folder-workspace-path-status' -import { translate } from '@/i18n/i18n' export function getFolderWorkspacePathStatusTitle( status: FolderWorkspacePathStatus | null | undefined @@ -10,21 +10,21 @@ export function getFolderWorkspacePathStatusTitle( } switch (status.reason) { case 'missing': - return translate('auto.lib.folder.workspace.path.status.folderNotFound', 'Folder not found') + return translate('auto.lib.folderWorkspacePathStatus.title.missing', 'Folder not found') case 'not-directory': return translate( - 'auto.lib.folder.workspace.path.status.pathNotFolder', + 'auto.lib.folderWorkspacePathStatus.title.notDirectory', 'Path is not a folder' ) case 'ambiguous-connection': return translate( - 'auto.lib.folder.workspace.path.status.cannotDetermineConnection', + 'auto.lib.folderWorkspacePathStatus.title.ambiguousConnection', 'Cannot determine connection' ) - case 'unavailable': case undefined: + case 'unavailable': return translate( - 'auto.lib.folder.workspace.path.status.cannotCheckFolder', + 'auto.lib.folderWorkspacePathStatus.title.unavailable', 'Cannot check folder' ) } @@ -39,25 +39,25 @@ export function getFolderWorkspacePathStatusDescription( switch (status.reason) { case 'missing': return translate( - 'auto.lib.folder.workspace.path.status.missingDescription', - 'Orca cannot find {{value0}}. Remove and re-import this folder workspace.', - { value0: status.path } + 'auto.lib.folderWorkspacePathStatus.description.missing', + 'Orca cannot find {{path}}. Remove and re-import this folder workspace.', + { path: status.path } ) case 'not-directory': return translate( - 'auto.lib.folder.workspace.path.status.notDirectoryDescription', - '{{value0}} exists, but it is not a folder.', - { value0: status.path } + 'auto.lib.folderWorkspacePathStatus.description.notDirectory', + '{{path}} exists, but it is not a folder.', + { path: status.path } ) case 'ambiguous-connection': return translate( - 'auto.lib.folder.workspace.path.status.ambiguousConnectionDescription', + 'auto.lib.folderWorkspacePathStatus.description.ambiguousConnection', 'Orca cannot tell which SSH connection owns this folder scope.' ) - case 'unavailable': case undefined: + case 'unavailable': return translate( - 'auto.lib.folder.workspace.path.status.unavailableDescription', + 'auto.lib.folderWorkspacePathStatus.description.unavailable', 'Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.' ) } @@ -71,35 +71,38 @@ export function formatFolderWorkspaceCreateError(error: unknown): { const path = message.includes(':') ? message.slice(message.indexOf(':') + 1) : '' if (message.startsWith('folder_workspace_path_missing:')) { return { - title: translate('auto.lib.folder.workspace.path.status.folderNotFound', 'Folder not found'), + title: translate( + 'auto.lib.folderWorkspacePathStatus.createError.title.missing', + 'Folder not found' + ), description: translate( - 'auto.lib.folder.workspace.path.status.createMissingDescription', - 'Orca cannot find {{value0}}. Remove and re-import the folder.', - { value0: path } + 'auto.lib.folderWorkspacePathStatus.createError.description.missing', + 'Orca cannot find {{path}}. Remove and re-import the folder.', + { path } ) } } if (message.startsWith('folder_workspace_path_not_directory:')) { return { title: translate( - 'auto.lib.folder.workspace.path.status.pathNotFolder', + 'auto.lib.folderWorkspacePathStatus.createError.title.notDirectory', 'Path is not a folder' ), description: translate( - 'auto.lib.folder.workspace.path.status.notDirectoryDescription', - '{{value0}} exists, but it is not a folder.', - { value0: path } + 'auto.lib.folderWorkspacePathStatus.createError.description.notDirectory', + '{{path}} exists, but it is not a folder.', + { path } ) } } if (message.startsWith('folder_workspace_connection_ambiguous:')) { return { title: translate( - 'auto.lib.folder.workspace.path.status.cannotDetermineConnection', + 'auto.lib.folderWorkspacePathStatus.createError.title.ambiguousConnection', 'Cannot determine connection' ), description: translate( - 'auto.lib.folder.workspace.path.status.ambiguousConnectionDescription', + 'auto.lib.folderWorkspacePathStatus.createError.description.ambiguousConnection', 'Orca cannot tell which SSH connection owns this folder scope.' ) } @@ -107,18 +110,18 @@ export function formatFolderWorkspaceCreateError(error: unknown): { if (message.startsWith('folder_workspace_path_unavailable:')) { return { title: translate( - 'auto.lib.folder.workspace.path.status.cannotCheckFolder', + 'auto.lib.folderWorkspacePathStatus.createError.title.unavailable', 'Cannot check folder' ), description: translate( - 'auto.lib.folder.workspace.path.status.unavailableDescription', + 'auto.lib.folderWorkspacePathStatus.createError.description.unavailable', 'Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.' ) } } return { title: translate( - 'auto.lib.folder.workspace.path.status.createFailed', + 'auto.lib.folderWorkspacePathStatus.createError.title.generic', 'Failed to create folder workspace' ), description: message diff --git a/src/renderer/src/lib/pane-manager/mobile-driver-state.test.ts b/src/renderer/src/lib/pane-manager/mobile-driver-state.test.ts index 94b6e15c98b..b1a3c49fef4 100644 --- a/src/renderer/src/lib/pane-manager/mobile-driver-state.test.ts +++ b/src/renderer/src/lib/pane-manager/mobile-driver-state.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { + getAllDrivers, getDriverForPty, hydrateDrivers, isPtyLocked, @@ -24,6 +25,21 @@ describe('mobile-driver-state', () => { expect(isPtyLocked('pty-1')).toBe(false) }) + it('returns a defensive snapshot of all non-idle drivers', () => { + setDriverForPty('pty-1', { kind: 'mobile', clientId: 'phone-1' }) + setDriverForPty('pty-2', { kind: 'desktop' }) + + const drivers = getAllDrivers() + expect([...drivers.entries()]).toEqual([ + ['pty-1', { kind: 'mobile', clientId: 'phone-1' }], + ['pty-2', { kind: 'desktop' }] + ]) + drivers.clear() + + expect(getDriverForPty('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-1' }) + expect(getDriverForPty('pty-2')).toEqual({ kind: 'desktop' }) + }) + it('hydrates driver snapshots and notifies affected listeners', () => { setDriverForPty('pty-old', { kind: 'mobile', clientId: 'phone-old' }) const listener = vi.fn() diff --git a/src/renderer/src/lib/pane-manager/mobile-driver-state.ts b/src/renderer/src/lib/pane-manager/mobile-driver-state.ts index 3e97491e7ef..504a4c23a64 100644 --- a/src/renderer/src/lib/pane-manager/mobile-driver-state.ts +++ b/src/renderer/src/lib/pane-manager/mobile-driver-state.ts @@ -46,6 +46,10 @@ export function getDriverForPty(ptyId: string): DriverState { return driverByPtyId.get(ptyId) ?? { kind: 'idle' } } +export function getAllDrivers(): Map { + return new Map(driverByPtyId) +} + export function isPtyLocked(ptyId: string): boolean { return driverByPtyId.get(ptyId)?.kind === 'mobile' } diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts new file mode 100644 index 00000000000..f808bb54090 --- /dev/null +++ b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { useAppStore } from '@/store' +import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' + +const initialAppStoreState = useAppStore.getState() + +afterEach(() => { + vi.unstubAllGlobals() + useAppStore.setState(initialAppStoreState, true) +}) + +function makeRecord( + overrides: Partial = {} +): SleepingAgentSessionRecord { + return { + paneKey: 'tab-1:leaf-1', + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeTerminalTab(id: string, worktreeId: string): Record { + return { + id, + ptyId: null, + worktreeId, + title: 'shell', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +describe('resumeSleepingAgentSessionsForWorktree', () => { + it('skips quit-captured records — their restored pane owns recovery', () => { + const record = makeRecord({ origin: 'quit' }) + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1') + + expect(launched).toBe(0) + // Why: the restored pane either warm-reattaches the still-running agent or + // cold-restores with the resume command; a separate tab here would + // duplicate the session. + expect(useAppStore.getState().tabsByWorktree['wt-1']).toHaveLength(1) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) + }) + + it('resumes legacy sleep records without an origin even when their tab still exists', () => { + const record = makeRecord() + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1') + + expect(launched).toBe(1) + const state = useAppStore.getState() + const resumedTab = (state.tabsByWorktree['wt-1'] ?? []).find((tab) => tab.id !== 'tab-1') + expect(resumedTab?.launchAgent).toBe('claude') + expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + }) + + it('resumes worktree-sleep records into a fresh tab', () => { + const record = makeRecord({ origin: 'worktree-sleep' }) + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1') + + expect(launched).toBe(1) + const state = useAppStore.getState() + const tabs = state.tabsByWorktree['wt-1'] ?? [] + expect(tabs).toHaveLength(1) + expect(tabs[0]?.launchAgent).toBe('claude') + expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + }) +}) diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index dc379128606..a428fbcf4e1 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -80,6 +80,11 @@ function launchSleepingAgentSession(record: SleepingAgentSessionRecord): boolean export function resumeSleepingAgentSessionsForWorktree(worktreeId: string): number { const records = Object.values(useAppStore.getState().sleepingAgentSessionsByPaneKey) .filter((record) => record.worktreeId === worktreeId) + // Why: quit-time captures (#5232) cover panes that still exist in the + // restored session. Those panes own their own recovery — warm reattach + // when the daemon kept the agent alive, or the pane-level cold-restore + // resume — so launching a separate tab here would duplicate the session. + .filter((record) => record.origin !== 'quit') .sort((a, b) => a.capturedAt - b.capturedAt || a.updatedAt - b.updatedAt) let launched = 0 diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 69e07a61aa0..424e838c234 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -2,6 +2,7 @@ import './assets/main.css' import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { useTranslation } from 'react-i18next' import App from './App' import { RecoverableRenderErrorBoundary } from './components/error-boundaries/RecoverableRenderErrorBoundary' import { @@ -35,8 +36,9 @@ if (!rootElement) { throw new Error('Renderer root element not found.') } -createRoot(rootElement).render( - +function RendererRoot(): React.JSX.Element { + useTranslation() + return ( - - - + + ) +} + +createRoot(rootElement).render( + + + + ) recordRendererCrashBreadcrumb('renderer_bootstrap_rendered') diff --git a/src/renderer/src/store/slices/agent-status-quit-capture.test.ts b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts new file mode 100644 index 00000000000..a49fd479d33 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { AppState } from '../types' +import { createTestStore, makeTab } from './store-test-helpers' + +function makeAgentEntry(overrides: { + paneKey: string + worktreeId: string + sessionId?: string +}): AgentStatusEntry { + return { + state: 'working', + prompt: 'finish the task', + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [], + agentType: 'claude', + paneKey: overrides.paneKey, + worktreeId: overrides.worktreeId, + ...(overrides.sessionId + ? { providerSession: { key: 'session_id' as const, id: overrides.sessionId } } + : {}) + } +} + +describe('captureAllSleepingAgentSessions', () => { + it('captures resumable agents across every worktree, not just one', () => { + const store = createTestStore() + store.setState({ + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })], + 'wt-2': [makeTab({ id: 'tab-2', worktreeId: 'wt-2' })] + }, + agentStatusByPaneKey: { + 'tab-1:leaf-1': makeAgentEntry({ + paneKey: 'tab-1:leaf-1', + worktreeId: 'wt-1', + sessionId: 'sess-1' + }), + 'tab-2:leaf-2': makeAgentEntry({ + paneKey: 'tab-2:leaf-2', + worktreeId: 'wt-2', + sessionId: 'sess-2' + }) + } + } as Partial) + + store.getState().captureAllSleepingAgentSessions() + + const records = store.getState().sleepingAgentSessionsByPaneKey + expect(records['tab-1:leaf-1']).toMatchObject({ + agent: 'claude', + worktreeId: 'wt-1', + tabId: 'tab-1', + providerSession: { key: 'session_id', id: 'sess-1' }, + origin: 'quit' + }) + expect(records['tab-2:leaf-2']).toMatchObject({ + agent: 'claude', + worktreeId: 'wt-2', + tabId: 'tab-2', + providerSession: { key: 'session_id', id: 'sess-2' }, + origin: 'quit' + }) + }) + + it('skips done agents — there is no turn left to resume', () => { + const store = createTestStore() + const entry = makeAgentEntry({ + paneKey: 'tab-1:leaf-1', + worktreeId: 'wt-1', + sessionId: 'sess-1' + }) + entry.state = 'done' + store.setState({ + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] + }, + agentStatusByPaneKey: { 'tab-1:leaf-1': entry } + } as Partial) + + store.getState().captureAllSleepingAgentSessions() + + expect(store.getState().sleepingAgentSessionsByPaneKey).toEqual({}) + }) + + it('skips agents without a resumable provider session', () => { + const store = createTestStore() + store.setState({ + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] + }, + agentStatusByPaneKey: { + 'tab-1:leaf-1': makeAgentEntry({ paneKey: 'tab-1:leaf-1', worktreeId: 'wt-1' }) + } + } as Partial) + + store.getState().captureAllSleepingAgentSessions() + + expect(store.getState().sleepingAgentSessionsByPaneKey).toEqual({}) + }) + + it('captures entries attributed only via tab prefix when the entry has no worktreeId', () => { + const store = createTestStore() + const entry = makeAgentEntry({ + paneKey: 'tab-1:leaf-1', + worktreeId: 'wt-1', + sessionId: 'sess-1' + }) + delete entry.worktreeId + store.setState({ + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] + }, + agentStatusByPaneKey: { 'tab-1:leaf-1': entry } + } as Partial) + + store.getState().captureAllSleepingAgentSessions() + + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({ + worktreeId: 'wt-1', + providerSession: { key: 'session_id', id: 'sess-1' } + }) + }) +}) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 8cf38172044..5f950320a94 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -113,6 +113,9 @@ export type AgentStatusSlice = { dropAgentStatusByWorktree: (worktreeId: string) => void captureSleepingAgentSessionsByWorktree: (worktreeId: string) => void + /** Capture resumable agent sessions across every worktree. Called from the + * quit flush so provider session ids survive an app restart. */ + captureAllSleepingAgentSessions: () => void clearSleepingAgentSession: (paneKey: string) => void clearSleepingAgentSessionsByWorktree: (worktreeId: string) => void pruneSleepingAgentSessions: (validWorktreeIds: Set) => void @@ -189,6 +192,7 @@ function sleepingRecordFromEntry(args: { worktreeId: string tab?: TerminalTab capturedAt: number + origin?: SleepingAgentSessionRecord['origin'] }): SleepingAgentSessionRecord | null { const agent = args.entry.agentType if (!isResumableTuiAgent(agent) || !args.entry.providerSession) { @@ -213,7 +217,8 @@ function sleepingRecordFromEntry(args: { : {}), ...(args.entry.lastAssistantMessage ? { lastAssistantMessage: args.entry.lastAssistantMessage } - : {}) + : {}), + ...(args.origin ? { origin: args.origin } : {}) } } @@ -1058,6 +1063,42 @@ export const createAgentStatusSlice: StateCreator { + // Why: the quit flush must persist provider session ids for every live + // agent pane — otherwise agents whose daemon PTYs die while the app is + // closed have nothing to `--resume` from (#5232). Only live entries are + // captured: retained rows belong to panes the user already closed, and + // `done` sessions have nothing to resume. + set((s) => { + const capturedAt = Date.now() + const next: Record = { + ...s.sleepingAgentSessionsByPaneKey + } + let changed = false + for (const entry of Object.values(s.agentStatusByPaneKey)) { + if (entry.state === 'done') { + continue + } + const worktreeId = entry.worktreeId ?? findAgentPaneWorktreeId(s, entry.paneKey) + if (!worktreeId) { + continue + } + const record = sleepingRecordFromEntry({ + state: s, + entry, + worktreeId, + capturedAt, + origin: 'quit' + }) + if (record && next[record.paneKey] !== record) { + next[record.paneKey] = record + changed = true + } + } + return changed ? { sleepingAgentSessionsByPaneKey: next } : s + }) + }, + clearSleepingAgentSession: (paneKey) => { set((s) => { if (!(paneKey in s.sleepingAgentSessionsByPaneKey)) { diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 42c2d4ae14c..b03ea7cd3e2 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -717,6 +717,8 @@ export type UISlice = { markSetupGuideBrowserMilestoneMigrated: (legacyComplete: boolean) => void browserImportHintHidden: boolean setBrowserImportHintHidden: (hidden: boolean) => void + projectOrderManualDefaultNoticeDismissed: boolean + dismissProjectOrderManualDefaultNotice: () => void usageEmptyStateDismissed: boolean dismissUsageEmptyState: () => void groupBy: 'none' | 'workspace-status' | 'repo' | 'pr-status' @@ -1694,6 +1696,15 @@ export const createUISlice: StateCreator = (set, get) window.api.ui.set({ browserImportHintHidden: hidden }).catch(console.error) return { browserImportHintHidden: hidden } }), + projectOrderManualDefaultNoticeDismissed: true, + dismissProjectOrderManualDefaultNotice: () => + set((s) => { + if (s.projectOrderManualDefaultNoticeDismissed) { + return s + } + window.api.ui.set({ projectOrderManualDefaultNoticeDismissed: true }).catch(console.error) + return { projectOrderManualDefaultNoticeDismissed: true } + }), usageEmptyStateDismissed: false, dismissUsageEmptyState: () => set((s) => { @@ -2060,6 +2071,8 @@ export const createUISlice: StateCreator = (set, get) setupGuideBrowserMilestoneLegacyComplete: ui.setupGuideBrowserMilestoneLegacyComplete === true, browserImportHintHidden: ui.browserImportHintHidden === true, + projectOrderManualDefaultNoticeDismissed: + ui.projectOrderManualDefaultNoticeDismissed === true, // Why: default false when undefined so existing users still see the CTA; // only an explicit dismissal persists true. usageEmptyStateDismissed: ui.usageEmptyStateDismissed === true, diff --git a/src/renderer/src/web/main.tsx b/src/renderer/src/web/main.tsx index 6ebb49937ea..83932348cc8 100644 --- a/src/renderer/src/web/main.tsx +++ b/src/renderer/src/web/main.tsx @@ -2,6 +2,7 @@ import '../assets/main.css' import { lazy, Suspense, useMemo, useState } from 'react' import ReactDOM from 'react-dom/client' +import { useTranslation } from 'react-i18next' import WebConnect from './WebConnect' import { RecoverableRenderErrorBoundary } from '../components/error-boundaries/RecoverableRenderErrorBoundary' import { @@ -46,23 +47,30 @@ function WebRoot(): React.JSX.Element { installWebPreloadApi() return ( }> - - - + ) } +function WebRootBoundary(): React.JSX.Element { + useTranslation() + return ( + + + + ) +} + ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - - - + + + ) diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index ccf174fcdcc..f5ba56529a8 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -34,6 +34,12 @@ export type SleepingAgentSessionRecord = { terminalTitle?: string lastAssistantMessage?: string connectionId?: string | null + /** How the record was captured. Worktree-sleep records (legacy records have + * no origin) are consumed by worktree activation, which opens a fresh tab. + * Quit records describe panes that still exist in the restored session, so + * only the pane's own cold-restore path may consume them — activation + * launching a tab too would duplicate a warm-reattached session (#5232). */ + origin?: 'worktree-sleep' | 'quit' } const RESUMABLE_TUI_AGENT_SET: ReadonlySet = new Set(RESUMABLE_TUI_AGENTS) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index fc4149281ed..300dc3a9295 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -441,6 +441,9 @@ export function getDefaultUIState(): PersistedUIState { setupGuideBrowserMilestoneMigrated: true, setupGuideBrowserMilestoneLegacyComplete: false, browserImportHintHidden: false, + // Why: brand-new profiles never saw recent project ordering; only upgraded + // profiles get the one-time sidebar notice on first launch. + projectOrderManualDefaultNoticeDismissed: true, workspaceCleanup: { dismissals: {} }, featureTipsSeenIds: [], featureInteractions: {}, diff --git a/src/shared/project-order-manual-default-notice.ts b/src/shared/project-order-manual-default-notice.ts new file mode 100644 index 00000000000..6bed1cb3ad0 --- /dev/null +++ b/src/shared/project-order-manual-default-notice.ts @@ -0,0 +1,29 @@ +export function resolveProjectOrderManualDefaultNoticeDismissed(args: { + rawDismissed: unknown + rawProjectOrderBy: unknown + isExistingProfile: boolean +}): boolean { + if (args.rawDismissed === true) { + return true + } + if (!args.isExistingProfile) { + return true + } + // Why: users who already opted into recent ordering keep it without a notice. + if (args.rawProjectOrderBy === 'recent') { + return true + } + return false +} + +export function isExistingPersistedProfile(args: { + repoCount: number + onboardingClosedAt: number | null | undefined + ui: unknown +}): boolean { + return ( + args.repoCount > 0 || + args.onboardingClosedAt != null || + (args.ui != null && typeof args.ui === 'object' && Object.keys(args.ui).length > 0) + ) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index cde79b12f7e..edc2e995db9 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1382,8 +1382,12 @@ export type GitHubCreateIssueFields = { assignees?: string[] } +export type GitHubIssueCloseReason = 'completed' | 'not_planned' | 'duplicate' + export type GitHubIssueUpdate = { state?: 'open' | 'closed' + stateReason?: GitHubIssueCloseReason + duplicateOf?: number title?: string // Why: body writes use the REST issue endpoint instead of `gh issue edit` // because that command does not consistently cover every body-edit case the @@ -2755,6 +2759,9 @@ export type PersistedUIState = { /** User-dismissed browser import hint in the browser toolbar. Import remains * available from Settings > Browser and the toolbar overflow menu. */ browserImportHintHidden?: boolean + /** One-shot rollout notice for manual project ordering becoming the default. + * Absent or true means the sidebar callout stays hidden. */ + projectOrderManualDefaultNoticeDismissed?: boolean /** User-hidden empty-state usage CTA in the status bar. Permanently hides the * "Connect AI accounts to see usage" prompt even if all providers are later * disconnected — a dismissed teaching nudge stays dismissed. */ diff --git a/tests/e2e/agent-session-quit-resume.spec.ts b/tests/e2e/agent-session-quit-resume.spec.ts new file mode 100644 index 00000000000..fe059d015f2 --- /dev/null +++ b/tests/e2e/agent-session-quit-resume.spec.ts @@ -0,0 +1,130 @@ +import { existsSync, readFileSync } from 'fs' +import path from 'path' +import type { ElectronApplication } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { + execInTerminal, + waitForActivePaneHookDescriptor, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForPaneCount, + waitForTerminalOutput +} from './helpers/terminal' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { PROTOCOL_VERSION } from '../../src/main/daemon/types' + +const PROVIDER_SESSION_ID = 'e2e-quit-resume-session' + +function readDaemonPid(userDataDir: string): number { + const raw = readFileSync( + path.join(userDataDir, 'daemon', `daemon-v${PROTOCOL_VERSION}.pid`), + 'utf8' + ) + const parsed = JSON.parse(raw) as { pid?: unknown } + if (typeof parsed.pid !== 'number') { + throw new Error(`Daemon pid file did not contain a numeric pid: ${raw}`) + } + return parsed.pid +} + +test.describe.configure({ mode: 'serial' }) + +test('resumes an agent session after quit when its daemon PTY died while the app was closed', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. +{}, testInfo) => { + const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim() + if (!repoPath || !existsSync(repoPath)) { + test.skip(true, 'Global setup did not produce a seeded test repo') + return + } + test.skip(process.platform === 'win32', 'Uses POSIX SIGKILL to simulate daemon death') + + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + + try { + const firstLaunch = await session.launch() + firstApp = firstLaunch.app + const page = await firstApp.firstWindow() + const worktreeId = await attachRepoAndOpenTerminal(page, repoPath) + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneCount(page, 1, 30_000) + + const marker = `AGENT_QUIT_RESUME_${Date.now()}` + const descriptor = await waitForActivePaneHookDescriptor(page) + const firstPtyId = await waitForActivePanePtyId(page) + await execInTerminal(page, firstPtyId, `echo ${marker}`) + await waitForTerminalOutput(page, marker) + + // Why: a real agent run reports its provider session id over the hook + // server; seeding the same store entry keeps this test hermetic (no agent + // CLI install or auth) while exercising the identical persistence path. + await page.evaluate( + ({ paneKey, worktreeId: wtId, providerSessionId }) => { + window.__store + ?.getState() + .setAgentStatus( + paneKey, + { state: 'working', prompt: 'finish the task', agentType: 'codex' }, + 'Codex', + undefined, + { worktreeId: wtId }, + { providerSession: { key: 'session_id', id: providerSessionId } } + ) + }, + { + paneKey: descriptor.paneKey, + worktreeId: descriptor.worktreeId, + providerSessionId: PROVIDER_SESSION_ID + } + ) + + const daemonPid = readDaemonPid(session.userDataDir) + + await session.close(firstApp) + firstApp = null + + // Why: simulates the daemon (and the agent CLI inside it) dying while the + // app is closed — reboot, crash, or update kill. SIGKILL leaves history + // checkpoints unclean so the relaunch takes the cold-restore path. + process.kill(daemonPid, 'SIGKILL') + + const secondLaunch = await session.launch() + secondApp = secondLaunch.app + await waitForSessionReady(secondLaunch.page) + await expect + .poll( + async () => secondLaunch.page.evaluate(() => window.__store?.getState().activeWorktreeId), + { timeout: 15_000 } + ) + .toBe(worktreeId) + await ensureTerminalVisible(secondLaunch.page) + await waitForActiveTerminalManager(secondLaunch.page, 30_000) + await waitForPaneCount(secondLaunch.page, 1, 30_000) + + // The quit-captured provider session id must drive a resume command into + // the cold-restored pane (the command text echoes in the terminal). + await waitForTerminalOutput(secondLaunch.page, PROVIDER_SESSION_ID, 30_000) + + // No duplicate resume tab: the quit-origin record must not be consumed by + // worktree activation on top of the pane-level cold-restore. + const terminalTabCount = await secondLaunch.page.evaluate( + (wtId) => (window.__store?.getState().tabsByWorktree[wtId] ?? []).length, + worktreeId + ) + expect(terminalTabCount).toBe(1) + } finally { + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } +}) diff --git a/tests/e2e/daemon-slow-health-check-preservation.spec.ts b/tests/e2e/daemon-slow-health-check-preservation.spec.ts new file mode 100644 index 00000000000..8a825c85cf7 --- /dev/null +++ b/tests/e2e/daemon-slow-health-check-preservation.spec.ts @@ -0,0 +1,135 @@ +import { existsSync, readFileSync } from 'fs' +import path from 'path' +import type { ElectronApplication } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { + discoverActivePtyId, + execInTerminal, + getTerminalContent, + waitForActiveTerminalManager, + waitForPaneCount, + waitForTerminalOutput +} from './helpers/terminal' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { PROTOCOL_VERSION } from '../../src/main/daemon/types' +import { PTY_SESSION_ID_SEPARATOR } from '../../src/shared/pty-session-id-format' + +// Why: must land after the relaunched app's 3s daemon health check has timed +// out (so the unhealthy guard runs) but before the guard's 5s client hello +// budget expires. Daemon init starts within the first ~2s of main startup. +const RESUME_DAEMON_AFTER_MS = 6_500 + +function readDaemonPid(userDataDir: string): number { + const raw = readFileSync( + path.join(userDataDir, 'daemon', `daemon-v${PROTOCOL_VERSION}.pid`), + 'utf8' + ) + const parsed = JSON.parse(raw) as { pid?: unknown } + if (typeof parsed.pid !== 'number') { + throw new Error(`Daemon pid file did not contain a numeric pid: ${raw}`) + } + return parsed.pid +} + +test.describe.configure({ mode: 'serial' }) + +test('preserves a live daemon PTY when the daemon is too slow for the startup health check', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. +{}, testInfo) => { + const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim() + if (!repoPath || !existsSync(repoPath)) { + test.skip(true, 'Global setup did not produce a seeded test repo') + return + } + test.skip(process.platform === 'win32', 'SIGSTOP/SIGCONT are POSIX-only') + + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + let daemonPid: number | null = null + + try { + const firstLaunch = await session.launch() + firstApp = firstLaunch.app + const page = await firstApp.firstWindow() + const worktreeId = await attachRepoAndOpenTerminal(page, repoPath) + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneCount(page, 1, 30_000) + const ptyId = await discoverActivePtyId(page) + expect(ptyId).toContain(PTY_SESSION_ID_SEPARATOR) + + const marker = `DAEMON_SLOW_HEALTH_PRESERVE_${Date.now()}` + await execInTerminal(firstLaunch.page, ptyId, `echo ${marker}`) + await waitForTerminalOutput(firstLaunch.page, marker) + + daemonPid = readDaemonPid(session.userDataDir) + + await session.close(firstApp) + firstApp = null + + // Why: a stopped daemon still accepts socket connections at the kernel + // level but answers nothing — the same observable behavior as a daemon + // that is too busy to respond within the health-check budget. + process.kill(daemonPid, 'SIGSTOP') + + const stderrLines: string[] = [] + const resumeTimer = setTimeout(() => { + if (daemonPid !== null) { + process.kill(daemonPid, 'SIGCONT') + } + }, RESUME_DAEMON_AFTER_MS) + try { + const secondLaunch = await session.launch() + secondApp = secondLaunch.app + secondApp.process().stderr?.on('data', (chunk: Buffer) => { + stderrLines.push(chunk.toString()) + }) + + await waitForSessionReady(secondLaunch.page) + await expect + .poll( + async () => secondLaunch.page.evaluate(() => window.__store?.getState().activeWorktreeId), + { timeout: 15_000 } + ) + .toBe(worktreeId) + await ensureTerminalVisible(secondLaunch.page) + await waitForActiveTerminalManager(secondLaunch.page, 30_000) + await waitForPaneCount(secondLaunch.page, 1, 30_000) + await waitForTerminalOutput(secondLaunch.page, marker, 20_000) + + // The guard path must actually have run: the daemon failed the health + // check and was preserved because its live session was verified. + await expect + .poll(() => stderrLines.join(''), { timeout: 10_000 }) + .toContain('Preserving daemon that failed the health check') + expect(readDaemonPid(session.userDataDir)).toBe(daemonPid) + // Why: a killed daemon cold-restores scrollback from history, so the + // marker text alone cannot distinguish a live session from a dead one. + // The restore banner only appears for cold-restored (dead) sessions. + expect(await getTerminalContent(secondLaunch.page)).not.toContain('--- session restored ---') + } finally { + clearTimeout(resumeTimer) + } + } finally { + if (daemonPid !== null) { + try { + // Idempotent: ensures the daemon is resumable for harness cleanup even + // if the test failed before the resume timer fired. + process.kill(daemonPid, 'SIGCONT') + } catch { + // Daemon already gone + } + } + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } +}) diff --git a/tests/e2e/daemon-slow-init-pty-gate.spec.ts b/tests/e2e/daemon-slow-init-pty-gate.spec.ts new file mode 100644 index 00000000000..6bbf901c034 --- /dev/null +++ b/tests/e2e/daemon-slow-init-pty-gate.spec.ts @@ -0,0 +1,113 @@ +import { existsSync, readFileSync } from 'fs' +import path from 'path' +import type { ElectronApplication } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { + discoverActivePtyId, + execInTerminal, + getTerminalContent, + waitForActiveTerminalManager, + waitForPaneCount, + waitForTerminalOutput +} from './helpers/terminal' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { PROTOCOL_VERSION } from '../../src/main/daemon/types' +import { PTY_SESSION_ID_SEPARATOR } from '../../src/shared/pty-session-id-format' + +// Why: longer than FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS (12s) so the first +// window fails open before the daemon provider exists — the exact race that +// used to flip restored panes onto non-restorable LocalPtyProvider terminals +// (#5232 Bug 1) — but well under the 60s local-PTY fail-open cap. +const DAEMON_INIT_DELAY_MS = 15_000 + +function readDaemonPid(userDataDir: string): number { + const raw = readFileSync( + path.join(userDataDir, 'daemon', `daemon-v${PROTOCOL_VERSION}.pid`), + 'utf8' + ) + const parsed = JSON.parse(raw) as { pid?: unknown } + if (typeof parsed.pid !== 'number') { + throw new Error(`Daemon pid file did not contain a numeric pid: ${raw}`) + } + return parsed.pid +} + +test.describe.configure({ mode: 'serial' }) + +test('reattaches daemon PTYs when daemon init outlasts the first-window timeout', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. +{}, testInfo) => { + const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim() + if (!repoPath || !existsSync(repoPath)) { + test.skip(true, 'Global setup did not produce a seeded test repo') + return + } + + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + + try { + const firstLaunch = await session.launch() + firstApp = firstLaunch.app + const page = await firstApp.firstWindow() + const worktreeId = await attachRepoAndOpenTerminal(page, repoPath) + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneCount(page, 1, 30_000) + const ptyId = await discoverActivePtyId(page) + expect(ptyId).toContain(PTY_SESSION_ID_SEPARATOR) + + const marker = `DAEMON_SLOW_INIT_GATE_${Date.now()}` + await execInTerminal(firstLaunch.page, ptyId, `echo ${marker}`) + await waitForTerminalOutput(firstLaunch.page, marker) + + const daemonPidBefore = readDaemonPid(session.userDataDir) + + await session.close(firstApp) + firstApp = null + + // Why: session.launch() inherits this process's env, so this reaches the + // relaunched app's main process and delays initDaemonPtyProvider past the + // first-window timeout. + process.env.ORCA_E2E_DAEMON_INIT_DELAY_MS = String(DAEMON_INIT_DELAY_MS) + try { + const secondLaunch = await session.launch() + secondApp = secondLaunch.app + + await waitForSessionReady(secondLaunch.page) + await expect + .poll( + async () => secondLaunch.page.evaluate(() => window.__store?.getState().activeWorktreeId), + { timeout: 15_000 } + ) + .toBe(worktreeId) + await ensureTerminalVisible(secondLaunch.page) + await waitForActiveTerminalManager(secondLaunch.page, 45_000) + await waitForPaneCount(secondLaunch.page, 1, 45_000) + // Why: pre-fix, the pane spawned a fresh LocalPtyProvider terminal here + // (numeric pty id, no marker); post-fix it waits out the daemon init and + // warm-reattaches the original daemon session. + await waitForTerminalOutput(secondLaunch.page, marker, 45_000) + + const reattachedPtyId = await discoverActivePtyId(secondLaunch.page) + expect(reattachedPtyId).toContain(PTY_SESSION_ID_SEPARATOR) + expect(reattachedPtyId).toBe(ptyId) + expect(readDaemonPid(session.userDataDir)).toBe(daemonPidBefore) + expect(await getTerminalContent(secondLaunch.page)).not.toContain('--- session restored ---') + } finally { + delete process.env.ORCA_E2E_DAEMON_INIT_DELAY_MS + } + } finally { + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } +}) diff --git a/tests/e2e/helpers/e2e-completed-onboarding-profile.ts b/tests/e2e/helpers/e2e-completed-onboarding-profile.ts index fe9b6350ce6..6161f1579a3 100644 --- a/tests/e2e/helpers/e2e-completed-onboarding-profile.ts +++ b/tests/e2e/helpers/e2e-completed-onboarding-profile.ts @@ -37,7 +37,8 @@ export function getE2ECompletedOnboardingProfile() { ]) ), contextualToursSeenIds: [...SEEN_FIRST_RUN_CONTEXTUAL_TOUR_IDS], - contextualToursAutoEligible: false + contextualToursAutoEligible: false, + projectOrderManualDefaultNoticeDismissed: true } } }