From ca869561acefa764c9c002793e99c958e6eaf9a7 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:08:31 +0200 Subject: [PATCH] Voice-first streaming, non-blocking follow-ups and chat UX (#205) * media: check ffmpeg filter support before subtitle operations * sandbox: handle synchronous spawn errors safely in desktop sandbox * Voice-first streaming, non-blocking follow-ups, quiet routines and chat UX Why: questions blocked the chat, follow-up messages had no acknowledgement, message actions sat beside the bubble, and voice turn-taking added latency. What: waiting runs accept steering messages and the bot marks each claimed follow-up as seen; unclaimed follow-ups show as queued. Reaction, copy and reply controls sit under each message. Streaming voice (VAD endpointing, barge-in that stops the run, clause-based TTS), an optimistic input queue, procedural earcons and delta-based quiet routines were added. Onboarding can be skipped straight to the main conversation. Dual-surface layout and project pickers are included. Status colors use semantic tokens. Tested: unit suites for core, adapters, api and web pass, typecheck and Biome are clean. Browser end-to-end tests were not run locally. * Update translation catalogs Re-extracted catalogs for the new and moved strings; the catalog sync test passes. * Add project plan and test readiness notes Notes from the milestone work: feature inventory, test infrastructure and test readiness. No code changes. * fix(ci): align scale guards, space deletion, computer toggle, and mobile expo deps * fix(ci): restore space deletion guard, browser toggle, and run-steps contracts * fix(ci): restore model resolution errors, group stage, and onboarding flow * fix(ci): restore in-app-browser close button, disable stop button while sending, and sync lingui catalogs * fix(ci): address web e2e failures for bot-crud, choice cards, message quote, and voice onboarding * test(web): wait for avatar and rig visibility in oo-avatar motion spec * test(web): wait for persisted message IDs in message-hover-actions pagination spec * Onboarding: always drop the local voice when the voice step is skipped Skipping waited only for the latest voice load and disconnected only when the client saw the connect succeed. An earlier or client-failed connect could still persist the local credential, so a user who skipped voice was left with one. Wait for every load and always disconnect. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs * Shell: hide the optimistic copy once the transcript shows the send A message sent while the bot works streamed into the transcript before its send request resolved, so the same text showed twice until the optimistic bubble was removed. Drop an optimistic message as soon as a persisted user message with the same text appears. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs --------- Co-authored-by: Claude --- PROJECT.md | 114 + TEST_INFRA.md | 135 ++ TEST_READY.md | 123 ++ apps/api/src/project-directories.test.ts | 43 + apps/api/src/project-directories.ts | 95 + apps/api/src/project-github.test.ts | 114 + apps/api/src/project-github.ts | 200 ++ apps/api/src/router.ts | 125 +- apps/api/src/thread-target.test.ts | 40 +- apps/api/src/thread-target.ts | 10 +- apps/api/src/voice.test.ts | 25 +- apps/api/src/voice.ts | 109 +- apps/mobile/lib/sound.ts | 69 + apps/web/e2e/message-hover-actions.spec.ts | 46 +- apps/web/e2e/message-quote.spec.ts | 2 +- apps/web/e2e/oo-avatar.spec.ts | 5 +- apps/web/package.json | 1 + apps/web/src/components/AskCard.tsx | 66 +- .../web/src/components/DualSurfaceDivider.tsx | 91 + .../components/FolderPickerDialog.test.tsx | 108 + .../web/src/components/FolderPickerDialog.tsx | 332 +++ .../src/components/GithubRepoPicker.test.tsx | 117 + apps/web/src/components/GithubRepoPicker.tsx | 305 +++ .../components/MessageHoverMetadata.test.tsx | 26 +- .../src/components/MessageHoverMetadata.tsx | 4 +- .../src/components/ai/DelegatedTaskCard.tsx | 39 +- .../src/components/browser/InAppBrowser.tsx | 22 +- .../components/browser/OoComputerBrowser.tsx | 5 +- .../onboarding/OnboardingVoiceStep.tsx | 13 +- .../hooks/useDualSurfaceLayout.stress.test.ts | 578 +++++ .../src/hooks/useDualSurfaceLayout.test.ts | 401 ++++ apps/web/src/hooks/useDualSurfaceLayout.ts | 152 ++ .../src/hooks/useDualSurfaceResize.test.ts | 650 ++++++ apps/web/src/hooks/useDualSurfaceResize.ts | 300 +++ apps/web/src/lib/agent-activity.ts | 34 +- .../web/src/lib/browser-notifications.test.ts | 30 + apps/web/src/lib/browser-notifications.ts | 4 + apps/web/src/lib/call-view-voice.test.ts | 35 +- apps/web/src/lib/call-view-voice.ts | 20 +- apps/web/src/lib/desktop.test.ts | 4 +- apps/web/src/lib/dictation.ts | 168 +- .../src/lib/dual-surface-adversarial.test.ts | 567 +++++ apps/web/src/lib/dual-surface-pref.test.ts | 351 +++ apps/web/src/lib/dual-surface-pref.ts | 147 ++ apps/web/src/lib/oo-sound.test.ts | 16 +- apps/web/src/lib/oo-sound.ts | 84 +- apps/web/src/lib/queued-follow-ups.test.ts | 40 + apps/web/src/lib/queued-follow-ups.ts | 25 + apps/web/src/lib/tts.test.ts | 31 +- apps/web/src/lib/tts.ts | 196 +- apps/web/src/locales/de/messages.po | 338 ++- apps/web/src/locales/en/messages.po | 338 ++- apps/web/src/locales/es/messages.po | 336 ++- apps/web/src/locales/hi/messages.po | 336 ++- apps/web/src/locales/ko/messages.po | 336 ++- apps/web/src/locales/pt-BR/messages.po | 336 ++- apps/web/src/locales/ru/messages.po | 340 ++- apps/web/src/locales/tr/messages.po | 336 ++- apps/web/src/locales/zh-CN/messages.po | 336 ++- apps/web/src/pages/CallView.tsx | 139 +- apps/web/src/pages/Shell.tsx | 1936 +++++++++++------ apps/web/src/pages/shell/composer.tsx | 2 +- .../pages/shell/computer-panel-preview.tsx | 2 + apps/web/src/pages/shell/computer-screen.tsx | 11 +- apps/web/src/pages/shell/dialogs.tsx | 370 +++- .../shell/dual-surface-header-controls.tsx | 96 + .../src/pages/shell/project-look-dialog.tsx | 300 ++- apps/web/src/pages/shell/project-overview.tsx | 153 +- apps/web/src/pages/shell/roster-format.ts | 4 + apps/web/src/pages/shell/roster-rows.tsx | 32 +- apps/web/src/pages/shell/run-steps.tsx | 468 +++- .../web/src/pages/shell/side-panel-header.tsx | 194 +- .../pages/shell/sidebar-toggle-buttons.tsx | 10 +- .../src/pages/shell/transcript-status.test.ts | 87 + apps/web/src/pages/shell/transcript-status.ts | 35 + apps/web/src/pages/shell/transcript.tsx | 260 ++- apps/web/src/pages/shell/use-sidebar-model.ts | 8 +- apps/web/src/pages/shell/workspace-files.tsx | 246 +++ .../src/tests/barge-in-r3-challenge.test.tsx | 844 +++++++ apps/web/src/tests/barge-in-stress.test.tsx | 289 +++ ...face-divider-controls-adversarial.test.tsx | 642 ++++++ ...l-surface-empirical-challenger-m4.test.tsx | 753 +++++++ apps/web/src/tests/dual-surface-view.test.tsx | 598 +++++ .../e2e-autonomous-agency-queues.test.ts | 451 ++++ .../tests/e2e-cross-feature-workflows.test.ts | 402 ++++ .../tests/e2e-minimalist-tactile-ux.test.tsx | 786 +++++++ .../tests/e2e-voice-first-foundation.test.ts | 689 ++++++ .../src/tests/fixtures/agency-contracts.ts | 48 + apps/web/src/tests/fixtures/ux-contracts.ts | 36 + .../nested-fences-voice-challenge.test.tsx | 503 +++++ ...l-responsive-contract.adversarial.test.tsx | 731 +++++++ ...ell-surface-adversarial-challenge.test.tsx | 999 +++++++++ .../useDualSurfaceResize.adversarial.test.tsx | 917 ++++++++ packages/adapter-kit/src/types.ts | 1 + packages/adapters/src/desktop-sandbox.ts | 16 +- .../src/executor-instructions.test.ts | 2 +- packages/adapters/src/executor-prompt.test.ts | 20 + packages/adapters/src/executor-prompt.ts | 7 +- .../adapters/src/executor-routine.test.ts | 260 +++ packages/adapters/src/executor-routine.ts | 173 ++ packages/adapters/src/executor.ts | 142 +- packages/adapters/src/job-reconciler.ts | 67 +- .../adapters/src/lazy-tool-catalog.test.ts | 15 +- packages/adapters/src/lazy-tool-catalog.ts | 10 +- packages/adapters/src/media/ffmpeg.ts | 25 + .../src/media/video-operations.test.ts | 62 +- .../adapters/src/media/video-operations.ts | 13 +- packages/adapters/src/model-selection.test.ts | 12 + packages/adapters/src/model-selection.ts | 4 + packages/adapters/src/pi-local-provider.ts | 20 +- packages/adapters/src/pi-model-config.ts | 68 +- .../src/pi-runtime-bargein-stress.test.ts | 197 ++ ...ime-midstep-multi-tool-adversarial.test.ts | 951 ++++++++ ...pi-runtime-midstep-steering-stress.test.ts | 1186 ++++++++++ .../src/pi-runtime-tool-dispatch.test.ts | 39 + packages/adapters/src/pi-runtime.test.ts | 39 +- packages/adapters/src/pi-runtime.ts | 269 ++- ...remediation-challenge-r2.challenge.test.ts | 632 ++++++ ...self-healing-adversarial.challenge.test.ts | 828 +++++++ packages/adapters/src/user-progress.test.ts | 8 + packages/adapters/src/user-progress.ts | 13 +- packages/contracts/src/domain.ts | 11 +- packages/contracts/src/reactions.ts | 2 +- packages/contracts/src/rpc.ts | 58 +- packages/core/package.json | 1 + ...ion-and-optimistic-queue.challenge.test.ts | 1201 ++++++++++ packages/core/src/action-queue.test.ts | 336 +++ packages/core/src/action-queue.ts | 314 +++ packages/core/src/index.ts | 7 + packages/core/src/message-reactions.test.ts | 4 +- packages/core/src/message-reactions.ts | 6 +- packages/core/src/optimistic-queue.test.ts | 106 + packages/core/src/optimistic-queue.ts | 104 + packages/core/src/routine-policy.test.ts | 265 +++ packages/core/src/routine-policy.ts | 662 ++++++ packages/core/src/routine-runner.test.ts | 127 ++ packages/core/src/routine-runner.ts | 235 ++ .../src/sound-and-routine.challenge.test.ts | 1047 +++++++++ packages/core/src/sound-synthesizer.ts | 130 ++ packages/core/src/sound.test.ts | 172 ++ packages/core/src/sound.ts | 454 ++++ packages/core/src/speech-text.test.ts | 36 +- packages/core/src/speech-text.ts | 11 +- .../src/streaming-voice.challenge.test.ts | 789 +++++++ packages/core/src/streaming-voice.test.ts | 222 ++ packages/core/src/streaming-voice.ts | 337 +++ packages/db/src/events.ts | 76 +- packages/db/src/project-workspace.ts | 33 +- packages/db/src/spaces.ts | 20 +- packages/db/src/transaction-retry.ts | 3 + 150 files changed, 32500 insertions(+), 1638 deletions(-) create mode 100644 PROJECT.md create mode 100644 TEST_INFRA.md create mode 100644 TEST_READY.md create mode 100644 apps/api/src/project-directories.test.ts create mode 100644 apps/api/src/project-directories.ts create mode 100644 apps/api/src/project-github.test.ts create mode 100644 apps/api/src/project-github.ts create mode 100644 apps/mobile/lib/sound.ts create mode 100644 apps/web/src/components/DualSurfaceDivider.tsx create mode 100644 apps/web/src/components/FolderPickerDialog.test.tsx create mode 100644 apps/web/src/components/FolderPickerDialog.tsx create mode 100644 apps/web/src/components/GithubRepoPicker.test.tsx create mode 100644 apps/web/src/components/GithubRepoPicker.tsx create mode 100644 apps/web/src/hooks/useDualSurfaceLayout.stress.test.ts create mode 100644 apps/web/src/hooks/useDualSurfaceLayout.test.ts create mode 100644 apps/web/src/hooks/useDualSurfaceLayout.ts create mode 100644 apps/web/src/hooks/useDualSurfaceResize.test.ts create mode 100644 apps/web/src/hooks/useDualSurfaceResize.ts create mode 100644 apps/web/src/lib/dual-surface-adversarial.test.ts create mode 100644 apps/web/src/lib/dual-surface-pref.test.ts create mode 100644 apps/web/src/lib/dual-surface-pref.ts create mode 100644 apps/web/src/lib/queued-follow-ups.test.ts create mode 100644 apps/web/src/lib/queued-follow-ups.ts create mode 100644 apps/web/src/pages/shell/dual-surface-header-controls.tsx create mode 100644 apps/web/src/pages/shell/transcript-status.test.ts create mode 100644 apps/web/src/pages/shell/transcript-status.ts create mode 100644 apps/web/src/pages/shell/workspace-files.tsx create mode 100644 apps/web/src/tests/barge-in-r3-challenge.test.tsx create mode 100644 apps/web/src/tests/barge-in-stress.test.tsx create mode 100644 apps/web/src/tests/dual-surface-divider-controls-adversarial.test.tsx create mode 100644 apps/web/src/tests/dual-surface-empirical-challenger-m4.test.tsx create mode 100644 apps/web/src/tests/dual-surface-view.test.tsx create mode 100644 apps/web/src/tests/e2e-autonomous-agency-queues.test.ts create mode 100644 apps/web/src/tests/e2e-cross-feature-workflows.test.ts create mode 100644 apps/web/src/tests/e2e-minimalist-tactile-ux.test.tsx create mode 100644 apps/web/src/tests/e2e-voice-first-foundation.test.ts create mode 100644 apps/web/src/tests/fixtures/agency-contracts.ts create mode 100644 apps/web/src/tests/fixtures/ux-contracts.ts create mode 100644 apps/web/src/tests/nested-fences-voice-challenge.test.tsx create mode 100644 apps/web/src/tests/shell-responsive-contract.adversarial.test.tsx create mode 100644 apps/web/src/tests/shell-surface-adversarial-challenge.test.tsx create mode 100644 apps/web/src/tests/useDualSurfaceResize.adversarial.test.tsx create mode 100644 packages/adapters/src/executor-routine.test.ts create mode 100644 packages/adapters/src/executor-routine.ts create mode 100644 packages/adapters/src/pi-runtime-bargein-stress.test.ts create mode 100644 packages/adapters/src/pi-runtime-midstep-multi-tool-adversarial.test.ts create mode 100644 packages/adapters/src/pi-runtime-midstep-steering-stress.test.ts create mode 100644 packages/adapters/src/routine-remediation-challenge-r2.challenge.test.ts create mode 100644 packages/adapters/src/routine-self-healing-adversarial.challenge.test.ts create mode 100644 packages/core/src/action-and-optimistic-queue.challenge.test.ts create mode 100644 packages/core/src/action-queue.test.ts create mode 100644 packages/core/src/action-queue.ts create mode 100644 packages/core/src/optimistic-queue.test.ts create mode 100644 packages/core/src/optimistic-queue.ts create mode 100644 packages/core/src/routine-policy.test.ts create mode 100644 packages/core/src/routine-policy.ts create mode 100644 packages/core/src/routine-runner.test.ts create mode 100644 packages/core/src/routine-runner.ts create mode 100644 packages/core/src/sound-and-routine.challenge.test.ts create mode 100644 packages/core/src/sound-synthesizer.ts create mode 100644 packages/core/src/sound.test.ts create mode 100644 packages/core/src/sound.ts create mode 100644 packages/core/src/streaming-voice.challenge.test.ts create mode 100644 packages/core/src/streaming-voice.test.ts create mode 100644 packages/core/src/streaming-voice.ts diff --git a/PROJECT.md b/PROJECT.md new file mode 100644 index 0000000..510ecbf --- /dev/null +++ b/PROJECT.md @@ -0,0 +1,114 @@ +# Project: Dual-Surface-View in evOo (`apps/web`) + +## Architecture +evOo (`apps/web`) organizes the workspace into two primary surfaces: +1. **Mission Stream**: The chat, agent transcript (`data-testid="transcript"`), run steps (`data-testid="run-steps"`), and composer inside `
`. +2. **Living Canvas**: The computer surface (`data-testid="computer-preview"`), workspace files explorer (`WorkspaceFilesView`), code inspector, in-app browser, and artifact viewer inside `
+ ); - + ); + })()} {/* Centered Create Bot Modal */} {showCreateBotModal && createPortal( @@ -4117,7 +4599,7 @@ export function ShellPage() { {spaceMenu ? ( {spaces.some( @@ -4194,7 +4676,10 @@ export function ShellPage() { item={deleteSpaceTarget} noun="space" description={ - Only empty spaces can be deleted. Delete its bots and groups first. + + This will permanently delete this project and all its conversations, bots, and + memory. + } onCancel={() => setDeleteSpaceTarget(null)} onConfirm={async () => { @@ -4285,21 +4770,50 @@ export function ShellPage() { variant={newSpaceDialogVariant} canLinkFolder={bootstrapMe?.isDeploymentOwner === true} onCancel={() => setNewSpaceOpen(false)} - onConfirm={async ({ name, workspacePath }) => { - const space = await rpc.spaces.create({ - name, - ...(workspacePath ? { workspacePath } : {}), - }); + onConfirm={async (input) => { + const space = await rpc.spaces.create(input); setNewSpaceOpen(false); if (!selectSpace(space.id)) { await refreshBots(); return; } + if (newSpaceDialogVariant === "project") { + try { + const main = await rpc.spaces.ensureMain({ spaceId: space.id }); + if (input.goal?.trim()) { + await rpc.threads.send({ + groupId: main.groupId, + text: input.goal.trim(), + }); + } + window.location.assign(`/app/g/${main.groupId}`); + return; + } catch { + // Fallback to /app below + } + } window.location.assign("/app"); }} /> ) : null} + {newInquiryOpen && activeGroup ? ( + s.id === activeGroup.spaceId)?.name ?? activeGroup.name} + onCancel={() => setNewInquiryOpen(false)} + onConfirm={async (inquiryName) => { + const coordinatorBotId = + activeSnapshot?.leadBotId ?? + bots.find((b) => b.kind === "main" && b.spaceId === activeGroup.spaceId)?.id ?? + bots[0]?.id; + if (coordinatorBotId) { + await createGroup({ name: inquiryName, botIds: [coordinatorBotId] }); + } + setNewInquiryOpen(false); + }} + /> + ) : null} + {pickerInfoTopic ? ( setPickerInfoTopic(null)} /> ) : null} diff --git a/apps/web/src/pages/shell/composer.tsx b/apps/web/src/pages/shell/composer.tsx index 9275100..33c7379 100644 --- a/apps/web/src/pages/shell/composer.tsx +++ b/apps/web/src/pages/shell/composer.tsx @@ -783,7 +783,7 @@ export const Composer = memo(function Composer({ variant="outline" size="icon" aria-label={t`Stop`} - disabled={sending} + disabled={disabled || sending} onClick={() => void onStop()} className="tactile-press size-10 rounded-full border border-border bg-foreground/[0.08] text-foreground/90 shadow-sm transition-colors hover:bg-foreground/15 hover:text-foreground flex items-center justify-center cursor-pointer" > diff --git a/apps/web/src/pages/shell/computer-panel-preview.tsx b/apps/web/src/pages/shell/computer-panel-preview.tsx index c591690..39787de 100644 --- a/apps/web/src/pages/shell/computer-panel-preview.tsx +++ b/apps/web/src/pages/shell/computer-panel-preview.tsx @@ -13,6 +13,7 @@ import { DesktopKindEmptyState, screenIframeSandbox, } from "./computer-screen"; +import { WorkspaceFilesView } from "./workspace-files"; /** The bot's screen in the side panel: a live, non-interactive preview that opens full window. */ export function ComputerPanelPreview({ @@ -98,6 +99,7 @@ export function ComputerPanelPreview({

{t`${active.name}'s screen`}

+ ); } diff --git a/apps/web/src/pages/shell/computer-screen.tsx b/apps/web/src/pages/shell/computer-screen.tsx index 9f8990c..7d9e2f1 100644 --- a/apps/web/src/pages/shell/computer-screen.tsx +++ b/apps/web/src/pages/shell/computer-screen.tsx @@ -58,10 +58,13 @@ export function screenIframeSandbox(url: string | null) { export function DesktopKindEmptyState({ className }: { className?: string }) { return ( -
- - This bot runs on this computer, not a Linux desktop. Shell and files use your home folder. - +
+ + macOS Native Sandbox + +

+ Fast, direct host execution with native shell, git, and local tools. +

); } diff --git a/apps/web/src/pages/shell/dialogs.tsx b/apps/web/src/pages/shell/dialogs.tsx index 3542790..c3569c4 100644 --- a/apps/web/src/pages/shell/dialogs.tsx +++ b/apps/web/src/pages/shell/dialogs.tsx @@ -1,5 +1,6 @@ import { Trans, useLingui } from "@lingui/react/macro"; import type { Bot, BotSection } from "@rakazo/contracts"; +import { PROJECT_COLORS, PROJECT_ICONS, projectLook } from "@rakazo/core"; import { AlertDialog, AlertDialogAction, @@ -17,9 +18,13 @@ import { DialogHeader, DialogTitle, Input, + Textarea, } from "@rakazo/ui-web"; -import { FolderOpen, Lock, Users } from "lucide-react"; +import { FolderOpen, GitBranch, Lock, Plus, Target, Users, X } from "lucide-react"; import { useId, useState } from "react"; +import { FolderPickerDialog } from "../../components/FolderPickerDialog"; +import { GithubRepoPicker } from "../../components/GithubRepoPicker"; +import { PROJECT_ICON_COMPONENTS, ProjectIcon } from "../../components/project-icon"; import { desktopBridge } from "../../lib/desktop"; /** Each dialog is mounted only while open, so `open` is always true and the @@ -33,7 +38,11 @@ function closeUnlessBusy(busy: boolean, onCancel: () => void) { export type NewSpaceConfirmInput = { name: string; + icon?: string; + color?: string; workspacePath?: string; + gitRemoteUrl?: string; + goal?: string; }; export function NewSpaceDialog({ @@ -51,14 +60,25 @@ export function NewSpaceDialog({ const { t } = useLingui(); const nameId = useId(); const folderId = useId(); + const gitId = useId(); + const goalId = useId(); + const isProject = variant === "project"; + const start = projectLook({}); + const [name, setName] = useState(""); + const [icon, setIcon] = useState(start.icon.id); + const [color, setColor] = useState(start.color.id); const [workspacePath, setWorkspacePath] = useState(""); + const [folderPickerOpen, setFolderPickerOpen] = useState(false); + const [gitRemoteUrl, setGitRemoteUrl] = useState(""); + const [goal, setGoal] = useState(""); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - const isProject = variant === "project"; const showFolder = isProject && canLinkFolder; const desktop = desktopBridge(); const trimmedWorkspacePath = workspacePath.trim(); + const trimmedGit = gitRemoteUrl.trim(); + const trimmedGoal = goal.trim(); const create = () => { const trimmed = name.trim(); @@ -67,7 +87,10 @@ export function NewSpaceDialog({ setError(null); void onConfirm({ name: trimmed, + ...(isProject ? { icon, color } : {}), ...(showFolder && trimmedWorkspacePath ? { workspacePath: trimmedWorkspacePath } : {}), + ...(isProject && trimmedGit ? { gitRemoteUrl: trimmedGit } : {}), + ...(isProject && trimmedGoal ? { goal: trimmedGoal } : {}), }).catch((reason: unknown) => { setError( reason instanceof Error @@ -80,97 +103,292 @@ export function NewSpaceDialog({ }); }; + return ( + <> + + + + + {isProject ? ( + <> + + + New project + + + ) : ( + <> + + + +
+ + + {isProject ? ( + <> +
+ + Color + +
+ {PROJECT_COLORS.map((option) => ( +
+
+ +
+ + Icon + +
+ {PROJECT_ICONS.map((option) => { + const Glyph = PROJECT_ICON_COMPONENTS[option.id]; + return ( + + ); + })} +
+
+ + {showFolder ? ( +
+ +

+ + Choose an existing folder on this computer. Git remotes are linked + automatically when a repository is present. + +

+
+ ) : null} + + {isProject ? ( + { + setGitRemoteUrl(url); + if (repo?.name && !name.trim()) { + setName(repo.name); + } + }} + onSuggestProjectName={(suggestedName) => { + if (!name.trim()) setName(suggestedName); + }} + onSuggestWorkspacePath={(suggestedPath) => { + if (!workspacePath.trim()) setWorkspacePath(suggestedPath); + }} + disabled={saving} + /> + ) : null} + +