mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 08:03:09 +00:00
+1
-1
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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) ────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+285
@@ -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)
|
||||
}
|
||||
|
||||
+24
-16
@@ -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
Reference in New Issue
Block a user