Merge main into Linear CLI v2

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-06-11 21:19:45 -07:00
co-authored by Orca
173 changed files with 11295 additions and 4208 deletions
+1 -1
View File
@@ -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",
@@ -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)
-21
View File
@@ -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()
}
+9 -30
View File
@@ -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<boolean> {
})
}
export function checkDaemonHealth(
socketPath: string,
tokenPath: string
): Promise<DaemonHealthCheckResult> {
export function healthCheckDaemon(socketPath: string, tokenPath: string): Promise<boolean> {
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<string, unknown>
} 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<boolean> {
return (await checkDaemonHealth(socketPath, tokenPath)) === 'healthy'
}
function isSystemResolverHealth(value: unknown): value is SystemResolverHealth {
return value === 'healthy' || value === 'unhealthy' || value === 'unknown'
}
+52 -13
View File
@@ -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<void> }>
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<void> }>
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<void> }>
checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy')
healthCheckDaemonMock.mockResolvedValueOnce(false)
forkMock.mockImplementationOnce(() => {
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
message: [],
+57 -61
View File
@@ -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<void> {
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({
+11 -11
View File
@@ -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 () => {
-8
View File
@@ -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<void>
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<void>
private clients = new Map<string, ConnectedClient>()
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<void> {
@@ -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()
+3 -62
View File
@@ -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<typeof LocalPtyUtils>()
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)
-70
View File
@@ -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<void> {
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<void>((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') {
@@ -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<void>((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<ListSessionsResult>('listSessions', undefined)
const liveSessionCount = result.sessions.filter((session) => session.isAlive).length
expect(liveSessionCount).toBe(1)
}
)
})
-6
View File
@@ -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) ────────────
+13 -4
View File
@@ -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
+23 -3
View File
@@ -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<unknown>(patchArgs)
if (!r.ok) {
return { ok: false, error: r.error }
+18 -85
View File
@@ -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([])
})
+55 -64
View File
@@ -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<string, ProjectRef | null>()
@@ -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<ProjectRef, 'host'>,
connectionId?: string | null
): Promise<boolean> {
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)
@@ -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()
})
})
+84
View File
@@ -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
}
}
+20
View File
@@ -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 () => {
+22
View File
@@ -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
@@ -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(),
@@ -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'
})
})
})
@@ -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<void>((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()
@@ -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<typeof setTimeout> | null = null
let resolveTimedOut!: () => void
const timedOut = new Promise<void>((resolve) => {
resolveTimedOut = resolve
let windowTimeout: ReturnType<typeof setTimeout> | null = null
let failOpenTimeout: ReturnType<typeof setTimeout> | 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<void>((resolve) => {
timeout = setTimeout(() => {
windowTimeout = setTimeout(resolve, FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS)
})
])
const localPtyReady = Promise.race([
servicesSettled,
new Promise<void>((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 }
}
+5
View File
@@ -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.
+55
View File
@@ -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 {
@@ -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;
+54 -284
View File
@@ -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({
<div
key={comment.id}
className={cn(
'min-w-0 overflow-hidden rounded-lg border border-border/40 bg-card/50 shadow-xs',
isReply && 'ml-6 max-w-[calc(100%-1.5rem)]',
'min-w-0 overflow-hidden rounded-md border border-border/60 bg-card shadow-xs',
isReply && 'ml-8 max-w-[calc(100%-2rem)] border-l-2 border-l-border/80',
comment.isResolved && PR_COMMENT_RESOLVED_CONTAINER_CLASS
)}
>
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-3 py-2">
<div className="flex min-w-0 items-center gap-2 border-b border-border/50 bg-muted/20 px-3 py-2">
{comment.authorAvatarUrl ? (
<img
src={comment.authorAvatarUrl}
@@ -2965,9 +2966,9 @@ function ConversationTab({
{detailsLoaded ? (
<>
<div className="flex items-center gap-2 pt-1">
<div className="flex items-center gap-2 border-b border-border/40 pb-2 pt-1">
<MessageSquare className="size-4 text-muted-foreground" />
<span className="text-[13px] font-medium text-foreground">
<span className="text-[13px] font-semibold text-foreground">
{translate('auto.components.GitHubItemDialog.1506916c09', 'Comments')}
</span>
{comments.length > 0 && (
@@ -3001,13 +3002,13 @@ function ConversationTab({
)}
{comments.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/50 px-3 py-6 text-left text-[13px] text-muted-foreground">
<p className="px-1 py-2 text-[13px] text-muted-foreground">
{translate('auto.components.GitHubItemDialog.5a94f3d0e9', 'No comments yet.')}
</div>
</p>
) : visibleComments.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/50 px-3 py-6 text-center text-[13px] text-muted-foreground">
<p className="px-1 py-2 text-center text-[13px] text-muted-foreground">
{getPRCommentAudienceEmptyLabel(commentFilter)}
</div>
</p>
) : (
<div className="flex min-w-0 flex-col gap-3">
{visibleCommentGroups.map(renderCommentGroup)}
@@ -3017,13 +3018,19 @@ function ConversationTab({
) : null}
{detailsLoaded && repoPath && (
<GHCommentComposer
className="mt-1"
<GitHubIssueCommentComposer
className="mt-2"
repoPath={repoPath}
repoId={item.repoId}
issueNumber={item.number}
itemType={item.type}
itemState={localState}
itemId={item.id}
projectOrigin={projectOrigin}
previewGithubRepo={markdownGitHubRepo}
onCommentAdded={onCommentAdded}
onStateChange={onStateChange}
onMutated={onMutated}
/>
)}
</div>
@@ -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 (
<div className={cn(separated && 'mt-1 border-t border-border/60 pt-1')}>
<button
type="button"
onClick={() => {
onOpen?.()
void window.api.shell.openUrl(url)
}}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
<Settings className="size-3.5 shrink-0" />
<span className="min-w-0 flex-1 text-left">
{translate('auto.components.GitHubItemDialog.2aa9acdf34', 'Edit labels on GitHub')}
</span>
<ExternalLink className="size-3 shrink-0 opacity-70" />
</button>
</div>
)
}
function GHEditSection({
item,
repoPath,
@@ -4732,18 +4706,6 @@ function GHEditSection({
return null
}
const checkIcon = (
<svg className="size-2.5" viewBox="0 0 12 12" fill="none">
<path
d="M2 6l3 3 5-5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
if (layout === 'sidebar') {
return (
<aside className="flex flex-col gap-5 text-[13px]">
@@ -4825,41 +4787,14 @@ function GHEditSection({
className="popover-scroll-content scrollbar-sleek w-60 p-1"
align="end"
>
{repoAssignees.error ? (
<div className="px-2 py-3 text-center text-[12px] text-destructive">
{repoAssignees.error}
</div>
) : (
<div>
{repoAssignees.data.map((user) => (
<button
key={user.login}
type="button"
onClick={() => handleAssigneeToggle(user.login)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
localAssignees.includes(user.login)
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{localAssignees.includes(user.login) && checkIcon}
</span>
<span className="min-w-0 flex-1 text-left">
<span className="block truncate">{user.login}</span>
{user.name && (
<span className="block truncate text-[11px] text-muted-foreground">
{user.name}
</span>
)}
</span>
</button>
))}
</div>
)}
<GitHubWorkItemAssigneePopoverContent
open={assigneePopoverOpen}
assignees={repoAssignees.data}
selectedLogins={localAssignees}
error={repoAssignees.error}
loading={repoAssignees.loading}
onToggleAssignee={handleAssigneeToggle}
/>
</PopoverContent>
</Popover>
</div>
@@ -4918,39 +4853,15 @@ function GHEditSection({
className="popover-scroll-content scrollbar-sleek w-60 p-1"
align="end"
>
{repoLabels.error ? (
<div className="px-2 py-3 text-center text-[12px] text-destructive">
{repoLabels.error}
</div>
) : null}
{!repoLabels.error ? (
<div>
{repoLabels.data.map((label) => (
<button
key={label}
type="button"
onClick={() => handleLabelToggle(label)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
localLabels.includes(label)
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{localLabels.includes(label) && checkIcon}
</span>
{label}
</button>
))}
</div>
) : null}
<GitHubLabelsSettingsLink
url={repositoryLabelsUrl}
separated={!repoLabels.error && repoLabels.data.length > 0}
onOpen={() => setLabelPopoverOpen(false)}
<GitHubWorkItemLabelPopoverContent
open={labelPopoverOpen}
labels={repoLabels.data}
selectedLabels={localLabels}
error={repoLabels.error}
loading={repoLabels.loading}
repositoryLabelsUrl={repositoryLabelsUrl}
onToggleLabel={handleLabelToggle}
onOpenSettingsLink={() => setLabelPopoverOpen(false)}
/>
</PopoverContent>
</Popover>
@@ -5111,39 +5022,15 @@ function GHEditSection({
</button>
</PopoverTrigger>
<PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start">
{repoLabels.error ? (
<div className="px-2 py-3 text-center text-[12px] text-destructive">
{repoLabels.error}
</div>
) : null}
{!repoLabels.error ? (
<div>
{repoLabels.data.map((label) => (
<button
key={label}
type="button"
onClick={() => handleLabelToggle(label)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
localLabels.includes(label)
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{localLabels.includes(label) && checkIcon}
</span>
{label}
</button>
))}
</div>
) : null}
<GitHubLabelsSettingsLink
url={repositoryLabelsUrl}
separated={!repoLabels.error && repoLabels.data.length > 0}
onOpen={() => setLabelPopoverOpen(false)}
<GitHubWorkItemLabelPopoverContent
open={labelPopoverOpen}
labels={repoLabels.data}
selectedLabels={localLabels}
error={repoLabels.error}
loading={repoLabels.loading}
repositoryLabelsUrl={repositoryLabelsUrl}
onToggleLabel={handleLabelToggle}
onOpenSettingsLink={() => setLabelPopoverOpen(false)}
/>
</PopoverContent>
</Popover>
@@ -5175,41 +5062,14 @@ function GHEditSection({
</button>
</PopoverTrigger>
<PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start">
{repoAssignees.error ? (
<div className="px-2 py-3 text-center text-[12px] text-destructive">
{repoAssignees.error}
</div>
) : (
<div>
{repoAssignees.data.map((user) => (
<button
key={user.login}
type="button"
onClick={() => handleAssigneeToggle(user.login)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
localAssignees.includes(user.login)
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{localAssignees.includes(user.login) && checkIcon}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate">{user.login}</span>
{user.name && (
<span className="block truncate text-[11px] text-muted-foreground">
{user.name}
</span>
)}
</span>
</button>
))}
</div>
)}
<GitHubWorkItemAssigneePopoverContent
open={assigneePopoverOpen}
assignees={repoAssignees.data}
selectedLogins={localAssignees}
error={repoAssignees.error}
loading={repoAssignees.loading}
onToggleAssignee={handleAssigneeToggle}
/>
</PopoverContent>
</Popover>
@@ -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 (
<div className={cn('flex flex-col items-start gap-2', className)}>
<GitHubMarkdownComposer
value={body}
onChange={setBody}
placeholder={translate('auto.components.GitHubItemDialog.c5c117270e', 'Add a comment…')}
disabled={submitting}
minHeightClassName="min-h-28"
className="w-full"
onSubmitShortcut={() => void handleSubmit()}
/>
<Button
onClick={handleSubmit}
disabled={!body.trim() || submitting}
className="gap-2"
aria-label={translate('auto.components.GitHubItemDialog.0a73f59e85', 'Send comment')}
>
{submitting ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<Send className="size-3.5" />
)}
{translate('auto.components.GitHubItemDialog.bf43425540', 'Comment')}
</Button>
</div>
)
}
// 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
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={label}
disabled={disabled}
className={cn('linear-issue-markdown-toolbar-button', active && 'is-active')}
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
)
}
function LinearIssueMarkdownToolbarSeparator(): React.JSX.Element {
return <div className="linear-issue-markdown-toolbar-separator" />
}
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 (
<div
className="linear-issue-markdown-toolbar"
aria-label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.7c52151156',
'Issue description formatting'
)}
>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.68a41d5665',
'Body text'
)}
disabled={disabled}
onClick={() => runCommand((nextEditor) => nextEditor.chain().focus().setParagraph().run())}
>
<Pilcrow className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.e3f741d258',
'Heading 1'
)}
active={editor?.isActive('heading', { level: 1 }) ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleHeading({ level: 1 }).run())
}
>
<Heading1 className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.dddaa7a0a6',
'Heading 2'
)}
active={editor?.isActive('heading', { level: 2 }) ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleHeading({ level: 2 }).run())
}
>
<Heading2 className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarSeparator />
<LinearIssueMarkdownToolbarButton
label={translate('auto.components.LinearIssueMarkdownDescriptionEditor.caa88f50d0', 'Bold')}
active={editor?.isActive('bold') ?? false}
disabled={disabled}
onClick={() => runCommand((nextEditor) => nextEditor.chain().focus().toggleBold().run())}
>
<Bold className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.5666b4493d',
'Italic'
)}
active={editor?.isActive('italic') ?? false}
disabled={disabled}
onClick={() => runCommand((nextEditor) => nextEditor.chain().focus().toggleItalic().run())}
>
<Italic className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.28fd951b83',
'Strike'
)}
active={editor?.isActive('strike') ?? false}
disabled={disabled}
onClick={() => runCommand((nextEditor) => nextEditor.chain().focus().toggleStrike().run())}
>
<Strikethrough className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.ad1869bd54',
'Inline code'
)}
active={editor?.isActive('code') ?? false}
disabled={disabled}
onClick={() => runCommand((nextEditor) => nextEditor.chain().focus().toggleCode().run())}
>
<Code className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarSeparator />
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.c82917e06e',
'Bullet list'
)}
active={editor?.isActive('bulletList') ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleBulletList().run())
}
>
<List className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.d6b2f3d35b',
'Numbered list'
)}
active={editor?.isActive('orderedList') ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleOrderedList().run())
}
>
<ListOrdered className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.e2a0267c8c',
'Checklist'
)}
active={editor?.isActive('taskList') ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleTaskList().run())
}
>
<ListTodo className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarSeparator />
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.9eaf02ac01',
'Quote'
)}
active={editor?.isActive('blockquote') ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleBlockquote().run())
}
>
<Quote className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={
editor?.isActive('link')
? translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.340160f4e8',
'Remove link'
)
: translate('auto.components.LinearIssueMarkdownDescriptionEditor.632096eb1c', 'Link')
}
active={editor?.isActive('link') ?? false}
disabled={disabled}
onClick={() => runCommand(applyLinearIssueLink)}
>
<LinkIcon className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
</div>
)
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<Editor | null>(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
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={label}
disabled={disabled}
className={cn('linear-issue-markdown-toolbar-button', active && 'is-active')}
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
)
}
function LinearIssueMarkdownToolbarSeparator(): React.JSX.Element {
return <div className="linear-issue-markdown-toolbar-separator" />
}
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 (
<div
className="linear-issue-markdown-toolbar"
aria-label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.7c52151156',
'Issue description formatting'
)}
>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.68a41d5665',
'Body text'
)}
disabled={disabled}
onClick={() => runCommand((nextEditor) => nextEditor.chain().focus().setParagraph().run())}
>
<Pilcrow className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.e3f741d258',
'Heading 1'
)}
active={editor?.isActive('heading', { level: 1 }) ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleHeading({ level: 1 }).run())
}
>
<Heading1 className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.dddaa7a0a6',
'Heading 2'
)}
active={editor?.isActive('heading', { level: 2 }) ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleHeading({ level: 2 }).run())
}
>
<Heading2 className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarSeparator />
<LinearIssueMarkdownToolbarButton
label={translate('auto.components.LinearIssueMarkdownDescriptionEditor.caa88f50d0', 'Bold')}
active={editor?.isActive('bold') ?? false}
disabled={disabled}
onClick={() => runCommand((nextEditor) => nextEditor.chain().focus().toggleBold().run())}
>
<Bold className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.5666b4493d',
'Italic'
)}
active={editor?.isActive('italic') ?? false}
disabled={disabled}
onClick={() => runCommand((nextEditor) => nextEditor.chain().focus().toggleItalic().run())}
>
<Italic className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.28fd951b83',
'Strike'
)}
active={editor?.isActive('strike') ?? false}
disabled={disabled}
onClick={() => runCommand((nextEditor) => nextEditor.chain().focus().toggleStrike().run())}
>
<Strikethrough className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.ad1869bd54',
'Inline code'
)}
active={editor?.isActive('code') ?? false}
disabled={disabled}
onClick={() => runCommand((nextEditor) => nextEditor.chain().focus().toggleCode().run())}
>
<Code className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarSeparator />
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.c82917e06e',
'Bullet list'
)}
active={editor?.isActive('bulletList') ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleBulletList().run())
}
>
<List className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.d6b2f3d35b',
'Numbered list'
)}
active={editor?.isActive('orderedList') ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleOrderedList().run())
}
>
<ListOrdered className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.e2a0267c8c',
'Checklist'
)}
active={editor?.isActive('taskList') ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleTaskList().run())
}
>
<ListTodo className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarSeparator />
<LinearIssueMarkdownToolbarButton
label={translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.9eaf02ac01',
'Quote'
)}
active={editor?.isActive('blockquote') ?? false}
disabled={disabled}
onClick={() =>
runCommand((nextEditor) => nextEditor.chain().focus().toggleBlockquote().run())
}
>
<Quote className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
<LinearIssueMarkdownToolbarButton
label={
editor?.isActive('link')
? translate(
'auto.components.LinearIssueMarkdownDescriptionEditor.340160f4e8',
'Remove link'
)
: translate('auto.components.LinearIssueMarkdownDescriptionEditor.632096eb1c', 'Link')
}
active={editor?.isActive('link') ?? false}
disabled={disabled}
onClick={() => runCommand(applyLinearIssueLink)}
>
<LinkIcon className="size-3.5" />
</LinearIssueMarkdownToolbarButton>
</div>
)
}
@@ -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<SshConnectionStatus, string> = {
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<Record<SshConnectionStatus, string>> = {
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'
+40 -75
View File
@@ -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 = (
<svg className="size-2.5" viewBox="0 0 12 12" fill="none">
<path
d="M2 6l3 3 5-5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2.5">
{/* State */}
@@ -5269,34 +5279,16 @@ function GHEditSection({
</button>
</PopoverTrigger>
<PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start">
{repoLabels.error ? (
<div className="px-2 py-3 text-center text-[12px] text-destructive">
{repoLabels.error}
</div>
) : (
<div>
{repoLabels.data.map((label) => (
<button
key={label}
type="button"
onClick={() => handleLabelToggle(label)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
localLabels.includes(label)
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{localLabels.includes(label) && checkIcon}
</span>
{label}
</button>
))}
</div>
)}
<GitHubWorkItemLabelPopoverContent
open={labelPopoverOpen}
labels={repoLabels.data}
selectedLabels={localLabels}
error={repoLabels.error}
loading={repoLabels.loading}
repositoryLabelsUrl={repositoryLabelsUrl}
onToggleLabel={handleLabelToggle}
onOpenSettingsLink={() => setLabelPopoverOpen(false)}
/>
</PopoverContent>
</Popover>
@@ -5327,41 +5319,14 @@ function GHEditSection({
</button>
</PopoverTrigger>
<PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start">
{repoAssignees.error ? (
<div className="px-2 py-3 text-center text-[12px] text-destructive">
{repoAssignees.error}
</div>
) : (
<div>
{repoAssignees.data.map((user) => (
<button
key={user.login}
type="button"
onClick={() => handleAssigneeToggle(user.login)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
localAssignees.includes(user.login)
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{localAssignees.includes(user.login) && checkIcon}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate">{user.login}</span>
{user.name && (
<span className="block truncate text-[11px] text-muted-foreground">
{user.name}
</span>
)}
</span>
</button>
))}
</div>
)}
<GitHubWorkItemAssigneePopoverContent
open={assigneePopoverOpen}
assignees={repoAssignees.data}
selectedLogins={localAssignees}
error={repoAssignees.error}
loading={repoAssignees.loading}
onToggleAssignee={handleAssigneeToggle}
/>
</PopoverContent>
</Popover>
@@ -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
@@ -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<EditorToggleValue, ViewModeMetadata> = {
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<EditorToggleValue, ViewModeMetadata> =
// which we don't offer, so callers can override the per-mode presentation.
export const CSV_VIEW_MODE_METADATA: Partial<Record<MarkdownViewMode, ViewModeMetadata>> = {
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<Record<MarkdownViewMode, ViewModeMetadata>> = {
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 (
<TooltipProvider delayDuration={300}>
<ToggleGroup
@@ -1,4 +1,5 @@
import React, { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
import type { NodeViewProps } from '@tiptap/react'
import { Copy, Check } from 'lucide-react'
@@ -14,100 +15,148 @@ import { translate } from '@/i18n/i18n'
const LANGUAGES = [
{
value: '',
label: translate('auto.components.editor.RichMarkdownCodeBlock.13822cdfda', 'Plain text')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.13822cdfda', 'Plain text')
}
},
{
value: 'bash',
label: translate('auto.components.editor.RichMarkdownCodeBlock.4227cf50fe', 'Bash')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.4227cf50fe', 'Bash')
}
},
{ value: 'c', label: 'C' },
{
value: 'cpp',
label: translate('auto.components.editor.RichMarkdownCodeBlock.4daed43ae3', 'C++')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.4daed43ae3', 'C++')
}
},
{
value: 'css',
label: translate('auto.components.editor.RichMarkdownCodeBlock.026653f21f', 'CSS')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.026653f21f', 'CSS')
}
},
{
value: 'diff',
label: translate('auto.components.editor.RichMarkdownCodeBlock.bf6ee5caaa', 'Diff')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.bf6ee5caaa', 'Diff')
}
},
{
value: 'go',
label: translate('auto.components.editor.RichMarkdownCodeBlock.edfcc64182', 'Go')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.edfcc64182', 'Go')
}
},
{
value: 'graphql',
label: translate('auto.components.editor.RichMarkdownCodeBlock.706fd85738', 'GraphQL')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.706fd85738', 'GraphQL')
}
},
{
value: 'html',
label: translate('auto.components.editor.RichMarkdownCodeBlock.8c4a3fa02d', 'HTML')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.8c4a3fa02d', 'HTML')
}
},
{
value: 'java',
label: translate('auto.components.editor.RichMarkdownCodeBlock.36536ad539', 'Java')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.36536ad539', 'Java')
}
},
{
value: 'javascript',
label: translate('auto.components.editor.RichMarkdownCodeBlock.a209c57063', 'JavaScript')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.a209c57063', 'JavaScript')
}
},
{
value: 'json',
label: translate('auto.components.editor.RichMarkdownCodeBlock.78eba32de4', 'JSON')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.78eba32de4', 'JSON')
}
},
{
value: 'kotlin',
label: translate('auto.components.editor.RichMarkdownCodeBlock.bcb236e2d8', 'Kotlin')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.bcb236e2d8', 'Kotlin')
}
},
{
value: 'markdown',
label: translate('auto.components.editor.RichMarkdownCodeBlock.983b9576b4', 'Markdown')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.983b9576b4', 'Markdown')
}
},
{
value: 'mermaid',
label: translate('auto.components.editor.RichMarkdownCodeBlock.89d6cc14fb', 'Mermaid')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.89d6cc14fb', 'Mermaid')
}
},
{
value: 'python',
label: translate('auto.components.editor.RichMarkdownCodeBlock.2391f9cda9', 'Python')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.2391f9cda9', 'Python')
}
},
{
value: 'ruby',
label: translate('auto.components.editor.RichMarkdownCodeBlock.96182a2f64', 'Ruby')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.96182a2f64', 'Ruby')
}
},
{
value: 'rust',
label: translate('auto.components.editor.RichMarkdownCodeBlock.e72e6b03f4', 'Rust')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.e72e6b03f4', 'Rust')
}
},
{
value: 'scss',
label: translate('auto.components.editor.RichMarkdownCodeBlock.5af8251002', 'SCSS')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.5af8251002', 'SCSS')
}
},
{
value: 'shell',
label: translate('auto.components.editor.RichMarkdownCodeBlock.d01f55be57', 'Shell')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.d01f55be57', 'Shell')
}
},
{
value: 'sql',
label: translate('auto.components.editor.RichMarkdownCodeBlock.3009f722b9', 'SQL')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.3009f722b9', 'SQL')
}
},
{
value: 'swift',
label: translate('auto.components.editor.RichMarkdownCodeBlock.9e384d48dc', 'Swift')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.9e384d48dc', 'Swift')
}
},
{
value: 'typescript',
label: translate('auto.components.editor.RichMarkdownCodeBlock.88d777bc07', 'TypeScript')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.88d777bc07', 'TypeScript')
}
},
{
value: 'xml',
label: translate('auto.components.editor.RichMarkdownCodeBlock.5ef5605cb7', 'XML')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.5ef5605cb7', 'XML')
}
},
{
value: 'yaml',
label: translate('auto.components.editor.RichMarkdownCodeBlock.74eab1d9b2', 'YAML')
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.74eab1d9b2', 'YAML')
}
}
]
@@ -115,6 +164,7 @@ export function RichMarkdownCodeBlock({
node,
updateAttributes
}: NodeViewProps): React.JSX.Element {
useTranslation()
const language = (node.attrs.language as string) || ''
const [copied, setCopied] = useState(false)
const copiedResetTimerRef = useRef<number | null>(null)
@@ -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
}
]
@@ -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, '🙂')
}
}
]
@@ -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()
}
@@ -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.
@@ -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: <Workflow className="size-4" />
},
{
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: <Globe2 className="size-4" />
},
{
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: <MonitorCog className="size-4" />
}
]
@@ -127,17 +127,22 @@ const TERM_ENTRIES: readonly { entry: TermEntry; minPhase: Phase }[] = [
{
entry: {
kind: 'ok',
html: (
<>
{translate('auto.components.feature.wall.BrowserAnimatedVisual.4fa59ca545', '✓ Updated')}{' '}
<code className="text-emerald-600 dark:text-emerald-400">
get html() {
return (
<>
{translate(
'auto.components.feature.wall.BrowserAnimatedVisual.051c97d15a',
'.pp-card[data-card="starter"] .pp-cta'
)}
</code>
</>
)
'auto.components.feature.wall.BrowserAnimatedVisual.4fa59ca545',
'✓ Updated'
)}{' '}
<code className="text-emerald-600 dark:text-emerald-400">
{translate(
'auto.components.feature.wall.BrowserAnimatedVisual.051c97d15a',
'.pp-card[data-card="starter"] .pp-cta'
)}
</code>
</>
)
}
},
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'
}
@@ -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 (
<div className="absolute inset-0 flex flex-col gap-5 bg-card px-4 py-4">
<div className="text-[14.5px] font-semibold uppercase tracking-[0.07em] leading-none text-muted-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.56a0271428", "Isolated workspaces")}</div>
{translate(
'auto.components.feature.wall.FeatureTourPreview.56a0271428',
'Isolated workspaces'
)}
</div>
{/* 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 {
<div className="grid grid-cols-3 gap-3 px-4">
<FeatureTourWorkspaceCard
status="working"
title={translate("auto.components.feature.wall.FeatureTourPreview.3c4adfd821", "fix login race condition")}
title={translate(
'auto.components.feature.wall.FeatureTourPreview.3c4adfd821',
'fix login race condition'
)}
agents={[
{ kind: 'claude', barWidth: '60%', state: 'working' },
{ kind: 'codex', barWidth: '52%', state: 'working' }
@@ -65,12 +46,18 @@ function WorkspaceFrame(): JSX.Element {
/>
<FeatureTourWorkspaceCard
status="done"
title={translate("auto.components.feature.wall.FeatureTourPreview.9c812e0d7c", "speed up CI pipeline")}
title={translate(
'auto.components.feature.wall.FeatureTourPreview.9c812e0d7c',
'speed up CI pipeline'
)}
agents={[{ kind: 'opencode-go', barWidth: '70%', state: 'done' }]}
/>
<FeatureTourWorkspaceCard
status="working"
title={translate("auto.components.feature.wall.FeatureTourPreview.e38112b289", "refactor billing webhook")}
title={translate(
'auto.components.feature.wall.FeatureTourPreview.e38112b289',
'refactor billing webhook'
)}
agents={[{ kind: 'claude', barWidth: '38%', state: 'working' }]}
/>
</div>
@@ -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 (
<div className="absolute inset-0 flex flex-col gap-5 bg-card px-4 py-4">
<div className="text-[14.5px] font-semibold uppercase tracking-[0.07em] leading-none text-muted-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.e44269e97d", "Agent orchestration")}</div>
{translate(
'auto.components.feature.wall.FeatureTourPreview.e44269e97d',
'Agent orchestration'
)}
</div>
<div className="relative w-full flex-1">
{/* 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 {
<div className="flex items-center gap-2">
<WorkingSpinner />
<span className="truncate text-[15px] font-medium leading-none text-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.cebc7769cd", "redesign auth flow")}</span>
{translate(
'auto.components.feature.wall.FeatureTourPreview.cebc7769cd',
'redesign auth flow'
)}
</span>
</div>
<div className="mt-2 flex items-center gap-1.5 pl-3.5">
<WorkingSpinner size="xs" />
<ClaudeIcon size={13} />
<span className="truncate text-[12.5px] leading-none text-muted-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.5171768676", "orchestrating 3 agents")}</span>
{translate(
'auto.components.feature.wall.FeatureTourPreview.5171768676',
'orchestrating 3 agents'
)}
</span>
</div>
</div>
{/* 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 }) => (
<div
key={key}
className={cn(
@@ -188,9 +171,9 @@ function OrchestrationFrame(): JSX.Element {
</div>
<div className="mt-2 flex items-center gap-1.5 pl-3.5">
<WorkingSpinner size="xs" />
{agent === "claude" ? (
{agent === 'claude' ? (
<ClaudeIcon size={12} />
) : agent === "codex" ? (
) : agent === 'codex' ? (
<CodexInlineIcon />
) : (
<OpenCodeGoIcon size={12} />
@@ -200,7 +183,7 @@ function OrchestrationFrame(): JSX.Element {
</div>
))}
{ORCH_CHILDREN.map(({ key }) => (
{FEATURE_TOUR_ORCHESTRATION_CHILDREN.map(({ key }) => (
<div key={`bubble-${key}`} className={cn('feature-tour-orch-bubble', key)}>
<MailGlyph />
</div>
@@ -218,12 +201,17 @@ function TasksFrame(): JSX.Element {
return (
<div className="absolute inset-0 flex flex-col gap-5 bg-card px-4 py-4">
<div className="text-[14.5px] font-semibold uppercase tracking-[0.07em] leading-none text-muted-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.bee6b4088d", "GitHub & Linear tasks")}</div>
{translate(
'auto.components.feature.wall.FeatureTourPreview.bee6b4088d',
'GitHub & Linear tasks'
)}
</div>
<div className="relative grid flex-1 grid-cols-[minmax(0,1fr)_minmax(0,1fr)] items-center gap-4 px-4">
<div className="flex flex-col gap-2">
<div className="flex h-9 items-center gap-2.5 rounded-md border border-border bg-background px-3">
<span className="inline-flex h-5 items-center justify-center rounded-[3px] border border-border bg-muted px-1.5 font-mono text-[13px] leading-none text-muted-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.0688842445", "GH #1799")}</span>
{translate('auto.components.feature.wall.FeatureTourPreview.0688842445', 'GH #1799')}
</span>
{/* 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 {
</div>
<div className="feature-tour-tasks-row relative flex h-9 items-center gap-2.5 rounded-md border border-border bg-background px-3">
<span className="inline-flex h-5 items-center justify-center rounded-[3px] border border-border bg-muted px-1.5 font-mono text-[13px] leading-none text-muted-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.fc0cc0b267", "GH #1842")}</span>
{translate('auto.components.feature.wall.FeatureTourPreview.fc0cc0b267', 'GH #1842')}
</span>
<span className="truncate text-[15px] font-medium leading-none text-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.c1f28c03b2", "Worktree picker truncates")}</span>
{translate(
'auto.components.feature.wall.FeatureTourPreview.c1f28c03b2',
'Worktree picker truncates'
)}
</span>
<span className="feature-tour-tasks-pill relative ml-auto flex h-6 items-center justify-center overflow-hidden rounded-full border border-emerald-500/30 bg-emerald-500/15">
<span className="feature-tour-tasks-pill-label flex items-center gap-1 whitespace-nowrap pl-3 pr-2.5 text-[13px] font-semibold leading-none tracking-[0.01em] text-primary-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.40bbd92ef4", "Start")}<svg
{translate('auto.components.feature.wall.FeatureTourPreview.40bbd92ef4', 'Start')}
<svg
width="11"
height="11"
viewBox="0 0 16 16"
@@ -262,7 +256,8 @@ function TasksFrame(): JSX.Element {
</div>
<div className="flex h-9 items-center gap-2.5 rounded-md border border-border bg-background px-3">
<span className="inline-flex h-5 items-center justify-center rounded-[3px] border border-border bg-muted px-1.5 font-mono text-[13px] leading-none text-muted-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.d54aefe09e", "LIN-329")}</span>
{translate('auto.components.feature.wall.FeatureTourPreview.d54aefe09e', 'LIN-329')}
</span>
<span className="h-2 w-[45%] rounded-full bg-foreground/12" />
</div>
</div>
@@ -271,7 +266,11 @@ function TasksFrame(): JSX.Element {
<div className="flex items-center gap-2.5">
<WorkingSpinner />
<span className="truncate text-[15.5px] font-medium leading-none text-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.3822d8d14b", "fix/worktree-picker-truncates")}</span>
{translate(
'auto.components.feature.wall.FeatureTourPreview.3822d8d14b',
'fix/worktree-picker-truncates'
)}
</span>
<span className="ml-auto inline-flex">
<ClaudeIcon size={13} />
</span>
@@ -281,83 +280,11 @@ function TasksFrame(): JSX.Element {
<ClaudeIcon size={12} />
<span className="h-2 w-[55%] rounded-full bg-foreground/15" />
</div>
<div className="text-[13.5px] leading-none text-muted-foreground">{translate("auto.components.feature.wall.FeatureTourPreview.2a7cfc82c8", "Linked to GH #1842")}</div>
</div>
</div>
</div>
)
}
function TerminalFrame(): JSX.Element {
return (
<div className="absolute inset-0 flex flex-col gap-5 bg-card px-4 py-4">
<div className="text-[14.5px] font-semibold uppercase tracking-[0.07em] leading-none text-muted-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.1aa8a9a24a", "Splittable terminal")}</div>
<div className="mx-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-background">
<div className="flex items-center gap-1.5 border-b border-border bg-muted/40 px-2 py-1">
<span className="size-1.5 rounded-full bg-foreground/15" />
<span className="size-1.5 rounded-full bg-foreground/15" />
<span className="size-1.5 rounded-full bg-foreground/15" />
<span className="ml-2 font-mono text-[13.5px] leading-none text-muted-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.04d54d50ec", "orca · zsh")}</span>
</div>
<div className="grid flex-1 grid-cols-2 divide-x divide-border font-mono text-[14.5px] leading-[1.4] text-foreground">
<div className="min-w-0 p-2">
<div className="flex items-center gap-1">
<span className="text-emerald-500">$</span>
<span className="feature-tour-terminal-line relative inline-block whitespace-nowrap text-foreground">
{translate("auto.components.feature.wall.FeatureTourPreview.6218a9014d", "pnpm playwright test")}</span>
</div>
<div className="mt-1.5 flex flex-col gap-1">
<div
className="feature-tour-terminal-output truncate text-muted-foreground"
data-line="1"
>
{translate("auto.components.feature.wall.FeatureTourPreview.8279e9d95b", "Running 12 tests")}</div>
<div
className="feature-tour-terminal-output flex min-w-0 items-center gap-1.5"
data-line="2"
>
<span className="font-bold text-emerald-600">✓</span>
<span className="truncate">{translate("auto.components.feature.wall.FeatureTourPreview.24fedd5a52", "login.spec.ts")}</span>
</div>
<div
className="feature-tour-terminal-output flex min-w-0 items-center gap-1.5"
data-line="3"
>
<span className="inline-block size-2 animate-spin rounded-full border-[1.5px] border-foreground/20 border-t-foreground" />
<span className="truncate">{translate("auto.components.feature.wall.FeatureTourPreview.6ed43cb0e0", "dashboard.spec.ts")}</span>
</div>
</div>
</div>
<div className="min-w-0 p-2">
<div className="flex items-center gap-1">
<span className="text-emerald-500">$</span>
<span className="text-foreground">{translate("auto.components.feature.wall.FeatureTourPreview.771d8881c2", "claude")}</span>
</div>
<div className="mt-1.5 flex flex-col gap-1">
<div
className="feature-tour-terminal-output flex min-w-0 items-center gap-1.5"
data-line="1"
>
<ClaudeIcon size={12} />
<span className="truncate text-muted-foreground">{translate("auto.components.feature.wall.FeatureTourPreview.952d3ddd9a", "session started")}</span>
</div>
<div
className="feature-tour-terminal-output flex min-w-0 items-center gap-1"
data-line="2"
>
<span className="text-amber-600">{translate("auto.components.feature.wall.FeatureTourPreview.1170621527", ">")}</span>
<span className="truncate">{translate("auto.components.feature.wall.FeatureTourPreview.ef8b164dd1", "review src/auth")}</span>
</div>
<div
className="feature-tour-terminal-output flex min-w-0 items-center gap-1.5"
data-line="3"
>
<span className="inline-block size-2 animate-spin rounded-full border-[1.5px] border-amber-600/20 border-t-amber-600" />
<span className="truncate text-muted-foreground">{translate("auto.components.feature.wall.FeatureTourPreview.304ad0dfc1", "Thinking...")}</span>
</div>
</div>
<div className="text-[13.5px] leading-none text-muted-foreground">
{translate(
'auto.components.feature.wall.FeatureTourPreview.2a7cfc82c8',
'Linked to GH #1842'
)}
</div>
</div>
</div>
@@ -385,7 +312,7 @@ export function FeatureTourPreview(props: { className?: string }): JSX.Element {
<TasksFrame />
</div>
<div className="feature-tour-frame" data-frame="4">
<TerminalFrame />
<FeatureTourTerminalFrame />
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[6] h-[66px] border-t border-border/70 bg-card/95">
{FEATURE_TOUR_PREVIEW_COPY.map((frame) => (
@@ -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 (
<div className="absolute inset-0 flex flex-col gap-5 bg-card px-4 py-4">
<div className="text-[14.5px] font-semibold uppercase tracking-[0.07em] leading-none text-muted-foreground">
{translate(
'auto.components.feature.wall.FeatureTourPreview.1aa8a9a24a',
'Splittable terminal'
)}
</div>
<div className="mx-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-background">
<div className="flex items-center gap-1.5 border-b border-border bg-muted/40 px-2 py-1">
<span className="size-1.5 rounded-full bg-foreground/15" />
<span className="size-1.5 rounded-full bg-foreground/15" />
<span className="size-1.5 rounded-full bg-foreground/15" />
<span className="ml-2 font-mono text-[13.5px] leading-none text-muted-foreground">
{translate('auto.components.feature.wall.FeatureTourPreview.04d54d50ec', 'orca · zsh')}
</span>
</div>
<div className="grid flex-1 grid-cols-2 divide-x divide-border font-mono text-[14.5px] leading-[1.4] text-foreground">
<div className="min-w-0 p-2">
<div className="flex items-center gap-1">
<span className="text-emerald-500">$</span>
<span className="feature-tour-terminal-line relative inline-block whitespace-nowrap text-foreground">
{translate(
'auto.components.feature.wall.FeatureTourPreview.6218a9014d',
'pnpm playwright test'
)}
</span>
</div>
<div className="mt-1.5 flex flex-col gap-1">
<div
className="feature-tour-terminal-output truncate text-muted-foreground"
data-line="1"
>
{translate(
'auto.components.feature.wall.FeatureTourPreview.8279e9d95b',
'Running 12 tests'
)}
</div>
<div
className="feature-tour-terminal-output flex min-w-0 items-center gap-1.5"
data-line="2"
>
<span className="font-bold text-emerald-600">✓</span>
<span className="truncate">
{translate(
'auto.components.feature.wall.FeatureTourPreview.24fedd5a52',
'login.spec.ts'
)}
</span>
</div>
<div
className="feature-tour-terminal-output flex min-w-0 items-center gap-1.5"
data-line="3"
>
<span className="inline-block size-2 animate-spin rounded-full border-[1.5px] border-foreground/20 border-t-foreground" />
<span className="truncate">
{translate(
'auto.components.feature.wall.FeatureTourPreview.6ed43cb0e0',
'dashboard.spec.ts'
)}
</span>
</div>
</div>
</div>
<div className="min-w-0 p-2">
<div className="flex items-center gap-1">
<span className="text-emerald-500">$</span>
<span className="text-foreground">
{translate('auto.components.feature.wall.FeatureTourPreview.771d8881c2', 'claude')}
</span>
</div>
<div className="mt-1.5 flex flex-col gap-1">
<div
className="feature-tour-terminal-output flex min-w-0 items-center gap-1.5"
data-line="1"
>
<ClaudeIcon size={12} />
<span className="truncate text-muted-foreground">
{translate(
'auto.components.feature.wall.FeatureTourPreview.952d3ddd9a',
'session started'
)}
</span>
</div>
<div
className="feature-tour-terminal-output flex min-w-0 items-center gap-1"
data-line="2"
>
<span className="text-amber-600">
{translate('auto.components.feature.wall.FeatureTourPreview.1170621527', '>')}
</span>
<span className="truncate">
{translate(
'auto.components.feature.wall.FeatureTourPreview.ef8b164dd1',
'review src/auth'
)}
</span>
</div>
<div
className="feature-tour-terminal-output flex min-w-0 items-center gap-1.5"
data-line="3"
>
<span className="inline-block size-2 animate-spin rounded-full border-[1.5px] border-amber-600/20 border-t-amber-600" />
<span className="truncate text-muted-foreground">
{translate(
'auto.components.feature.wall.FeatureTourPreview.304ad0dfc1',
'Thinking...'
)}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -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<HTMLDivElement | null>(null)
useEffect(() => {
const root = rootRef.current
if (!root) {
return
}
const sidebarPeek = root.querySelector<HTMLDivElement>('[data-checks-sidebar-peek]')
const prCard = root.querySelector<HTMLDivElement>('[data-pr-view-card]')
const cursor = root.querySelector<HTMLDivElement>('[data-cursor]')
const explorerTab = root.querySelector<HTMLSpanElement>('[data-explorer-tab]')
const checksTab = root.querySelector<HTMLSpanElement>('[data-checks-tab]')
const checksTooltip = root.querySelector<HTMLSpanElement>('[data-checks-tooltip]')
const checksBlock = root.querySelector<HTMLDivElement>('[data-checks-block]')
const commentsBlock = root.querySelector<HTMLDivElement>('[data-comments-block]')
const comments = Array.from(root.querySelectorAll<HTMLDivElement>('[data-comment-card]'))
const commentsCount = root.querySelector<HTMLSpanElement>('[data-comments-count]')
const checkSummary = root.querySelector<HTMLDivElement>('[data-check-summary]')
const checkSummaryLabel = root.querySelector<HTMLSpanElement>('[data-check-summary-label]')
const checkSummaryMeta = root.querySelector<HTMLSpanElement>('[data-check-summary-meta]')
const verifyRow = root.querySelector<HTMLDivElement>('[data-check-row="verify"]')
const verifyState = root.querySelector<HTMLSpanElement>('[data-check-verify-state]')
const mergeBtn = root.querySelector<HTMLButtonElement>('[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<void> =>
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<void> {
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 (
<div ref={rootRef} className="ravpr-stage" data-page="pr-view">
@@ -312,7 +137,12 @@ export function ReviewPRViewAnimatedVisual(props: { reducedMotion: boolean }): J
<div className="ravpr-sidebar is-visible" data-checks-sidebar-peek>
<SidebarTabs active="explorer" interactiveChecks />
<div className="ravpr-explorer">
<div className="ravpr-heading">{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.6e3f5223c5", "Explorer")}</div>
<div className="ravpr-heading">
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.6e3f5223c5',
'Explorer'
)}
</div>
<div className="ravpr-file-list">
<ExplorerSkeletonRow active width={190} />
<ExplorerSkeletonRow width={158} />
@@ -327,38 +157,89 @@ export function ReviewPRViewAnimatedVisual(props: { reducedMotion: boolean }): J
<div className="ravpr-body">
<div className="ravpr-number-row">
<span className="ravpr-number">#2351</span>
<span className="ravpr-open">{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.dfe313e0c9", "OPEN")}</span>
<span className="ravpr-open">
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.dfe313e0c9',
'OPEN'
)}
</span>
</div>
<div className="ravpr-title">
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.0aab7ab84a',
'Add local diagnostics error tracking'
)}
</div>
<div className="ravpr-title">{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.0aab7ab84a", "Add local diagnostics error tracking")}</div>
<button className="ravpr-merge" data-merge-btn type="button">
<GitBranch className="size-3" />
{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.2f37142229", "Squash and merge")}<ChevDownIcon />
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.2f37142229',
'Squash and merge'
)}
<ChevDownIcon />
</button>
<div className="ravpr-reveal" data-checks-block>
<div className="ravpr-section-row" data-check-summary>
<StatusCell />
<span className="ravpr-label" data-check-summary-label>
{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.9a097cae12", "1 pending")}</span>
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.9a097cae12',
'1 pending'
)}
</span>
<span className="ravpr-meta" data-check-summary-meta>
{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.d340c052fb", "verify")}</span>
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.d340c052fb',
'verify'
)}
</span>
</div>
<div className="ravpr-check-list">
<div className="ravpr-check-row" data-check-row="verify">
<StatusCell />
<span>{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.d340c052fb", "verify")}</span>
<span>
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.d340c052fb',
'verify'
)}
</span>
<span className="ravpr-check-state" data-check-verify-state>
{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.8ed213397c", "Running")}</span>
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.8ed213397c',
'Running'
)}
</span>
</div>
<div className="ravpr-check-row is-done">
<StatusCell />
<span>{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.2ef0b97954", "typecheck")}</span>
<span className="ravpr-check-state">{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.ca36f7b27c", "Passed")}</span>
<span>
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.2ef0b97954',
'typecheck'
)}
</span>
<span className="ravpr-check-state">
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.ca36f7b27c',
'Passed'
)}
</span>
</div>
<div className="ravpr-check-row is-done">
<StatusCell />
<span>{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.25f6838e43", "lint")}</span>
<span className="ravpr-check-state">{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.ca36f7b27c", "Passed")}</span>
<span>
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.25f6838e43',
'lint'
)}
</span>
<span className="ravpr-check-state">
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.ca36f7b27c',
'Passed'
)}
</span>
</div>
</div>
</div>
@@ -366,15 +247,43 @@ export function ReviewPRViewAnimatedVisual(props: { reducedMotion: boolean }): J
<div className="ravpr-reveal" data-comments-block>
<div className="ravpr-section-row">
<MessageSquare className="size-3.5" />
<span className="ravpr-label">{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.7a8b896e11", "Comments")}</span>
<span className="ravpr-label">
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.7a8b896e11',
'Comments'
)}
</span>
<span className="ravpr-meta">
<span data-comments-count>0</span> {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.fb1a856b6d", "open")}</span>
<span data-comments-count>0</span>{' '}
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.fb1a856b6d',
'open'
)}
</span>
</div>
<div className="ravpr-comment-list">
<CommentCard index={0} path="src/main/diagnostics.ts">
{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.71828fba75", "Can we include the failing command in the diagnostic payload?")}</CommentCard>
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.71828fba75',
'Can we include the failing command in the diagnostic payload?'
)}
</CommentCard>
<CommentCard index={1} path="tests/diagnostics.test.ts">
{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.6f4c2d7cb7", "Add a coverage case for")}<code>{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.c2062da7ec", "stderr")}</code> {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.7c2808ecff", "truncation before merge.")}</CommentCard>
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.6f4c2d7cb7',
'Add a coverage case for'
)}
<code>
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.c2062da7ec',
'stderr'
)}
</code>{' '}
{translate(
'auto.components.feature.wall.ReviewPRViewAnimatedVisual.7c2808ecff',
'truncation before merge.'
)}
</CommentCard>
</div>
</div>
</div>
@@ -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'
)
}
}
]
@@ -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'
}
]
@@ -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<HTMLDivElement | null>,
reducedMotion: boolean
): void {
useEffect(() => {
const root = rootRef.current
if (!root) {
return
}
const sidebarPeek = root.querySelector<HTMLDivElement>('[data-checks-sidebar-peek]')
const prCard = root.querySelector<HTMLDivElement>('[data-pr-view-card]')
const cursor = root.querySelector<HTMLDivElement>('[data-cursor]')
const explorerTab = root.querySelector<HTMLSpanElement>('[data-explorer-tab]')
const checksTab = root.querySelector<HTMLSpanElement>('[data-checks-tab]')
const checksTooltip = root.querySelector<HTMLSpanElement>('[data-checks-tooltip]')
const checksBlock = root.querySelector<HTMLDivElement>('[data-checks-block]')
const commentsBlock = root.querySelector<HTMLDivElement>('[data-comments-block]')
const comments = Array.from(root.querySelectorAll<HTMLDivElement>('[data-comment-card]'))
const commentsCount = root.querySelector<HTMLSpanElement>('[data-comments-count]')
const checkSummary = root.querySelector<HTMLDivElement>('[data-check-summary]')
const checkSummaryLabel = root.querySelector<HTMLSpanElement>('[data-check-summary-label]')
const checkSummaryMeta = root.querySelector<HTMLSpanElement>('[data-check-summary-meta]')
const verifyRow = root.querySelector<HTMLDivElement>('[data-check-row="verify"]')
const verifyState = root.querySelector<HTMLSpanElement>('[data-check-verify-state]')
const mergeBtn = root.querySelector<HTMLButtonElement>('[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<void> =>
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<void> {
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])
}
@@ -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 (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="sm"
variant="secondary"
className="px-2"
disabled={disabled}
aria-label={translate(
'auto.components.github.CloseReasonDropdown.e1f2a3b4c5',
'Choose close reason'
)}
>
<ChevronDown className="size-3.5 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
{CLOSE_ISSUE_REASONS.map((option) => (
<DropdownMenuItem key={option.reason} onSelect={() => onCloseReasonChange(option.reason)}>
<div className="flex min-w-0 items-start gap-2">
{closeReason === option.reason ? (
<Check className="mt-0.5 size-4 shrink-0" />
) : (
<span className="size-4 shrink-0" />
)}
<div className="min-w-0">
<div className="flex items-center gap-2 text-[13px] font-medium">
{option.icon}
<span>{option.label}</span>
</div>
<p className="mt-0.5 text-[11px] text-muted-foreground">{option.description}</p>
</div>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -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<GitHubIssueCloseReason>('completed')
const [viewer, setViewer] = useState<GitHubViewer | null>(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 ? (
<img
src={githubAvatarUrl(viewer.login)}
alt={viewer.login}
className="size-8 shrink-0 rounded-full border border-border/50 bg-muted"
/>
) : (
<div className="size-8 shrink-0 rounded-full border border-border/50 bg-muted" />
)
return (
<div className={cn('github-issue-comment-composer', className)}>
<div className="flex items-start gap-3">
{avatar}
<div className="min-w-0 flex-1">
<h3 className="mb-2 text-[13px] font-semibold text-foreground">
{translate(
'auto.components.github.GitHubIssueCommentComposer.a1b2c3d4e5',
'Add a comment'
)}
</h3>
<GitHubMarkdownComposer
value={body}
onChange={setBody}
placeholder={translate(
'auto.components.github.GitHubIssueCommentComposer.c5c117270e',
'Add your comment here, be kind'
)}
disabled={submitting || statePending}
minHeightClassName="min-h-28"
className="w-full"
layout="tabbed"
previewGithubRepo={previewGithubRepo}
onSubmitShortcut={() => void handleSubmit()}
/>
<div className="mt-2 flex flex-wrap items-center justify-end gap-2">
{canMutateIssueState ? (
<ButtonGroup>
<Button
type="button"
size="sm"
variant="secondary"
className="gap-2"
disabled={statePending}
onClick={() => void handleCloseIssue(closeReason)}
>
{statePending ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
selectedCloseReason.icon
)}
{translate(
'auto.components.github.GitHubIssueCommentComposer.f6a7b8c9d0',
'Close issue'
)}
</Button>
<CloseReasonDropdown
closeReason={closeReason}
disabled={statePending}
onCloseReasonChange={(reason) => {
setCloseReason(reason)
void handleCloseIssue(reason)
}}
/>
</ButtonGroup>
) : null}
{canReopenIssue ? (
<Button
type="button"
size="sm"
variant="secondary"
disabled={statePending}
onClick={() => void handleReopenIssue()}
>
{statePending ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
translate(
'auto.components.github.GitHubIssueCommentComposer.b1c2d3e4f5',
'Reopen issue'
)
)}
</Button>
) : null}
<Button
onClick={() => void handleSubmit()}
disabled={!body.trim() || submitting || statePending}
size="sm"
className="gap-2 bg-emerald-600 text-white hover:bg-emerald-700 disabled:bg-emerald-600/50"
aria-label={translate(
'auto.components.github.GitHubIssueCommentComposer.0a73f59e85',
'Send comment'
)}
>
{submitting ? <LoaderCircle className="size-3.5 animate-spin" /> : null}
{translate('auto.components.github.GitHubIssueCommentComposer.bf43425540', 'Comment')}
</Button>
</div>
</div>
</div>
</div>
)
}
@@ -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<HTMLDivElement | null>(null)
const editorRef = useRef<Editor | null>(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<ComposerTab>('write')
const [linkBubble, setLinkBubble] = useState<LinkBubbleState | null>(null)
const [isEditingLink, setIsEditingLink] = useState(false)
const [imageInputOpen, setImageInputOpen] = useState(false)
const [imageUrl, setImageUrl] = useState('')
const imageInputRef = useRef<HTMLInputElement | null>(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 = (
<RichMarkdownToolbar
editor={editor}
onToggleLink={openLinkEditor}
onImagePick={openImagePicker}
/>
)
const imageInputRow = imageInputOpen ? (
<form
className="github-markdown-composer-image-row"
onSubmit={(event) => {
event.preventDefault()
insertImageUrl()
}}
>
<ImageIcon className="size-3.5 shrink-0 text-muted-foreground" />
<Input
ref={imageInputRef}
value={imageUrl}
onChange={(event) => 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"
/>
<Button type="submit" size="xs" disabled={disabled || !imageUrl.trim()}>
{translate('auto.components.github.GitHubMarkdownComposer.e3bd59143c', 'Insert')}
</Button>
<Button type="button" variant="ghost" size="xs" onClick={() => setImageInputOpen(false)}>
{translate('auto.components.github.GitHubMarkdownComposer.015b4e607d', 'Cancel')}
</Button>
</form>
) : null
const editorPane = (
<div className="max-h-[360px] overflow-y-auto scrollbar-sleek">
<EditorContent editor={editor} />
</div>
)
const previewPane = (
<GitHubMarkdownComposerPreviewPane
value={value}
minHeightClassName={minHeightClassName}
previewGithubRepo={previewGithubRepo}
/>
)
const attachmentFooter = isTabbed ? (
<button
type="button"
className="github-markdown-composer-attachment"
disabled={disabled}
onClick={openImagePicker}
>
<Paperclip className="size-3.5 shrink-0" />
<span>
{translate(
'auto.components.github.GitHubMarkdownComposer.b7e4a1c902',
'Paste, drop, or click to add files'
)}
</span>
</button>
) : null
return (
<div
ref={rootRef}
className={cn(
'github-markdown-composer relative overflow-hidden rounded-md border border-input bg-background shadow-xs',
isTabbed && 'github-markdown-composer-tabbed',
disabled && 'opacity-60',
className
)}
>
<RichMarkdownToolbar
editor={editor}
onToggleLink={openLinkEditor}
onImagePick={() => {
if (!disabledRef.current) {
setImageInputOpen(true)
}
}}
/>
{imageInputOpen ? (
<form
className="github-markdown-composer-image-row"
onSubmit={(event) => {
event.preventDefault()
insertImageUrl()
}}
>
<ImageIcon className="size-3.5 shrink-0 text-muted-foreground" />
<Input
ref={imageInputRef}
value={imageUrl}
onChange={(event) => 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"
/>
<Button type="submit" size="xs" disabled={disabled || !imageUrl.trim()}>
{translate('auto.components.github.GitHubMarkdownComposer.e3bd59143c', 'Insert')}
</Button>
<Button type="button" variant="ghost" size="xs" onClick={() => setImageInputOpen(false)}>
{translate('auto.components.github.GitHubMarkdownComposer.015b4e607d', 'Cancel')}
</Button>
</form>
) : null}
<div className="max-h-[360px] overflow-y-auto scrollbar-sleek">
<EditorContent editor={editor} />
</div>
{isTabbed ? (
<GitHubMarkdownComposerTabbar activeTab={activeTab} onTabChange={setActiveTab}>
{toolbar}
</GitHubMarkdownComposerTabbar>
) : (
toolbar
)}
{imageInputRow}
{isTabbed ? (activeTab === 'write' ? editorPane : previewPane) : editorPane}
{attachmentFooter}
{linkBubble ? (
<RichMarkdownLinkBubble
linkBubble={linkBubble}
@@ -0,0 +1,116 @@
import React, { useEffect, useMemo, useState } from 'react'
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { GitHubAssignableUser } from '../../../../shared/types'
import { filterGitHubWorkItemAssignees } from './github-work-item-assignee-filter'
const assigneeCheckIcon = (
<svg className="size-2.5" viewBox="0 0 12 12" fill="none">
<path
d="M2 6l3 3 5-5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
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 <div className="px-2 py-3 text-center text-[12px] text-destructive">{error}</div>
}
const emptyText = loading
? translate(
'auto.components.github.GitHubWorkItemAssigneePopoverContent.cddd9b04a7',
'Loading assignees'
)
: translate(
'auto.components.github.GitHubWorkItemAssigneePopoverContent.a00830d3f7',
'No users'
)
return (
<Command shouldFilter={false} className="bg-transparent">
<CommandInput
placeholder={translate(
'auto.components.github.GitHubWorkItemAssigneePopoverContent.4f8b6f2c1d',
'Filter assignees...'
)}
value={query}
onValueChange={setQuery}
className="h-8 text-[12px]"
wrapperClassName="border-b border-border/60 px-2"
iconClassName="size-3.5"
/>
<CommandList className="max-h-60">
<CommandEmpty className="px-2 py-3 text-center text-[12px] text-muted-foreground">
{emptyText}
</CommandEmpty>
{filteredAssignees.map((user) => {
const isSelected = selectedSet.has(user.login)
return (
<CommandItem
key={user.login}
value={user.login}
onSelect={() => onToggleAssignee(user.login)}
className="flex items-center gap-2 rounded-sm px-2 py-1.5 text-[12px]"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
isSelected ? 'border-primary bg-primary text-primary-foreground' : 'border-input'
)}
>
{isSelected ? assigneeCheckIcon : null}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate">{user.login}</span>
{user.name ? (
<span className="block truncate text-[11px] text-muted-foreground">
{user.name}
</span>
) : null}
</span>
</CommandItem>
)
})}
</CommandList>
</Command>
)
}
@@ -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 = (
<svg className="size-2.5" viewBox="0 0 12 12" fill="none">
<path
d="M2 6l3 3 5-5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
function GitHubLabelsSettingsLink({
url,
separated,
onOpen
}: {
url: string | null
separated?: boolean
onOpen?: () => void
}): React.JSX.Element | null {
if (!url) {
return null
}
return (
<div className={cn(separated && 'mt-1 border-t border-border/60 pt-1')}>
<button
type="button"
onClick={() => {
onOpen?.()
void window.api.shell.openUrl(url)
}}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
<Settings className="size-3.5 shrink-0" />
<span className="min-w-0 flex-1 text-left">
{translate(
'auto.components.github.GitHubWorkItemLabelPopoverContent.2aa9acdf34',
'Edit labels on GitHub'
)}
</span>
<ExternalLink className="size-3 shrink-0 opacity-70" />
</button>
</div>
)
}
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 <div className="px-2 py-3 text-center text-[12px] text-destructive">{error}</div>
}
const emptyText = loading
? translate(
'auto.components.github.GitHubWorkItemLabelPopoverContent.cddd9b04a7',
'Loading labels'
)
: translate('auto.components.github.GitHubWorkItemLabelPopoverContent.de26e2eb06', 'No labels')
return (
<>
<Command shouldFilter={false} className="bg-transparent">
<CommandInput
placeholder={translate(
'auto.components.github.GitHubWorkItemLabelPopoverContent.8b0d52ee3a',
'Filter labels...'
)}
value={query}
onValueChange={setQuery}
className="h-8 text-[12px]"
wrapperClassName="border-b border-border/60 px-2"
iconClassName="size-3.5"
/>
<CommandList className="max-h-60">
<CommandEmpty className="px-2 py-3 text-center text-[12px] text-muted-foreground">
{emptyText}
</CommandEmpty>
{filteredLabels.map((label) => {
const isSelected = selectedSet.has(label)
return (
<CommandItem
key={label}
value={label}
onSelect={() => onToggleLabel(label)}
className="flex items-center gap-2 rounded-sm px-2 py-1.5 text-[12px]"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
isSelected
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{isSelected ? labelCheckIcon : null}
</span>
<span className="min-w-0 truncate">{label}</span>
</CommandItem>
)
})}
</CommandList>
</Command>
<GitHubLabelsSettingsLink
url={repositoryLabelsUrl ?? null}
separated={labels.length > 0}
onOpen={onOpenSettingsLink}
/>
</>
)
}
@@ -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: <CheckCircle2 className="size-4 text-violet-500" />
},
{
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: <Ban className="size-4 text-muted-foreground" />
},
{
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: <Copy className="size-4 text-muted-foreground" />
}
]
@@ -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<void> {
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<Awaited<ReturnType<typeof window.api.gh.updateIssueBySlug>>>(
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<Awaited<ReturnType<typeof window.api.gh.addIssueComment>>> {
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`
}
@@ -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 (
<div
className={`github-markdown-composer-preview scrollbar-sleek max-h-[360px] overflow-y-auto ${minHeightClassName}`}
>
{value.trim() ? (
<CommentMarkdown
content={value}
variant="document"
githubRepo={previewGithubRepo}
className="min-w-0 max-w-full overflow-hidden break-words text-[13px] leading-relaxed [&_a]:break-all [&_code]:break-words [&_pre]:max-w-full"
/>
) : (
<p className="text-[13px] italic text-muted-foreground">
{translate(
'auto.components.github.GitHubMarkdownComposer.8f1c2d4e6a',
'Nothing to preview'
)}
</p>
)}
</div>
)
}
@@ -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 (
<div className="github-markdown-composer-tabbar">
<div className="github-markdown-composer-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={activeTab === 'write'}
className={cn('github-markdown-composer-tab', activeTab === 'write' && 'is-active')}
onClick={() => onTabChange('write')}
>
{translate('auto.components.github.GitHubMarkdownComposer.c91f0a2b14', 'Write')}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'preview'}
className={cn('github-markdown-composer-tab', activeTab === 'preview' && 'is-active')}
onClick={() => onTabChange('preview')}
>
{translate('auto.components.github.GitHubMarkdownComposer.d82b1e3f05', 'Preview')}
</button>
</div>
{activeTab === 'write' ? (
<div className="github-markdown-composer-tabbar-toolbar">{children}</div>
) : null}
</div>
)
}
@@ -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')
}
}
]
@@ -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]])
})
})
@@ -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)
)
}
@@ -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'])
})
})
@@ -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))
}
@@ -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<Editor | null>,
disabledRef: React.MutableRefObject<boolean>,
onOpen?: () => void
): {
imageUrl: string
imageInputOpen: boolean
imageInputRef: React.RefObject<HTMLInputElement | null>
openImagePicker: () => void
setImageUrl: (value: string) => void
setImageInputOpen: (open: boolean) => void
insertImageUrl: () => void
} {
const [imageInputOpen, setImageInputOpen] = useState(false)
const [imageUrl, setImageUrl] = useState('')
const imageInputRef = useRef<HTMLInputElement | null>(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
}
}
@@ -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
@@ -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 (
<div>
<div className="mp-eyebrow-row">
<span className="mp-eyebrow">
{translate('auto.components.mobile.MobileHero.5410d55d79', 'Orca Mobile')}
</span>
</div>
<h1 className="mp-h1">
{devices.length === 1
? translate('auto.components.mobile.MobileHero.051978a785', 'Your phone is paired.')
: translate('auto.components.mobile.MobileHero.d0b52871ce', 'Your phones are paired.')}
</h1>
<p className="mp-lead-sm">
{translate(
'auto.components.mobile.MobileHero.266c18c105',
'Open Orca Mobile to pick up where you left off, or pair another device.'
)}
</p>
<ul className="mp-paired-list">
{devices.map((device) => {
const revoking = revokingDeviceIds.includes(device.deviceId)
return (
<li key={device.deviceId} className="mp-paired-row">
<div className="mp-paired-icon">
<Smartphone className="size-4" />
</div>
<div className="mp-paired-main">
<div className="mp-paired-name">{device.name}</div>
<div className="mp-paired-meta">
{translate('auto.components.mobile.MobileHero.94829abdb1', 'Paired')}{' '}
{new Date(device.pairedAt).toLocaleDateString()}
</div>
</div>
<button
type="button"
className="mp-paired-revoke"
onClick={() => onRevoke(device.deviceId)}
disabled={revoking}
aria-label={translate(
'auto.components.mobile.MobileHero.34f878d04f',
'Revoke {{value0}}',
{ value0: device.name }
)}
title={translate('auto.components.mobile.MobileHero.f9cbf4bb53', 'Revoke device')}
>
<Trash2 className="size-3.5" />
</button>
</li>
)
})}
</ul>
<div className="mp-flow-actions">
<button type="button" className="mp-secondary-action" onClick={onPairAnother}>
<Smartphone className="size-3.5" />
{translate('auto.components.mobile.MobileHero.ff48d9d520', 'Pair another device')}
</button>
<span />
</div>
</div>
)
}
@@ -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'
}
@@ -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<typeof useAppStore.getState>['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 }) => (
<svg viewBox="0 0 24 24" aria-hidden className={className} fill="currentColor">
<path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z" />
</svg>
)
},
{
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 }) => (
<Button
key={id}
type="button"
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { i18n } from '@/i18n/i18n'
import { getMrStateFilters, getSmartWorkspaceNameModes } from './smart-workspace-localized-options'
describe('smart-workspace-localized-options', () => {
beforeEach(async () => {
await i18n.changeLanguage('en')
})
it('refreshes create-workspace source tabs when the UI language changes', async () => {
expect(getSmartWorkspaceNameModes().map((mode) => mode.label)).toEqual([
'Smart',
'GitHub',
'Linear',
'GitLab',
'Branch',
'Name'
])
await i18n.changeLanguage('zh')
expect(getSmartWorkspaceNameModes().map((mode) => mode.label)).toEqual([
'智能',
'GitHub',
'Linear',
'GitLab',
'分支',
'姓名'
])
await i18n.changeLanguage('en')
expect(getSmartWorkspaceNameModes().map((mode) => mode.label)).toEqual([
'Smart',
'GitHub',
'Linear',
'GitLab',
'Branch',
'Name'
])
})
it('refreshes GitLab state filters when the UI language changes', async () => {
expect(getMrStateFilters().map((filter) => filter.label)).toEqual([
'Open',
'Merged',
'Closed',
'All'
])
await i18n.changeLanguage('zh')
expect(getMrStateFilters().map((filter) => filter.label)).toEqual([
'进行中',
'合并',
'已关闭',
'全部'
])
})
})
@@ -0,0 +1,89 @@
import type React from 'react'
import { CaseSensitive, GitBranch, Github, Gitlab, Sparkles } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import type { SmartNameMode } from './smart-workspace-source-results'
export type MrStateFilter = 'opened' | 'merged' | 'closed' | 'all'
export type SmartWorkspaceNameModeOption = {
id: SmartNameMode
label: string
Icon: React.ComponentType<{ className?: string }>
}
function LinearModeIcon({ className }: { className?: string }): React.JSX.Element {
return (
<svg viewBox="0 0 24 24" aria-hidden className={className} fill="currentColor">
<path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z" />
</svg>
)
}
export function getMrStateFilters(): { id: MrStateFilter; label: string }[] {
return [
{
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')
}
]
}
export function getSmartWorkspaceNameModes(): SmartWorkspaceNameModeOption[] {
return [
{
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: LinearModeIcon
},
{
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
}
]
}
@@ -23,37 +23,52 @@ type FeatureSetupRow = {
const FEATURE_SETUP_ROWS: readonly FeatureSetupRow[] = [
{
id: 'browserUse',
title: translate(
'auto.components.onboarding.FeatureSetupChecklist.ea85d9e628',
'Agent Browser Use'
),
description: translate(
'auto.components.onboarding.FeatureSetupChecklist.01426f3a23',
'Agents can navigate sites, inspect pages, and work through browser tasks.'
),
get title() {
return translate(
'auto.components.onboarding.FeatureSetupChecklist.ea85d9e628',
'Agent Browser Use'
)
},
get description() {
return translate(
'auto.components.onboarding.FeatureSetupChecklist.01426f3a23',
'Agents can navigate sites, inspect pages, and work through browser tasks.'
)
},
setupSummary: 'Enables browser use, prepares orca-cli, and leaves cookies for Settings.',
icon: <Globe2 className="size-4" />
},
{
id: 'computerUse',
title: translate('auto.components.onboarding.FeatureSetupChecklist.1ecfb490ac', 'Computer Use'),
description: translate(
'auto.components.onboarding.FeatureSetupChecklist.c5292c409d',
'Agents can inspect app windows and operate local apps when you ask.'
),
get title() {
return translate(
'auto.components.onboarding.FeatureSetupChecklist.1ecfb490ac',
'Computer Use'
)
},
get description() {
return translate(
'auto.components.onboarding.FeatureSetupChecklist.c5292c409d',
'Agents can inspect app windows and operate local apps when you ask.'
)
},
setupSummary: 'Registers the Orca CLI, opens permissions, and prepares the skill.',
icon: <MonitorCog className="size-4" />
},
{
id: 'orchestration',
title: translate(
'auto.components.onboarding.FeatureSetupChecklist.399cf885c0',
'Agent Orchestration'
),
description: translate(
'auto.components.onboarding.FeatureSetupChecklist.77f74946f5',
'Agents can message each other, take tasks, and coordinate handoffs.'
),
get title() {
return translate(
'auto.components.onboarding.FeatureSetupChecklist.399cf885c0',
'Agent Orchestration'
)
},
get description() {
return translate(
'auto.components.onboarding.FeatureSetupChecklist.77f74946f5',
'Agents can message each other, take tasks, and coordinate handoffs.'
)
},
setupSummary: 'Registers the Orca CLI, enables orchestration, and prepares the skill.',
icon: <Workflow className="size-4" />
},
@@ -17,41 +17,60 @@ import { translate } from '@/i18n/i18n'
const stepCopy = {
agent: {
title: translate(
'auto.components.onboarding.OnboardingFlow.198b148b3c',
'Pick your default agent'
),
subtitle: translate(
'auto.components.onboarding.OnboardingFlow.322fc50a18',
"Orca works with every CLI agent. Choose the one you'll reach for most. Switch any time."
)
get title() {
return translate(
'auto.components.onboarding.OnboardingFlow.198b148b3c',
'Pick your default agent'
)
},
get subtitle() {
return translate(
'auto.components.onboarding.OnboardingFlow.322fc50a18',
"Orca works with every CLI agent. Choose the one you'll reach for most. Switch any time."
)
}
},
theme: {
title: translate(
'auto.components.onboarding.OnboardingFlow.f396db9f20',
'Make it feel like home'
),
subtitle: translate(
'auto.components.onboarding.OnboardingFlow.04ae28d8ca',
'Pick the look you want to stare at for hours.'
)
get title() {
return translate(
'auto.components.onboarding.OnboardingFlow.f396db9f20',
'Make it feel like home'
)
},
get subtitle() {
return translate(
'auto.components.onboarding.OnboardingFlow.04ae28d8ca',
'Pick the look you want to stare at for hours.'
)
}
},
notifications: {
title: translate(
'auto.components.onboarding.OnboardingFlow.b054332836',
'Set up notifications'
),
subtitle: translate(
'auto.components.onboarding.OnboardingFlow.ff92d15436',
'Orca will notify you know when agents are done or need help.'
)
get title() {
return translate(
'auto.components.onboarding.OnboardingFlow.b054332836',
'Set up notifications'
)
},
get subtitle() {
return translate(
'auto.components.onboarding.OnboardingFlow.ff92d15436',
'Orca will notify you when agents are done or need help.'
)
}
},
integrations: {
title: translate('auto.components.onboarding.OnboardingFlow.ae3b00ca82', 'Set up GitHub tasks'),
subtitle: translate(
'auto.components.onboarding.OnboardingFlow.97c42cda00',
'Install the GitHub CLI to:'
)
get title() {
return translate(
'auto.components.onboarding.OnboardingFlow.ae3b00ca82',
'Set up GitHub tasks'
)
},
get subtitle() {
return translate(
'auto.components.onboarding.OnboardingFlow.97c42cda00',
'Install the GitHub CLI to:'
)
}
}
} as const
@@ -10,14 +10,18 @@ import {
import { translate } from '@/i18n/i18n'
export const ONBOARDING_SKIP_CONFIRMATION_COPY = {
title: translate(
'auto.components.onboarding.OnboardingSkipConfirmationDialog.e4726b2d50',
'Skip onboarding?'
),
description: translate(
'auto.components.onboarding.OnboardingSkipConfirmationDialog.9f47f345a4',
"It won't take long!"
),
get title() {
return translate(
'auto.components.onboarding.OnboardingSkipConfirmationDialog.e4726b2d50',
'Skip onboarding?'
)
},
get description() {
return translate(
'auto.components.onboarding.OnboardingSkipConfirmationDialog.9f47f345a4',
"It won't take long!"
)
},
skipLabel: 'Skip',
keepGoingLabel: 'No, keep going'
} as const
@@ -11,6 +11,7 @@ import type {
GlobalSettings
} from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
import { ChromePreview } from './theme-chrome-preview'
type ThemeStepProps = {
theme: GlobalSettings['theme']
@@ -273,78 +274,6 @@ export function ThemeStep({ theme, onThemeChange, settings, updateSettings }: Th
)
}
function ChromePreview({ variant }: { variant: GlobalSettings['theme'] }) {
if (variant === 'system') {
return (
<div className="relative size-full">
<div
className="absolute inset-0"
style={{ clipPath: 'polygon(0 0, 50% 0, 50% 100%, 0 100%)' }}
>
<ChromeMock dark />
</div>
<div
className="absolute inset-0"
style={{ clipPath: 'polygon(50% 0, 100% 0, 100% 100%, 50% 100%)' }}
>
<ChromeMock dark={false} />
</div>
<div
aria-hidden
className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border/70"
/>
</div>
)
}
return <ChromeMock dark={variant === 'dark'} />
}
function ChromeMock({ dark }: { dark: boolean }) {
// Tiny Orca chrome: sidebar with two rows + a content area with a tab and
// a composer line. Pure Tailwind so it stays lightweight inside the tile.
const bg = dark ? 'bg-[#0f1115]' : 'bg-[#f7f8fa]'
const sidebar = dark ? 'bg-[#16181d]' : 'bg-[#eceef2]'
const sidebarBorder = dark ? 'border-white/5' : 'border-black/5'
const row = dark ? 'bg-white/10' : 'bg-black/10'
const rowDim = dark ? 'bg-white/5' : 'bg-black/5'
const tab = dark ? 'bg-[#1d2026] border-white/5' : 'bg-white border-black/5'
const accent = 'bg-violet-500/80'
return (
<div className={cn('flex size-full', bg)}>
<div className={cn('flex w-[34%] flex-col gap-1 border-r p-1.5', sidebar, sidebarBorder)}>
<div className={cn('h-1 w-7 rounded-sm', rowDim)} />
<div className="mt-0.5 flex items-center gap-1">
<span className={cn('size-1 rounded-full', accent)} />
<span className={cn('h-1 flex-1 rounded-sm', row)} />
</div>
<div className="flex items-center gap-1">
<span className={cn('size-1 rounded-full', rowDim)} />
<span className={cn('h-1 flex-1 rounded-sm', rowDim)} />
</div>
<div className="flex items-center gap-1">
<span className={cn('size-1 rounded-full', rowDim)} />
<span className={cn('h-1 w-3/4 rounded-sm', rowDim)} />
</div>
</div>
<div className="flex flex-1 flex-col p-1.5">
<div className="flex gap-1">
<div className={cn('h-2 w-8 rounded-sm border', tab)} />
<div className={cn('h-2 w-5 rounded-sm', rowDim)} />
</div>
<div className="mt-1.5 flex-1 space-y-1">
<div className={cn('h-1 w-full rounded-sm', rowDim)} />
<div className={cn('h-1 w-5/6 rounded-sm', rowDim)} />
<div className={cn('h-1 w-2/3 rounded-sm', rowDim)} />
</div>
<div className={cn('mt-1 flex h-2.5 items-center gap-1 rounded-sm border px-1', tab)}>
<span className={cn('size-1 rounded-full', accent)} />
<span className={cn('h-0.5 flex-1 rounded-sm', rowDim)} />
</div>
</div>
</div>
)
}
function humanFields(diff: Partial<GlobalSettings>): string[] {
// Why: chip labels are a friendly summary, not a strict 1:1 of mapper keys.
// Group related diff keys (font weight + family + size → "Font") so the row
@@ -0,0 +1,74 @@
import { cn } from '@/lib/utils'
import type { GlobalSettings } from '../../../../shared/types'
export function ChromePreview({ variant }: { variant: GlobalSettings['theme'] }) {
if (variant === 'system') {
return (
<div className="relative size-full">
<div
className="absolute inset-0"
style={{ clipPath: 'polygon(0 0, 50% 0, 50% 100%, 0 100%)' }}
>
<ChromeMock dark />
</div>
<div
className="absolute inset-0"
style={{ clipPath: 'polygon(50% 0, 100% 0, 100% 100%, 50% 100%)' }}
>
<ChromeMock dark={false} />
</div>
<div
aria-hidden
className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border/70"
/>
</div>
)
}
return <ChromeMock dark={variant === 'dark'} />
}
function ChromeMock({ dark }: { dark: boolean }) {
// Tiny Orca chrome: sidebar with two rows + a content area with a tab and
// a composer line. Pure Tailwind so it stays lightweight inside the tile.
const bg = dark ? 'bg-[#0f1115]' : 'bg-[#f7f8fa]'
const sidebar = dark ? 'bg-[#16181d]' : 'bg-[#eceef2]'
const sidebarBorder = dark ? 'border-white/5' : 'border-black/5'
const row = dark ? 'bg-white/10' : 'bg-black/10'
const rowDim = dark ? 'bg-white/5' : 'bg-black/5'
const tab = dark ? 'bg-[#1d2026] border-white/5' : 'bg-white border-black/5'
const accent = 'bg-violet-500/80'
return (
<div className={cn('flex size-full', bg)}>
<div className={cn('flex w-[34%] flex-col gap-1 border-r p-1.5', sidebar, sidebarBorder)}>
<div className={cn('h-1 w-7 rounded-sm', rowDim)} />
<div className="mt-0.5 flex items-center gap-1">
<span className={cn('size-1 rounded-full', accent)} />
<span className={cn('h-1 flex-1 rounded-sm', row)} />
</div>
<div className="flex items-center gap-1">
<span className={cn('size-1 rounded-full', rowDim)} />
<span className={cn('h-1 flex-1 rounded-sm', rowDim)} />
</div>
<div className="flex items-center gap-1">
<span className={cn('size-1 rounded-full', rowDim)} />
<span className={cn('h-1 w-3/4 rounded-sm', rowDim)} />
</div>
</div>
<div className="flex flex-1 flex-col p-1.5">
<div className="flex gap-1">
<div className={cn('h-2 w-8 rounded-sm border', tab)} />
<div className={cn('h-2 w-5 rounded-sm', rowDim)} />
</div>
<div className="mt-1.5 flex-1 space-y-1">
<div className={cn('h-1 w-full rounded-sm', rowDim)} />
<div className={cn('h-1 w-5/6 rounded-sm', rowDim)} />
<div className={cn('h-1 w-2/3 rounded-sm', rowDim)} />
</div>
<div className={cn('mt-1 flex h-2.5 items-center gap-1 rounded-sm border px-1', tab)}>
<span className={cn('size-1 rounded-full', accent)} />
<span className={cn('h-0.5 flex-1 rounded-sm', rowDim)} />
</div>
</div>
</div>
)
}
@@ -21,17 +21,23 @@ export type BundledPet = {
export const BUNDLED_PETS: readonly BundledPet[] = [
{
id: DEFAULT_PET_ID,
label: translate('auto.components.pet.pet.models.2528586aa7', 'Claudino'),
get label() {
return translate('auto.components.pet.pet.models.2528586aa7', 'Claudino')
},
url: claudeUrl
},
{
id: OPENCODE_PET_ID,
label: translate('auto.components.pet.pet.models.a84d5677ff', 'OpenCode'),
get label() {
return translate('auto.components.pet.pet.models.a84d5677ff', 'OpenCode')
},
url: opencodeUrl
},
{
id: GREMLIN_PET_ID,
label: translate('auto.components.pet.pet.models.7433516faf', 'Gremlin'),
get label() {
return translate('auto.components.pet.pet.models.7433516faf', 'Gremlin')
},
url: gremlinUrl
}
] as const
@@ -19,15 +19,24 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
}) => <div data-disabled={disabled ? 'true' : undefined}>{children}</div>
}))
function renderHeader(canUnlinkPullRequest = true): string {
function renderHeader({
canUnlinkPullRequest = true,
provider = 'github'
}: {
canUnlinkPullRequest?: boolean
provider?: 'github' | 'gitlab'
} = {}): string {
const isGitLab = provider === 'gitlab'
return renderToStaticMarkup(
<ChecksPanelReviewHeader
review={{
provider: 'github',
number: 2964,
title: 'fix: pr-bug-scan validated finding',
provider,
number: isGitLab ? 31 : 2964,
title: isGitLab ? 'Fix GitLab MR creation' : 'fix: pr-bug-scan validated finding',
state: 'open',
url: 'https://github.com/stablyai/orca/pull/2964',
url: isGitLab
? 'https://gitlab.com/acme/orca/-/merge_requests/31'
: 'https://github.com/stablyai/orca/pull/2964',
status: 'pending',
updatedAt: '2026-05-31T22:58:01Z',
mergeable: 'UNKNOWN'
@@ -57,9 +66,19 @@ describe('ChecksPanelReviewHeader', () => {
})
it('disables unlinking when the displayed PR is not manually linked', () => {
const markup = renderHeader(false)
const markup = renderHeader({ canUnlinkPullRequest: false })
expect(markup).toContain('data-disabled="true"')
expect(markup).toContain('unlink PR')
})
it('shows GitLab MR unlink actions in the menu', () => {
const markup = renderHeader({ provider: 'gitlab' })
expect(markup).toContain('Open on GitLab')
expect(markup).toContain('!31')
expect(markup).toContain('More MR actions')
expect(markup).toContain('unlink MR')
expect(markup).not.toContain('Link another PR')
})
})
@@ -94,9 +94,12 @@ import {
shouldShowChecksPanelPublishBranchAction
} from './checks-panel-empty-state'
import {
cancelRuntimeGeneratePullRequestFields,
generateRuntimePullRequestFields,
getRuntimeGitScope,
getRuntimeGitStatus,
getRuntimeGitUpstreamStatus
getRuntimeGitUpstreamStatus,
type RuntimeGeneratePullRequestFieldsOverrides
} from '@/runtime/runtime-git-client'
import {
buildChecksPanelGitStatusContextKey,
@@ -134,6 +137,21 @@ import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-
import { CreateHostedReviewComposer } from './CreateHostedReviewComposer'
import { formatCreateError } from './create-pull-request-review-copy'
import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields'
import {
resolveChecksPanelHostedReviewBaseRef,
shouldOpenChecksPanelCreateComposer
} from './checks-panel-review-creation'
import {
createRunningPullRequestGenerationRecord,
getPullRequestGenerationRecordKey,
resolvePullRequestGenerationCancel,
resolvePullRequestGenerationFailure,
resolvePullRequestGenerationSuccess,
shouldHydratePullRequestGenerationResult,
type PullRequestFieldRevisions,
type PullRequestGenerationContext,
type PullRequestGenerationFields
} from '@/store/slices/pull-request-generation'
import { localizedHostedReviewCopy } from '@/i18n/hosted-review-localized-copy'
import { translate } from '@/i18n/i18n'
import { groupPRComments, type PRCommentGroup } from '@/lib/pr-comment-groups'
@@ -184,7 +202,8 @@ export function ChecksPanelReviewHeader({
const reviewNumberLabel = review.provider === 'gitlab' ? `!${review.number}` : `#${review.number}`
const ReviewIcon = review.provider === 'gitlab' ? GitMerge : PullRequestIcon
const reviewHostLabel = review.provider === 'gitlab' ? 'GitLab' : 'GitHub'
const showPullRequestMenu = review.provider === 'github'
const showPullRequestMenu = review.provider === 'github' || review.provider === 'gitlab'
const shortReviewLabel = review.provider === 'gitlab' ? 'MR' : 'PR'
return (
<div className="flex items-center gap-2">
@@ -226,12 +245,14 @@ export function ChecksPanelReviewHeader({
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.right.sidebar.ChecksPanel.653c105ecc',
'More PR actions'
'auto.components.right.sidebar.ChecksPanel.b4f3ec62a1',
'More {{value0}} actions',
{ value0: shortReviewLabel }
)}
title={translate(
'auto.components.right.sidebar.ChecksPanel.653c105ecc',
'More PR actions'
'auto.components.right.sidebar.ChecksPanel.b4f3ec62a1',
'More {{value0}} actions',
{ value0: shortReviewLabel }
)}
className="text-muted-foreground hover:text-foreground"
>
@@ -241,12 +262,21 @@ export function ChecksPanelReviewHeader({
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem disabled={!canUnlinkPullRequest} onSelect={onUnlinkPullRequest}>
<Unlink className="size-3.5" />
{translate('auto.components.right.sidebar.ChecksPanel.7202f4a40a', 'unlink PR')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onLinkAnotherPullRequest}>
<Link className="size-3.5" />
{translate('auto.components.right.sidebar.ChecksPanel.07871c0589', 'Link another PR')}
{translate(
'auto.components.right.sidebar.ChecksPanel.a9d7c128e4',
'unlink {{value0}}',
{ value0: shortReviewLabel }
)}
</DropdownMenuItem>
{review.provider === 'github' ? (
<DropdownMenuItem onSelect={onLinkAnotherPullRequest}>
<Link className="size-3.5" />
{translate(
'auto.components.right.sidebar.ChecksPanel.07871c0589',
'Link another PR'
)}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -344,6 +374,12 @@ export default function ChecksPanel(): React.JSX.Element {
(s) => s.getHostedReviewCreationEligibility
)
const createHostedReview = useAppStore((s) => s.createHostedReview)
const prGenerationRecords = useAppStore((s) => s.pullRequestGenerationRecords)
const allocatePullRequestGenerationRequestId = useAppStore(
(s) => s.allocatePullRequestGenerationRequestId
)
const setPullRequestGenerationRecord = useAppStore((s) => s.setPullRequestGenerationRecord)
const updatePullRequestGenerationRecord = useAppStore((s) => s.updatePullRequestGenerationRecord)
const enqueueGitHubPRRefresh = useAppStore((s) => s.enqueueGitHubPRRefresh)
const conflictOperation = useAppStore((s) =>
activeWorktreeId ? (s.gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown') : 'unknown'
@@ -461,6 +497,25 @@ export default function ChecksPanel(): React.JSX.Element {
const branch = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : ''
const activeWorktreePath = activeWorktree?.path ?? null
const activeWorktreePushTarget = activeWorktree?.pushTarget ?? null
const hostedReviewCreationBaseRef = resolveChecksPanelHostedReviewBaseRef({
worktreeBaseRef: activeWorktree?.baseRef,
repoBaseRef: repo?.worktreeBaseRef
})
const activePullRequestGenerationKey = getPullRequestGenerationRecordKey({
worktreeId: activeWorktreeId,
worktreePath: activeWorktreePath,
repoId: repo?.id,
branch
})
const activePullRequestGenerationRecordCandidate = activePullRequestGenerationKey
? (prGenerationRecords[activePullRequestGenerationKey] ?? null)
: null
const activePullRequestGenerationRecord =
activePullRequestGenerationRecordCandidate &&
activePullRequestGenerationRecordCandidate.context.repoId === repo?.id &&
activePullRequestGenerationRecordCandidate.context.branch === branch
? activePullRequestGenerationRecordCandidate
: null
const activeSourceControlLaunchPlatform = resolveSourceControlLaunchPlatform({
connectionId: activeConnectionId,
worktreePath: activeWorktreePath
@@ -619,7 +674,7 @@ export default function ChecksPanel(): React.JSX.Element {
runtimeEnvironmentId,
connectionId: repoConnectionId,
branch,
base: repo.worktreeBaseRef ?? null,
base: hostedReviewCreationBaseRef,
hasUncommittedChanges:
gitStatusSnapshot?.contextKey === panelContextKey
? gitStatusSnapshot.hasUncommittedChanges
@@ -674,6 +729,141 @@ export default function ChecksPanel(): React.JSX.Element {
const connectionId = activeConnectionId ?? undefined
await fetchUpstreamStatus(activeWorktreeId, activeWorktree.path, connectionId)
}, [activeConnectionId, activeWorktree?.path, activeWorktreeId, fetchUpstreamStatus])
const handleGeneratePullRequestFieldsForActive = useCallback(
async (
fields: PullRequestGenerationFields,
fieldRevisions: PullRequestFieldRevisions,
overrides?: RuntimeGeneratePullRequestFieldsOverrides
): Promise<void> => {
if (!repo || !activePullRequestGenerationKey || !activeWorktreePath || !branch) {
return
}
const generationKey = activePullRequestGenerationKey
if (
useAppStore.getState().pullRequestGenerationRecords[generationKey]?.status === 'running'
) {
return
}
const requestId = allocatePullRequestGenerationRequestId()
const context: PullRequestGenerationContext = {
worktreeId: activeWorktreeId,
worktreePath: activeWorktreePath,
connectionId: activeConnectionId ?? undefined,
requestId,
repoId: repo.id,
branch
}
const seed = { ...fields }
// Why: Checks stays mounted across tab switches, but the composer itself
// can unmount when eligibility refreshes; keep generation tied to the branch.
setPullRequestGenerationRecord(
generationKey,
createRunningPullRequestGenerationRecord(context, seed, fieldRevisions)
)
try {
const result = await generateRuntimePullRequestFields(
{
settings: useAppStore.getState().settings,
worktreeId: context.worktreeId,
worktreePath: context.worktreePath,
connectionId: context.connectionId
},
{
base: stripBaseRef((seed.base ?? '').trim()),
title: seed.title,
body: seed.body,
draft: seed.draft
},
overrides
)
if (result.branchChangedByPreparation) {
await handleBranchChangedByPullRequestGeneration()
}
if (result.success) {
useAppStore.getState().recordFeatureInteraction('ai-pr-generation')
}
updatePullRequestGenerationRecord(generationKey, (record) => {
if (!result.success) {
return resolvePullRequestGenerationFailure({
record,
requestId,
canceled: result.canceled,
error: result.canceled ? null : result.error
})
}
if (!record) {
return null
}
return resolvePullRequestGenerationSuccess({
record,
requestId,
result: {
base: stripBaseRef(result.fields.base),
title: result.fields.title,
body: result.fields.body,
draft: result.fields.draft
}
})
})
} catch (error) {
updatePullRequestGenerationRecord(generationKey, (record) =>
resolvePullRequestGenerationFailure({
record,
requestId,
error:
error instanceof Error ? error.message : 'Failed to generate pull request details'
})
)
}
},
[
activeConnectionId,
activePullRequestGenerationKey,
activeWorktreeId,
activeWorktreePath,
allocatePullRequestGenerationRequestId,
branch,
handleBranchChangedByPullRequestGeneration,
repo,
setPullRequestGenerationRecord,
updatePullRequestGenerationRecord
]
)
const handleCancelGeneratePullRequestFieldsForActive = useCallback((): void => {
if (!activePullRequestGenerationKey) {
return
}
const record = prGenerationRecords[activePullRequestGenerationKey]
if (!record || record.status !== 'running') {
return
}
const generationKey = activePullRequestGenerationKey
updatePullRequestGenerationRecord(generationKey, (current) => {
if (!current || current.context.requestId !== record.context.requestId) {
return null
}
return resolvePullRequestGenerationCancel(current)
})
void cancelRuntimeGeneratePullRequestFields({
settings: useAppStore.getState().settings,
worktreeId: record.context.worktreeId,
worktreePath: record.context.worktreePath,
connectionId: record.context.connectionId
}).catch((error) => {
updatePullRequestGenerationRecord(generationKey, (current) => {
if (!current || current.context.requestId !== record.context.requestId) {
return null
}
return {
...current,
status: 'failed',
error: error instanceof Error ? error.message : 'Failed to stop pull request generation',
hydrated: false
}
})
})
}, [activePullRequestGenerationKey, prGenerationRecords, updatePullRequestGenerationRecord])
const prCreationDefaults = useMemo(() => {
if (!settings) {
return DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS
@@ -696,12 +886,12 @@ export default function ChecksPanel(): React.JSX.Element {
prCreationProductDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS
})
}, [repo, settings])
const createComposerOpen =
!activeReview &&
!isFolder &&
Boolean(branch) &&
(hostedReviewCreation?.canCreate === true ||
hostedReviewCreation?.blockedReason === 'needs_push')
const createComposerOpen = shouldOpenChecksPanelCreateComposer({
activeReview,
isFolder,
branch,
hostedReviewCreation
})
const {
aiGenerationEnabled: prAiGenerationEnabled,
base: prBase,
@@ -722,7 +912,8 @@ export default function ChecksPanel(): React.JSX.Element {
generateDisabled: prGenerateDisabled,
generateDisabledReason: prGenerateDisabledReason,
handleGenerate: handleGeneratePullRequestFields,
handleCancelGenerate: handleCancelGeneratePullRequestFields
handleCancelGenerate: handleCancelGeneratePullRequestFields,
applyGeneratedFields: applyGeneratedPullRequestFields
} = useCreatePullRequestDialogFields({
open: createComposerOpen,
repoId: repo?.id ?? '',
@@ -734,8 +925,57 @@ export default function ChecksPanel(): React.JSX.Element {
settings,
submitting: isCreatingPr,
prCreationDefaults,
onBranchChangedByGeneration: handleBranchChangedByPullRequestGeneration
onBranchChangedByGeneration: handleBranchChangedByPullRequestGeneration,
generation: {
generating: activePullRequestGenerationRecord?.status === 'running',
generateError: activePullRequestGenerationRecord?.error ?? null,
onGenerate: (fields, fieldRevisions, overrides) => {
void handleGeneratePullRequestFieldsForActive(fields, fieldRevisions, overrides)
},
onCancelGenerate: handleCancelGeneratePullRequestFieldsForActive
}
})
useEffect(() => {
if (
!activePullRequestGenerationKey ||
!activePullRequestGenerationRecord ||
activePullRequestGenerationRecord.status !== 'succeeded' ||
!activePullRequestGenerationRecord.result ||
activePullRequestGenerationRecord.hydrated
) {
return
}
if (
!shouldHydratePullRequestGenerationResult({
record: activePullRequestGenerationRecord
})
) {
return
}
applyGeneratedPullRequestFields(
activePullRequestGenerationRecord.result,
activePullRequestGenerationRecord.seedFieldRevisions
)
updatePullRequestGenerationRecord(activePullRequestGenerationKey, (record) => {
if (
!record ||
record.context.requestId !== activePullRequestGenerationRecord.context.requestId
) {
return null
}
return {
...record,
hydrated: true
}
})
}, [
activePullRequestGenerationKey,
activePullRequestGenerationRecord,
applyGeneratedPullRequestFields,
updatePullRequestGenerationRecord
])
const handlePrBaseChange = useCallback(
(value: string): void => {
setCreatePrError(null)
@@ -985,7 +1225,7 @@ export default function ChecksPanel(): React.JSX.Element {
repoPath: repo.path,
...(activeWorktreePath ? { worktreePath: activeWorktreePath } : {}),
branch,
base: repo.worktreeBaseRef ?? null,
base: hostedReviewCreationBaseRef,
hasUncommittedChanges,
hasUpstream: remoteStatus?.hasUpstream,
ahead: remoteStatus?.ahead,
@@ -1023,6 +1263,7 @@ export default function ChecksPanel(): React.JSX.Element {
getHostedReviewCreationEligibility,
gitStatusReadyForPanelContext,
hasUncommittedChanges,
hostedReviewCreationBaseRef,
hostedReviewCreationRequestKey,
isFolder,
isPanelVisible,
@@ -2466,11 +2707,17 @@ export default function ChecksPanel(): React.JSX.Element {
}, [activeReview, activeWorktreeId])
const handleUnlinkPullRequest = useCallback(() => {
if (!activeWorktreeId || activeReview?.provider !== 'github' || linkedPR === null) {
if (!activeWorktreeId || !activeReview) {
return
}
void updateWorktreeMeta(activeWorktreeId, { linkedPR: null })
}, [activeReview?.provider, activeWorktreeId, linkedPR, updateWorktreeMeta])
if (activeReview.provider === 'github' && linkedPR !== null) {
void updateWorktreeMeta(activeWorktreeId, { linkedPR: null })
return
}
if (activeReview.provider === 'gitlab' && linkedGitLabMR !== null) {
void updateWorktreeMeta(activeWorktreeId, { linkedGitLabMR: null })
}
}, [activeReview, activeWorktreeId, linkedGitLabMR, linkedPR, updateWorktreeMeta])
const handleLinkAnotherPullRequest = useCallback(() => {
if (!activeWorktreeId || !activeWorktree || activeReview?.provider !== 'github') {
@@ -2951,7 +3198,9 @@ export default function ChecksPanel(): React.JSX.Element {
<ChecksPanelReviewHeader
review={activeReview}
isRefreshing={isRefreshing}
canUnlinkPullRequest={linkedPR !== null}
canUnlinkPullRequest={
activeReview.provider === 'gitlab' ? linkedGitLabMR !== null : linkedPR !== null
}
onRefresh={() => void handleRefresh()}
onOpenReview={handleOpenPR}
onUnlinkPullRequest={handleUnlinkPullRequest}
@@ -384,8 +384,18 @@ describe('ConflictSummaryCard', () => {
expect(cherryPickMarkup).not.toContain('Abort rebase')
})
it('renders rebase abort with the quiet outline review-conflicts button treatment', () => {
const markup = renderToStaticMarkup(
it('renders abort actions with the quiet outline review-conflicts button treatment', () => {
const mergeMarkup = renderToStaticMarkup(
<ConflictSummaryCard
conflictOperation="merge"
unresolvedCount={1}
isResolvingWithAI={false}
onAbortOperation={vi.fn()}
onResolveWithAI={vi.fn()}
onReview={vi.fn()}
/>
)
const rebaseMarkup = renderToStaticMarkup(
<ConflictSummaryCard
conflictOperation="rebase"
unresolvedCount={1}
@@ -396,8 +406,10 @@ describe('ConflictSummaryCard', () => {
/>
)
expect(buttonContaining(markup, 'Review conflicts')).toContain('data-variant="outline"')
expect(buttonContaining(markup, 'Abort rebase')).toContain('data-variant="outline"')
expect(buttonContaining(mergeMarkup, 'Review conflicts')).toContain('data-variant="outline"')
expect(buttonContaining(mergeMarkup, 'Abort merge')).toContain('data-variant="outline"')
expect(buttonContaining(rebaseMarkup, 'Review conflicts')).toContain('data-variant="outline"')
expect(buttonContaining(rebaseMarkup, 'Abort rebase')).toContain('data-variant="outline"')
})
it('renders the Sparkles icon on the idle Resolve with AI button', () => {
@@ -435,7 +447,7 @@ describe('OperationBanner', () => {
expect(cherryPickMarkup).not.toContain('Abort rebase')
})
it('keeps rebase abort non-destructive while preserving merge abort styling', () => {
it('renders abort actions with the quiet outline button treatment', () => {
const mergeMarkup = renderToStaticMarkup(
<OperationBanner conflictOperation="merge" onAbortOperation={vi.fn()} />
)
@@ -443,7 +455,7 @@ describe('OperationBanner', () => {
<OperationBanner conflictOperation="rebase" onAbortOperation={vi.fn()} />
)
expect(buttonContaining(mergeMarkup, 'Abort merge')).toContain('data-variant="destructive"')
expect(buttonContaining(mergeMarkup, 'Abort merge')).toContain('data-variant="outline"')
expect(buttonContaining(rebaseMarkup, 'Abort rebase')).toContain('data-variant="outline"')
})
})
@@ -1,6 +1,5 @@
import React, { useCallback, useMemo, useState } from 'react'
import React, { useCallback, useMemo } from 'react'
import { LoaderCircle, GitMerge, ChevronDown, GitPullRequestClosed } from 'lucide-react'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
@@ -12,11 +11,8 @@ import {
DropdownMenuItem,
DropdownMenuSeparator
} from '@/components/ui/dropdown-menu'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { PRInfo, Repo, Worktree } from '../../../../shared/types'
import type { GitHubPRMergeMethod } from '../../../../shared/types'
import { resolveGitHubPRMergeMethods } from '../../../../shared/github-pr-merge-methods'
import { runWorktreeDelete } from '../sidebar/delete-worktree-flow'
import { presentGitLabMRMergeState } from './gitlab-mr-merge-state'
@@ -25,19 +21,9 @@ import {
HostedReviewActionError,
MergedReviewActions
} from './HostedReviewStateActions'
import { useHostedReviewActions, type HostedReviewActionInfo } from './use-hosted-review-actions'
import { translate } from '@/i18n/i18n'
type HostedReviewActionInfo = Pick<
HostedReviewInfo,
'provider' | 'number' | 'state' | 'status' | 'mergeable'
> &
Partial<
Pick<
HostedReviewInfo,
'reviewDecision' | 'autoMergeEnabled' | 'mergeQueueRequired' | 'mergeStateStatus'
>
>
export default function HostedReviewActions({
review,
githubPR,
@@ -54,11 +40,6 @@ export default function HostedReviewActions({
const isDeletingWorktree = useAppStore(
(s) => s.deleteStateByWorktreeId[worktree.id]?.isDeleting ?? false
)
const confirm = useConfirmationDialog()
const [merging, setMerging] = useState(false)
const [stateUpdating, setStateUpdating] = useState<'open' | 'closed' | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const isGitLab = review.provider === 'gitlab'
const shortLabel = isGitLab ? 'MR' : 'PR'
const reviewLabel = isGitLab ? 'merge request' : 'pull request'
@@ -81,6 +62,25 @@ export default function HostedReviewActions({
() => resolveGitHubPRMergeMethods(isGitLab ? null : (githubPR?.mergeMethodSettings ?? null)),
[githubPR?.mergeMethodSettings, isGitLab]
)
const {
merging,
stateUpdating,
actionError,
handleMerge,
handleAutoMerge,
handleCloseReview,
handleReopenReview
} = useHostedReviewActions({
review,
githubPR,
repo,
isGitLab,
shortLabel,
reviewLabel,
defaultMergeMethod: mergeMethods.defaultMethod,
autoMergeAction: mergePresentation.autoMergeAction,
onRefreshReview
})
const isUpdatingReviewState = stateUpdating !== null
const primaryMergeDisabled =
merging ||
@@ -90,169 +90,6 @@ export default function HostedReviewActions({
merging || isUpdatingReviewState || !mergePresentation.directMergeAvailable
const menuDisabled = merging || isUpdatingReviewState
const handleMerge = useCallback(
async (method: GitHubPRMergeMethod = mergeMethods.defaultMethod) => {
setMerging(true)
setActionError(null)
try {
const result = isGitLab
? await window.api.gl.mergeMR({
repoPath: repo.path,
iid: review.number,
method
})
: await window.api.gh.mergePR({
repoPath: repo.path,
repoId: repo.id,
prNumber: review.number,
method,
prRepo: githubPR?.prRepo ?? null
})
if (!result.ok) {
setActionError(result.error)
} else {
await onRefreshReview()
}
} catch (err) {
setActionError(err instanceof Error ? err.message : 'Merge failed')
} finally {
setMerging(false)
}
},
[
githubPR?.prRepo,
isGitLab,
mergeMethods.defaultMethod,
onRefreshReview,
repo.id,
repo.path,
review.number
]
)
const handleAutoMerge = useCallback(async () => {
if (isGitLab || !mergePresentation.autoMergeAction) {
return
}
const enabled = mergePresentation.autoMergeAction.kind === 'enable'
setMerging(true)
setActionError(null)
try {
const result = await window.api.gh.setPRAutoMerge({
repoPath: repo.path,
repoId: repo.id,
prNumber: review.number,
enabled,
prRepo: githubPR?.prRepo ?? null
})
if (!result.ok) {
setActionError(result.error)
} else {
await onRefreshReview()
}
} catch (err) {
setActionError(err instanceof Error ? err.message : 'Auto-merge update failed')
} finally {
setMerging(false)
}
}, [
githubPR?.prRepo,
isGitLab,
mergePresentation.autoMergeAction,
onRefreshReview,
repo.id,
repo.path,
review.number
])
const handleReviewStateChange = useCallback(
async (nextState: 'open' | 'closed') => {
if (stateUpdating) {
return
}
const isClosing = nextState === 'closed'
const label = isClosing ? 'Close' : 'Reopen'
const confirmed = await confirm({
title: `${label} ${shortLabel} ${isGitLab ? '!' : '#'}${review.number}?`,
description: isClosing
? translate(
'auto.components.right.sidebar.HostedReviewActions.a3d572a4de',
'This will close the {{value0}}.',
{ value0: reviewLabel }
)
: translate(
'auto.components.right.sidebar.HostedReviewActions.78f5ff294c',
'This will reopen the {{value0}}.',
{ value0: reviewLabel }
),
confirmLabel: label,
confirmVariant: isClosing ? 'destructive' : 'default'
})
if (!confirmed) {
return
}
setStateUpdating(nextState)
setActionError(null)
try {
const result = isGitLab
? isClosing
? await window.api.gl.closeMR({ repoPath: repo.path, iid: review.number })
: await window.api.gl.reopenMR({ repoPath: repo.path, iid: review.number })
: await window.api.gh.updatePRState({
repoPath: repo.path,
repoId: repo.id,
prNumber: review.number,
updates: { state: nextState }
})
if (!result.ok) {
setActionError(result.error)
toast.error(result.error)
} else {
toast.success(
isClosing
? translate(
'auto.components.right.sidebar.HostedReviewActions.fa3ee9a515',
'{{value0}} closed',
{ value0: shortLabel }
)
: translate(
'auto.components.right.sidebar.HostedReviewActions.377269db6f',
'{{value0}} reopened',
{ value0: shortLabel }
)
)
await onRefreshReview()
}
} catch (err) {
const message =
err instanceof Error ? err.message : `Failed to ${label.toLowerCase()} ${reviewLabel}`
setActionError(message)
toast.error(message)
} finally {
setStateUpdating(null)
}
},
[
confirm,
isGitLab,
onRefreshReview,
repo.id,
repo.path,
review.number,
reviewLabel,
shortLabel,
stateUpdating
]
)
const handleCloseReview = useCallback(async () => {
await handleReviewStateChange('closed')
}, [handleReviewStateChange])
const handleReopenReview = useCallback(async () => {
await handleReviewStateChange('open')
}, [handleReviewStateChange])
const handleDeleteWorktree = useCallback(() => {
// Why: route every UI delete entry point through the shared funnel so
// skip-confirm, main-worktree, and child-workspace safeguards cannot drift.
@@ -389,7 +226,6 @@ export default function HostedReviewActions({
/>
)
}
if (review.state === 'merged') {
return (
<MergedReviewActions
@@ -5687,14 +5687,6 @@ function DiffCommentsInlineList({
)
}
function conflictAbortButtonVariant(
conflictOperation: GitConflictOperation
): 'outline' | 'destructive' {
// Why: aborting a rebase is the escape hatch for this state, so it should
// match the quiet outline conflict-review action instead of reading as red.
return conflictOperation === 'rebase' ? 'outline' : 'destructive'
}
export function ConflictSummaryCard({
conflictOperation,
unresolvedCount,
@@ -5770,7 +5762,9 @@ export function ConflictSummaryCard({
{(conflictOperation === 'merge' || conflictOperation === 'rebase') && onAbortOperation ? (
<Button
type="button"
variant={conflictAbortButtonVariant(conflictOperation)}
// Why: abort is the escape hatch for this state, so match the quiet
// outline conflict-review action instead of reading as destructive.
variant="outline"
size="sm"
className="mt-1.5 h-7 w-full text-xs"
disabled={isResolvingWithAI || isAbortingOperation}
@@ -5821,7 +5815,9 @@ export function OperationBanner({
{(conflictOperation === 'merge' || conflictOperation === 'rebase') && onAbortOperation ? (
<Button
type="button"
variant={conflictAbortButtonVariant(conflictOperation)}
// Why: abort is the escape hatch for this state, so match the quiet
// outline conflict-review action instead of reading as destructive.
variant="outline"
size="sm"
className="mt-2 h-7 w-full text-xs"
disabled={isAbortingOperation}
@@ -0,0 +1,293 @@
// @vitest-environment happy-dom
import path from 'node:path'
import React, { type ReactNode, useState } from 'react'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import type { SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions'
import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types'
const mocks = vi.hoisted(() => ({
ensureDetectedAgents: vi.fn(),
ensureRemoteDetectedAgents: vi.fn(),
onOpenChange: vi.fn(),
onSaveAgentDefault: vi.fn(),
onLaunched: vi.fn(),
onStart: vi.fn(),
planSourceControlAgentActionLaunch: vi.fn(),
toastError: vi.fn()
}))
vi.mock('@/components/agent/AgentCombobox', () => ({
default: ({ value }: { value: string | null }) =>
React.createElement('div', { 'data-agent-value': value ?? '' })
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ open, children }: { open: boolean; children?: ReactNode }) =>
open ? React.createElement('div', { 'data-dialog-open': 'true' }, children) : null,
DialogContent: ({ children }: { children?: ReactNode }) =>
React.createElement('div', null, children),
DialogDescription: ({ children }: { children?: ReactNode }) =>
React.createElement('p', null, children),
DialogFooter: ({ children }: { children?: ReactNode }) =>
React.createElement('div', null, children),
DialogHeader: ({ children }: { children?: ReactNode }) =>
React.createElement('div', null, children),
DialogTitle: ({ children }: { children?: ReactNode }) => React.createElement('h2', null, children)
}))
vi.mock('@/components/ui/select', () => ({
Select: ({ children }: { children?: ReactNode }) => React.createElement('div', null, children),
SelectContent: ({ children }: { children?: ReactNode }) =>
React.createElement('div', null, children),
SelectItem: ({ children, value }: { children?: ReactNode; value: string }) =>
React.createElement('div', { 'data-select-item': value }, children),
SelectTrigger: ({ children }: { children?: ReactNode }) =>
React.createElement('button', null, children),
SelectValue: () => React.createElement('span')
}))
vi.mock('../source-control/SourceControlActionVariableChips', () => ({
SourceControlActionVariableChips: () => React.createElement('div')
}))
vi.mock('@/lib/source-control-agent-action-plan', () => ({
planSourceControlAgentActionLaunch: mocks.planSourceControlAgentActionLaunch
}))
vi.mock('sonner', () => ({
toast: { error: mocks.toastError }
}))
import { useAppStore, type AppState } from '@/store'
import { SourceControlAgentActionDialog } from './SourceControlAgentActionDialog'
let container: HTMLDivElement
let root: Root
let initialState: AppState
function settingsWithGlobalRecipe(
recipe: SourceControlActionRecipe | null = {
agentId: 'codex',
commandInputTemplate: '{basePrompt}',
agentArgs: ''
},
disabledTuiAgents: GlobalSettings['disabledTuiAgents'] = []
): GlobalSettings {
const base = getDefaultSettings(path.resolve('tmp'))
return {
...base,
defaultTuiAgent: 'codex',
disabledTuiAgents,
sourceControlAi: {
...base.sourceControlAi!,
enabled: true,
agentId: 'codex',
customAgentCommand: '',
actions: recipe ? { resolveConflicts: recipe } : {}
}
}
}
function repoWithSavedRecipe(): Repo {
return {
id: 'repo-1',
sourceControlAi: {
enabled: true,
actionOverrides: {
resolveConflicts: {
agentId: 'codex',
commandInputTemplate: '{basePrompt}',
agentArgs: ''
}
}
}
} as Repo
}
function resetStore(settings: GlobalSettings, repos: Repo[] = []): void {
useAppStore.setState(
{
...initialState,
settings,
repos,
ensureDetectedAgents: mocks.ensureDetectedAgents,
ensureRemoteDetectedAgents: mocks.ensureRemoteDetectedAgents
},
true
)
}
function renderControlledDialog(
overrides: Partial<React.ComponentProps<typeof SourceControlAgentActionDialog>> = {},
options: { strictMode?: boolean } = {}
): void {
function Harness(): React.JSX.Element {
const [open, setOpen] = useState(true)
return (
<SourceControlAgentActionDialog
open={open}
onOpenChange={(nextOpen) => {
mocks.onOpenChange(nextOpen)
setOpen(nextOpen)
}}
actionId="resolveConflicts"
title="Launch agent"
description="Review the launch recipe before starting."
baseCommandInput="Resolve conflicts."
savedCommandInputTemplate="{basePrompt}"
savedAgentArgs=""
launchSource="source_control_recovery"
savedAgentId="codex"
onSaveAgentDefault={mocks.onSaveAgentDefault}
onLaunched={mocks.onLaunched}
onStart={mocks.onStart}
{...overrides}
/>
)
}
act(() => {
root.render(
options.strictMode ? (
<React.StrictMode>
<Harness />
</React.StrictMode>
) : (
<Harness />
)
)
})
}
async function flushEffects(): Promise<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
describe('SourceControlAgentActionDialog', () => {
beforeEach(() => {
;(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true
initialState = useAppStore.getState()
vi.clearAllMocks()
mocks.ensureDetectedAgents.mockResolvedValue(['codex'])
mocks.ensureRemoteDetectedAgents.mockResolvedValue(['codex'])
mocks.onStart.mockResolvedValue(true)
mocks.planSourceControlAgentActionLaunch.mockReturnValue({
ok: true,
summary: 'Ready to launch.',
commandLabel: 'codex',
caveat: 'The prompt will be submitted after the agent is ready.'
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
resetStore(settingsWithGlobalRecipe())
})
afterEach(() => {
act(() => {
root.unmount()
})
container.remove()
useAppStore.setState(initialState, true)
})
it('hides the dialog and auto-starts once when the saved global launch recipe matches', async () => {
renderControlledDialog()
expect(container.textContent).not.toContain('Launch agent')
await vi.waitFor(() => expect(mocks.onStart).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(mocks.onOpenChange).toHaveBeenCalledWith(false))
expect(mocks.ensureDetectedAgents).toHaveBeenCalledTimes(1)
expect(mocks.onStart).toHaveBeenCalledWith({
agent: 'codex',
commandInput: 'Resolve conflicts.',
agentArgs: ''
})
expect(mocks.onLaunched).toHaveBeenCalledTimes(1)
expect(mocks.onSaveAgentDefault).not.toHaveBeenCalled()
expect(container.textContent).not.toContain('Launch agent')
})
it('hides the dialog and auto-starts once when the saved repo launch recipe matches', async () => {
resetStore(
settingsWithGlobalRecipe({ agentId: 'claude', commandInputTemplate: '{basePrompt}' }),
[repoWithSavedRecipe()]
)
renderControlledDialog({ repoId: 'repo-1' })
expect(container.textContent).not.toContain('Launch agent')
await vi.waitFor(() => expect(mocks.onStart).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(mocks.onOpenChange).toHaveBeenCalledWith(false))
expect(mocks.ensureDetectedAgents).toHaveBeenCalledTimes(1)
expect(mocks.onLaunched).toHaveBeenCalledTimes(1)
expect(mocks.onSaveAgentDefault).not.toHaveBeenCalled()
expect(container.textContent).not.toContain('Launch agent')
})
it('renders the form and does not auto-start when the saved launch recipe mismatches', async () => {
resetStore(
settingsWithGlobalRecipe({ agentId: 'claude', commandInputTemplate: '{basePrompt}' })
)
renderControlledDialog()
await vi.waitFor(() => expect(mocks.ensureDetectedAgents).toHaveBeenCalledTimes(1))
await flushEffects()
expect(mocks.onStart).not.toHaveBeenCalled()
expect(container.textContent).toContain('Launch agent')
expect(container.textContent).toContain('Save & start agent')
})
it('reveals the form with status copy when the saved agent is unavailable', async () => {
mocks.ensureDetectedAgents.mockResolvedValue([])
resetStore(settingsWithGlobalRecipe())
renderControlledDialog()
expect(container.textContent).not.toContain('Launch agent')
await vi.waitFor(() =>
expect(container.textContent?.toLowerCase()).toContain('not enabled or was not detected')
)
expect(mocks.onStart).not.toHaveBeenCalled()
expect(container.textContent).toContain('Launch agent')
})
it('reveals the dialog and remains open when auto-start fails', async () => {
mocks.onStart.mockResolvedValue(false)
renderControlledDialog()
expect(container.textContent).not.toContain('Launch agent')
await vi.waitFor(() => expect(mocks.onStart).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(container.textContent).toContain('Launch agent'))
expect(mocks.onLaunched).not.toHaveBeenCalled()
expect(mocks.onOpenChange).not.toHaveBeenCalledWith(false)
expect(mocks.toastError).toHaveBeenCalledTimes(1)
})
it('does not auto-start when a saved receipt appears after the dialog is already open', async () => {
let setSavedAgentId: (agent: TuiAgent | null) => void = () => {}
function Harness(): React.JSX.Element {
const [savedAgentId, setNextSavedAgentId] = useState<TuiAgent | null>(null)
setSavedAgentId = setNextSavedAgentId
return (
<SourceControlAgentActionDialog
open
onOpenChange={mocks.onOpenChange}
actionId="resolveConflicts"
title="Launch agent"
description="Review the launch recipe before starting."
baseCommandInput="Resolve conflicts."
savedCommandInputTemplate="{basePrompt}"
savedAgentArgs=""
launchSource="source_control_recovery"
savedAgentId={savedAgentId}
onSaveAgentDefault={mocks.onSaveAgentDefault}
onLaunched={mocks.onLaunched}
onStart={mocks.onStart}
/>
)
}
act(() => {
root.render(<Harness />)
})
await vi.waitFor(() => expect(container.textContent).toContain('Launch agent'))
act(() => {
setSavedAgentId('codex')
})
await flushEffects()
expect(mocks.onStart).not.toHaveBeenCalled()
expect(container.textContent).toContain('Launch agent')
})
it('does not double-start during StrictMode effect replay', async () => {
renderControlledDialog({}, { strictMode: true })
await vi.waitFor(() => expect(mocks.onStart).toHaveBeenCalledTimes(1))
await flushEffects()
expect(mocks.onStart).toHaveBeenCalledTimes(1)
expect(mocks.onLaunched).toHaveBeenCalledTimes(1)
})
})
@@ -64,6 +64,7 @@ export function SourceControlAgentActionDialog(
} = props
const {
handleOpenChange,
shouldRenderDialog,
agentOptions,
selectedAgent,
hasEnabledAgents,
@@ -89,42 +90,46 @@ export function SourceControlAgentActionDialog(
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="flex max-h-[min(82vh,42rem)] min-w-0 flex-col overflow-hidden sm:max-w-2xl">
<DialogHeader className="shrink-0">
<DialogTitle className="text-sm">{title}</DialogTitle>
<DialogDescription className="text-xs">{description}</DialogDescription>
</DialogHeader>
<SourceControlAgentActionDialogForm
actionId={actionId}
baseCommandInput={baseCommandInput}
agentOptions={agentOptions}
selectedAgent={selectedAgent}
hasEnabledAgents={hasEnabledAgents}
detecting={detecting}
statusCopy={statusCopy}
agentArgs={agentArgs}
commandTemplate={commandTemplate}
savedCommandInputTemplate={savedCommandInputTemplate}
saveLaunchRecipe={saveLaunchRecipe}
saveTargetValue={saveTargetValue}
saveTargets={saveTargets}
settings={settings}
repo={repo}
canSaveAgentDefault={Boolean(onSaveAgentDefault)}
deliveryPlan={deliveryPlan}
canStart={canStart}
isStarting={isStarting}
startLabel={startLabel}
onSelectedAgentChange={onSelectedAgentChange}
onAgentArgsChange={onAgentArgsChange}
onCommandTemplateChange={onCommandTemplateChange}
onSaveLaunchRecipeChange={onSaveLaunchRecipeChange}
onSaveAgentDefaultChange={onSaveAgentDefaultChange}
onOpenSettings={onOpenSettings}
onCancel={() => handleOpenChange(false)}
onStart={() => void handleStart()}
/>
</DialogContent>
{/* Why: saved receipts auto-start in the background, so the fallback content
stays unmounted to avoid flashing a dialog the user already skipped. */}
{shouldRenderDialog ? (
<DialogContent className="flex max-h-[min(82vh,42rem)] min-w-0 flex-col overflow-hidden sm:max-w-2xl">
<DialogHeader className="shrink-0">
<DialogTitle className="text-sm">{title}</DialogTitle>
<DialogDescription className="text-xs">{description}</DialogDescription>
</DialogHeader>
<SourceControlAgentActionDialogForm
actionId={actionId}
baseCommandInput={baseCommandInput}
agentOptions={agentOptions}
selectedAgent={selectedAgent}
hasEnabledAgents={hasEnabledAgents}
detecting={detecting}
statusCopy={statusCopy}
agentArgs={agentArgs}
commandTemplate={commandTemplate}
savedCommandInputTemplate={savedCommandInputTemplate}
saveLaunchRecipe={saveLaunchRecipe}
saveTargetValue={saveTargetValue}
saveTargets={saveTargets}
settings={settings}
repo={repo}
canSaveAgentDefault={Boolean(onSaveAgentDefault)}
deliveryPlan={deliveryPlan}
canStart={canStart}
isStarting={isStarting}
startLabel={startLabel}
onSelectedAgentChange={onSelectedAgentChange}
onAgentArgsChange={onAgentArgsChange}
onCommandTemplateChange={onCommandTemplateChange}
onSaveLaunchRecipeChange={onSaveLaunchRecipeChange}
onSaveAgentDefaultChange={onSaveAgentDefaultChange}
onOpenSettings={onOpenSettings}
onCancel={() => handleOpenChange(false)}
onStart={() => void handleStart()}
/>
</DialogContent>
) : null}
</Dialog>
)
}
@@ -107,16 +107,6 @@ describe('SourceControlAgentActionDialogForm', () => {
expect(markup).toContain('Resolve the merge conflicts reported for this pull request.')
})
it('keeps save target controls visible when the current launch recipe is already saved', () => {
const settings = settingsWithSavedGlobalRecipe()
const markup = renderForm({ settings })
expect(markup).toContain('Launch recipe already saved')
expect(markup).toContain('Save for')
expect(markup).toContain('All repositories')
})
it('checks already-saved copy against the selected save target', () => {
const settings = settingsWithSavedGlobalRecipe()
const saveTargets = [
@@ -0,0 +1,148 @@
import { describe, expect, it } from 'vitest'
import {
resolveChecksPanelHostedReviewBaseRef,
shouldOpenChecksPanelCreateComposer
} from './checks-panel-review-creation'
describe('resolveChecksPanelHostedReviewBaseRef', () => {
it('prefers the worktree base ref over the repo default', () => {
expect(
resolveChecksPanelHostedReviewBaseRef({
worktreeBaseRef: ' release/1.4 ',
repoBaseRef: 'main'
})
).toBe('release/1.4')
})
it('falls back to the repo base ref when the worktree has no override', () => {
expect(
resolveChecksPanelHostedReviewBaseRef({
worktreeBaseRef: null,
repoBaseRef: ' main '
})
).toBe('main')
})
it('returns null when both inputs are null', () => {
expect(
resolveChecksPanelHostedReviewBaseRef({
worktreeBaseRef: null,
repoBaseRef: null
})
).toBe(null)
})
it('returns null when worktree base ref is whitespace-only', () => {
expect(
resolveChecksPanelHostedReviewBaseRef({
worktreeBaseRef: ' ',
repoBaseRef: null
})
).toBe(null)
})
it('strips origin prefix from the worktree base ref', () => {
expect(
resolveChecksPanelHostedReviewBaseRef({
worktreeBaseRef: 'origin/main',
repoBaseRef: 'develop'
})
).toBe('main')
})
it('strips upstream prefix from the repo base ref', () => {
expect(
resolveChecksPanelHostedReviewBaseRef({
worktreeBaseRef: null,
repoBaseRef: 'upstream/develop'
})
).toBe('develop')
})
})
describe('shouldOpenChecksPanelCreateComposer', () => {
it('opens for GitLab MR creation eligibility', () => {
expect(
shouldOpenChecksPanelCreateComposer({
activeReview: null,
isFolder: false,
branch: 'feature/gitlab-mr',
hostedReviewCreation: {
provider: 'gitlab',
review: null,
canCreate: true,
blockedReason: null,
nextAction: null
}
})
).toBe(true)
})
it('opens for push-before-create recovery', () => {
expect(
shouldOpenChecksPanelCreateComposer({
activeReview: null,
isFolder: false,
branch: 'feature/gitlab-mr',
hostedReviewCreation: {
provider: 'gitlab',
review: null,
canCreate: false,
blockedReason: 'needs_push',
nextAction: 'push'
}
})
).toBe(true)
})
it('does not open when an active review exists', () => {
expect(
shouldOpenChecksPanelCreateComposer({
activeReview: { provider: 'github', number: 123 },
isFolder: false,
branch: 'feature/test',
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: true,
blockedReason: null,
nextAction: null
}
})
).toBe(false)
})
it('does not open for folder repos', () => {
expect(
shouldOpenChecksPanelCreateComposer({
activeReview: null,
isFolder: true,
branch: 'feature/test',
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: true,
blockedReason: null,
nextAction: null
}
})
).toBe(false)
})
it('does not open when branch is empty', () => {
expect(
shouldOpenChecksPanelCreateComposer({
activeReview: null,
isFolder: false,
branch: '',
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: true,
blockedReason: null,
nextAction: null
}
})
).toBe(false)
})
})
@@ -0,0 +1,30 @@
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
import { normalizeHostedReviewBaseRef } from '../../../../shared/hosted-review-refs'
export function resolveChecksPanelHostedReviewBaseRef(input: {
worktreeBaseRef?: string | null
repoBaseRef?: string | null
}): string | null {
const worktreeBaseRef = normalizeChecksPanelHostedReviewBaseRef(input.worktreeBaseRef)
return worktreeBaseRef || normalizeChecksPanelHostedReviewBaseRef(input.repoBaseRef)
}
function normalizeChecksPanelHostedReviewBaseRef(ref: string | null | undefined): string | null {
const normalizedRef = ref ? normalizeHostedReviewBaseRef(ref) : ''
return normalizedRef || null
}
export function shouldOpenChecksPanelCreateComposer(input: {
activeReview: unknown | null
isFolder: boolean
branch: string
hostedReviewCreation: HostedReviewCreationEligibility | null
}): boolean {
return (
!input.activeReview &&
!input.isFolder &&
Boolean(input.branch) &&
(input.hostedReviewCreation?.canCreate === true ||
input.hostedReviewCreation?.blockedReason === 'needs_push')
)
}
@@ -80,6 +80,33 @@ describe('sourceControlActionRecipeMatchesTarget', () => {
).toBe(true)
})
it('returns true when the resolve conflicts recipe matches the repo saved recipe', () => {
expect(
sourceControlActionRecipeMatchesTarget({
actionId: 'resolveConflicts',
target: { type: 'repo', repoId: 'repo-1' },
recipe: {
agentId: 'codex',
commandInputTemplate: '{basePrompt}',
agentArgs: ''
},
settings: settings(),
repo: {
sourceControlAi: {
enabled: true,
actionOverrides: {
resolveConflicts: {
agentId: 'codex',
commandInputTemplate: '{basePrompt}',
agentArgs: ''
}
}
}
} satisfies Pick<Repo, 'sourceControlAi'>
})
).toBe(true)
})
it('returns true when a repo recipe inherits the global command template', () => {
const currentSettings = settings()
currentSettings.sourceControlAi = {
@@ -6,6 +6,7 @@ import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlA
export type UseSourceControlAgentActionDialogResult = {
handleOpenChange: (nextOpen: boolean) => void
shouldRenderDialog: boolean
agentOptions: ReturnType<typeof getAgentCatalog>
selectedAgent: TuiAgent | null
hasEnabledAgents: boolean
@@ -0,0 +1,227 @@
import { useCallback, useState } from 'react'
import { toast } from 'sonner'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import type { GitHubPRAutoMergeAction } from '@/components/github-pr-merge-state'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { PRInfo, Repo } from '../../../../shared/types'
import type { GitHubPRMergeMethod } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
export type HostedReviewActionInfo = Pick<
HostedReviewInfo,
'provider' | 'number' | 'state' | 'status' | 'mergeable'
> &
Partial<
Pick<
HostedReviewInfo,
'reviewDecision' | 'autoMergeEnabled' | 'mergeQueueRequired' | 'mergeStateStatus'
>
>
export function useHostedReviewActions({
review,
githubPR,
repo,
isGitLab,
shortLabel,
reviewLabel,
defaultMergeMethod,
autoMergeAction,
onRefreshReview
}: {
review: HostedReviewActionInfo
githubPR?: PRInfo | null
repo: Repo
isGitLab: boolean
shortLabel: string
reviewLabel: string
defaultMergeMethod: GitHubPRMergeMethod
autoMergeAction: GitHubPRAutoMergeAction | null
onRefreshReview: () => Promise<void>
}): {
merging: boolean
stateUpdating: 'open' | 'closed' | null
actionError: string | null
handleMerge: (method?: GitHubPRMergeMethod) => Promise<void>
handleAutoMerge: () => Promise<void>
handleCloseReview: () => Promise<void>
handleReopenReview: () => Promise<void>
} {
const confirm = useConfirmationDialog()
const [merging, setMerging] = useState(false)
const [stateUpdating, setStateUpdating] = useState<'open' | 'closed' | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const handleMerge = useCallback(
async (method: GitHubPRMergeMethod = defaultMergeMethod) => {
setMerging(true)
setActionError(null)
try {
const result = isGitLab
? await window.api.gl.mergeMR({
repoPath: repo.path,
iid: review.number,
method
})
: await window.api.gh.mergePR({
repoPath: repo.path,
repoId: repo.id,
prNumber: review.number,
method,
prRepo: githubPR?.prRepo ?? null
})
if (!result.ok) {
setActionError(result.error)
} else {
await onRefreshReview()
}
} catch (err) {
setActionError(err instanceof Error ? err.message : 'Merge failed')
} finally {
setMerging(false)
}
},
[
githubPR?.prRepo,
isGitLab,
defaultMergeMethod,
onRefreshReview,
repo.id,
repo.path,
review.number
]
)
const handleAutoMerge = useCallback(async () => {
if (isGitLab || !autoMergeAction) {
return
}
const enabled = autoMergeAction.kind === 'enable'
setMerging(true)
setActionError(null)
try {
const result = await window.api.gh.setPRAutoMerge({
repoPath: repo.path,
repoId: repo.id,
prNumber: review.number,
enabled,
prRepo: githubPR?.prRepo ?? null
})
if (!result.ok) {
setActionError(result.error)
} else {
await onRefreshReview()
}
} catch (err) {
setActionError(err instanceof Error ? err.message : 'Auto-merge update failed')
} finally {
setMerging(false)
}
}, [
githubPR?.prRepo,
isGitLab,
autoMergeAction,
onRefreshReview,
repo.id,
repo.path,
review.number
])
const handleReviewStateChange = useCallback(
async (nextState: 'open' | 'closed') => {
if (stateUpdating) {
return
}
const isClosing = nextState === 'closed'
const label = isClosing ? 'Close' : 'Reopen'
const confirmed = await confirm({
title: `${label} ${shortLabel} ${isGitLab ? '!' : '#'}${review.number}?`,
description: isClosing
? translate(
'auto.components.right.sidebar.HostedReviewActions.a3d572a4de',
'This will close the {{value0}}.',
{ value0: reviewLabel }
)
: translate(
'auto.components.right.sidebar.HostedReviewActions.78f5ff294c',
'This will reopen the {{value0}}.',
{ value0: reviewLabel }
),
confirmLabel: label,
confirmVariant: isClosing ? 'destructive' : 'default'
})
if (!confirmed) {
return
}
setStateUpdating(nextState)
setActionError(null)
try {
const result = isGitLab
? isClosing
? await window.api.gl.closeMR({ repoPath: repo.path, iid: review.number })
: await window.api.gl.reopenMR({ repoPath: repo.path, iid: review.number })
: await window.api.gh.updatePRState({
repoPath: repo.path,
repoId: repo.id,
prNumber: review.number,
updates: { state: nextState }
})
if (!result.ok) {
setActionError(result.error)
toast.error(result.error)
} else {
toast.success(
isClosing
? translate(
'auto.components.right.sidebar.HostedReviewActions.fa3ee9a515',
'{{value0}} closed',
{ value0: shortLabel }
)
: translate(
'auto.components.right.sidebar.HostedReviewActions.377269db6f',
'{{value0}} reopened',
{ value0: shortLabel }
)
)
await onRefreshReview()
}
} catch (err) {
const message =
err instanceof Error ? err.message : `Failed to ${label.toLowerCase()} ${reviewLabel}`
setActionError(message)
toast.error(message)
} finally {
setStateUpdating(null)
}
},
[
confirm,
isGitLab,
onRefreshReview,
repo.id,
repo.path,
review.number,
reviewLabel,
shortLabel,
stateUpdating
]
)
const handleCloseReview = useCallback(async () => {
await handleReviewStateChange('closed')
}, [handleReviewStateChange])
const handleReopenReview = useCallback(async () => {
await handleReviewStateChange('open')
}, [handleReviewStateChange])
return {
merging,
stateUpdating,
actionError,
handleMerge,
handleAutoMerge,
handleCloseReview,
handleReopenReview
}
}
@@ -0,0 +1,285 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type {
SourceControlActionRecipe,
SourceControlLaunchActionId
} from '../../../../shared/source-control-ai-actions'
import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types'
import { isSourceControlAgentDetectedAndEnabled } from './source-control-agent-action-dialog-support'
import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match'
type SavedSourceControlAgentActionTargetValue = 'repo' | 'global'
const NO_SAVED_RECEIPT_KEY = '__no_saved_receipt__'
type UseSavedSourceControlAgentActionAutoStartArgs = {
open: boolean
openCycle: number
detectionReady: boolean
actionId: SourceControlLaunchActionId
baseCommandInput: string
savedAgentId?: TuiAgent | null
savedCommandInputTemplate?: string | null
savedAgentArgs?: string | null
settings: Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'> | null | undefined
repo: Pick<Repo, 'sourceControlAi'> | null
repoId?: string | null
worktreeId?: string | null
connectionId?: string | null
selectedAgent: TuiAgent | null
trimmedCommandInput: string
connectionUnavailable: boolean
detecting: boolean
isStarting: boolean
detectedAgents: TuiAgent[]
disabledAgents: TuiAgent[] | undefined
onAutoStart: (args: {
detectedAgents: TuiAgent[]
saveTargetValue: SavedSourceControlAgentActionTargetValue
}) => Promise<boolean>
}
type SavedSourceControlAgentActionAutoStartResult = {
autoLaunchPending: boolean
matchedSavedReceiptTargetValue: SavedSourceControlAgentActionTargetValue | null
}
type AutoLaunchReceiptState = {
openCycle: number
receiptKey: string
revealed: boolean
}
function buildSavedLaunchRecipe(input: {
savedAgentId?: TuiAgent | null
savedCommandInputTemplate?: string | null
savedAgentArgs?: string | null
}): SourceControlActionRecipe | null {
if (!input.savedAgentId) {
return null
}
return {
agentId: input.savedAgentId,
commandInputTemplate: input.savedCommandInputTemplate ?? '{basePrompt}',
agentArgs: input.savedAgentArgs ?? ''
}
}
function getMatchedSavedReceiptTargetValue(input: {
actionId: SourceControlLaunchActionId
recipe: SourceControlActionRecipe | null
settings: Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'> | null | undefined
repo: Pick<Repo, 'sourceControlAi'> | null
repoId?: string | null
}): SavedSourceControlAgentActionTargetValue | null {
if (!input.recipe) {
return null
}
if (
input.repoId &&
input.repo &&
sourceControlActionRecipeMatchesTarget({
actionId: input.actionId,
target: { type: 'repo', repoId: input.repoId },
recipe: input.recipe,
settings: input.settings,
repo: input.repo
})
) {
return 'repo'
}
if (
sourceControlActionRecipeMatchesTarget({
actionId: input.actionId,
target: { type: 'global' },
recipe: input.recipe,
settings: input.settings,
repo: input.repo
})
) {
return 'global'
}
return null
}
function buildReceiptKey(input: {
actionId: SourceControlLaunchActionId
targetValue: SavedSourceControlAgentActionTargetValue
savedAgentId: TuiAgent
savedCommandInputTemplate?: string | null
savedAgentArgs?: string | null
repoId?: string | null
connectionId?: string | null
worktreeId?: string | null
baseCommandInput: string
}): string {
return JSON.stringify([
input.actionId,
input.targetValue,
input.savedAgentId,
input.savedCommandInputTemplate ?? '{basePrompt}',
input.savedAgentArgs ?? '',
input.repoId ?? null,
input.connectionId ?? null,
input.worktreeId ?? null,
input.baseCommandInput
])
}
export function useSavedSourceControlAgentActionAutoStart({
open,
openCycle,
detectionReady,
actionId,
baseCommandInput,
savedAgentId,
savedCommandInputTemplate,
savedAgentArgs,
settings,
repo,
repoId,
worktreeId,
connectionId,
selectedAgent,
trimmedCommandInput,
connectionUnavailable,
detecting,
isStarting,
detectedAgents,
disabledAgents,
onAutoStart
}: UseSavedSourceControlAgentActionAutoStartArgs): SavedSourceControlAgentActionAutoStartResult {
const autoStartedOpenCycleRef = useRef(0)
const [receiptState, setReceiptState] = useState<AutoLaunchReceiptState | null>(null)
const savedLaunchRecipe = useMemo(
() =>
buildSavedLaunchRecipe({
savedAgentId,
savedCommandInputTemplate,
savedAgentArgs
}),
[savedAgentArgs, savedAgentId, savedCommandInputTemplate]
)
const matchedSavedReceiptTargetValue = useMemo(
() =>
getMatchedSavedReceiptTargetValue({
actionId,
recipe: savedLaunchRecipe,
settings,
repo,
repoId
}),
[actionId, repo, repoId, savedLaunchRecipe, settings]
)
const receiptKey = useMemo(() => {
if (!savedAgentId || !matchedSavedReceiptTargetValue) {
return null
}
return buildReceiptKey({
actionId,
targetValue: matchedSavedReceiptTargetValue,
savedAgentId,
savedCommandInputTemplate,
savedAgentArgs,
repoId,
connectionId,
worktreeId,
baseCommandInput
})
}, [
actionId,
baseCommandInput,
connectionId,
matchedSavedReceiptTargetValue,
repoId,
savedAgentArgs,
savedAgentId,
savedCommandInputTemplate,
worktreeId
])
const currentReceiptState = receiptState?.openCycle === openCycle ? receiptState : null
const consideredDifferentReceipt = Boolean(
currentReceiptState && receiptKey && currentReceiptState.receiptKey !== receiptKey
)
const autoLaunchPending = Boolean(
open &&
matchedSavedReceiptTargetValue &&
receiptKey &&
!consideredDifferentReceipt &&
!currentReceiptState?.revealed
)
useEffect(() => {
if (!open) {
autoStartedOpenCycleRef.current = 0
setReceiptState(null)
return
}
if (receiptState?.openCycle !== openCycle) {
setReceiptState({
openCycle,
receiptKey: receiptKey ?? NO_SAVED_RECEIPT_KEY,
revealed: !receiptKey
})
}
if (!matchedSavedReceiptTargetValue || !receiptKey || !savedAgentId) {
return
}
if (receiptState?.openCycle === openCycle && receiptState.receiptKey !== receiptKey) {
return
}
if (receiptState?.openCycle === openCycle && receiptState.revealed) {
return
}
const revealDialog = (): void => {
setReceiptState({ openCycle, receiptKey, revealed: true })
}
if (!detectionReady || detecting || isStarting) {
return
}
if (
selectedAgent !== savedAgentId ||
!trimmedCommandInput ||
connectionUnavailable ||
!isSourceControlAgentDetectedAndEnabled(savedAgentId, detectedAgents, disabledAgents)
) {
revealDialog()
return
}
if (autoStartedOpenCycleRef.current === openCycle) {
return
}
autoStartedOpenCycleRef.current = openCycle
void onAutoStart({
detectedAgents,
saveTargetValue: matchedSavedReceiptTargetValue
})
.then((launched) => {
if (!launched) {
revealDialog()
}
})
.catch(() => {
revealDialog()
})
}, [
connectionUnavailable,
detectedAgents,
detectionReady,
detecting,
disabledAgents,
isStarting,
matchedSavedReceiptTargetValue,
onAutoStart,
open,
openCycle,
receiptKey,
receiptState,
savedAgentId,
selectedAgent,
trimmedCommandInput
])
return { autoLaunchPending, matchedSavedReceiptTargetValue }
}
@@ -1,22 +1,20 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getAgentCatalog } from '@/lib/agent-catalog'
import { pickSourceControlLaunchAgent } from '@/lib/source-control-launch-agent-selection'
import { buildSourceControlAgentDeliveryPlan } from './buildSourceControlAgentDeliveryPlan'
import { useAppStore } from '@/store'
import { useRepoById } from '@/store/selectors'
import { renderSourceControlActionCommandTemplate } from '../../../../shared/source-control-ai-actions'
import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
import type { TuiAgent } from '../../../../shared/types'
import { type SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm'
import type { SourceControlAgentActionDialogProps } from './SourceControlAgentActionDialog'
import type { UseSourceControlAgentActionDialogResult } from './source-control-agent-action-dialog-result'
import { useSavedSourceControlAgentActionAutoStart } from './useSavedSourceControlAgentActionAutoStart'
import {
buildSourceControlAgentConnectionErrorPlan,
buildSourceControlAgentSaveTargets,
buildSourceControlAgentStatusCopy,
isSourceControlAgentDetectedAndEnabled
} from './source-control-agent-action-dialog-support'
import { runSourceControlAgentActionStart } from './runSourceControlAgentActionStart'
import { useSourceControlAgentActionStart } from './useSourceControlAgentActionStart'
const DEFAULT_SAVE_TARGET_VALUE = 'global'
@@ -50,10 +48,10 @@ export function useSourceControlAgentActionDialog({
const [selectedAgent, setSelectedAgent] = useState<TuiAgent | null>(savedAgentId ?? null)
const [detectedAgents, setDetectedAgents] = useState<TuiAgent[]>([])
const [detecting, setDetecting] = useState(false)
const [deliveryPlan, setDeliveryPlan] = useState<SourceControlAgentActionDeliveryPlanState>({
status: 'idle'
})
const [isStarting, setIsStarting] = useState(false)
const openCycleRef = useRef(0)
const wasOpenRef = useRef(false)
const [openCycle, setOpenCycle] = useState(0)
const [detectedOpenCycle, setDetectedOpenCycle] = useState<number | null>(null)
const saveTargets = useMemo(() => buildSourceControlAgentSaveTargets(repoId), [repoId])
const [saveLaunchRecipe, setSaveLaunchRecipe] = useState(true)
const [saveTargetValue, setSaveTargetValue] = useState(DEFAULT_SAVE_TARGET_VALUE)
@@ -82,8 +80,16 @@ export function useSourceControlAgentActionDialog({
useEffect(() => {
if (!open) {
wasOpenRef.current = false
return
}
const cycle = wasOpenRef.current ? openCycleRef.current : openCycleRef.current + 1
if (!wasOpenRef.current) {
openCycleRef.current = cycle
setOpenCycle(cycle)
}
wasOpenRef.current = true
setDetectedOpenCycle(null)
setCommandTemplate(savedCommandInputTemplate ?? '{basePrompt}')
setAgentArgs(savedAgentArgs ?? '')
setSelectedAgent(savedAgentId ?? null)
@@ -91,7 +97,7 @@ export function useSourceControlAgentActionDialog({
setSaveTargetValue(DEFAULT_SAVE_TARGET_VALUE)
let stale = false
void refreshDetectedAgents().then((nextAgents) => {
if (stale) {
if (stale || openCycleRef.current !== cycle) {
return
}
setSelectedAgent(
@@ -104,6 +110,7 @@ export function useSourceControlAgentActionDialog({
disabledAgents
})
)
setDetectedOpenCycle(cycle)
})
return () => {
stale = true
@@ -119,17 +126,7 @@ export function useSourceControlAgentActionDialog({
settings?.defaultTuiAgent
])
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (!nextOpen) {
setDeliveryPlan({ status: 'idle' })
setSaveLaunchRecipe(true)
setSaveTargetValue(DEFAULT_SAVE_TARGET_VALUE)
}
onOpenChange(nextOpen)
},
[onOpenChange]
)
const closeDialog = useCallback(() => onOpenChange(false), [onOpenChange])
const enabledDetectedAgents = useMemo(
() => detectedAgents.filter((agent) => isTuiAgentEnabled(agent, disabledAgents)),
@@ -151,6 +148,33 @@ export function useSourceControlAgentActionDialog({
basePrompt: baseCommandInput
})
const trimmedCommandInput = commandInput.trim()
const { deliveryPlan, resetDeliveryPlan, isStarting, handleStart, startWithDetectedAgents } =
useSourceControlAgentActionStart({
selectedAgent,
commandInput,
trimmedCommandInput,
agentArgs,
commandTemplate,
saveLaunchRecipe,
saveTargetValue,
actionId,
repoId,
settings,
repo,
worktreeId,
groupId,
promptDelivery,
launchPlatform,
launchSource,
connectionUnavailable,
refreshDetectedAgents,
onStart,
onSaveAgentDefault,
onLaunched,
onClose: closeDialog
})
const canStart =
Boolean(trimmedCommandInput) &&
Boolean(selectedAgent) &&
@@ -159,95 +183,45 @@ export function useSourceControlAgentActionDialog({
!detecting &&
!isStarting
const buildPlan = useCallback(
async (agentsOverride?: TuiAgent[]): Promise<SourceControlAgentActionDeliveryPlanState> => {
const currentDetectedAgents = agentsOverride ?? (await refreshDetectedAgents())
return buildSourceControlAgentDeliveryPlan({
selectedAgent,
commandInput,
agentArgs,
promptDelivery,
detectedAgents: currentDetectedAgents,
connectionUnavailable,
launchPlatform
})
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (!nextOpen) {
resetDeliveryPlan()
setSaveLaunchRecipe(true)
setSaveTargetValue(DEFAULT_SAVE_TARGET_VALUE)
}
onOpenChange(nextOpen)
},
[
agentArgs,
commandInput,
connectionUnavailable,
promptDelivery,
refreshDetectedAgents,
selectedAgent,
launchPlatform
]
[onOpenChange, resetDeliveryPlan]
)
const handleStart = useCallback(async () => {
if (!selectedAgent || isStarting) {
return
}
if (connectionUnavailable) {
setDeliveryPlan(buildSourceControlAgentConnectionErrorPlan())
return
}
setIsStarting(true)
try {
const nextAgents = await refreshDetectedAgents()
const nextPlan = await buildPlan(nextAgents)
if (nextPlan.status === 'error') {
setDeliveryPlan(nextPlan)
return
}
setDeliveryPlan(nextPlan)
await runSourceControlAgentActionStart({
selectedAgent,
trimmedCommandInput,
agentArgs,
commandTemplate,
saveTargetValue: saveLaunchRecipe ? saveTargetValue : 'none',
actionId,
repoId,
settings,
repo,
worktreeId,
groupId,
promptDelivery,
launchPlatform,
launchSource,
onStart,
onSaveAgentDefault,
onLaunched,
onClose: () => handleOpenChange(false)
})
} finally {
setIsStarting(false)
}
}, [
const { autoLaunchPending } = useSavedSourceControlAgentActionAutoStart({
open,
openCycle,
detectionReady: detectedOpenCycle === openCycle,
actionId,
agentArgs,
buildPlan,
commandTemplate,
connectionUnavailable,
groupId,
isStarting,
launchSource,
launchPlatform,
handleOpenChange,
onLaunched,
onSaveAgentDefault,
onStart,
promptDelivery,
refreshDetectedAgents,
baseCommandInput,
savedAgentId,
savedCommandInputTemplate,
savedAgentArgs,
settings,
repo,
repoId,
saveLaunchRecipe,
saveTargetValue,
settings,
worktreeId,
connectionId,
selectedAgent,
trimmedCommandInput,
worktreeId
])
connectionUnavailable,
detecting,
isStarting,
detectedAgents,
disabledAgents,
onAutoStart: ({ detectedAgents: agentsForLaunch, saveTargetValue: matchedTargetValue }) =>
startWithDetectedAgents({
detectedAgents: agentsForLaunch,
saveTargetValueOverride: matchedTargetValue
})
})
const statusCopy = buildSourceControlAgentStatusCopy({
selectedAgent,
@@ -257,25 +231,38 @@ export function useSourceControlAgentActionDialog({
detecting
})
const onSelectedAgentChange = useCallback((agent: TuiAgent | null) => {
setSelectedAgent(agent)
setDeliveryPlan({ status: 'idle' })
}, [])
const onAgentArgsChange = useCallback((value: string) => {
setAgentArgs(value)
setDeliveryPlan({ status: 'idle' })
}, [])
const onCommandTemplateChange = useCallback((value: string) => {
setCommandTemplate(value)
setDeliveryPlan({ status: 'idle' })
}, [])
const onSaveLaunchRecipeChange = useCallback((value: boolean) => {
setSaveLaunchRecipe(value)
setDeliveryPlan({ status: 'idle' })
}, [])
const onSelectedAgentChange = useCallback(
(agent: TuiAgent | null) => {
setSelectedAgent(agent)
resetDeliveryPlan()
},
[resetDeliveryPlan]
)
const onAgentArgsChange = useCallback(
(value: string) => {
setAgentArgs(value)
resetDeliveryPlan()
},
[resetDeliveryPlan]
)
const onCommandTemplateChange = useCallback(
(value: string) => {
setCommandTemplate(value)
resetDeliveryPlan()
},
[resetDeliveryPlan]
)
const onSaveLaunchRecipeChange = useCallback(
(value: boolean) => {
setSaveLaunchRecipe(value)
resetDeliveryPlan()
},
[resetDeliveryPlan]
)
return {
handleOpenChange,
shouldRenderDialog: !autoLaunchPending,
agentOptions,
selectedAgent,
hasEnabledAgents,
@@ -0,0 +1,203 @@
import { useCallback, useRef, useState } from 'react'
import type { LaunchSource } from '../../../../shared/telemetry-events'
import type {
SourceControlActionRecipe,
SourceControlLaunchActionId
} from '../../../../shared/source-control-ai-actions'
import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types'
import { buildSourceControlAgentDeliveryPlan } from './buildSourceControlAgentDeliveryPlan'
import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm'
import { runSourceControlAgentActionStart } from './runSourceControlAgentActionStart'
import { buildSourceControlAgentConnectionErrorPlan } from './source-control-agent-action-dialog-support'
type UseSourceControlAgentActionStartArgs = {
selectedAgent: TuiAgent | null
commandInput: string
trimmedCommandInput: string
agentArgs: string
commandTemplate: string
saveLaunchRecipe: boolean
saveTargetValue: string
actionId: SourceControlLaunchActionId
repoId?: string | null
settings: GlobalSettings | null
repo: Pick<Repo, 'id' | 'sourceControlAi'> | null
worktreeId?: string | null
groupId?: string | null
promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready'
launchPlatform?: NodeJS.Platform
launchSource: LaunchSource
connectionUnavailable: boolean
refreshDetectedAgents: () => Promise<TuiAgent[]>
onStart?: (args: {
agent: TuiAgent
commandInput: string
agentArgs: string
}) => boolean | Promise<boolean>
onSaveAgentDefault?: (
target: SourceControlAiWriteTarget,
actionId: SourceControlLaunchActionId,
recipe: SourceControlActionRecipe
) => void | Promise<void>
onLaunched?: () => void
onClose: () => void
}
type SourceControlAgentActionStartWithDetectedAgentsArgs = {
detectedAgents: TuiAgent[]
saveTargetValueOverride?: string
}
type UseSourceControlAgentActionStartResult = {
deliveryPlan: SourceControlAgentActionDeliveryPlanState
resetDeliveryPlan: () => void
isStarting: boolean
handleStart: () => Promise<void>
startWithDetectedAgents: (
args: SourceControlAgentActionStartWithDetectedAgentsArgs
) => Promise<boolean>
}
export function useSourceControlAgentActionStart({
selectedAgent,
commandInput,
trimmedCommandInput,
agentArgs,
commandTemplate,
saveLaunchRecipe,
saveTargetValue,
actionId,
repoId,
settings,
repo,
worktreeId,
groupId,
promptDelivery,
launchPlatform,
launchSource,
connectionUnavailable,
refreshDetectedAgents,
onStart,
onSaveAgentDefault,
onLaunched,
onClose
}: UseSourceControlAgentActionStartArgs): UseSourceControlAgentActionStartResult {
const [deliveryPlan, setDeliveryPlan] = useState<SourceControlAgentActionDeliveryPlanState>({
status: 'idle'
})
const [isStarting, setIsStarting] = useState(false)
const isStartingRef = useRef(false)
const resetDeliveryPlan = useCallback(() => setDeliveryPlan({ status: 'idle' }), [])
const buildPlan = useCallback(
async (agentsOverride?: TuiAgent[]): Promise<SourceControlAgentActionDeliveryPlanState> => {
const currentDetectedAgents = agentsOverride ?? (await refreshDetectedAgents())
return buildSourceControlAgentDeliveryPlan({
selectedAgent,
commandInput,
agentArgs,
promptDelivery,
detectedAgents: currentDetectedAgents,
connectionUnavailable,
launchPlatform
})
},
[
agentArgs,
commandInput,
connectionUnavailable,
promptDelivery,
refreshDetectedAgents,
selectedAgent,
launchPlatform
]
)
const startWithDetectedAgents = useCallback(
async ({
detectedAgents: nextAgents,
saveTargetValueOverride
}: SourceControlAgentActionStartWithDetectedAgentsArgs): Promise<boolean> => {
if (!selectedAgent || isStartingRef.current) {
return false
}
if (connectionUnavailable) {
setDeliveryPlan(buildSourceControlAgentConnectionErrorPlan())
return false
}
isStartingRef.current = true
setIsStarting(true)
try {
const nextPlan = await buildPlan(nextAgents)
if (nextPlan.status === 'error') {
setDeliveryPlan(nextPlan)
return false
}
setDeliveryPlan(nextPlan)
return await runSourceControlAgentActionStart({
selectedAgent,
trimmedCommandInput,
agentArgs,
commandTemplate,
saveTargetValue: saveLaunchRecipe ? (saveTargetValueOverride ?? saveTargetValue) : 'none',
actionId,
repoId,
settings,
repo,
worktreeId,
groupId,
promptDelivery,
launchPlatform,
launchSource,
onStart,
onSaveAgentDefault,
onLaunched,
onClose: () => {
resetDeliveryPlan()
onClose()
}
})
} finally {
isStartingRef.current = false
setIsStarting(false)
}
},
[
actionId,
agentArgs,
buildPlan,
commandTemplate,
connectionUnavailable,
groupId,
launchSource,
launchPlatform,
onClose,
onLaunched,
onSaveAgentDefault,
onStart,
promptDelivery,
resetDeliveryPlan,
repo,
repoId,
saveLaunchRecipe,
saveTargetValue,
settings,
selectedAgent,
trimmedCommandInput,
worktreeId
]
)
const handleStart = useCallback(async () => {
if (!selectedAgent || isStartingRef.current) {
return
}
// Why: manual starts intentionally re-check the current host, while the
// saved-receipt bypass reuses the detection result that unlocked it.
const nextAgents = await refreshDetectedAgents()
await startWithDetectedAgents({ detectedAgents: nextAgents })
}, [refreshDetectedAgents, selectedAgent, startWithDetectedAgents])
return { deliveryPlan, resetDeliveryPlan, isStarting, handleStart, startWithDetectedAgents }
}
@@ -42,20 +42,28 @@ type PermissionDefinition = {
const PERMISSIONS: PermissionDefinition[] = [
{
id: 'accessibility',
label: translate('auto.components.settings.ComputerUsePane.6b5a2cd3a5', 'Accessibility'),
description: translate(
'auto.components.settings.ComputerUsePane.4d03dec2d0',
'Read app interface trees and perform requested actions.'
),
get label() {
return translate('auto.components.settings.ComputerUsePane.6b5a2cd3a5', 'Accessibility')
},
get description() {
return translate(
'auto.components.settings.ComputerUsePane.4d03dec2d0',
'Read app interface trees and perform requested actions.'
)
},
icon: <Accessibility className="size-4" />
},
{
id: 'screenshots',
label: translate('auto.components.settings.ComputerUsePane.07bbe4c4cb', 'Screenshots'),
description: translate(
'auto.components.settings.ComputerUsePane.0c9a33f468',
'Capture app windows so agents can inspect visual state.'
),
get label() {
return translate('auto.components.settings.ComputerUsePane.07bbe4c4cb', 'Screenshots')
},
get description() {
return translate(
'auto.components.settings.ComputerUsePane.0c9a33f468',
'Capture app windows so agents can inspect visual state.'
)
},
icon: <Camera className="size-4" />
}
]
@@ -34,103 +34,142 @@ type PermissionDefinition = {
const PERMISSIONS: PermissionDefinition[] = [
{
id: 'microphone',
label: translate('auto.components.settings.DeveloperPermissionsPane.16381e040a', 'Microphone'),
description: translate(
'auto.components.settings.DeveloperPermissionsPane.cc8151d9fa',
'Voice input, transcription, audio recording, sox, ffmpeg, and Whisper CLIs.'
),
get label() {
return translate('auto.components.settings.DeveloperPermissionsPane.16381e040a', 'Microphone')
},
get description() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.cc8151d9fa',
'Voice input, transcription, audio recording, sox, ffmpeg, and Whisper CLIs.'
)
},
actionLabel: 'Request',
icon: <Mic className="size-4" />
},
{
id: 'camera',
label: translate('auto.components.settings.DeveloperPermissionsPane.e5b5f3d6b9', 'Camera'),
description: translate(
'auto.components.settings.DeveloperPermissionsPane.550cfa3750',
'Webcam capture and camera-driven local test apps.'
),
get label() {
return translate('auto.components.settings.DeveloperPermissionsPane.e5b5f3d6b9', 'Camera')
},
get description() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.550cfa3750',
'Webcam capture and camera-driven local test apps.'
)
},
actionLabel: 'Request',
icon: <Camera className="size-4" />
},
{
id: 'screen',
label: translate(
'auto.components.settings.DeveloperPermissionsPane.f24f31a884',
'Screen Recording'
),
description: translate(
'auto.components.settings.DeveloperPermissionsPane.0639db5496',
'Screenshot, visual automation, and UI inspection tools.'
),
get label() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.f24f31a884',
'Screen Recording'
)
},
get description() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.0639db5496',
'Screenshot, visual automation, and UI inspection tools.'
)
},
actionLabel: 'Open Settings',
icon: <MonitorUp className="size-4" />
},
{
id: 'accessibility',
label: translate(
'auto.components.settings.DeveloperPermissionsPane.5b2f22ca2d',
'Accessibility'
),
description: translate(
'auto.components.settings.DeveloperPermissionsPane.9f35980756',
'Keystroke injection, window control, and UI automation tools.'
),
get label() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.5b2f22ca2d',
'Accessibility'
)
},
get description() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.9f35980756',
'Keystroke injection, window control, and UI automation tools.'
)
},
actionLabel: 'Request',
icon: <Accessibility className="size-4" />
},
{
id: 'full-disk-access',
label: translate(
'auto.components.settings.DeveloperPermissionsPane.c566bca278',
'Full Disk Access'
),
description: translate(
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8',
'Persistent access to protected folders from terminal sessions.'
),
get label() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.c566bca278',
'Full Disk Access'
)
},
get description() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8',
'Persistent access to protected folders from terminal sessions.'
)
},
actionLabel: 'Open Settings',
icon: <HardDrive className="size-4" />
},
{
id: 'automation',
label: translate('auto.components.settings.DeveloperPermissionsPane.e119f0d66b', 'Automation'),
description: translate(
'auto.components.settings.DeveloperPermissionsPane.4a73f5217a',
'Apple Events for scripts that control other local apps.'
),
get label() {
return translate('auto.components.settings.DeveloperPermissionsPane.e119f0d66b', 'Automation')
},
get description() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.4a73f5217a',
'Apple Events for scripts that control other local apps.'
)
},
actionLabel: 'Trigger Prompt',
icon: <Workflow className="size-4" />
},
{
id: 'local-network',
label: translate(
'auto.components.settings.DeveloperPermissionsPane.e7bb06007c',
'Local Network'
),
description: translate(
'auto.components.settings.DeveloperPermissionsPane.f903bf20b5',
'Discovery and access for development servers on your network.'
),
get label() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.e7bb06007c',
'Local Network'
)
},
get description() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.f903bf20b5',
'Discovery and access for development servers on your network.'
)
},
actionLabel: 'Trigger Prompt',
icon: <Network className="size-4" />
},
{
id: 'usb',
label: translate('auto.components.settings.DeveloperPermissionsPane.bf51e4a542', 'USB Devices'),
description: translate(
'auto.components.settings.DeveloperPermissionsPane.dfbc12c8c8',
'Hardware debugging and device tools that talk to USB devices.'
),
get label() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.bf51e4a542',
'USB Devices'
)
},
get description() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.dfbc12c8c8',
'Hardware debugging and device tools that talk to USB devices.'
)
},
actionLabel: 'Open Settings',
icon: <Usb className="size-4" />
},
{
id: 'bluetooth',
label: translate('auto.components.settings.DeveloperPermissionsPane.b2210b1b4f', 'Bluetooth'),
description: translate(
'auto.components.settings.DeveloperPermissionsPane.4cfaa7e98a',
'Bluetooth device tools and local hardware experiments.'
),
get label() {
return translate('auto.components.settings.DeveloperPermissionsPane.b2210b1b4f', 'Bluetooth')
},
get description() {
return translate(
'auto.components.settings.DeveloperPermissionsPane.4cfaa7e98a',
'Bluetooth device tools and local hardware experiments.'
)
},
actionLabel: 'Open Settings',
icon: <Bluetooth className="size-4" />
}
@@ -5,13 +5,9 @@ import { useMountedRef } from '@/hooks/useMountedRef'
import type { Repo } from '../../../../shared/types'
import {
canInspectLocalMcpConfigRoot,
getMcpConfigCandidateParentDir,
getMcpConfigParentDirs,
inspectMcpConfigContent,
MCP_CONFIG_CANDIDATES,
MCP_STARTER_CONFIG,
selectExistingMcpConfigCandidates,
type McpConfigDirectoryEntry
MCP_STARTER_CONFIG
} from '../../../../shared/mcp-config'
import { useAppStore } from '../../store'
import { joinPath } from '../../lib/path'
@@ -22,10 +18,10 @@ import { McpConfigFileRow, type LoadedMcpConfigInspection } from './McpConfigFil
import {
EMPTY_MCP_WORKTREES,
countMcpConfigServers,
isMissingMcpConfigFileError,
selectMcpTargetWorktree
} from './mcp-config-inspection-state'
import { McpMissingConfigList } from './McpMissingConfigList'
import { loadMcpConfigInspections } from './mcp-config-inspection'
import { translate } from '@/i18n/i18n'
type McpConfigSectionProps = {
@@ -124,81 +120,7 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
return
}
const entriesByRelativeDir = new Map<string, readonly McpConfigDirectoryEntry[]>()
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<string, string>()
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
)
)
const next = await Promise.all(
MCP_CONFIG_CANDIDATES.map(async (candidate): Promise<LoadedMcpConfigInspection> => {
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 (isMissingMcpConfigFileError(error)) {
return { ...inspectMcpConfigContent(candidate, null), absolutePath }
}
return {
...inspectMcpConfigContent(candidate, null),
exists: false,
status: 'invalid',
absolutePath,
readError: extractIpcErrorMessage(error, 'Unable to read config file.')
}
}
})
)
const next = await loadMcpConfigInspections(targetRootPath, connectionId)
if (mountedRef.current) {
setConfigs(next)
}
@@ -19,31 +19,39 @@ type RepositorySourceControlAiHostedReviewDefaultsProps = {
const HOSTED_REVIEW_DEFAULT_ROWS: { key: HostedReviewDefaultKey; label: string }[] = [
{
key: 'draft',
label: translate(
'auto.components.settings.RepositorySourceControlAiHostedReviewDefaults.981eae7e14',
'Draft by default'
)
get label() {
return translate(
'auto.components.settings.RepositorySourceControlAiHostedReviewDefaults.981eae7e14',
'Draft by default'
)
}
},
{
key: 'useTemplate',
label: translate(
'auto.components.settings.RepositorySourceControlAiHostedReviewDefaults.d32b87e754',
'Use review template when available'
)
get label() {
return translate(
'auto.components.settings.RepositorySourceControlAiHostedReviewDefaults.d32b87e754',
'Use review template when available'
)
}
},
{
key: 'generateDetailsOnOpen',
label: translate(
'auto.components.settings.RepositorySourceControlAiHostedReviewDefaults.14f1eb99d0',
'Generate details when opening Create PR'
)
get label() {
return translate(
'auto.components.settings.RepositorySourceControlAiHostedReviewDefaults.14f1eb99d0',
'Generate details when opening Create PR'
)
}
},
{
key: 'openAfterCreate',
label: translate(
'auto.components.settings.RepositorySourceControlAiHostedReviewDefaults.629ed8a9d3',
'Open hosted review after creation'
)
get label() {
return translate(
'auto.components.settings.RepositorySourceControlAiHostedReviewDefaults.629ed8a9d3',
'Open hosted review after creation'
)
}
}
]
@@ -89,29 +89,51 @@ import { translate } from '@/i18n/i18n'
const SETTINGS_NAV_GROUPS = [
{
id: 'capabilities',
title: translate('auto.components.settings.Settings.23c6874fdf', 'AI Capabilities')
get title() {
return translate('auto.components.settings.Settings.23c6874fdf', 'AI Capabilities')
}
},
{
id: 'setup',
get title() {
return translate('auto.components.settings.Settings.9abb9be3bc', 'Set Up')
}
},
{ id: 'setup', title: translate('auto.components.settings.Settings.9abb9be3bc', 'Set Up') },
{
id: 'workflows',
title: translate('auto.components.settings.Settings.e1578cd4bc', 'Workflows')
get title() {
return translate('auto.components.settings.Settings.e1578cd4bc', 'Workflows')
}
},
{
id: 'interface',
title: translate('auto.components.settings.Settings.8bd117d669', 'Interface')
get title() {
return translate('auto.components.settings.Settings.8bd117d669', 'Interface')
}
},
{
id: 'remote',
title: translate('auto.components.settings.Settings.23931df7e8', 'Remote Access')
get title() {
return translate('auto.components.settings.Settings.23931df7e8', 'Remote Access')
}
},
{
id: 'security',
title: translate('auto.components.settings.Settings.084d8fac5b', 'Privacy & Security')
get title() {
return translate('auto.components.settings.Settings.084d8fac5b', 'Privacy & Security')
}
},
{
id: 'advanced',
get title() {
return translate('auto.components.settings.Settings.1c87f8d024', 'Advanced')
}
},
{ id: 'advanced', title: translate('auto.components.settings.Settings.1c87f8d024', 'Advanced') },
{
id: 'experimental',
title: translate('auto.components.settings.Settings.8b017f2506', 'Experimental')
get title() {
return translate('auto.components.settings.Settings.8b017f2506', 'Experimental')
}
}
] as const
@@ -29,36 +29,51 @@ type SourceControlAiActionRecipeDefaultsProps = {
}
const ACTION_RECIPES_SEARCH_ENTRY = {
title: translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.a79c567194',
'Action recipes'
),
description: translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.cf01d41bce',
'Agent, CLI arguments, and command template used by each Source Control AI button.'
),
keywords: [
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.926d58e87f', 'agent'),
translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.db9bd75d10',
'arguments'
),
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.2576299196', 'args'),
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.673369fe0c', 'cli'),
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.d74fdc776c', 'command'),
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.eb7e8f3b39', 'model'),
translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.2037c78a6f',
'template'
),
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.cb67b938c5', 'fix'),
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.06a9dab64d', 'checks'),
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.e5b24893ba', 'commit'),
translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.7ab1437a12',
'pull request'
get title() {
return translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.a79c567194',
'Action recipes'
)
]
},
get description() {
return translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.cf01d41bce',
'Agent, CLI arguments, and command template used by each Source Control AI button.'
)
},
get keywords() {
return [
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.926d58e87f', 'agent'),
translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.db9bd75d10',
'arguments'
),
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.2576299196', 'args'),
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.673369fe0c', 'cli'),
translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.d74fdc776c',
'command'
),
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.eb7e8f3b39', 'model'),
translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.2037c78a6f',
'template'
),
translate('auto.components.settings.SourceControlAiActionRecipeDefaults.cb67b938c5', 'fix'),
translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.06a9dab64d',
'checks'
),
translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.e5b24893ba',
'commit'
),
translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.7ab1437a12',
'pull request'
)
]
}
}
export function SourceControlAiActionRecipeDefaults({
@@ -127,19 +142,7 @@ export function SourceControlAiActionRecipeDefaults({
<SearchableSetting
title={ACTION_RECIPES_SEARCH_ENTRY.title}
description={ACTION_RECIPES_SEARCH_ENTRY.description}
keywords={[
'agent',
'arguments',
'args',
'cli',
'command',
'model',
'template',
'fix',
'checks',
'commit',
'pull request'
]}
keywords={ACTION_RECIPES_SEARCH_ENTRY.keywords}
className="space-y-3 px-1 py-2"
>
<div className="space-y-0.5">
@@ -29,7 +29,9 @@ export const STATUS_LABELS: Record<SshConnectionStatus, string> = {
connected: 'Connected',
reconnecting: 'Reconnecting\u2026',
'reconnection-failed': 'Reconnection failed',
error: translate('auto.components.settings.SshTargetCard.18968ede9e', 'Error')
get error() {
return translate('auto.components.settings.SshTargetCard.18968ede9e', 'Error')
}
}
export function statusColor(status: SshConnectionStatus): string {
@@ -26,38 +26,54 @@ const TASK_PROVIDER_OPTIONS: readonly {
}[] = [
{
id: 'github',
label: translate('auto.components.settings.TasksPane.e14063e727', 'GitHub'),
description: translate(
'auto.components.settings.TasksPane.1db47236cd',
'Show GitHub in the Tasks source picker and sidebar shortcuts.'
),
get label() {
return translate('auto.components.settings.TasksPane.e14063e727', 'GitHub')
},
get description() {
return translate(
'auto.components.settings.TasksPane.1db47236cd',
'Show GitHub in the Tasks source picker and sidebar shortcuts.'
)
},
Icon: ({ className }) => <Github className={className} />
},
{
id: 'gitlab',
label: translate('auto.components.settings.TasksPane.7c5d7fdc20', 'GitLab'),
description: translate(
'auto.components.settings.TasksPane.dd67a1b6e1',
'Show GitLab in the Tasks source picker and sidebar shortcuts.'
),
get label() {
return translate('auto.components.settings.TasksPane.7c5d7fdc20', 'GitLab')
},
get description() {
return translate(
'auto.components.settings.TasksPane.dd67a1b6e1',
'Show GitLab in the Tasks source picker and sidebar shortcuts.'
)
},
Icon: ({ className }) => <Gitlab className={className} />
},
{
id: 'linear',
label: translate('auto.components.settings.TasksPane.09ae2d7c51', 'Linear'),
description: translate(
'auto.components.settings.TasksPane.e4170c9615',
'Show Linear in the Tasks source picker and sidebar shortcuts.'
),
get label() {
return translate('auto.components.settings.TasksPane.09ae2d7c51', 'Linear')
},
get description() {
return translate(
'auto.components.settings.TasksPane.e4170c9615',
'Show Linear in the Tasks source picker and sidebar shortcuts.'
)
},
Icon: ({ className }) => <LinearIcon className={className} />
},
{
id: 'jira',
label: translate('auto.components.settings.TasksPane.6b23a34f6d', 'Jira'),
description: translate(
'auto.components.settings.TasksPane.8e1305fcc6',
'Show Jira in the Tasks source picker and sidebar shortcuts.'
),
get label() {
return translate('auto.components.settings.TasksPane.6b23a34f6d', 'Jira')
},
get description() {
return translate(
'auto.components.settings.TasksPane.8e1305fcc6',
'Show Jira in the Tasks source picker and sidebar shortcuts.'
)
},
Icon: ({ className }) => <JiraIcon className={className} />
}
]
@@ -1,6 +1,6 @@
import { useRef, useState } from 'react'
import { RotateCw } from 'lucide-react'
import type { GlobalSettings, TerminalColorOverrides } from '../../../../shared/types'
import type { GlobalSettings } from '../../../../shared/types'
import { Button } from '../ui/button'
import { Label } from '../ui/label'
import { ColorField, NumberField } from './SettingsFormControls'
@@ -14,65 +14,7 @@ type TerminalWindowSectionProps = {
updateSettings: (updates: Partial<GlobalSettings>) => void
}
const COLOR_OVERRIDE_GROUPS: {
label: string
keys: { key: keyof TerminalColorOverrides; label: string; description: string }[]
}[] = [
{
label: translate("auto.components.settings.TerminalWindowSection.cf37ff69f6", "Base"),
keys: [
{ key: 'foreground', label: translate("auto.components.settings.TerminalWindowSection.79f6bfb76e", "Foreground"), description: translate("auto.components.settings.TerminalWindowSection.026a0b8013", "Main text color") },
{ key: 'background', label: translate("auto.components.settings.TerminalWindowSection.cc1b2ffeb2", "Background"), description: translate("auto.components.settings.TerminalWindowSection.da64e8f4c1", "Terminal background color") },
{ key: 'cursor', label: translate("auto.components.settings.TerminalWindowSection.c9e1fdf42f", "Cursor"), description: translate("auto.components.settings.TerminalWindowSection.cd0700762b", "Cursor color") },
{
key: 'cursorAccent',
label: translate("auto.components.settings.TerminalWindowSection.a2d9f095a7", "Cursor Text"),
description: translate("auto.components.settings.TerminalWindowSection.7f4063076c", "Color of text under the cursor (block cursor)")
},
{
key: 'selectionBackground',
label: translate("auto.components.settings.TerminalWindowSection.40c3cfd30a", "Selection Background"),
description: translate("auto.components.settings.TerminalWindowSection.74d8555f85", "Background color of selected text")
},
{
key: 'selectionForeground',
label: translate("auto.components.settings.TerminalWindowSection.8b450b5305", "Selection Foreground"),
description: translate("auto.components.settings.TerminalWindowSection.b2c0857c49", "Text color of selected text")
},
{
key: 'bold',
label: translate("auto.components.settings.TerminalWindowSection.862e463f7f", "Bold Text"),
description: translate("auto.components.settings.TerminalWindowSection.fb8c6f1967", "Color for bold text. Falls back to the normal color if not set.")
}
]
},
{
label: translate("auto.components.settings.TerminalWindowSection.68e9f07de0", "ANSI Normal"),
keys: [
{ key: 'black', label: translate("auto.components.settings.TerminalWindowSection.adfdee23cb", "Black"), description: translate("auto.components.settings.TerminalWindowSection.cf4437a2f7", "ANSI black color") },
{ key: 'red', label: translate("auto.components.settings.TerminalWindowSection.3a78f30b50", "Red"), description: translate("auto.components.settings.TerminalWindowSection.b41270f5ca", "ANSI red color") },
{ key: 'green', label: translate("auto.components.settings.TerminalWindowSection.8f2092b315", "Green"), description: translate("auto.components.settings.TerminalWindowSection.8a673d4206", "ANSI green color") },
{ key: 'yellow', label: translate("auto.components.settings.TerminalWindowSection.bb516de873", "Yellow"), description: translate("auto.components.settings.TerminalWindowSection.09c1c6b096", "ANSI yellow color") },
{ key: 'blue', label: translate("auto.components.settings.TerminalWindowSection.292a4c7316", "Blue"), description: translate("auto.components.settings.TerminalWindowSection.9635a71c51", "ANSI blue color") },
{ key: 'magenta', label: translate("auto.components.settings.TerminalWindowSection.d5e92fcd94", "Magenta"), description: translate("auto.components.settings.TerminalWindowSection.1705318506", "ANSI magenta color") },
{ key: 'cyan', label: translate("auto.components.settings.TerminalWindowSection.fb8bb4eb1f", "Cyan"), description: translate("auto.components.settings.TerminalWindowSection.bd4c759327", "ANSI cyan color") },
{ key: 'white', label: translate("auto.components.settings.TerminalWindowSection.0cb4459fb8", "White"), description: translate("auto.components.settings.TerminalWindowSection.28846b1ca6", "ANSI white color") }
]
},
{
label: translate("auto.components.settings.TerminalWindowSection.1be593d3e8", "ANSI Bright"),
keys: [
{ key: 'brightBlack', label: translate("auto.components.settings.TerminalWindowSection.260d69ce9a", "Bright Black"), description: translate("auto.components.settings.TerminalWindowSection.f30c492769", "ANSI bright black color") },
{ key: 'brightRed', label: translate("auto.components.settings.TerminalWindowSection.32b1b6acd7", "Bright Red"), description: translate("auto.components.settings.TerminalWindowSection.667de68863", "ANSI bright red color") },
{ key: 'brightGreen', label: translate("auto.components.settings.TerminalWindowSection.7dafd57730", "Bright Green"), description: translate("auto.components.settings.TerminalWindowSection.0ffb02f921", "ANSI bright green color") },
{ key: 'brightYellow', label: translate("auto.components.settings.TerminalWindowSection.936a326be3", "Bright Yellow"), description: translate("auto.components.settings.TerminalWindowSection.e2ef5f4ab7", "ANSI bright yellow color") },
{ key: 'brightBlue', label: translate("auto.components.settings.TerminalWindowSection.66820332fa", "Bright Blue"), description: translate("auto.components.settings.TerminalWindowSection.bef6c0f6bf", "ANSI bright blue color") },
{ key: 'brightMagenta', label: translate("auto.components.settings.TerminalWindowSection.e56e7d6ea0", "Bright Magenta"), description: translate("auto.components.settings.TerminalWindowSection.fe4d89ef85", "ANSI bright magenta color") },
{ key: 'brightCyan', label: translate("auto.components.settings.TerminalWindowSection.f94adc4113", "Bright Cyan"), description: translate("auto.components.settings.TerminalWindowSection.1601140f03", "ANSI bright cyan color") },
{ key: 'brightWhite', label: translate("auto.components.settings.TerminalWindowSection.16948119cb", "Bright White"), description: translate("auto.components.settings.TerminalWindowSection.42e01a6055", "ANSI bright white color") }
]
}
]
import { COLOR_OVERRIDE_GROUPS } from './terminal-window-color-groups'
export function TerminalWindowSection({
settings,
@@ -106,18 +48,37 @@ export function TerminalWindowSection({
return (
<section className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">{translate("auto.components.settings.TerminalWindowSection.b96ba13ed1", "Window")}</h3>
<p className="text-xs text-muted-foreground">{translate("auto.components.settings.TerminalWindowSection.00eaa6b881", "Window appearance and background settings.")}</p>
<h3 className="text-sm font-semibold">
{translate('auto.components.settings.TerminalWindowSection.b96ba13ed1', 'Window')}
</h3>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.TerminalWindowSection.00eaa6b881',
'Window appearance and background settings.'
)}
</p>
</div>
<SearchableSetting
title={translate("auto.components.settings.TerminalWindowSection.ea7b1a158e", "Background Opacity")}
description={translate("auto.components.settings.TerminalWindowSection.03acb60aa0", "Controls the transparency of the terminal background.")}
title={translate(
'auto.components.settings.TerminalWindowSection.ea7b1a158e',
'Background Opacity'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.03acb60aa0',
'Controls the transparency of the terminal background.'
)}
keywords={['opacity', 'transparency', 'background', 'alpha']}
>
<NumberField
label={translate("auto.components.settings.TerminalWindowSection.ea7b1a158e", "Background Opacity")}
description={translate("auto.components.settings.TerminalWindowSection.809f37738d", "Controls the transparency of the terminal background. 1 is fully opaque, 0 is fully transparent.")}
label={translate(
'auto.components.settings.TerminalWindowSection.ea7b1a158e',
'Background Opacity'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.809f37738d',
'Controls the transparency of the terminal background. 1 is fully opaque, 0 is fully transparent.'
)}
value={settings.terminalBackgroundOpacity ?? 1}
defaultValue={1}
min={0}
@@ -131,16 +92,31 @@ export function TerminalWindowSection({
</SearchableSetting>
<SearchableSetting
title={translate("auto.components.settings.TerminalWindowSection.2b82242f43", "Window Blur")}
description={translate("auto.components.settings.TerminalWindowSection.97950bb087", "Apply background blur to the terminal window. Requires restart.")}
title={translate(
'auto.components.settings.TerminalWindowSection.2b82242f43',
'Window Blur'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.97950bb087',
'Apply background blur to the terminal window. Requires restart.'
)}
keywords={['window', 'blur', 'background', 'transparency', 'vibrancy']}
className="space-y-3 py-2"
>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label>{translate("auto.components.settings.TerminalWindowSection.2b82242f43", "Window Blur")}</Label>
<Label>
{translate(
'auto.components.settings.TerminalWindowSection.2b82242f43',
'Window Blur'
)}
</Label>
<p className="text-xs text-muted-foreground">
{translate("auto.components.settings.TerminalWindowSection.97950bb087", "Apply background blur to the terminal window. Requires restart.")}</p>
{translate(
'auto.components.settings.TerminalWindowSection.97950bb087',
'Apply background blur to the terminal window. Requires restart.'
)}
</p>
</div>
<button
role="switch"
@@ -162,9 +138,17 @@ export function TerminalWindowSection({
<div className="flex items-center justify-between gap-3 rounded-md border border-yellow-500/50 bg-yellow-500/10 px-3 py-2.5">
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium text-yellow-700 dark:text-yellow-300">
{translate("auto.components.settings.TerminalWindowSection.c65bb9ce63", "Restart required")}</p>
{translate(
'auto.components.settings.TerminalWindowSection.c65bb9ce63',
'Restart required'
)}
</p>
<p className="text-xs text-muted-foreground">
{translate("auto.components.settings.TerminalWindowSection.53ce336e15", "Restart Orca to apply the window blur change.")}</p>
{translate(
'auto.components.settings.TerminalWindowSection.53ce336e15',
'Restart Orca to apply the window blur change.'
)}
</p>
</div>
<Button
size="sm"
@@ -174,20 +158,40 @@ export function TerminalWindowSection({
onClick={() => void handleRelaunch()}
>
<RotateCw className={`size-3 ${relaunchingBlur ? 'animate-spin' : ''}`} />
{relaunchingBlur ? translate("auto.components.settings.TerminalWindowSection.907131d741", "Restarting…") : translate("auto.components.settings.TerminalWindowSection.8abdab9f7c", "Restart now")}
{relaunchingBlur
? translate(
'auto.components.settings.TerminalWindowSection.907131d741',
'Restarting…'
)
: translate(
'auto.components.settings.TerminalWindowSection.8abdab9f7c',
'Restart now'
)}
</Button>
</div>
) : null}
</SearchableSetting>
<SearchableSetting
title={translate("auto.components.settings.TerminalWindowSection.36b8402015", "Horizontal Padding")}
description={translate("auto.components.settings.TerminalWindowSection.25e2f8e8e1", "Horizontal padding around the terminal grid in pixels.")}
title={translate(
'auto.components.settings.TerminalWindowSection.36b8402015',
'Horizontal Padding'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.25e2f8e8e1',
'Horizontal padding around the terminal grid in pixels.'
)}
keywords={['padding', 'horizontal', 'spacing', 'margin']}
>
<NumberField
label={translate("auto.components.settings.TerminalWindowSection.36b8402015", "Horizontal Padding")}
description={translate("auto.components.settings.TerminalWindowSection.25e2f8e8e1", "Horizontal padding around the terminal grid in pixels.")}
label={translate(
'auto.components.settings.TerminalWindowSection.36b8402015',
'Horizontal Padding'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.25e2f8e8e1',
'Horizontal padding around the terminal grid in pixels.'
)}
value={settings.terminalPaddingX ?? 4}
defaultValue={4}
min={0}
@@ -199,13 +203,25 @@ export function TerminalWindowSection({
</SearchableSetting>
<SearchableSetting
title={translate("auto.components.settings.TerminalWindowSection.1afcc1d973", "Vertical Padding")}
description={translate("auto.components.settings.TerminalWindowSection.1846f6ee6a", "Vertical padding around the terminal grid in pixels.")}
title={translate(
'auto.components.settings.TerminalWindowSection.1afcc1d973',
'Vertical Padding'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.1846f6ee6a',
'Vertical padding around the terminal grid in pixels.'
)}
keywords={['padding', 'vertical', 'spacing', 'margin']}
>
<NumberField
label={translate("auto.components.settings.TerminalWindowSection.1afcc1d973", "Vertical Padding")}
description={translate("auto.components.settings.TerminalWindowSection.1846f6ee6a", "Vertical padding around the terminal grid in pixels.")}
label={translate(
'auto.components.settings.TerminalWindowSection.1afcc1d973',
'Vertical Padding'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.1846f6ee6a',
'Vertical padding around the terminal grid in pixels.'
)}
value={settings.terminalPaddingY ?? 4}
defaultValue={4}
min={0}
@@ -217,15 +233,30 @@ export function TerminalWindowSection({
</SearchableSetting>
<SearchableSetting
title={translate("auto.components.settings.TerminalWindowSection.3530908ef9", "Hide Mouse While Typing")}
description={translate("auto.components.settings.TerminalWindowSection.1d1920dc8a", "Hide the mouse cursor when typing in the terminal.")}
title={translate(
'auto.components.settings.TerminalWindowSection.3530908ef9',
'Hide Mouse While Typing'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.1d1920dc8a',
'Hide the mouse cursor when typing in the terminal.'
)}
keywords={['mouse', 'hide', 'typing', 'cursor']}
className="flex items-center justify-between gap-4 py-2"
>
<div className="space-y-0.5">
<Label>{translate("auto.components.settings.TerminalWindowSection.3530908ef9", "Hide Mouse While Typing")}</Label>
<Label>
{translate(
'auto.components.settings.TerminalWindowSection.3530908ef9',
'Hide Mouse While Typing'
)}
</Label>
<p className="text-xs text-muted-foreground">
{translate("auto.components.settings.TerminalWindowSection.1d1920dc8a", "Hide the mouse cursor when typing in the terminal.")}</p>
{translate(
'auto.components.settings.TerminalWindowSection.1d1920dc8a',
'Hide the mouse cursor when typing in the terminal.'
)}
</p>
</div>
<button
role="switch"
@@ -250,8 +281,14 @@ export function TerminalWindowSection({
</SearchableSetting>
<SearchableSetting
title={translate("auto.components.settings.TerminalWindowSection.63f8d9336e", "Color Overrides")}
description={translate("auto.components.settings.TerminalWindowSection.e86e09b5c7", "Override individual terminal colors.")}
title={translate(
'auto.components.settings.TerminalWindowSection.63f8d9336e',
'Color Overrides'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.e86e09b5c7',
'Override individual terminal colors.'
)}
keywords={['color', 'override', 'ansi', 'palette', 'theme']}
className="space-y-3"
>
@@ -263,7 +300,11 @@ export function TerminalWindowSection({
<span className={`transition-transform ${colorOverridesExpanded ? 'rotate-90' : ''}`}>
▶
</span>
{translate("auto.components.settings.TerminalWindowSection.63f8d9336e", "Color Overrides")}</button>
{translate(
'auto.components.settings.TerminalWindowSection.63f8d9336e',
'Color Overrides'
)}
</button>
<div
className={`grid overflow-hidden transition-all duration-300 ease-out ${
colorOverridesExpanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
@@ -299,7 +340,11 @@ export function TerminalWindowSection({
size="sm"
onClick={() => updateSettings({ terminalColorOverrides: undefined })}
>
{translate("auto.components.settings.TerminalWindowSection.03c855d15f", "Reset all color overrides")}</Button>
{translate(
'auto.components.settings.TerminalWindowSection.03c855d15f',
'Reset all color overrides'
)}
</Button>
</div>
</div>
</div>
@@ -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<LoadedMcpConfigInspection[]> {
const entriesByRelativeDir = new Map<string, readonly McpConfigDirectoryEntry[]>()
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<string, string>()
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<LoadedMcpConfigInspection> => {
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.')
}
}
})
)
}

Some files were not shown because too many files have changed in this diff Show More