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} + +