diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 7bef4e0bd..23c59dad4 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -461,6 +461,12 @@ async function runRendererOnboardingSmoke(win: BrowserWindow): Promise { throw new Error('isolated packaged smoke should be locked by OPENALICE_HOME') } + const setup = await json(await fetch('/api/workspaces/project-setup')) + const workspaceList = await json(await fetch('/api/workspaces')) + if (setup.pending?.length || !workspaceList.workspaces?.some(ws => ws.template === 'chat')) { + throw new Error('new project did not prepare Chat before opening the renderer') + } + const agents = await json(await fetch('/api/workspaces/agents')) const pi = agents.agents?.find((agent) => agent.id === 'pi') if (!pi?.installed) throw new Error('managed Pi was not detected by packaged /agents') @@ -484,15 +490,15 @@ async function runRendererOnboardingSmoke(win: BrowserWindow): Promise { }, 60000) if (!initialReadiness.agents.pi?.ready) { - await waitFor('AI credential action', () => { + const addCredential = await waitFor('AI credential action', () => { const button = document.querySelector('[data-testid="first-run-guide-primary"]') return button && !button.disabled && button.getAttribute('data-onboarding-action') === 'add-credential' - ? true - : false + ? button + : document.querySelector('[data-testid="first-run-guide-add-provider"]') }) - clickPrimary() + addCredential.click() await waitFor('credential modal', () => credentialPrimary()) credentialPrimary().click() await waitFor('verified credential', () => { @@ -518,13 +524,10 @@ async function runRendererOnboardingSmoke(win: BrowserWindow): Promise { const snapshot = await json(await fetch('/api/agent-runtimes/readiness')) const row = snapshot.agents?.pi const button = document.querySelector('[data-testid="first-run-guide-primary"]') - return activeStep() === 'ai' && - row?.ready === true && - button && - !button.disabled && - button.getAttribute('data-onboarding-action') === 'continue' + return row?.ready === true && (activeStep() === 'broker' || (activeStep() === 'ai' && + button && !button.disabled && button.getAttribute('data-onboarding-action') === 'continue')) }, 60000) - clickPrimary() + if (activeStep() === 'ai') clickPrimary() await waitFor('broker step', () => activeStep() === 'broker' ? true : false) return { diff --git a/docs/alice-project.md b/docs/alice-project.md index 42859c424..5da460713 100644 --- a/docs/alice-project.md +++ b/docs/alice-project.md @@ -67,9 +67,31 @@ plus the matching Settings categories Ask Alice or Settings. AutoQuant and Tracked stay visible until that boundary is reviewed separately. -A new AliceProject with no Chat Workspace opens Ask Alice on the shared -harness setup page rather than an empty composer; Chat does not pin a Harness -version. +Named AliceProject creation selects the workspaces to prepare. Chat is the +default; Auto Quant and Auto Prediction are optional, and selecting none leaves +setup for later. The TUI Foundry adds a third Workspaces step (arrows to move, +Space/click to toggle) and finishes with Create & start. Scripted creation uses +`--workspaces chat,auto-quant,auto-prediction` or `--workspaces none`; it records +the selection without starting a Runtime. The next startup prepares it. + +The CLI and TUI share the same registration and `workspace-setup.json` birth +request. Under its writer lease, the backend resolves or creates each selected +Workspace and saves the canonical Harness default before checkpointing success. +It never starts an Agent Session or copies credentials. An interrupted attempt +reuses the existing Workspace. Failed items remain pending, while successful +items are not recreated. Quick Start reports remaining setup and offers Retry; +the existing per-Harness setup page still supports deferred/legacy projects. +A missing request leaves older homes unchanged. Chat does not pin a Harness version. + +A prepared Workspace may still need an Agent or credentials before its first +Session; the normal launch controls own that readiness, independently of setup. +The first-run guide offers an explicit Pi provider connection action. Saving a +compatible provider binds it to an unconfigured Chat's interactive defaults; +existing runtime choices and headless defaults are preserved. The guide stays +on the confirmed AI-ready step until the user continues. Its page layer sits +below the shared credential/UTA dialogs so these remain visible and operable. +Packaged onboarding smoke uses isolated Pi state, the local mock provider, and +the same Chat birth request before verifying the renderer and provider binding. Create a named project from the CLI: @@ -87,7 +109,11 @@ for AI credential rows: it copies only `credentials` from the per-home `ai-provider-manager.json`, writes into the destination home, and never prints secrets. Workspace launch preferences and broker credentials stay project-local. -The Supervisor TUI create path still registers a Trader-equivalent home. +The Supervisor TUI create path registers a Trader-equivalent home. + +For isolated first-run verification, use +`OPENALICE_ONBOARDING_WORKSPACES=chat pnpm dev:onboarding`. Omit the variable +to exercise the legacy/deferred setup page. `none` explicitly skips preparation. The application/source root is launch metadata, not identity. Ports and Web URLs are live discovery data and may change between launches. Guardian's diff --git a/docs/cli-supervisor.md b/docs/cli-supervisor.md index aabfefedb..a9cb3f838 100644 --- a/docs/cli-supervisor.md +++ b/docs/cli-supervisor.md @@ -722,7 +722,7 @@ intentionally parameter-free: 80-column baseline. Its Create row opens a two-stage AliceProject Foundry: Identity and Complete Home remain visible beside the focused Field Inspector on wide terminals and stack as a compact route at 80 columns. Validation - remains ordered, and only the final `Create & select` action registers the + remains ordered, and only the final `Create & start` action registers the new complete home. AI vault copy is a separate command: `openalice project copy-ai-creds`; - `p` opens Setup for data home, browser port, update checks, and resolved @@ -898,7 +898,12 @@ switches the live Supervisor view and records it as the next bare-start default; it does not stop, move, copy, or delete another project. Creating an AliceProject collects a validated lowercase key and separate complete home inside the TUI, rejects equal or nested registered homes, and selects the new -entry atomically. An existing target must be empty or recognizable as an +entry atomically. The final Workspaces step defaults to Chat, allows optional +Auto Quant and Auto Prediction (or none), then starts the selected project. +The backend prepares those durable instances before the first page opens; +Agent Sessions remain stopped. Failed preparation can be retried from Quick +Start. CLI `create alice-project --workspaces` records the same selection +for the next start. See [[docs/alice-project.md]]. An existing target must be empty or recognizable as an OpenAlice complete home; an unrelated non-empty directory is rejected. A new target is created and canonicalized when registered, so a later missing registered Home is never silently recreated. A bare TUI launch falls back to diff --git a/packages/cli/package.json b/packages/cli/package.json index bbadd14e8..f44574f6b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -80,7 +80,8 @@ "src/server-control.mjs", "src/ssh-connect.mjs", "src/uninstall.mjs", - "src/update.mjs" + "src/update.mjs", + "src/project-workspaces.ts" ], "scripts": { "build": "tsc -p tsconfig.json --noEmit && node --check bin/openalice.ts && node --check bin/openalice.mjs && node --check src/alice-project.ts && node --check src/alice-project-product.ts && node --check src/ai-credential-copy.ts && node --check src/create-alice-project.ts && node --check src/project-command.ts && node --check src/project-transfer.ts && node --check src/project-transfer-files.ts && node --check src/project-transfer-secrets.ts && node --check src/project-transfer-ssh.ts && node --check src/project-transfer-stream.ts && node --check src/machine-command.ts && node --check src/machine-inventory.ts && node --check src/machine-registry.ts && node --check src/managed-source.ts && node --check src/supervisor-config.ts && node --check src/supervisor-boot-sequence.ts && node --check src/supervisor-connection-chronicle.ts && node --check src/supervisor-command-deck.ts && node --check src/supervisor-confirmation.ts && node --check src/supervisor-display.ts && node --check src/supervisor-doctor-view.ts && node --check src/supervisor-fleet.ts && node --check src/supervisor-help-view.ts && node --check src/supervisor-inbox.ts && node --check src/supervisor-launch-flight.ts && node --check src/supervisor-navigation.ts && node --check src/supervisor-transfer.ts && node --check src/supervisor-transfer-view.ts && node --check src/supervisor-overlay-pointer.ts && node --check src/supervisor-projects-view.ts && node --check src/supervisor-project-foundry-view.ts && node --check src/supervisor-source-view.ts && node --check src/supervisor-release-view.ts && node --check src/supervisor-task-surface.ts && node --check src/supervisor-scroll-rail.ts && node --check src/supervisor-setup-view.ts && node --check src/supervisor-tui-pointer.ts && node --check src/supervisor-tui-feedback.ts && node --check src/supervisor-tui-logs.ts && node --check src/supervisor-terminal-clipboard.ts && node --check src/supervisor-tui-theme.ts && node --check src/supervisor-tui-view.ts && node --check src/activation.mjs && node --check src/activation-runtime.mjs && node --check src/bun-standalone.mjs && node --check src/doctor.mjs && node --check src/install-layout.mjs && node --check src/install-source.mjs && node --check src/lifecycle-command.mjs && node --check src/lifecycle.mjs && node --check src/local-start.mjs && node --check src/logs.mjs && node --check src/observability-command.mjs && node --check src/package-manager.mjs && node --check src/remote.mjs && node --check src/runtime-deps.mjs && node --check src/runtime-bundle.mjs && node --check src/runtime-client.mjs && node --check src/server.mjs && node --check src/server-control.mjs && node --check src/ssh-connect.mjs && node --check src/uninstall.mjs && node --check src/update.mjs", diff --git a/packages/cli/src/__fixtures__/supervisor-project-create-fixture.ts b/packages/cli/src/__fixtures__/supervisor-project-create-fixture.ts new file mode 100644 index 000000000..e204d5d68 --- /dev/null +++ b/packages/cli/src/__fixtures__/supervisor-project-create-fixture.ts @@ -0,0 +1,9 @@ +import { runSupervisorTui } from '../supervisor-tui.ts' +// Real creation/registry and PTY input; lifecycle is isolated from network/process launches. +const code = await runSupervisorTui({}, { + env: process.env, + inspect: async () => ({ class: 'absent', state: 'absent', owner: null, endpoints: {} }), + start: async () => {}, + open: async () => {}, +}) +process.exitCode = code diff --git a/packages/cli/src/create-alice-project.spec.ts b/packages/cli/src/create-alice-project.spec.ts index 8a217e89d..56c3fdb06 100644 --- a/packages/cli/src/create-alice-project.spec.ts +++ b/packages/cli/src/create-alice-project.spec.ts @@ -46,6 +46,7 @@ describe('openalice create alice-project', () => { homeDir, }, )).resolves.toBe(0) + expect(JSON.parse(await readFile(join(home, 'workspace-setup.json'), 'utf8')).pending).toEqual(['chat']) expect(stdout.join('')).toContain('NanoAlice') expect(stdout.join('')).toContain('openalice up --project office') expect(JSON.parse(await readFile(aliceProjectProductStampPath(home), 'utf8'))).toEqual({ @@ -59,3 +60,9 @@ describe('openalice create alice-project', () => { expect(saved.projects?.office?.product).toBe('nano') }) }) + +it('parses explicit workspace selection and rejects typos', () => { + expect(parseCreateAliceProjectArgs(['--workspaces', 'chat,auto-quant,chat']).workspaces).toEqual(['chat', 'auto-quant']) + expect(parseCreateAliceProjectArgs(['--workspaces', 'none']).workspaces).toEqual([]) + expect(() => parseCreateAliceProjectArgs(['--workspaces', 'quant'])).toThrow('Workspaces must') +}) diff --git a/packages/cli/src/create-alice-project.ts b/packages/cli/src/create-alice-project.ts index fb503f8cb..ec2f8ada5 100644 --- a/packages/cli/src/create-alice-project.ts +++ b/packages/cli/src/create-alice-project.ts @@ -1,3 +1,4 @@ +import { parseProjectWorkspaces, type ProjectWorkspace } from './project-workspaces.ts' /** * `openalice create alice-project` — interactive or scripted AliceProject birth. */ @@ -33,6 +34,7 @@ Options: --name Project key (lowercase, not "default") --home Complete OPENALICE_HOME for this project --product trader (default) or nano + --workspaces chat (default), auto-quant, auto-prediction; or none --yes Non-interactive; requires --name and --home ` } @@ -42,6 +44,7 @@ export interface CreateAliceProjectOptions { home?: string product?: AliceProjectProduct yes?: boolean + workspaces?: ProjectWorkspace[] } export function parseCreateAliceProjectArgs(argv: string[]): CreateAliceProjectOptions { @@ -52,6 +55,10 @@ export function parseCreateAliceProjectArgs(argv: string[]): CreateAliceProjectO options.yes = true continue } + if (arg === '--workspaces') { + options.workspaces = parseProjectWorkspaces(requireValue(argv, ++index, arg)) + continue + } if (arg === '--name') { options.name = requireValue(argv, ++index, arg) continue @@ -115,6 +122,10 @@ export async function runCreateAliceProjectCommand( ?? (interactive ? await prompt(`Complete home [${suggestedHome}]: `) : suggestedHome) ).trim() || suggestedHome + const workspaces = options.workspaces ?? (interactive + ? parseProjectWorkspaces((await prompt('Workspaces: chat, auto-quant, auto-prediction, or none [chat]: ')).trim() || 'chat') + : ['chat'] as ProjectWorkspace[]) + if (interactive && !options.yes) { stdout.write( `Create AliceProject "${name}" as ${product === 'nano' ? 'NanoAlice' : 'TraderAlice'} at ${home}?\n`, @@ -129,12 +140,14 @@ export async function runCreateAliceProjectCommand( const context = await (io.resolveContext ?? (() => resolveStoredLaunchContext({})))() await createSupervisorAliceProject(context, name, home, { product, + workspaces, homeDir: io.homeDir, cwd: home, }) stdout.write( `Created AliceProject ${name} (${product === 'nano' ? 'NanoAlice' : 'TraderAlice'}).\n` + `Home: ${home}\n` + + `Workspaces: ${workspaces.join(', ') || 'none (set up later)'}. Prepared automatically on first start; no Agent is launched.\n` + `Selected as the next bare-start default. Start with: openalice up --project ${name}\n`, ) return 0 diff --git a/packages/cli/src/project-workspaces.ts b/packages/cli/src/project-workspaces.ts new file mode 100644 index 000000000..1b4824b9a --- /dev/null +++ b/packages/cli/src/project-workspaces.ts @@ -0,0 +1,29 @@ +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +export const PROJECT_WORKSPACES = ['chat', 'auto-quant', 'auto-prediction'] as const +export type ProjectWorkspace = typeof PROJECT_WORKSPACES[number] +export const PROJECT_WORKSPACE_LABELS: Record = { + chat: 'Chat — everyday conversations and tasks', + 'auto-quant': 'Auto Quant — quantitative research', + 'auto-prediction': 'Auto Prediction — prediction market research', +} + +export function parseProjectWorkspaces(value: string): ProjectWorkspace[] { + if (value.trim() === 'none') return [] + const values = value.split(',').map(item => item.trim()) + if (values.some(item => !PROJECT_WORKSPACES.includes(item as ProjectWorkspace))) { + throw new Error('Workspaces must be chat, auto-quant, auto-prediction (comma-separated), or none.') + } + return [...new Set(values)] as ProjectWorkspace[] +} + +/** Birth-time intent only. The owning backend prepares these without starting Agents. */ +export async function writeProjectWorkspaceRequest(home: string, workspaces: readonly ProjectWorkspace[]): Promise { + // Exclusive creation: never replace an existing home's setup choices. + await writeFile(join(home, 'workspace-setup.json'), JSON.stringify({ + schemaVersion: 1, pending: workspaces, + }, null, 2) + '\n', { flag: 'wx', mode: 0o600 }).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'EEXIST') throw error + }) +} diff --git a/packages/cli/src/supervisor-config.ts b/packages/cli/src/supervisor-config.ts index 5ff62d9f0..a9c100140 100644 --- a/packages/cli/src/supervisor-config.ts +++ b/packages/cli/src/supervisor-config.ts @@ -1,3 +1,4 @@ +import { writeProjectWorkspaceRequest, type ProjectWorkspace } from './project-workspaces.ts' import { randomUUID } from 'node:crypto' import { constants } from 'node:fs' import { @@ -391,6 +392,7 @@ export async function createSupervisorAliceProject( home: string, options: PersistAliceProjectConfigOptions & { product?: AliceProjectProduct + workspaces?: readonly ProjectWorkspace[] displayName?: string select?: boolean } = {}, @@ -443,6 +445,8 @@ export async function createSupervisorAliceProject( }, } await assertRegistryHomesSeparate(next, options) + // Registration also serves transfers: only new-project flows request setup. + if (options.workspaces !== undefined) await writeProjectWorkspaceRequest(normalizedHome, options.workspaces) await writeConfig(context.supervisorRoot, next) } diff --git a/packages/cli/src/supervisor-project-foundry-view.spec.ts b/packages/cli/src/supervisor-project-foundry-view.spec.ts index aeb79762d..9090192c7 100644 --- a/packages/cli/src/supervisor-project-foundry-view.spec.ts +++ b/packages/cli/src/supervisor-project-foundry-view.spec.ts @@ -19,11 +19,11 @@ describe('Supervisor AliceProject Foundry', () => { message: 'Existing data is never copied or deleted.', }, 100) const output = rendered.lines.join('\n') - expect(output).toContain('Foundry · 2/2 · COMPLETE HOME') + expect(output).toContain('Foundry · 2/3 · COMPLETE HOME') expect(output).toContain('Create AliceProject · research') expect(output).toContain('✓ 01 Identity') expect(output).toContain('◆ 02 Complete Home') - expect(output).toContain('◆ [ Enter ] Create & select │ [ Esc ] Back') + expect(output).toContain('◆ [ Enter ] Continue │ [ Esc ] Back') expect(output).toContain('Foundry contract · COMPLETE HOME') expect(output).not.toContain('…') expect(rendered.lines.every((line) => displayWidth(line) <= 100)).toBe(true) @@ -39,8 +39,8 @@ describe('Supervisor AliceProject Foundry', () => { message: 'Create a named AliceProject without leaving the Supervisor.', }, 72) const output = rendered.lines.join('\n') - expect(output).toContain('AliceProject Foundry · 1/2 · IDENTITY') - expect(output).toContain('◆ Identity → Complete Home') + expect(output).toContain('AliceProject Foundry · 1/3 · IDENTITY') + expect(output).toContain('◆ Identity → Home → Workspaces') expect(output).toContain('Create AliceProject · Project key') expect(output).toContain('◆ CONTRACT') expect(rendered.lines).toHaveLength(14) diff --git a/packages/cli/src/supervisor-project-foundry-view.ts b/packages/cli/src/supervisor-project-foundry-view.ts index 6adef43c8..840b854ab 100644 --- a/packages/cli/src/supervisor-project-foundry-view.ts +++ b/packages/cli/src/supervisor-project-foundry-view.ts @@ -5,7 +5,7 @@ import { } from './supervisor-tui-theme.ts' import { renderSupervisorPanel } from './supervisor-tui-view.ts' -export type SupervisorProjectFoundryStep = 'identity' | 'home' +export type SupervisorProjectFoundryStep = 'identity' | 'home' | 'workspaces' export interface SupervisorProjectFoundryView { step: SupervisorProjectFoundryStep @@ -37,11 +37,12 @@ export function renderSupervisorProjectFoundry( ): SupervisorProjectFoundryRender { const safeWidth = Math.max(24, width) const home = view.step === 'home' - const active = home ? 1 : 0 - const signal = home ? 'COMPLETE HOME' : 'IDENTITY' - const field = home ? 'Complete home' : 'AliceProject key' - const action = home - ? '◆ [ Enter ] Create & select │ [ Esc ] Back' + const workspaces = view.step === 'workspaces' + const active = workspaces ? 2 : home ? 1 : 0 + const signal = workspaces ? 'WORKSPACES' : home ? 'COMPLETE HOME' : 'IDENTITY' + const field = workspaces ? 'Choose workspaces' : home ? 'Complete home' : 'AliceProject key' + const action = workspaces + ? '◆ [ Enter ] Create & start │ [ Esc ] Back' : '◆ [ Enter ] Continue │ [ Esc ] Back' const inspectorRows = [ `◆ ${field}`, @@ -57,10 +58,11 @@ export function renderSupervisorProjectFoundry( const height = Math.max(5, inspectorRows.length) const path = renderSupervisorPanel( 'Foundry', - `${active + 1}/2 · ${signal}`, + `${active + 1}/3 · ${signal}`, padRows([ - labelAndTail(`${home ? '✓' : '◆'} 01 Identity`, home ? 'DONE' : 'CURRENT', PATH_WIDTH - 4), - labelAndTail(`${home ? '◆' : '·'} 02 Complete Home`, home ? 'CURRENT' : 'NEXT', PATH_WIDTH - 4), + labelAndTail(`${active > 0 ? '✓' : '◆'} 01 Identity`, active > 0 ? 'DONE' : 'CURRENT', PATH_WIDTH - 4), + labelAndTail(`${workspaces ? '✓' : home ? '◆' : '·'} 02 Complete Home`, workspaces ? 'DONE' : home ? 'CURRENT' : 'NEXT', PATH_WIDTH - 4), + labelAndTail(`${workspaces ? '◆' : '·'} 03 Workspaces`, workspaces ? 'CURRENT' : 'NEXT', PATH_WIDTH - 4), '', `From · ${view.currentProjectName}`, home ? `Key · ${view.projectKey ?? 'pending'}` : 'Key · not reserved yet', @@ -69,7 +71,7 @@ export function renderSupervisorProjectFoundry( ) const inspector = renderSupervisorPanel( 'Create AliceProject', - home ? view.projectKey ?? 'Complete home' : 'Project key', + active > 0 ? view.projectKey ?? 'Workspaces' : 'Project key', padRows(inspectorRows, height), inspectorWidth, ) @@ -88,16 +90,18 @@ export function renderSupervisorProjectFoundry( } } - const route = home - ? '✓ Identity ◆ Complete Home' - : '◆ Identity → Complete Home' + const route = workspaces + ? '✓ Identity ✓ Home ◆ Workspaces' + : home + ? '✓ Identity ◆ Home → Workspaces' + : '◆ Identity → Home → Workspaces' return { lines: [ - ...renderSupervisorPanel('AliceProject Foundry', `${active + 1}/2 · ${signal}`, [route], safeWidth), + ...renderSupervisorPanel('AliceProject Foundry', `${active + 1}/3 · ${signal}`, [route], safeWidth), '', ...renderSupervisorPanel( 'Create AliceProject', - home ? view.projectKey ?? 'Complete home' : 'Project key', + active > 0 ? view.projectKey ?? 'Workspaces' : 'Project key', inspectorRows, safeWidth, ), @@ -113,7 +117,7 @@ export function decorateSupervisorProjectFoundry( hoveredCommand?: string, ): string[] { const actions = [ - '◆ [ Enter ] Create & select │ [ Esc ] Back', + '◆ [ Enter ] Create & start │ [ Esc ] Back', '◆ [ Enter ] Continue │ [ Esc ] Back', ] return lines.map((line) => { diff --git a/packages/cli/src/supervisor-tui.pty.spec.ts b/packages/cli/src/supervisor-tui.pty.spec.ts index b1e06b5ba..62e81057a 100644 --- a/packages/cli/src/supervisor-tui.pty.spec.ts +++ b/packages/cli/src/supervisor-tui.pty.spec.ts @@ -2977,7 +2977,7 @@ describe.skipIf(process.platform === 'win32')('Supervisor TUI PTY', () => { const childEnv = { ...process.env } delete childEnv.OPENALICE_HOME delete childEnv.OPENALICE_INSTANCE - const child = pty.spawn(process.execPath, [cliEntry], { + const child = pty.spawn(process.execPath, [join(dirname(cliEntry), '../src/__fixtures__/supervisor-project-create-fixture.ts')], { cols: 110, rows: 32, cwd: dirname(cliEntry), @@ -2995,6 +2995,7 @@ describe.skipIf(process.platform === 'win32')('Supervisor TUI PTY', () => { let requestedCreate = false let submittedName = false let acceptedHome = false + let prepared = false let reopenedProjects = false let focusedDefault = false let defaultFocusOffset = 0 @@ -3017,22 +3018,20 @@ describe.skipIf(process.platform === 'win32')('Supervisor TUI PTY', () => { && output.includes('AliceProject key') ) { submittedName = true - child.write('research') - setTimeout(() => { - child.write('\u001b[<35;60;10M') - setTimeout(() => child.write('\u001b[<0;60;10M'), 100) - }, 100) + child.write('research\r') } else if ( !acceptedHome && output.includes('Create AliceProject · research') && output.includes('Complete home') ) { acceptedHome = true - child.write('\u001b[<35;64;10M') - setTimeout(() => child.write('\u001b[<0;64;10M'), 100) + child.write('\r') + } else if (!prepared && output.includes('Choose workspaces')) { + prepared = true + child.write('\u001b[B \r') } else if ( !reopenedProjects - && output.includes('Created and selected AliceProject Research') + && output.includes('OpenAlice started and opened in your browser.') && output.includes('Research') ) { reopenedProjects = true @@ -3079,9 +3078,10 @@ describe.skipIf(process.platform === 'win32')('Supervisor TUI PTY', () => { home: await realpath(join(isolatedHome, '.openalice-research')), }) expect(transcript).toContain('AliceProject Switchboard · 1 PROJECT') - expect(transcript).toContain('› [ Enter ] Continue') - expect(transcript).toContain('› [ Enter ] Create & select') - expect(transcript).toContain('Created and selected AliceProject Research') + expect(transcript).toContain('[ Enter ] Continue') + expect(transcript).toContain('Create & start') + expect(transcript).toContain('Choose workspaces') + expect(JSON.parse(await readFile(join(isolatedHome, '.openalice-research/workspace-setup.json'), 'utf8')).pending).toEqual(['chat', 'auto-quant']) expect(transcript).toContain('Selected AliceProject Default AliceProject') expect(transcript).toContain('\u001b[?25h') expect(transcript).toContain('\u001b[?2004l') @@ -3123,7 +3123,7 @@ describe.skipIf(process.platform === 'win32')('Supervisor TUI PTY', () => { } else if (!requestedCreate && output.includes('+ Create AliceProject')) { requestedCreate = true child.write('\u001b[B\r') - } else if (!foundry && output.includes('AliceProject Foundry · 1/2 · IDENTITY')) { + } else if (!foundry && output.includes('AliceProject Foundry · 1/3 · IDENTITY')) { foundry = true child.write('\u001b') } else if (foundry && !returned && data.includes('AliceProject Switchboard')) { @@ -3140,8 +3140,8 @@ describe.skipIf(process.platform === 'win32')('Supervisor TUI PTY', () => { }) }) - expect(transcript).toContain('AliceProject Foundry · 1/2 · IDENTITY') - expect(transcript).toContain('◆ Identity → Complete Home') + expect(transcript).toContain('AliceProject Foundry · 1/3 · IDENTITY') + expect(transcript).toContain('◆ Identity → Home → Workspaces') expect(transcript).toContain('Create AliceProject · Project key') expect(transcript).toContain('◆ CONTRACT') expect(transcript).toContain('\u001b[?25h') diff --git a/packages/cli/src/supervisor-tui.ts b/packages/cli/src/supervisor-tui.ts index 78388e667..92ca35fc5 100644 --- a/packages/cli/src/supervisor-tui.ts +++ b/packages/cli/src/supervisor-tui.ts @@ -1,3 +1,4 @@ +import { PROJECT_WORKSPACES, PROJECT_WORKSPACE_LABELS, type ProjectWorkspace } from './project-workspaces.ts' import { spawn } from 'node:child_process' import { dirname, @@ -461,6 +462,7 @@ export interface SupervisorTuiDependencies { context: ResolvedLaunchContext, name: string, home: string, + workspaces?: readonly ProjectWorkspace[], ) => Promise prepareManagedSource?: () => Promise inspectManagedSource?: () => Promise @@ -1016,8 +1018,9 @@ export async function runSupervisorTui( currentContext, name, home, + workspaces, ) => { - await createSupervisorAliceProject(currentContext, name, home) + await createSupervisorAliceProject(currentContext, name, home, { workspaces: workspaces ?? ['chat'] }) return resolveStoredLaunchContext(launchFlags, { env: dependencies.env, }) @@ -2829,6 +2832,7 @@ export async function runSupervisorTui( let component: Component = list let projectListActive = true let creatorView: Omit | null = null + let selectWorkspaceRow: ((index: number) => void) | undefined const overlayOptions = supervisorTaskSurfaceOptions(terminalSize(), { width: '92%', maxHeight: '90%', @@ -2863,6 +2867,7 @@ export async function runSupervisorTui( const activateContext = async ( operation: () => Promise, notice: (next: ResolvedLaunchContext) => string, + start = false, ) => { if (changing) return changing = true @@ -2886,8 +2891,42 @@ export async function runSupervisorTui( changing = false await refreshRuntime() } + if (start && !projectsActive) await requestAction('start-open') } - const showCreateHomeInput = (name: string) => { + const showCreateWorkspaces = (name: string, home: string) => { + const selected = new Set(['chat']) + let cursor = 0 + selectWorkspaceRow = index => { cursor = index; ui.requestRender() } + ui.setShowHardwareCursor(false) + creatorView = { + step: 'workspaces', currentProjectName: projectContext.aliceProject.displayName, + projectKey: name, detail: '↑↓ Choose · Space Toggle · Chat recommended; others optional.', + message: 'Prepare selected workspaces on startup. Agent Sessions start only when you ask.', + } + setMessage(creatorView.message) + component = { + render: (width) => PROJECT_WORKSPACES.map((kind, index) => + truncateDisplayWidth(`${index === cursor ? '›' : ' '} [${selected.has(kind) ? 'x' : ' '}] ${PROJECT_WORKSPACE_LABELS[kind]}`, width)), + invalidate: () => {}, + handleInput: (data) => { + if (piTui.matchesKey(data, 'escape')) { showCreateHomeInput(name, home); return } + if (piTui.matchesKey(data, 'up')) cursor = (cursor + 2) % 3 + else if (piTui.matchesKey(data, 'down')) cursor = (cursor + 1) % 3 + else if (data === ' ') { + const kind = PROJECT_WORKSPACES[cursor]! + if (selected.has(kind)) selected.delete(kind); else selected.add(kind) + } else if (piTui.matchesKey(data, 'enter')) { + void activateContext( + () => createProject(projectContext, name, home, PROJECT_WORKSPACES.filter(kind => selected.has(kind))), + (next) => `Created ${next.aliceProject.displayName}. Starting and preparing workspaces…`, + true, + ) + } + ui.requestRender() + }, + } + } + const showCreateHomeInput = (name: string, previousHome?: string) => { projectListActive = false const defaultHome = registry.projects.find( (entry) => entry.key === 'default', @@ -2912,7 +2951,7 @@ export async function runSupervisorTui( return super.render(width) } })() - input.setValue(suggestedHome) + input.setValue(previousHome ?? suggestedHome) input.focused = true ui.setShowHardwareCursor(true) input.onEscape = () => { @@ -2925,10 +2964,8 @@ export async function runSupervisorTui( input.setDetail('Enter a complete home for this AliceProject.') return } - void activateContext( - () => createProject(projectContext, name, home), - (next) => `Created and selected AliceProject ${next.aliceProject.displayName}.`, - ) + input.focused = false + showCreateWorkspaces(name, home) } component = input creatorView = { @@ -3027,7 +3064,15 @@ export async function runSupervisorTui( width, overlayOptions, (data) => this.handleInput(data), - undefined, + creatorView.step === 'workspaces' ? { + firstRow: Math.max(0, lines.findIndex(line => line.includes('[x] Chat') || line.includes('[ ] Chat'))), + indexes: [0, 1, 2], + startColumn: width >= 92 ? 42 : 3, + endColumn: width - 2, + select: index => selectWorkspaceRow?.(index), + activate: () => component.handleInput?.(' '), + move: delta => component.handleInput?.(delta < 0 ? '\u001b[A' : '\u001b[B'), + } : undefined, (label) => { if (projectsHoveredCommand === label) return projectsHoveredCommand = label diff --git a/scripts/desktop-packaged-smoke-plan.mjs b/scripts/desktop-packaged-smoke-plan.mjs index a6026aebc..133ef66a5 100644 --- a/scripts/desktop-packaged-smoke-plan.mjs +++ b/scripts/desktop-packaged-smoke-plan.mjs @@ -91,7 +91,7 @@ export function buildDesktopPackagedSmokePlan(argv, env = process.env, opts = {} ...onboardingBuildEnv, OPENALICE_ONBOARDING_TEST: '1', OPENALICE_CREDENTIAL_TEST_MODE: 'mock', - OPENALICE_AGENT_RUNTIME_INSTALLS: 'real', + OPENALICE_AGENT_RUNTIME_INSTALLS: 'only:pi', OPENALICE_MCP_ENABLED: '0', OPENALICE_ELECTRON_SMOKE_ONBOARDING: '1', OPENALICE_ELECTRON_SMOKE_EXIT: '1', diff --git a/scripts/desktop-packaged-smoke-plan.spec.ts b/scripts/desktop-packaged-smoke-plan.spec.ts index 0504444d1..8ef40f077 100644 --- a/scripts/desktop-packaged-smoke-plan.spec.ts +++ b/scripts/desktop-packaged-smoke-plan.spec.ts @@ -39,7 +39,7 @@ describe('buildDesktopPackagedSmokePlan', () => { expect(plan.launchEnv).toMatchObject({ OPENALICE_ONBOARDING_TEST: '1', OPENALICE_CREDENTIAL_TEST_MODE: 'mock', - OPENALICE_AGENT_RUNTIME_INSTALLS: 'real', + OPENALICE_AGENT_RUNTIME_INSTALLS: 'only:pi', OPENALICE_MCP_ENABLED: '0', OPENALICE_ELECTRON_SMOKE_ONBOARDING: '1', OPENALICE_ELECTRON_SMOKE_EXIT: '1', diff --git a/scripts/desktop-packaged-smoke.mjs b/scripts/desktop-packaged-smoke.mjs index d4473389c..accdeb9ac 100644 --- a/scripts/desktop-packaged-smoke.mjs +++ b/scripts/desktop-packaged-smoke.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node +import { writeProjectWorkspaceRequest } from '../packages/cli/src/project-workspaces.ts' import { spawn, spawnSync } from 'node:child_process' -import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' import { createServer as createNetServer } from 'node:net' import { homedir, tmpdir } from 'node:os' import { delimiter, join, resolve } from 'node:path' @@ -214,6 +215,10 @@ async function main() { const smokeHome = smokeRoot ? join(smokeRoot, 'home') : null const smokeWorkspaces = smokeRoot ? join(smokeRoot, 'workspaces') : null const smokeGlobal = smokeRoot ? join(smokeRoot, 'global') : null + if (onboarding && smokeHome) { + mkdirSync(smokeHome, { recursive: true }) + await writeProjectWorkspaceRequest(smokeHome, ['chat']) + } const pathAdditions = [ process.env['OPENALICE_EXTRA_AGENT_PATH'], @@ -236,6 +241,7 @@ async function main() { env.OPENALICE_HOME = smokeHome env.AQ_LAUNCHER_ROOT = smokeWorkspaces env.OPENALICE_GLOBAL_DIR = smokeGlobal + if (onboarding) env.PI_CODING_AGENT_DIR = join(smokeRoot, 'pi-agent') } const receiptPath = workspaceAcceptance diff --git a/scripts/onboarding-test-dev.ts b/scripts/onboarding-test-dev.ts index 2ac4e86aa..aaeb2cfa7 100644 --- a/scripts/onboarding-test-dev.ts +++ b/scripts/onboarding-test-dev.ts @@ -1,3 +1,5 @@ +import { mkdir } from 'node:fs/promises' +import { parseProjectWorkspaces, writeProjectWorkspaceRequest } from '../packages/cli/src/project-workspaces.js' import { spawn } from 'node:child_process' import type { AddressInfo } from 'node:net' @@ -56,6 +58,14 @@ if (printOnly) { process.exit(0) } +// Opt in to the same birth request as CLI/TUI creation; omit for legacy-home coverage. +const workspaces = process.env['OPENALICE_ONBOARDING_WORKSPACES'] +if (workspaces !== undefined) { + const home = env['OPENALICE_HOME']! + await mkdir(home, { recursive: true }) + await writeProjectWorkspaceRequest(home, parseProjectWorkspaces(workspaces)) +} + const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' const child = spawn(pnpm, ['dev'], { env, diff --git a/src/webui/plugin.ts b/src/webui/plugin.ts index 13550dd53..05e20de3b 100644 --- a/src/webui/plugin.ts +++ b/src/webui/plugin.ts @@ -1,3 +1,4 @@ +import { prepareProjectWorkspaces } from '../workspaces/project-workspace-setup.js' import { Hono, type Context } from 'hono' import { cors } from 'hono/cors' import { createAdaptorServer, serve } from '@hono/node-server' @@ -278,6 +279,12 @@ export class WebPlugin implements Plugin { : {}), inboxStore: ctx.inboxStore, }) + await prepareProjectWorkspaces(this.workspaceService, { + onProgress: (workspace, error) => { + if (error) console.warn(`[workspace setup] ${workspace}: ${error}. Retry from Quick Start or restart the project.`) + else console.log(`[workspace setup] Preparing ${workspace}…`) + }, + }).catch((error: unknown) => console.warn('[workspace setup] Could not read setup request:', error)) this.workspacesIpc = attachWorkspacesIpc(this.workspaceService) if (this.workspaceServiceRef) this.workspaceServiceRef.current = this.workspaceService app.route('/api/workspaces', createWorkspaceRoutes(this.workspaceService)) diff --git a/src/webui/routes/workspaces.ts b/src/webui/routes/workspaces.ts index f1c8471b4..a8ee5592b 100644 --- a/src/webui/routes/workspaces.ts +++ b/src/webui/routes/workspaces.ts @@ -1,3 +1,4 @@ +import { prepareProjectWorkspaces, readProjectWorkspaceSetup } from '../../workspaces/project-workspace-setup.js'; /** * Hono routes for the Workspaces feature, mounted at /api/workspaces. * @@ -1061,6 +1062,12 @@ export function createWorkspaceRoutes( } }); + app.get('/project-setup', async (c) => c.json(await readProjectWorkspaceSetup())); + app.post('/project-setup/retry', async (c) => { + await prepareProjectWorkspaces(svc); + return c.json(await readProjectWorkspaceSetup()); + }); + app.post('/chat/initialize', async (c) => { try { const preference = await quickChatPreferences.readQuickChatPreferences(); diff --git a/src/workspaces/project-workspace-setup.spec.ts b/src/workspaces/project-workspace-setup.spec.ts new file mode 100644 index 000000000..0df72b2fb --- /dev/null +++ b/src/workspaces/project-workspace-setup.spec.ts @@ -0,0 +1,50 @@ +import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, expect, it, vi } from 'vitest' +import { prepareProjectWorkspaces, readProjectWorkspaceSetup } from './project-workspace-setup.js' +import type { WorkspaceService } from './service.js' + +const roots: string[] = [] +afterEach(async () => { await Promise.all(roots.splice(0).map(home => rm(home, { recursive: true, force: true }))) }) +async function home(pending?: string[]) { + const dir = await mkdtemp(join(tmpdir(), 'project-setup-')); roots.push(dir) + if (pending) await writeFile(join(dir, 'workspace-setup.json'), JSON.stringify({ schemaVersion: 1, pending })) + return dir +} +function service() { + return { + resolveOrCreateChatWorkspace: vi.fn(async () => ({ ok: true, workspace: { id: 'chat-id' } })), + resolveOrCreateAutoQuantWorkspace: vi.fn(async () => ({ ok: true, workspace: { id: 'quant-id' } })), + resolveOrCreateAutoPredictionWorkspace: vi.fn(async () => ({ ok: true, workspace: { id: 'prediction-id' } })), + } +} +it('prepares only requested workspaces, saves defaults, and consumes once across concurrent retries', async () => { + const dir = await home(['chat', 'auto-quant']); const svc = service() + await Promise.all([prepareProjectWorkspaces(svc as unknown as WorkspaceService, { home: dir }), prepareProjectWorkspaces(svc as unknown as WorkspaceService, { home: dir })]) + expect(svc.resolveOrCreateChatWorkspace).toHaveBeenCalledTimes(1) + expect(svc.resolveOrCreateAutoPredictionWorkspace).not.toHaveBeenCalled() + expect((await readProjectWorkspaceSetup(dir)).pending).toEqual([]) + const prefs = JSON.parse(await readFile(join(dir, 'data/preferences.json'), 'utf8')) + expect(prefs.quickChat.recentChatWorkspaceId).toBe('chat-id') + expect(prefs.autoQuant.defaultWorkspaceId).toBe('quant-id') +}) +it('keeps failures retryable while preparing other workspaces', async () => { + const dir = await home(['chat', 'auto-quant', 'auto-prediction']); const svc = service() + svc.resolveOrCreateAutoQuantWorkspace.mockRejectedValueOnce(new Error('offline')) + await prepareProjectWorkspaces(svc as unknown as WorkspaceService, { home: dir }) + expect(await readProjectWorkspaceSetup(dir)).toMatchObject({ pending: ['auto-quant'], errors: { 'auto-quant': 'offline' } }) + await prepareProjectWorkspaces(svc as unknown as WorkspaceService, { home: dir }) + expect(svc.resolveOrCreateChatWorkspace).toHaveBeenCalledTimes(1) + expect(await readProjectWorkspaceSetup(dir)).toMatchObject({ pending: [], errors: {} }) +}) +it('leaves old and explicitly skipped homes alone', async () => { + const svc = service() + for (const dir of [await home(), await home([])]) await prepareProjectWorkspaces(svc as unknown as WorkspaceService, { home: dir }) + expect(svc.resolveOrCreateChatWorkspace).not.toHaveBeenCalled() +}) +it('rejects corrupt intent without creating workspaces', async () => { + const dir = await home(['unknown']); const svc = service() + await expect(prepareProjectWorkspaces(svc as unknown as WorkspaceService, { home: dir })).rejects.toThrow() + expect(svc.resolveOrCreateChatWorkspace).not.toHaveBeenCalled() +}) diff --git a/src/workspaces/project-workspace-setup.ts b/src/workspaces/project-workspace-setup.ts new file mode 100644 index 000000000..6dbacd51a --- /dev/null +++ b/src/workspaces/project-workspace-setup.ts @@ -0,0 +1,74 @@ +import { readFile, rename, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { z } from 'zod' +import { userDataHome } from '../core/paths.js' +import { + readQuickChatPreferences, readAutoQuantPreferences, readAutoPredictionPreferences, + rememberRecentChatWorkspace, rememberAutoQuantDefaultWorkspace, rememberAutoPredictionDefaultWorkspace, +} from '../core/preferences.js' +import type { WorkspaceService } from './service.js' + +const setupSchema = z.object({ + schemaVersion: z.literal(1), + pending: z.array(z.enum(['chat', 'auto-quant', 'auto-prediction'])), + errors: z.record(z.string(), z.string()).optional(), +}) + +/** Consumes an explicit project-birth request under the backend's writer lease. + * Success is checkpointed only after the canonical default preference is saved. + * Resolvers reuse an existing instance if a previous attempt was interrupted. + */ +async function prepareUnlocked( + service: Pick, + options: { home?: string; onProgress?: (workspace: string, error?: string) => void } = {}, +): Promise { + const home = options.home ?? userDataHome + const path = join(home, 'workspace-setup.json') + let text: string + try { text = await readFile(path, 'utf8') } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + const request = setupSchema.parse(JSON.parse(text)) + const preferences = join(home, 'data', 'preferences.json') + for (const kind of [...new Set(request.pending)]) { + options.onProgress?.(kind) + try { + const result = kind === 'chat' + ? await service.resolveOrCreateChatWorkspace((await readQuickChatPreferences(preferences)).recentChatWorkspaceId) + : kind === 'auto-quant' + ? await service.resolveOrCreateAutoQuantWorkspace((await readAutoQuantPreferences(preferences)).defaultWorkspaceId) + : await service.resolveOrCreateAutoPredictionWorkspace((await readAutoPredictionPreferences(preferences)).defaultWorkspaceId) + if (!result.ok) throw new Error(result.message) + if (kind === 'chat') await rememberRecentChatWorkspace(result.workspace.id, preferences) + else if (kind === 'auto-quant') await rememberAutoQuantDefaultWorkspace(result.workspace.id, preferences) + else await rememberAutoPredictionDefaultWorkspace(result.workspace.id, preferences) + request.pending = request.pending.filter(item => item !== kind) + if (request.errors) delete request.errors[kind] + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + request.errors = { ...request.errors, [kind]: message } + options.onProgress?.(kind, message) + } + const temp = `${path}.tmp` + await writeFile(temp, JSON.stringify(request, null, 2) + '\n', { mode: 0o600 }) + await rename(temp, path) + } +} + +const gates = new WeakMap>() +export function prepareProjectWorkspaces(...args: Parameters): Promise { + const [service] = args + const run = (gates.get(service) ?? Promise.resolve()).catch(() => {}).then(() => prepareUnlocked(...args)) + gates.set(service, run) + return run +} + +export async function readProjectWorkspaceSetup(home = userDataHome) { + try { + return setupSchema.parse(JSON.parse(await readFile(join(home, 'workspace-setup.json'), 'utf8'))) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { schemaVersion: 1, pending: [], errors: {} } + throw error + } +} diff --git a/ui/src/components/FirstRunGuide.tsx b/ui/src/components/FirstRunGuide.tsx index 20dd4ac4d..14fe6e251 100644 --- a/ui/src/components/FirstRunGuide.tsx +++ b/ui/src/components/FirstRunGuide.tsx @@ -1,3 +1,4 @@ +import { prepareFirstChatProvider } from '../hooks/prepareFirstChatProvider' import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { @@ -580,7 +581,7 @@ export function FirstRunGuide() { } return ( -
+
@@ -638,7 +639,15 @@ export function FirstRunGuide() { {activeStep.key === 'language' ? ( ) : activeStep.key === 'ai' ? ( - + <> + + {model.hasManagedPi && !model.hasUsableAiChain && primaryAction !== 'add-credential' && ( + + )} + ) : activeStep.key === 'broker' ? ( setShowCredentialForm(false)} - onSaved={async () => { - const nextState = await refreshGuideState() - // The credential is durable now; release the modal immediately. - // Runtime probes continue in the onboarding surface and update rows - // incrementally, so a slow unrelated CLI cannot trap the user in a - // saving dialog after another runtime is already usable. + onSaved={async (saved) => { + // The credential is already durable; never invite a second Save if binding fails. setShowCredentialForm(false) - const runtimeReadiness = await runRuntimeReadinessProbe() - const nextModel = buildFirstRunGuideModel({ - agents, - runtimeReadiness: runtimeReadiness ?? nextState.runtimeReadiness, - credentials: nextState.credentials, - tradingStatus: nextState.tradingStatus, - utas: nextState.utas, - loaded: true, - dismissed: false, - }) - if (activeStep.key === 'ai' && nextModel.hasUsableAiChain) { - goToStep(activeStepIndex + 1) + if (saved?.compatibleAgents.includes('pi') && model.hasManagedPi) { + try { await prepareFirstChatProvider(saved.slug, saved.model) } + catch (error) { + setRuntimeProbeError(error instanceof Error ? error.message : String(error)) + await refreshGuideState() + return + } } + await refreshGuideState() + // Stay on AI access so the user can see the confirmed result and continue. + // Advancing through this pre-probe closure would clamp to its stale readiness. + await runRuntimeReadinessProbe() }} /> )} diff --git a/ui/src/components/ProjectWorkspaceSetupNotice.tsx b/ui/src/components/ProjectWorkspaceSetupNotice.tsx new file mode 100644 index 000000000..48cc7433d --- /dev/null +++ b/ui/src/components/ProjectWorkspaceSetupNotice.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from 'react-i18next' +import { useProjectWorkspaceSetup } from '../hooks/useProjectWorkspaceSetup' +import { useWorkspaces } from '../contexts/workspaces-context' +import { Button } from './ui/button' + +const labels: Record = { chat: 'Chat', 'auto-quant': 'Auto Quant', 'auto-prediction': 'Auto Prediction' } +export function ProjectWorkspaceSetupNotice({ onPrepared }: { onPrepared: () => void }) { + const { t } = useTranslation() + const { setup, error, busy, retry } = useProjectWorkspaceSetup() + const { refresh } = useWorkspaces() + if (!error && !setup?.pending.length) return null + return ( +
+

{t('projectSetup.title')}

+

{t('projectSetup.description')}

+ {setup?.pending.map(kind =>

+ {labels[kind] ?? kind}: {setup.errors?.[kind] ?? t('projectSetup.pending')} +

)} + {error &&

{error}

} + +
+ ) +} diff --git a/ui/src/components/credentials/CredentialModal.tsx b/ui/src/components/credentials/CredentialModal.tsx index 260b39054..ac5221273 100644 --- a/ui/src/components/credentials/CredentialModal.tsx +++ b/ui/src/components/credentials/CredentialModal.tsx @@ -60,7 +60,7 @@ export function CredentialModal({ mode, cred, presets, agents, initialPresetId, initialPresetId?: string initialApiKey?: string onClose: () => void - onSaved: () => Promise + onSaved: (saved?: { slug: string; model: string; compatibleAgents: string[] }) => Promise }) { const { t } = useTranslation() // In edit mode the vendor is fixed, so resolve its preset and matching region. @@ -191,6 +191,7 @@ export function CredentialModal({ mode, cred, presets, agents, initialPresetId, setSaving(true) setError('') try { + let savedSlug = cred?.slug if (mode === 'edit' && cred) { await api.config.updateCredential(cred.slug, { vendor, @@ -203,7 +204,7 @@ export function CredentialModal({ mode, cred, presets, agents, initialPresetId, ...(model.trim() ? { lastModel: model.trim() } : {}), }) } else { - await api.config.addCredential({ + const created = await api.config.addCredential({ vendor, wires, ...(isDirect && directUrl.trim() ? { baseUrl: directUrl.trim() } : {}), @@ -211,9 +212,10 @@ export function CredentialModal({ mode, cred, presets, agents, initialPresetId, ...(label ? { label } : {}), ...(model.trim() ? { lastModel: model.trim() } : {}), }) + savedSlug = created.slug } window.dispatchEvent(new CustomEvent('openalice:credentials-changed')) - await onSaved() + await onSaved(savedSlug ? { slug: savedSlug, model: model.trim(), compatibleAgents } : undefined) } catch (err) { setError(err instanceof Error ? err.message : t('aiProvider.saveFailed')) setSaving(false) diff --git a/ui/src/demo/handlers/workspaces.ts b/ui/src/demo/handlers/workspaces.ts index 2fd45ff02..fba7eb3d1 100644 --- a/ui/src/demo/handlers/workspaces.ts +++ b/ui/src/demo/handlers/workspaces.ts @@ -365,6 +365,8 @@ export const workspacesHandlers = [ demoAutoQuantDefaultWorkspaceId = workspace.id return HttpResponse.json({ defaultWorkspaceId: workspace.id, ready: true }) }), + http.get('/api/workspaces/project-setup', () => HttpResponse.json({ schemaVersion: 1, pending: [], errors: {} })), + http.post('/api/workspaces/project-setup/retry', () => HttpResponse.json({ schemaVersion: 1, pending: [], errors: {} })), http.post('/api/workspaces/chat/initialize', () => { const workspace = demoWorkspaces.find((candidate) => candidate.template === 'chat') if (!workspace) { diff --git a/ui/src/hooks/prepareFirstChatProvider.spec.ts b/ui/src/hooks/prepareFirstChatProvider.spec.ts new file mode 100644 index 000000000..0a5011c30 --- /dev/null +++ b/ui/src/hooks/prepareFirstChatProvider.spec.ts @@ -0,0 +1,25 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { initializeChatWorkspace, updateWorkspaceRuntimeDefaults } from '../components/workspace/api' +import { prepareFirstChatProvider } from './prepareFirstChatProvider' +vi.mock('../components/workspace/api', () => ({ initializeChatWorkspace: vi.fn(), updateWorkspaceRuntimeDefaults: vi.fn() })) +beforeEach(() => vi.clearAllMocks()) +it('binds a chosen provider to fresh Chat without launching an Agent', async () => { + vi.mocked(initializeChatWorkspace).mockResolvedValue({ id: 'chat-1' } as never) + await prepareFirstChatProvider('mock-key', 'model-1') + expect(updateWorkspaceRuntimeDefaults).toHaveBeenCalledWith('chat-1', { + interactive: { defaultAgent: 'pi', agents: { pi: { accessMode: 'vault', credentialSlug: 'mock-key', model: 'model-1' } } }, + headless: { defaultAgent: null, agents: {} }, + }) +}) +it('preserves an existing explicit runtime choice', async () => { + vi.mocked(initializeChatWorkspace).mockResolvedValue({ id: 'chat-1', runtimeSettings: { + runtime: { interactive: { defaultAgent: 'codex', agents: {}, recent: {} } }, + } } as never) + await prepareFirstChatProvider('mock-key') + expect(updateWorkspaceRuntimeDefaults).not.toHaveBeenCalled() +}) +it('does not overwrite malformed workspace settings', async () => { + vi.mocked(initializeChatWorkspace).mockResolvedValue({ id: 'chat-1', runtimeSettingsError: 'invalid settings' } as never) + await expect(prepareFirstChatProvider('mock-key')).rejects.toThrow('invalid settings') + expect(updateWorkspaceRuntimeDefaults).not.toHaveBeenCalled() +}) diff --git a/ui/src/hooks/prepareFirstChatProvider.ts b/ui/src/hooks/prepareFirstChatProvider.ts new file mode 100644 index 000000000..e2cf47769 --- /dev/null +++ b/ui/src/hooks/prepareFirstChatProvider.ts @@ -0,0 +1,14 @@ +import { initializeChatWorkspace, updateWorkspaceRuntimeDefaults } from '../components/workspace/api' + +/** Apply an explicitly chosen onboarding provider only to an unconfigured Chat. */ +export async function prepareFirstChatProvider(credentialSlug: string, model?: string) { + const workspace = await initializeChatWorkspace() + const existing = workspace.runtimeSettings?.runtime + const interactive = existing?.interactive + if (workspace.runtimeSettingsError) throw new Error(workspace.runtimeSettingsError) + if (interactive?.defaultAgent || interactive?.recent.agent || Object.keys(interactive?.agents ?? {}).length > 0) return + await updateWorkspaceRuntimeDefaults(workspace.id, { + interactive: { defaultAgent: 'pi', agents: { pi: { accessMode: 'vault', credentialSlug, ...(model ? { model } : {}) } } }, + headless: { defaultAgent: existing?.headless.defaultAgent ?? null, agents: existing?.headless.agents ?? {} }, + }) +} diff --git a/ui/src/hooks/useProjectWorkspaceSetup.spec.ts b/ui/src/hooks/useProjectWorkspaceSetup.spec.ts new file mode 100644 index 000000000..b087003dd --- /dev/null +++ b/ui/src/hooks/useProjectWorkspaceSetup.spec.ts @@ -0,0 +1,23 @@ +// @vitest-environment jsdom +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, expect, it, vi } from 'vitest' +import { fetchJson } from '../api/client' +import { useProjectWorkspaceSetup } from './useProjectWorkspaceSetup' +vi.mock('../api/client', () => ({ fetchJson: vi.fn() })) +beforeEach(() => vi.mocked(fetchJson).mockReset()) +it('loads pending workspaces and retries through the backend', async () => { + vi.mocked(fetchJson).mockResolvedValueOnce({ pending: ['chat'], errors: { chat: 'offline' } }).mockResolvedValueOnce({ pending: [] }) + const { result } = renderHook(() => useProjectWorkspaceSetup()) + expect(result.current.setup).toBeNull() + await waitFor(() => expect(result.current.setup?.pending).toEqual(['chat'])) + await act(async () => { await result.current.retry() }) + expect(fetchJson).toHaveBeenLastCalledWith('/api/workspaces/project-setup/retry', { method: 'POST' }) + expect(result.current.setup?.pending).toEqual([]) +}) +it('exposes a read failure without pretending setup is complete', async () => { + vi.mocked(fetchJson).mockRejectedValueOnce(new Error('unavailable')) + const { result } = renderHook(() => useProjectWorkspaceSetup()) + await waitFor(() => expect(result.current.error).toBe('unavailable')) + expect(result.current.setup).toBeNull() + expect(result.current.busy).toBe(false) +}) diff --git a/ui/src/hooks/useProjectWorkspaceSetup.ts b/ui/src/hooks/useProjectWorkspaceSetup.ts new file mode 100644 index 000000000..02e4ccc27 --- /dev/null +++ b/ui/src/hooks/useProjectWorkspaceSetup.ts @@ -0,0 +1,27 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { fetchJson } from '../api/client' + +type Setup = { pending: string[]; errors?: Record } +export function useProjectWorkspaceSetup() { + const [setup, setSetup] = useState(null) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + const generation = useRef(0) + const load = useCallback(async (retry = false) => { + const request = ++generation.current + setBusy(true) + setError(null) + try { + const result = await fetchJson(`/api/workspaces/project-setup${retry ? '/retry' : ''}`, retry ? { method: 'POST' } : undefined) + if (request === generation.current) setSetup(result) + return result + } catch (cause) { + if (request === generation.current) setError(cause instanceof Error ? cause.message : String(cause)) + return null + } finally { + if (request === generation.current) setBusy(false) + } + }, []) + useEffect(() => { void load(); return () => { ++generation.current } }, [load]) + return { setup, error, busy, retry: () => load(true) } +} diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index d31332f24..41d1676c5 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -31,6 +31,7 @@ export const en = { noReadme: "There is no README.md in this Workspace yet. You can ask your agent to write an overview of its purpose and contents.", noGuide: "No Harness guide is available.", }, + projectSetup: { connectPi: 'Connect a provider for Chat with Pi', title: 'Some workspaces need attention', description: 'Your project is ready. Retry preparing the remaining workspaces, or continue with the ones already available.', pending: 'Waiting for setup', preparing: 'Preparing…' }, quickStart: { chooseHarness: 'Choose Harness' }, nav: { quickStart: 'Quick Start', diff --git a/ui/src/i18n/locales/ja.ts b/ui/src/i18n/locales/ja.ts index 1fe750da9..fed82c1c2 100644 --- a/ui/src/i18n/locales/ja.ts +++ b/ui/src/i18n/locales/ja.ts @@ -20,6 +20,7 @@ export const ja: Resources = { noReadme: "このワークスペースにはまだ README.md がありません。目的や内容の概要をエージェントに作成してもらえます。", noGuide: "Harness ガイドはありません。", }, + projectSetup: { connectPi: 'Chat 用に Pi の AI プロバイダーを設定', title: '準備が必要なワークスペースがあります', description: 'プロジェクトは作成済みです。残りの準備を再試行するか、準備済みのワークスペースを利用できます。', pending: '準備待ち', preparing: '準備中…' }, quickStart: { chooseHarness: 'Harness を選択' }, nav: { quickStart: 'Quick Start', diff --git a/ui/src/i18n/locales/zh-Hant.ts b/ui/src/i18n/locales/zh-Hant.ts index ef0f2b511..24948a698 100644 --- a/ui/src/i18n/locales/zh-Hant.ts +++ b/ui/src/i18n/locales/zh-Hant.ts @@ -28,6 +28,7 @@ export const zhHant: Resources = { noReadme: "此工作區還沒有 README.md。你可以讓 Agent 撰寫一份說明,介紹它的用途和內容。", noGuide: "暫無 Harness 指南。", }, + projectSetup: { connectPi: '為 Chat 設定 Pi 的 AI 提供方', title: '部分工作區尚未準備完成', description: '專案已建立。可以重試準備剩餘工作區,也可以先使用已就緒的工作區。', pending: '等待準備', preparing: '正在準備…' }, quickStart: { chooseHarness: '選擇 Harness' }, nav: { quickStart: 'Quick Start', diff --git a/ui/src/i18n/locales/zh.ts b/ui/src/i18n/locales/zh.ts index d837c401b..7445769ff 100644 --- a/ui/src/i18n/locales/zh.ts +++ b/ui/src/i18n/locales/zh.ts @@ -20,6 +20,7 @@ export const zh: Resources = { noReadme: "此工作区还没有 README.md。你可以让 Agent 撰写一份说明,介绍它的用途和内容。", noGuide: "暂无 Harness 指南。", }, + projectSetup: { connectPi: '为 Chat 配置 Pi 的 AI 提供方', title: '部分工作区尚未准备完成', description: '项目已创建。可以重试准备剩余工作区,也可以先使用已就绪的工作区。', pending: '等待准备', preparing: '正在准备…' }, quickStart: { chooseHarness: '选择 Harness' }, nav: { quickStart: 'Quick Start', diff --git a/ui/src/pages/QuickStartPage.spec.tsx b/ui/src/pages/QuickStartPage.spec.tsx index a904a6f69..da9a2fc1c 100644 --- a/ui/src/pages/QuickStartPage.spec.tsx +++ b/ui/src/pages/QuickStartPage.spec.tsx @@ -15,6 +15,7 @@ vi.mock('./ChatLandingPage', () => {
return { ChatLandingPage: page('Chat'), AutoQuantLandingPage: page('Auto Quant'), AutoPredictionLandingPage: page('Auto Prediction') } }) +vi.mock('../components/ProjectWorkspaceSetupNotice', () => ({ ProjectWorkspaceSetupNotice: () => null })) afterEach(cleanup) it('defaults to the shared Chat flow and keeps separate drafts when selecting Harnesses', async () => { diff --git a/ui/src/pages/QuickStartPage.tsx b/ui/src/pages/QuickStartPage.tsx index aeef3ac9d..02afa2926 100644 --- a/ui/src/pages/QuickStartPage.tsx +++ b/ui/src/pages/QuickStartPage.tsx @@ -1,3 +1,4 @@ +import { ProjectWorkspaceSetupNotice } from '../components/ProjectWorkspaceSetupNotice' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { Binary, ChevronDown, MessageSquare, Microscope } from 'lucide-react' @@ -16,6 +17,7 @@ type Harness = typeof HARNESS_CHOICES[number]['id'] /** A launch surface only: workspace readiness and Session creation stay in each Harness. */ export function QuickStartPage() { const { t } = useTranslation() + const [prepared, setPrepared] = useState(0) const [harness, setHarness] = useState('chat') const [drafts, setDrafts] = useState>({ chat: '', 'auto-quant': '', prediction: '' }) const selected = HARNESS_CHOICES.find(choice => choice.id === harness)! @@ -43,7 +45,8 @@ export function QuickStartPage() { )} /> - setPrepared(value => value + 1)} /> + setDrafts(previous => ({ ...previous, [harness]: prompt }))} /> )