From 4e853100c88c2937659f4edc41efc1cbf8c096b4 Mon Sep 17 00:00:00 2001 From: Francisco Pizarro Date: Thu, 6 Aug 2026 20:13:59 -0400 Subject: [PATCH 1/3] feat(ui): redesign entry surfaces + remote machine --- packages/ui/src/components/WindowSideBar.tsx | 2 +- .../ui/src/components/WorkspaceSelector.tsx | 204 ++++--- .../ui/src/components/chat/ChatStatusBar.tsx | 2 +- .../ui/src/components/chat/ChatTopBar.tsx | 18 +- .../message/ImageActionContextMenu.tsx | 4 +- .../message/MessageItemAssistant.tsx | 14 +- .../workspace/RemoteWorkspaceSetup.tsx | 507 +++++++++--------- .../workspace/WorkspaceFileNode.tsx | 6 +- .../workspace/WorkspaceSelectorDialogs.tsx | 46 +- packages/ui/src/pages/AgentWelcomePage.tsx | 115 ++-- packages/ui/src/pages/NewThreadPage.tsx | 156 +++--- packages/ui/src/pages/WelcomePage.tsx | 465 ++++++---------- packages/ui/src/routes/_main.tsx | 2 + packages/ui/src/stores/ui/remoteSetup.ts | 75 +++ 14 files changed, 810 insertions(+), 806 deletions(-) create mode 100644 packages/ui/src/stores/ui/remoteSetup.ts diff --git a/packages/ui/src/components/WindowSideBar.tsx b/packages/ui/src/components/WindowSideBar.tsx index 8b6e1c72b..d554fb9f7 100644 --- a/packages/ui/src/components/WindowSideBar.tsx +++ b/packages/ui/src/components/WindowSideBar.tsx @@ -312,7 +312,7 @@ export default function WindowSideBar() { <>
diff --git a/packages/ui/src/components/WorkspaceSelector.tsx b/packages/ui/src/components/WorkspaceSelector.tsx index 92ee36939..233f9d15a 100644 --- a/packages/ui/src/components/WorkspaceSelector.tsx +++ b/packages/ui/src/components/WorkspaceSelector.tsx @@ -1,8 +1,9 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Icon } from "@iconify/react"; import { DropdownMenu, DropdownMenuContent, + DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, @@ -12,13 +13,13 @@ import { DropdownMenuTrigger, } from "#shadcn/components/ui/dropdown-menu"; import { - AddRemoteMachineDialog, EditMachineDialog, type MachineEdit, type WorkspaceDraft, } from "#/components/workspace/WorkspaceSelectorDialogs"; import { getHasActiveSession } from "#/stores/ui/session"; import { useWorkspaceStore, type WorkspaceEntry } from "#/stores/ui/workspace"; +import { useRemoteSetupStore } from "#/stores/ui/remoteSetup"; function deriveConnectionStatus( entry: WorkspaceEntry, @@ -83,7 +84,7 @@ function RemoteMachineActionItems({ { + onClick={(event) => { event.stopPropagation(); void Promise.resolve() .then(action.run) @@ -144,7 +145,7 @@ async function copyMachineDiagnostics(workspace: WorkspaceEntry): Promise export default function WorkspaceSelector() { const store = useWorkspaceStore(); - const [addDialogOpen, setAddDialogOpen] = useState(false); + const remoteSetup = useRemoteSetupStore(); const [recoveryWorkspace, setRecoveryWorkspace] = useState(null); const [machineOperationStatus, setMachineOperationStatus] = useState(""); const [editMachine, setEditMachine] = useState(null); @@ -155,30 +156,7 @@ export default function WorkspaceSelector() { .filter((workspace) => workspace.mode === "remote") .map((workspace) => workspace.remoteUrl); - const handleSwitch = async (id: string) => { - if (id === store.activeWorkspaceId) return; - const target = store.getWorkspace(id); - if ( - target?.mode === "remote" && - (target.trustState === "pairing-required" || target.trustState === "identity-changed") - ) { - setRecoveryWorkspace(target); - setAddDialogOpen(true); - return; - } - if ( - target && - getHasActiveSession() && - !window.confirm( - `Switch active machine to ${target.name}? Your current chat stays on its current machine and will not be moved.`, - ) - ) { - return; - } - await store.switchWorkspace(id); - }; - - const saveWorkspace = async (workspace: WorkspaceDraft) => { + const saveWorkspaceInternal = async (workspace: WorkspaceDraft) => { const existingByIdentity = workspace.environmentId ? workspaces.find( (candidate) => candidate.mode === "remote" && candidate.environmentId === workspace.environmentId, @@ -218,14 +196,49 @@ export default function WorkspaceSelector() { }; const handleSave = async (workspace: WorkspaceDraft) => { - await saveWorkspace(workspace); - setAddDialogOpen(false); + await saveWorkspaceInternal(workspace); + setRecoveryWorkspace(null); }; const handleSaveAndSwitch = async (workspace: WorkspaceDraft) => { - const id = await saveWorkspace(workspace); + const id = await saveWorkspaceInternal(workspace); + await store.switchWorkspace(id); + setRecoveryWorkspace(null); + }; + + useEffect(() => { + remoteSetup.registerHandlers({ + remoteUrls, + onSave: handleSave, + onSaveAndSwitch: handleSaveAndSwitch, + }); + return () => { + remoteSetup.clearHandlers(); + }; + // handleSave/handleSaveAndSwitch close over `store` and `workspaces`; re-register when workspaces change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspaces, store]); + + const handleSwitch = async (id: string) => { + if (id === store.activeWorkspaceId) return; + const target = store.getWorkspace(id); + if ( + target?.mode === "remote" && + (target.trustState === "pairing-required" || target.trustState === "identity-changed") + ) { + remoteSetup.openRemoteDialog(target); + return; + } + if ( + target && + getHasActiveSession() && + !window.confirm( + `Switch active machine to ${target.name}? Your current chat stays on its current machine and will not be moved.`, + ) + ) { + return; + } await store.switchWorkspace(id); - setAddDialogOpen(false); }; const handleRemove = async (workspace: WorkspaceEntry) => { @@ -283,8 +296,7 @@ export default function WorkspaceSelector() { }; const handlePairAgain = (workspace: WorkspaceEntry) => { - setRecoveryWorkspace(workspace); - setAddDialogOpen(true); + remoteSetup.openRemoteDialog(workspace); }; const handleEditAddress = async (workspace: WorkspaceEntry) => { @@ -352,58 +364,63 @@ export default function WorkspaceSelector() { - Machines - - {workspaces.map((ws) => { - const isActive = ws.id === store.activeWorkspaceId; - const status = deriveConnectionStatus(ws, store.connections); - if (ws.mode === "remote") { + + Machines + + {workspaces.map((ws) => { + const isActive = ws.id === store.activeWorkspaceId; + const status = deriveConnectionStatus(ws, store.connections); + if (ws.mode === "remote") { + return ( + + + + + {ws.name} ({status}) + {(ws.trustState === "pairing-required" || ws.trustState === "identity-changed") && ( + + ({ws.trustState === "identity-changed" ? "identity changed" : "pair again"}) + + )} + + + + void handleSwitch(ws.id)}> + + {isActive ? "Active machine" : "Switch to machine"} + + + window.argos?.workspace?.switchTo(ws.id)} + onRename={() => handleRename(ws)} + onPairAgain={() => handlePairAgain(ws)} + onEditAddress={() => handleEditAddress(ws)} + onCopyDiagnostics={() => copyMachineDiagnostics(ws)} + onRemove={() => handleRemove(ws)} + /> + + + ); + } return ( - - - - - {ws.name} ({status}) - {(ws.trustState === "pairing-required" || ws.trustState === "identity-changed") && ( - - ({ws.trustState === "identity-changed" ? "identity changed" : "pair again"}) - - )} - - - - void handleSwitch(ws.id)}> - - {isActive ? "Active machine" : "Switch to machine"} - - - window.argos?.workspace?.switchTo(ws.id)} - onRename={() => handleRename(ws)} - onPairAgain={() => handlePairAgain(ws)} - onEditAddress={() => handleEditAddress(ws)} - onCopyDiagnostics={() => copyMachineDiagnostics(ws)} - onRemove={() => handleRemove(ws)} - /> - - + void handleSwitch(ws.id)} + > + + {ws.name} + {isActive && } + ); - } - return ( - void handleSwitch(ws.id)} - > - - {ws.name} - {isActive && } - - ); - })} + })} + - setAddDialogOpen(true)}> + remoteSetup.openRemoteDialog(null)} + > Connect a remote machine @@ -413,27 +430,6 @@ export default function WorkspaceSelector() { {machineOperationStatus}

- { - setAddDialogOpen(open); - if (!open) setRecoveryWorkspace(null); - }} - onSave={async (workspace) => { - await handleSave(workspace); - setRecoveryWorkspace(null); - }} - onSaveAndSwitch={async (workspace) => { - await handleSaveAndSwitch(workspace); - setRecoveryWorkspace(null); - }} - onCancel={() => { - setRecoveryWorkspace(null); - setAddDialogOpen(false); - }} - /> setEditMachine((current) => (current ? { ...current, value } : current))} diff --git a/packages/ui/src/components/chat/ChatStatusBar.tsx b/packages/ui/src/components/chat/ChatStatusBar.tsx index e0046296b..b048b2fe9 100644 --- a/packages/ui/src/components/chat/ChatStatusBar.tsx +++ b/packages/ui/src/components/chat/ChatStatusBar.tsx @@ -2147,7 +2147,7 @@ const ChatStatusBar = forwardRef( void selectPermissionMode(option.value)} + onClick={() => void selectPermissionMode(option.value)} > {option.label} diff --git a/packages/ui/src/components/chat/ChatTopBar.tsx b/packages/ui/src/components/chat/ChatTopBar.tsx index 0575a7802..f5cd27ec1 100644 --- a/packages/ui/src/components/chat/ChatTopBar.tsx +++ b/packages/ui/src/components/chat/ChatTopBar.tsx @@ -273,7 +273,7 @@ const ChatTopBar: FC = ({ <>
{showCollapsedNewChatButton && ( @@ -398,19 +398,19 @@ const ChatTopBar: FC = ({ - void handleExport("markdown")}> + void handleExport("markdown")}> Markdown Document (.md) - void handleExport("html")}> + void handleExport("html")}> HTML Document (.html) - void handleExport("txt")}> + void handleExport("txt")}> Plain Text (.txt) - void handleExport("nowledge-mem")}> + void handleExport("nowledge-mem")}> Nowledge Memory (.json) @@ -432,20 +432,20 @@ const ChatTopBar: FC = ({ - void handleTogglePin()}> + void handleTogglePin()}> {isPinned ? "Unpin" : "Pin"} - void openMoveDialog()}> + void openMoveDialog()}> Move conversation - + Clear messages - + Delete diff --git a/packages/ui/src/components/message/ImageActionContextMenu.tsx b/packages/ui/src/components/message/ImageActionContextMenu.tsx index 80c7a3734..e03781576 100644 --- a/packages/ui/src/components/message/ImageActionContextMenu.tsx +++ b/packages/ui/src/components/message/ImageActionContextMenu.tsx @@ -41,11 +41,11 @@ export const ImageActionContextMenu: FC = ({ }>{children} - + Copy Image - + Save As diff --git a/packages/ui/src/components/message/MessageItemAssistant.tsx b/packages/ui/src/components/message/MessageItemAssistant.tsx index ce3a37fc3..cf97476d7 100644 --- a/packages/ui/src/components/message/MessageItemAssistant.tsx +++ b/packages/ui/src/components/message/MessageItemAssistant.tsx @@ -548,24 +548,24 @@ const MessageItemAssistant = forwardRef {showSelectionMenu ? ( <> - Copy - Translate - {!isReadOnly && Ask AI} + Copy + Translate + {!isReadOnly && Ask AI} ) : ( <> - handleAction("copy")}>Copy - {!isReadOnly && handleAction("retry")}>Retry} + handleAction("copy")}>Copy + {!isReadOnly && handleAction("retry")}>Retry} {!isReadOnly && ( handleAction("fork")} + onClick={() => handleAction("fork")} > Fork )} {!isReadOnly && } - {!isReadOnly && handleAction("delete")}>Delete} + {!isReadOnly && handleAction("delete")}>Delete} )} diff --git a/packages/ui/src/components/workspace/RemoteWorkspaceSetup.tsx b/packages/ui/src/components/workspace/RemoteWorkspaceSetup.tsx index 9f58c3452..a9cc92127 100644 --- a/packages/ui/src/components/workspace/RemoteWorkspaceSetup.tsx +++ b/packages/ui/src/components/workspace/RemoteWorkspaceSetup.tsx @@ -6,10 +6,10 @@ import { Button } from "#shadcn/components/ui/button"; import { Input } from "#shadcn/components/ui/input"; import { Label } from "#shadcn/components/ui/label"; import { Separator } from "#shadcn/components/ui/separator"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "#shadcn/components/ui/tabs"; import { useToast } from "#/components/use-toast"; import { getRemoteMachineCommands, type RemoteMachinePlatform } from "@argos/shared/remoteMachineCommands"; import type { RemotePairingProgressStage } from "@argos/shared-contracts/bridge"; +import { cn } from "#shadcn/lib/utils"; function getDefaultRemotePlatform(): RemoteMachinePlatform { const userAgent = typeof navigator === "undefined" ? "" : navigator.userAgent.toLowerCase(); @@ -44,23 +44,47 @@ type ConnectionState = | { kind: "success"; version?: string } | { kind: "error"; code?: string; message: string }; -type SetupView = "form" | "instructions"; - type SetupFormState = { - view: SetupView; name: string; pairingUrl: string; }; type SetupFormAction = - | { type: "set-view"; value: SetupView } | { type: "set-name"; value: string } | { type: "set-pairing-url"; value: string } | { type: "reset" }; +type View = "form" | "instructions"; + const deviceClient = createDeviceClient(); const REMOTE_MACHINE_GUIDE_URL = "https://github.com/dvaJi/argos/blob/master/docs/guides/remote-machines.md"; +const STAGE_LABELS: Record = { + parsing: "Checking entry...", + reaching: "Checking server...", + exchanging: "Exchanging...", + authenticating: "Authenticating...", + storing: "Securing session...", + connecting: "Connecting...", + events: "Checking events...", + handshaking: "Verifying identity...", + capabilities: "Checking capabilities...", + saving: "Saving machine...", +}; + +const STAGE_SR: Record = { + parsing: "Checking the pairing entry.", + reaching: "Checking the server connection.", + exchanging: "Exchanging the one-time pairing credential.", + authenticating: "Authenticating with Argos Server.", + storing: "Storing the session in the secure credential store.", + connecting: "Opening the authenticated event connection.", + events: "Confirming event readiness.", + handshaking: "Reading the verified server identity.", + capabilities: "Checking required server capabilities.", + saving: "Saving the remote machine.", +}; + function recoveryForPairingError(code?: string): string | null { switch (code) { case "pairing_expired": @@ -105,8 +129,6 @@ function deriveName(name: string, remoteUrl: string): string { function setupFormReducer(state: SetupFormState, action: SetupFormAction): SetupFormState { switch (action.type) { - case "set-view": - return { ...state, view: action.value }; case "set-name": return { ...state, name: action.value }; case "set-pairing-url": @@ -123,17 +145,14 @@ export function RemoteWorkspaceSetup({ onCancel, }: RemoteWorkspaceSetupProps) { const { toast } = useToast(); - const [form, dispatchForm] = useReducer(setupFormReducer, { - view: "form", - name: "", - pairingUrl: "", - }); + const [form, dispatchForm] = useReducer(setupFormReducer, { name: "", pairingUrl: "" }); const [connection, setConnection] = useState({ kind: "idle" }); const [pendingWorkspace, setPendingWorkspace] = useState(null); const [saveError, setSaveError] = useState(null); const [clientVersion, setClientVersion] = useState(); + const [view, setView] = useState("form"); - const { view, name, pairingUrl } = form; + const { name, pairingUrl } = form; const canConnect = connection.kind !== "checking" && pairingUrl.trim().length > 0; useEffect(() => { @@ -175,10 +194,6 @@ export function RemoteWorkspaceSetup({ return; } issuedCredentialRef = result.credentialRef; - // pairRemote performs the authenticated WebSocket, environment, capability, - // and event-readiness checks before returning. Do not turn a public health - // endpoint into a second save gate: it cannot prove that a paired machine is - // usable and may be deliberately unavailable behind a reverse proxy. setPendingWorkspace({ name: deriveName(name, result.remoteUrl), remoteUrl: result.remoteUrl, @@ -228,9 +243,13 @@ export function RemoteWorkspaceSetup({ } }; + const isReviewing = + pendingWorkspace && + (connection.kind === "review" || (connection.kind === "checking" && connection.stage === "saving")); + return ( -
-
+
+

Connect a remote machine

This computer is managed automatically by Argos Desktop. Use Argos Server on another machine when you want @@ -238,39 +257,24 @@ export function RemoteWorkspaceSetup({

- dispatchForm({ type: "set-view", value: value as SetupView })} - className="gap-4" - > - - - - Form - - - - Instructions - - - - - {pendingWorkspace && - (connection.kind === "review" || (connection.kind === "checking" && connection.stage === "saving")) ? ( - { - void discardPendingCredential(); - setPendingWorkspace(null); - setConnection({ kind: "idle" }); - }} - onSave={() => void handleSave(false)} - onSaveAndSwitch={() => void handleSave(true)} - /> - ) : ( + {isReviewing ? ( + { + void discardPendingCredential(); + setPendingWorkspace(null); + setConnection({ kind: "idle" }); + }} + onSave={() => void handleSave(false)} + onSaveAndSwitch={() => void handleSave(true)} + /> + ) : ( + <> + + {view === "form" ? ( dispatchForm({ type: "set-view", value: "instructions" })} /> + ) : ( + )} - - - - dispatchForm({ type: "set-view", value: "form" })} - /> - - + + )} +
+ ); +} + +function ViewToggle({ + view, + onChange, + connectionKind, +}: { + view: View; + onChange: (view: View) => void; + connectionKind: ConnectionState["kind"]; +}) { + if (connectionKind === "checking" || connectionKind === "success") return null; + const items: { value: View; label: string; icon: string }[] = [ + { value: "form", label: "Form", icon: "lucide:square-pen" }, + { value: "instructions", label: "Instructions", icon: "lucide:book-open" }, + ]; + return ( +
+ {items.map((item) => { + const active = view === item.value; + return ( + + ); + })}
); } @@ -313,7 +356,6 @@ function ConnectionForm({ onPairingUrlChange, onCancel, onConnect, - onShowInstructions, }: { name: string; pairingUrl: string; @@ -324,10 +366,10 @@ function ConnectionForm({ onPairingUrlChange: (value: string) => void; onCancel?: () => void; onConnect: () => void; - onShowInstructions: () => void; }) { const recovery = connection.kind === "error" ? recoveryForPairingError(connection.code) : null; const errorRef = useRef(null); + const pairingUrlRef = useRef(null); useEffect(() => { if (connection.kind === "error") { @@ -335,110 +377,83 @@ function ConnectionForm({ } }, [connection.kind]); + const isChecking = connection.kind === "checking"; + const buttonLabel = isChecking ? STAGE_LABELS[connection.stage] : "Pair and add"; + return ( -
-
-
- - onPairingUrlChange(event.target.value)} - /> -

- Pairing creates a revocable connection. You do not need to copy a bearer token. -

-
+
+
+ + onPairingUrlChange(event.target.value)} + disabled={isChecking} + /> +

+ Pairing creates a revocable connection. You do not need to copy a bearer token. +

+
-
- - onNameChange(event.target.value)} - /> -

Optional. If empty, Argos uses the server host name.

-
+
+ + onNameChange(event.target.value)} + disabled={isChecking} + /> +

Optional. If empty, Argos uses the server host name.

+
- {previousEndpoint && ( -

- Previously saved address: {previousEndpoint}. Pair again with a fresh - link to verify this machine before saving it. -

- )} + {previousEndpoint && ( +

+ Previously saved address: {previousEndpoint}. Pair again with a fresh link + to verify this machine before saving it. +

+ )} - {connection.kind === "error" && ( - - - Connection failed - -

{connection.message}

- {recovery &&

{recovery}

} -
-
- )} + {connection.kind === "error" && ( + + + Connection failed + +

{connection.message}

+ {recovery &&

{recovery}

} +
+
+ )} - {connection.kind === "success" && ( - - - Machine added - - {connection.version ? `Daemon v${connection.version} is ready.` : "The daemon is ready."} - - - )} + {connection.kind === "success" && ( + + + Machine added + + {connection.version ? `Daemon v${connection.version} is ready.` : "The daemon is ready."} + + + )} - {connection.kind === "checking" && ( -

- { - { - parsing: "Checking the pairing entry.", - reaching: "Checking the server connection.", - exchanging: "Exchanging the one-time pairing credential.", - authenticating: "Authenticating with Argos Server.", - storing: "Storing the session in the secure credential store.", - connecting: "Opening the authenticated event connection.", - events: "Confirming event readiness.", - handshaking: "Reading the verified server identity.", - capabilities: "Checking required server capabilities.", - saving: "Saving the remote machine.", - }[connection.stage] - } -

- )} + {isChecking && ( +

+ {STAGE_SR[connection.stage]} +

+ )} -
- -
- {onCancel && ( - - )} - -
-
+ )} +
-
+
); } @@ -460,7 +475,7 @@ function ReviewPanel({ onSaveAndSwitch: () => void; }) { return ( -
+

Review remote machine

@@ -544,22 +559,17 @@ function ReviewPanel({ {canSwitch && }

-
+
); } -function InstructionsPanel({ - onCopyCommand, - onShowForm, -}: { - onCopyCommand: (command: string) => void; - onShowForm: () => void; -}) { +function InstructionsPanel({ onCopyCommand }: { onCopyCommand: (command: string) => void }) { const [platform, setPlatform] = useState(getDefaultRemotePlatform); const commands = getRemoteMachineCommands(platform); const [showPrivateNetworkCommand, setShowPrivateNetworkCommand] = useState(false); + return ( -
+

Basic daemon instructions

@@ -567,96 +577,89 @@ function InstructionsPanel({

- + -
-
- - -

- Choose the platform of the machine that will run Argos Server. The installer detects its supported - architecture automatically. -

-
+
+ + +

+ Choose the platform of the machine that will run Argos Server. The installer detects its supported + architecture automatically. +

+
- {!commands.available && ( - - - Argos Server is not available for this platform - {commands.unavailableReason} - - )} - - {commands.available && ( - - )} - + {!commands.available && ( + + + Argos Server is not available for this platform + {commands.unavailableReason} + + )} + {commands.available && ( - - - - -
-

- A LAN or private-overlay server is reachable by other devices. Restrict its firewall to trusted clients. -

- - {showPrivateNetworkCommand && ( -
- -
- )} -
-
+ )} +
+ + {commands.available && ( + + + + +
+

+ A LAN or private-overlay server is reachable by other devices. Restrict its firewall to trusted clients. +

+ + {showPrivateNetworkCommand && ( +
+ +
+ )} +
+
+ )} - - - Pairing is the recommended connection - - Start Argos Server with its pairing option on the remote machine, then paste the short-lived link above. For - internet-distance access, use a private overlay network or HTTPS reverse proxy. - - - - - Open the remote-machine guide - + + + Pairing is the recommended connection + + Start Argos Server with its pairing option on the remote machine, then paste the short-lived link above. For + internet-distance access, use a private overlay network or HTTPS reverse proxy. + + -
- -
-
-
+ + Open the remote-machine guide + +
); } diff --git a/packages/ui/src/components/workspace/WorkspaceFileNode.tsx b/packages/ui/src/components/workspace/WorkspaceFileNode.tsx index fda01abb0..951bbcf2f 100644 --- a/packages/ui/src/components/workspace/WorkspaceFileNode.tsx +++ b/packages/ui/src/components/workspace/WorkspaceFileNode.tsx @@ -127,17 +127,17 @@ export default function WorkspaceFileNode({ {!node.isDirectory && ( - + Open File )} - + Reveal in Folder - onInsertPath?.(node.path)}> + onInsertPath?.(node.path)}> Insert Path diff --git a/packages/ui/src/components/workspace/WorkspaceSelectorDialogs.tsx b/packages/ui/src/components/workspace/WorkspaceSelectorDialogs.tsx index 21a536805..a77333976 100644 --- a/packages/ui/src/components/workspace/WorkspaceSelectorDialogs.tsx +++ b/packages/ui/src/components/workspace/WorkspaceSelectorDialogs.tsx @@ -10,6 +10,7 @@ import { Button } from "#shadcn/components/ui/button"; import { Input } from "#shadcn/components/ui/input"; import { Label } from "#shadcn/components/ui/label"; import { RemoteWorkspaceSetup } from "./RemoteWorkspaceSetup"; +import { useRemoteSetupStore } from "#/stores/ui/remoteSetup"; import type { WorkspaceEntry } from "#/stores/ui/workspace"; export type WorkspaceDraft = { @@ -28,25 +29,18 @@ export type MachineEdit = { value: string; }; -export function AddRemoteMachineDialog({ - open, - remoteUrls, - recoveryWorkspace, - onOpenChange, - onSave, - onSaveAndSwitch, - onCancel, -}: { - open: boolean; - remoteUrls: string[]; - recoveryWorkspace: WorkspaceEntry | null; - onOpenChange: (open: boolean) => void; - onSave: (workspace: WorkspaceDraft) => Promise; - onSaveAndSwitch: (workspace: WorkspaceDraft) => Promise; - onCancel: () => void; -}) { +export function AddRemoteMachineDialog() { + const remoteSetup = useRemoteSetupStore(); + const handlers = remoteSetup.handlers; + return ( - + { + if (!open) remoteSetup.closeRemoteDialog(); + }} + modal={false} + > Connect a remote machine @@ -54,13 +48,15 @@ export function AddRemoteMachineDialog({ Install Argos Server on another machine, pair it securely, and choose where work runs. - + {handlers && ( + + )} ); diff --git a/packages/ui/src/pages/AgentWelcomePage.tsx b/packages/ui/src/pages/AgentWelcomePage.tsx index 4a2236071..1834ec746 100644 --- a/packages/ui/src/pages/AgentWelcomePage.tsx +++ b/packages/ui/src/pages/AgentWelcomePage.tsx @@ -1,14 +1,21 @@ -import { type CSSProperties, useMemo } from "react"; -import { useSelector } from "@tanstack/react-store"; +import { useMemo } from "react"; +import { useSelector, useStore } from "@tanstack/react-store"; +import { Icon } from "@iconify/react"; import { agentStore } from "#/stores/ui/agent"; +import { themeStore } from "#/stores/theme"; import { createSettingsClient } from "#api/SettingsClient"; import AgentAvatar from "#/components/icons/AgentAvatar"; +import logo from "#/assets/logo.png"; import logoDark from "#/assets/logo-dark.png"; const settingsClient = createSettingsClient(); +const entranceClass = "animate-in fade-in slide-in-from-bottom-2 fill-mode-both duration-300 ease-out"; + export function AgentWelcomePage() { const agentState = useSelector(agentStore, (s) => s); + const theme = useStore(themeStore); + const displayedAgents = useMemo(() => agentState.agents.filter((a) => a.enabled).slice(0, 9), [agentState.agents]); const selectAgent = (agentId: string) => { @@ -21,43 +28,85 @@ export function AgentWelcomePage() { }); }; + const manageButtonClass = "text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"; + return ( -
-
-
- Argos -
+
+ + +
+
+
+ Argos +
+

Select an agent

+

+ Choose who handles this conversation. +

+
-

Select an Agent

+ {displayedAgents.length > 0 ? ( +
+
+

Agents

+ +
-
- {displayedAgents.map((agent) => ( +
+ {displayedAgents.map((agent) => ( + + ))} +
+
+ ) : ( +
+ + +

No agents set up yet

+

Install or enable an agent to start chatting.

- ))} -
- - +
+ )}
); diff --git a/packages/ui/src/pages/NewThreadPage.tsx b/packages/ui/src/pages/NewThreadPage.tsx index fff3d5990..953d18949 100644 --- a/packages/ui/src/pages/NewThreadPage.tsx +++ b/packages/ui/src/pages/NewThreadPage.tsx @@ -5,6 +5,7 @@ import { Button } from "#shadcn/components/ui/button"; import { DropdownMenu, DropdownMenuContent, + DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, @@ -12,7 +13,6 @@ import { } from "#shadcn/components/ui/dropdown-menu"; import ChatInputBox from "#/components/chat/ChatInputBox"; import { FolderPickerDialog } from "#/components/FolderPicker"; -import logoDark from "#/assets/logo-dark.png"; import ChatInputToolbar from "#/components/chat/ChatInputToolbar"; import ChatStatusBar from "#/components/chat/ChatStatusBar"; import { useToast } from "#/components/use-toast"; @@ -734,80 +734,27 @@ function NewThreadPage() { }; return ( -
-
-
- Argos -
- -

New Thread

+
+ -
- - Running on {activeMachine?.name ?? "This computer"} -
- - - - } - > - {selectedProjectName} - {selectedProjectDirectoryInvalid && ( - - ⚠ - - )} - - - Recent Projects - - No Project - - - {projectState.projects.map((project) => ( - selectProject(project.path)} - > -
- {project.name} - {project.path} -
- {isSelectedInvalidProjectPath(project.path) && ( - - ⚠ - - )} -
- ))} - setFolderPickerOpen(true)}> - Open Folder - -
-
+
+

New thread

{isAcpWorkdirMissing && (
- This agent needs a project. Pick one above to start chatting. + This agent needs a project. Pick one below to start chatting.
)} @@ -843,6 +790,75 @@ function NewThreadPage() { />
+
+ + + Running on {activeMachine?.name ?? "This computer"} + +
+
diff --git a/packages/ui/src/pages/WelcomePage.tsx b/packages/ui/src/pages/WelcomePage.tsx index 7e83900fb..1e02dd5b8 100644 --- a/packages/ui/src/pages/WelcomePage.tsx +++ b/packages/ui/src/pages/WelcomePage.tsx @@ -1,28 +1,28 @@ -import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useStore } from "@tanstack/react-store"; import { useNavigate } from "@tanstack/react-router"; +import { Icon } from "@iconify/react"; import { themeStore } from "#/stores/theme"; import { goToNewThread as goToNewThreadAction } from "#/stores/ui/pageRouter"; import { createConfigClient } from "#api/ConfigClient"; import { createOnboardingClient } from "#api/OnboardingClient"; import { isBrowserMode } from "#api/runtimeKind"; import { persistGuidedOnboardingResumeIntent, type GuidedOnboardingResumeTrigger } from "#/lib/onboardingResume"; +import { cn } from "#/lib/utils"; +import logo from "#/assets/logo.png"; import logoDark from "#/assets/logo-dark.png"; import { getNextGuidedOnboardingStepId, - getPreviousGuidedOnboardingStepId, isGuidedOnboardingChatStepId, resolveGuidedOnboardingStepTarget, type GuidedOnboardingSettingsRouteName, } from "@argos/shared/guidedOnboarding"; import { resolveSettingsNavigationPath } from "@argos/shared/settingsNavigation"; import ModelIcon from "#/components/icons/ModelIcon"; -import OnBoardingSpotlight from "#/components/onboarding/OnBoardingSpotlight"; -import { useOnBoarding } from "#/composables/useOnBoarding"; import type { GuidedOnboardingState, GuidedOnboardingStepId, - GuidedOnboardingStepStatus, + GuidedOnboardingStepState, } from "@argos/shared-contracts/routes"; const configClient = createConfigClient(); @@ -44,48 +44,47 @@ type SettingsWindowState = Window & { const SETTINGS_SECTION_EVENT = "argos:settings-section"; +const entranceClass = "animate-in fade-in slide-in-from-bottom-2 fill-mode-both duration-300 ease-out"; + +function stepStatusIcon(status: GuidedOnboardingStepState["status"], isCurrent: boolean) { + if (status === "completed") { + return { icon: "lucide:circle-check", className: "text-accent-500" }; + } + if (status === "skipped") { + return { icon: "lucide:circle-minus", className: "text-muted-foreground/50" }; + } + if (isCurrent || status === "in_progress") { + return { icon: "lucide:circle-dot", className: "text-accent-500" }; + } + return { icon: "lucide:circle", className: "text-muted-foreground/40" }; +} + export function WelcomePage() { const navigate = useNavigate(); const theme = useStore(themeStore); const [onboardingState, setOnboardingState] = useState(null); - const rootRef = useRef(null); - const guideCardRef = useRef(null); - const providerGridRef = useRef(null); - const [guideCoachmarkDismissed, setGuideCoachmarkDismissed] = useState(false); - const coachmarkPanelRef = useRef(null); - - const requiredGuideSteps = useMemo( - () => onboardingState?.steps?.filter((step) => step.required) ?? [], - [onboardingState], - ); - const optionalGuideSteps = useMemo( - () => onboardingState?.steps?.filter((step) => !step.required) ?? [], - [onboardingState], - ); - const completedRequiredSteps = useMemo( - () => requiredGuideSteps.filter((step) => step.status === "completed").length, - [requiredGuideSteps], - ); + + const guideSteps = useMemo(() => onboardingState?.steps ?? [], [onboardingState]); const guideStepTitle = (stepId: GuidedOnboardingStepId): string => { switch (stepId) { case "select-provider": - return "Select Provider"; + return "Select a provider"; case "provider-api-key": - return "API Key"; + return "Add an API key"; case "provider-model": - return "Select Model"; + return "Pick a default model"; case "switch-agent": - return "Switch Agent"; + return "Choose your agent"; case "mcp": - return "MCP"; + return "Connect MCP servers"; case "skills": - return "Skills"; + return "Install skills"; case "switch-model": - return "Switch Model"; + return "Switch models mid-chat"; case "first-chat": - return "First Chat"; + return "Send your first message"; default: return stepId; } @@ -98,42 +97,14 @@ export function WelcomePage() { return onboardingState?.steps?.find((step) => step.status === "pending")?.id ?? "select-provider"; }, [onboardingState]); - const currentGuideStepTitle = useMemo(() => guideStepTitle(currentGuideStepId), [currentGuideStepId]); - - const primaryGuideActionLabel = useMemo( - () => (isGuidedOnboardingChatStepId(currentGuideStepId) ? "Go to Chat" : "Continue Setup"), - [currentGuideStepId], + const completedStepCount = useMemo( + () => guideSteps.filter((step) => step.status === "completed").length, + [guideSteps], ); - const guideStepIds = useMemo(() => onboardingState?.steps?.map((step) => step.id) ?? [], [onboardingState]); - - const coachmarkStepId = currentGuideStepId; - const coachmarkStepTitle = guideStepTitle(coachmarkStepId); - const showGuideImportAction = coachmarkStepId === "select-provider"; - const showGuideCoachmark = onboardingState?.status === "active" && !guideCoachmarkDismissed; - const coachmarkTargetSurface = coachmarkStepId === "select-provider" ? "providers" : "guide-card"; - const coachmarkStepIndex = useMemo(() => { - const idx = guideStepIds.findIndex((id) => id === coachmarkStepId); - return idx >= 0 ? idx + 1 : 1; - }, [guideStepIds, coachmarkStepId]); - const coachmarkTotalSteps = onboardingState?.steps?.length ?? 1; - const canGoToPreviousGuideStep = Boolean(getPreviousGuidedOnboardingStepId(currentGuideStepId)); - const canGoToNextGuideStep = coachmarkStepIndex < coachmarkTotalSteps; - - const resolveCoachmarkTargetElement = () => - coachmarkTargetSurface === "providers" ? providerGridRef.current : guideCardRef.current; - - const coachmarkTargetEl = showGuideCoachmark ? resolveCoachmarkTargetElement() : null; - - const { - viewportWidth: coachmarkViewportWidth, - viewportHeight: coachmarkViewportHeight, - pathD: coachmarkPathD, - cutoutPathD: coachmarkCutoutPathD, - } = useOnBoarding(coachmarkTargetEl, { - visible: showGuideCoachmark, - radius: 28, - }); + const primaryGuideActionLabel = isGuidedOnboardingChatStepId(currentGuideStepId) ? "Go to chat" : "Continue"; + + const showGuide = Boolean(onboardingState && onboardingState.status !== "completed"); const persistGuideResumeIntent = ( trigger: GuidedOnboardingResumeTrigger, @@ -147,7 +118,6 @@ export function WelcomePage() { try { const state = await onboardingClient.getState(); setOnboardingState(state.status === "idle" ? await onboardingClient.start() : state); - setGuideCoachmarkDismissed(false); } catch (error) { console.error("Failed to sync welcome onboarding state:", error); } @@ -215,41 +185,8 @@ export function WelcomePage() { await openSettings(action.routeName, action.stepId); }; - const goToPreviousGuideStep = async () => { - const previousStepId = getPreviousGuidedOnboardingStepId(currentGuideStepId); - if (!previousStepId) return; - await resumeGuideStep(previousStepId); - }; - - const goToNextGuideStep = async () => { - if (!canGoToNextGuideStep) return; - await handlePrimaryGuideAction(); - }; - - const guideStepClass = (_stepId: GuidedOnboardingStepId, status: GuidedOnboardingStepStatus) => { - if (status === "completed") { - return "border-emerald-500/50 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; - } - if (status === "in_progress") { - return "border-primary/60 bg-primary/10 text-foreground"; - } - return "border-border/70 bg-background/60 text-muted-foreground"; - }; - - const guideStepIconClass = (_stepId: GuidedOnboardingStepId, status: GuidedOnboardingStepStatus) => { - if (status === "completed") return "text-emerald-600 dark:text-emerald-300"; - if (status === "in_progress") return "text-primary"; - return "text-muted-foreground/70"; - }; - const handlePrimaryGuideAction = async () => { - const action = resolveGuideAction(currentGuideStepId); - if (action.kind === "chat") { - await goToChat(action.stepId); - return; - } - persistGuideResumeIntent("window-focus", action.stepId); - await openSettings(action.routeName, action.stepId); + await resumeGuideStep(currentGuideStepId); }; const handleExperiencedGuideAction = async () => { @@ -288,235 +225,165 @@ export function WelcomePage() { }, []); return ( -
- {showGuideCoachmark && ( -
+ {showGuide && ( + - - )} -
-

- Complete the {coachmarkStepTitle} step to continue. -

- -
-
- - -
- -
- - -
-
-
-
+ Skip setup + )} -
-
- Argos -
- -

Welcome

-

- Set up your AI providers and start chatting. -

- - {onboardingState && ( -
+
+
+ Argos +
+

Welcome to Argos

+

+ Connect a model provider and start your first chat. +

+
+ + {showGuide && onboardingState && ( +
-
+
-

Getting Started

-

- Complete the {currentGuideStepTitle} step to continue. +

Get started

+

+ {completedStepCount} of {guideSteps.length} steps complete

-
- Progress - - {completedRequiredSteps}/{requiredGuideSteps.length} - -
- -
- {requiredGuideSteps.map((step) => ( -
-
- - {step.status === "completed" ? "✓" : step.status === "in_progress" ? "●" : "○"} - - {guideStepTitle(step.id)} -
-
- ))} +
+
+
0 ? (completedStepCount / guideSteps.length) * 100 : 0}%` }} + /> +
- {optionalGuideSteps.length > 0 && ( -
-

Optional

-
- {optionalGuideSteps.map((step) => ( - + {guideSteps.map((step: GuidedOnboardingStepState) => { + const isCurrent = step.id === currentGuideStepId && onboardingState.status === "active"; + const statusIcon = stepStatusIcon(step.status, isCurrent); + return ( +
  • +
  • -
    - )} -
    +
    )} -
    - {providers.map((provider) => ( +
    +
    +

    Add a provider

    - ))} -
    - -
    - -
    +
    -
    -
    -
    - Connect Agent -
    +
    + {providers.map((provider) => ( + + ))}
    +
    +
    diff --git a/packages/ui/src/routes/_main.tsx b/packages/ui/src/routes/_main.tsx index f93fd2a78..22451705b 100644 --- a/packages/ui/src/routes/_main.tsx +++ b/packages/ui/src/routes/_main.tsx @@ -17,6 +17,7 @@ import { uiSettingsStore } from "../stores/uiSettingsStore"; import TranslatePopup from "../components/popup/TranslatePopup"; import MessageDialog from "../components/ui/MessageDialog"; import McpSamplingDialog from "../components/mcp/McpSamplingDialog"; +import { AddRemoteMachineDialog } from "../components/workspace/WorkspaceSelectorDialogs"; import { initAppStores, useMcpInstallDeeplinkHandler } from "../lib/storeInitializer"; import { ensureIconsLoaded } from "../lib/iconLoader"; import AppBar from "../components/AppBar"; @@ -522,6 +523,7 @@ function MainLayout() {
    + diff --git a/packages/ui/src/stores/ui/remoteSetup.ts b/packages/ui/src/stores/ui/remoteSetup.ts new file mode 100644 index 000000000..26e2913ae --- /dev/null +++ b/packages/ui/src/stores/ui/remoteSetup.ts @@ -0,0 +1,75 @@ +import { Store } from "@tanstack/store"; +import { useStore } from "@tanstack/react-store"; +import type { WorkspaceDraft } from "#/components/workspace/WorkspaceSelectorDialogs"; +import type { WorkspaceEntry } from "@argos/shared/workspaceConfig"; + +export type { WorkspaceDraft }; + +export interface RemoteSetupHandlers { + remoteUrls: string[]; + onSave: (workspace: WorkspaceDraft) => Promise; + onSaveAndSwitch?: (workspace: WorkspaceDraft) => Promise; +} + +interface RemoteSetupState { + open: boolean; + recoveryWorkspace: WorkspaceEntry | null; + handlers: RemoteSetupHandlers | null; +} + +const remoteSetupStore = new Store({ + open: false, + recoveryWorkspace: null, + handlers: null, +}); + +function registerHandlers(handlers: RemoteSetupHandlers): void { + remoteSetupStore.setState((prev) => ({ ...prev, handlers })); +} + +function clearHandlers(): void { + remoteSetupStore.setState((prev) => ({ ...prev, handlers: null })); +} + +function openRemoteDialog(workspace?: WorkspaceEntry | null): void { + remoteSetupStore.setState((prev) => ({ + ...prev, + open: true, + recoveryWorkspace: workspace ?? null, + })); +} + +function closeRemoteDialog(): void { + remoteSetupStore.setState((prev) => ({ + ...prev, + open: false, + recoveryWorkspace: null, + })); +} + +async function saveWorkspace(workspace: WorkspaceDraft): Promise { + const { handlers } = remoteSetupStore.state; + if (!handlers) return; + await handlers.onSave(workspace); + closeRemoteDialog(); +} + +async function saveWorkspaceAndSwitch(workspace: WorkspaceDraft): Promise { + const { handlers } = remoteSetupStore.state; + if (!handlers?.onSaveAndSwitch) return; + await handlers.onSaveAndSwitch(workspace); + closeRemoteDialog(); +} + +export function useRemoteSetupStore() { + const state = useStore(remoteSetupStore); + return { + ...state, + registerHandlers, + clearHandlers, + openRemoteDialog, + closeRemoteDialog, + saveWorkspace, + saveWorkspaceAndSwitch, + }; +} From 785777fede9445ca55c5cadf5c129c940d7a1faa Mon Sep 17 00:00:00 2001 From: Francisco Pizarro Date: Thu, 6 Aug 2026 20:40:23 -0400 Subject: [PATCH 2/3] fix(ui): address review feedback on entry surfaces --- docs/features/welcome-page-redesign/plan.md | 31 +++++++++ docs/features/welcome-page-redesign/spec.md | 40 +++++++++++ docs/features/welcome-page-redesign/tasks.md | 10 +++ .../ui/src/components/WorkspaceSelector.tsx | 14 +--- .../ui/src/components/brand/BrandWordmark.tsx | 9 +++ .../workspace/RemoteWorkspaceSetup.tsx | 15 +++-- .../workspace/WorkspaceSelectorDialogs.tsx | 11 +++- packages/ui/src/lib/pageMotion.ts | 8 +++ packages/ui/src/pages/AgentWelcomePage.tsx | 42 +++++++----- packages/ui/src/pages/NewThreadPage.tsx | 65 +++++++++--------- packages/ui/src/pages/WelcomePage.tsx | 66 ++++++++----------- packages/ui/src/stores/ui/remoteSetup.ts | 43 ++++++++---- 12 files changed, 235 insertions(+), 119 deletions(-) create mode 100644 docs/features/welcome-page-redesign/plan.md create mode 100644 docs/features/welcome-page-redesign/spec.md create mode 100644 docs/features/welcome-page-redesign/tasks.md create mode 100644 packages/ui/src/components/brand/BrandWordmark.tsx create mode 100644 packages/ui/src/lib/pageMotion.ts diff --git a/docs/features/welcome-page-redesign/plan.md b/docs/features/welcome-page-redesign/plan.md new file mode 100644 index 000000000..53c61b624 --- /dev/null +++ b/docs/features/welcome-page-redesign/plan.md @@ -0,0 +1,31 @@ +# Plan — Welcome Page Redesign + +## Approach + +Single-file rewrite of `packages/ui/src/pages/WelcomePage.tsx`: + +1. **Delete the tour layer**: drop `OnBoardingSpotlight`, `useOnBoarding`, `guideCoachmarkDismissed`, `coachmarkPanelRef`, `guideCardRef`, `providerGridRef`, `rootRef`, prev/next coachmark handlers, and the coachmark JSX block. +2. **Keep the behavior core**: `syncOnboardingState`, `syncOnboardingStep`, `goToChat`, `openSettings` (browser vs. desktop settings routing), `persistGuideResumeIntent`, `handlePrimaryGuideAction`, `handleExperiencedGuideAction`, `onAddProvider`, `onImportProviders`, `onSetupAcp`. +3. **New layout** (single centered column, `max-w-md`, drag region on root): + - Quiet top-right "Skip setup" ghost action (experienced path, previously inside the coachmark). + - Logo in a small hairline-bordered tile, headline, one-line subtext. + - Guide card: header (title + `completed/total` + hairline progress bar, accent fill), then all onboarding steps as compact clickable rows with status icons (`lucide:circle-check` / accent dot / `lucide:circle` / `lucide:minus`), current step highlighted with accent hairline. Tertiary "Import providers" text action in card footer. + - Provider grid: 3-col hairline tiles (icon + name), hover raises border + fills `bg-accent`. + - Footer hairline divider + two quiet secondary actions: ACP agent setup, browse/import. +4. **Motion**: `animate-in fade-in slide-in-from-bottom-2` with inline `animation-delay` stagger (40ms steps), `fill-mode: both`. No JS animation library. +5. **Theme-aware logo**: `theme.isDark ? logoDark : logo`. + +## Affected Interfaces + +- `WelcomePage.tsx` only. No route contract, store, or backend changes. +- Test ids listed in `spec.md` remain stable; coachmark-only ids (`welcome-guide-coachmark`, `welcome-guide-panel`, `welcome-guide-prev-action`, `welcome-guide-next-action`, `welcome-guide-close-action`) are removed with the tour. No tests reference any of these ids. + +## Compatibility + +- Onboarding state and resume-intent persistence are untouched; settings-window handoff keeps working. +- `GuidedOnboardingOverlay` continues to own the spotlight tour inside settings/chat surfaces. + +## Test Strategy + +- `bun run typecheck` (touches TS surface), `bun run lint`, `bun run format`. +- Manual/visual verification of light and dark variants of the page. diff --git a/docs/features/welcome-page-redesign/spec.md b/docs/features/welcome-page-redesign/spec.md new file mode 100644 index 000000000..9e38f9fcf --- /dev/null +++ b/docs/features/welcome-page-redesign/spec.md @@ -0,0 +1,40 @@ +# Welcome Page Redesign + +## User Need + +The current `/welcome` page stacks a floating spotlight tour (`OnBoardingSpotlight` coachmark overlay) on top of a generic card layout. The overlay competes with the page for attention, duplicates guide controls, and the overall visual quality trails the Argos visual-identity token layer (cyan accent scale, hairline borders, calm radius, fast ease-out motion). + +## Goal + +Rewrite `packages/ui/src/pages/WelcomePage.tsx` to a Linear/Vercel-grade onboarding surface: + +1. Remove the in-page tour (spotlight coachmark, prev/next navigation, dismiss state). +2. Present guided onboarding as a single, quiet checklist card: ordered steps with status icons, a progress bar, and one primary continue action. Each step row is clickable and resumes that step. +3. Refine the provider grid and secondary actions (import, ACP, skip) into a coherent hairline layout with one accent color (cyan) and entrance stagger animation. +4. Redesign `packages/ui/src/pages/AgentWelcomePage.tsx` (the "select an agent" empty state shown on the new-thread route) to the same language: theme-aware logo, centered column, hairline agent grid, drag-region classes (`window-drag-region` / `window-no-drag-region`), and a proper empty state with one clear action. +5. Redesign the `packages/ui/src/pages/NewThreadPage.tsx` empty state (upstream of the same surface family) to match the opencode new-thread screen: a giant "argos" wordmark backdrop painted with a near-transparent `foreground`-token gradient (`bg-clip-text`), a centered composer with the machine/project meta folded into one quiet row beneath it, dropping the redundant small logo + heading. + +## Acceptance Criteria + +- No imports of `OnBoardingSpotlight` / `useOnBoarding` from `WelcomePage.tsx` (both stay in place for `GuidedOnboardingOverlay`, used by settings + NewThreadPage). +- All existing behavior preserved: state sync on mount (`getState` → `start` when idle), step resume via settings/chat routing, `persistGuidedOnboardingResumeIntent` on setup-bound transitions, provider grid → `settings-provider`, import → `settings-database` (`provider-import` section), ACP → `settings-acp`, skip → `complete({ force: true })` → `/chat`. +- Stable test ids kept: `welcome-guide-card`, `welcome-guide-primary-action`, `welcome-guide-import-action`, `welcome-guide-expert-action`, `welcome-provider-grid`. +- Light/dark parity via existing tokens; no new CSS files, no new dependencies. +- `bun run format`, `bun run lint`, and `bun run typecheck` pass. + +## Constraints + +- Use the Argos token layer and Tailwind utilities only (`bg-card`, `border-border`, `text-muted-foreground`, `accent-*`, motion already global). +- Icons via `@iconify/react` (`lucide:*`), the established project convention. +- Entrance motion: CSS-only stagger (tw-animate-css), ≤ 300ms, ease-out; reduced-motion honored by the global reset in `style.css`. +- No changes to shared contracts, daemon, or desktop main. + +## Non-Goals + +- Changing onboarding state machine, step definitions, or route contracts. +- Touching `GuidedOnboardingOverlay`, `OnBoardingSpotlight`, `useOnBoarding` (still used elsewhere). +- Copy rewrite of step titles beyond presentation casing. + +## Open Questions + +None. diff --git a/docs/features/welcome-page-redesign/tasks.md b/docs/features/welcome-page-redesign/tasks.md new file mode 100644 index 000000000..0b49a665a --- /dev/null +++ b/docs/features/welcome-page-redesign/tasks.md @@ -0,0 +1,10 @@ +# Tasks — Welcome Page Redesign + +- [x] 1. Write SDD artifacts (spec.md, plan.md, tasks.md) +- [x] 2. Rewrite `packages/ui/src/pages/WelcomePage.tsx` (remove tour, new layout, keep behavior + stable test ids) +- [x] 3. Run `bun run format`, `bun run lint`, `bun run typecheck`; fix findings +- [x] 4. Visual verification via `@argos/ui` production build (module graph + Tailwind compile) +- [x] 5. Fix click-blocking on the welcome page: scope the window drag region to the page background and mark the interactive column + skip action with the `window-no-drag-region` CSS class (dual `-webkit-app-region`/`app-region` properties — same mechanism as AppBar/sidebar) instead of per-button inline `WebkitAppRegion` styles +- [x] 6. Verify with a temporary Playwright probe (removed after run): `elementFromPoint` hit-tests confirmed the provider tile, import action, and window controls receive clicks, and a real click on a provider tile opened `/#/settings/provider/...`. Screenshot reviewed for dark-mode rendering quality. +- [x] 7. Redesign `AgentWelcomePage.tsx` to the same language (theme-aware logo tile, centered column, 2-col hairline agent grid, empty state with a single primary action, entrance stagger). Behavior preserved: `agentStore.selectedAgentId` selection and `settings-argos-agents` handoff. Verified with a temporary Playwright probe (removed after run): surface renders with real agents, first tile hit-test clickable, screenshot reviewed. +- [x] 8. Redesign the `NewThreadPage.tsx` empty state: giant "argos" wordmark backdrop (opencode-style) using a `foreground`-token gradient via `bg-clip-text` with a bottom mask fade, behind a centered composer; remove the now-redundant small logo + "New Thread" heading; fold the machine pill + project dropdown into a single quiet meta row beneath the composer; adjust the ACP helper copy. Kept all test ids (`new-thread-active-machine`, `new-thread-project-trigger`, guide refs) and behavior. Format / lint / typecheck / build all pass; dist rebuilt. diff --git a/packages/ui/src/components/WorkspaceSelector.tsx b/packages/ui/src/components/WorkspaceSelector.tsx index 233f9d15a..b6e0f358f 100644 --- a/packages/ui/src/components/WorkspaceSelector.tsx +++ b/packages/ui/src/components/WorkspaceSelector.tsx @@ -146,7 +146,6 @@ async function copyMachineDiagnostics(workspace: WorkspaceEntry): Promise export default function WorkspaceSelector() { const store = useWorkspaceStore(); const remoteSetup = useRemoteSetupStore(); - const [recoveryWorkspace, setRecoveryWorkspace] = useState(null); const [machineOperationStatus, setMachineOperationStatus] = useState(""); const [editMachine, setEditMachine] = useState(null); @@ -166,9 +165,6 @@ export default function WorkspaceSelector() { existingByIdentity ?? workspaces.find((candidate) => candidate.mode === "remote" && candidate.remoteUrl === workspace.remoteUrl); if (existing) { - const identityChanged = Boolean( - existing.environmentId && workspace.environmentId && existing.environmentId !== workspace.environmentId, - ); store.updateWorkspace(existing.id, { name: workspace.name || existing.name, remoteUrl: workspace.remoteUrl, @@ -177,7 +173,7 @@ export default function WorkspaceSelector() { lastKnownServerVersion: workspace.daemonVersion, lastKnownProtocolVersion: workspace.protocolVersion, lastKnownCapabilities: workspace.capabilities, - trustState: identityChanged ? "identity-changed" : workspace.credentialRef ? "paired" : "pairing-required", + trustState: workspace.credentialRef ? "paired" : "pairing-required", }); return existing.id; } @@ -197,13 +193,11 @@ export default function WorkspaceSelector() { const handleSave = async (workspace: WorkspaceDraft) => { await saveWorkspaceInternal(workspace); - setRecoveryWorkspace(null); }; const handleSaveAndSwitch = async (workspace: WorkspaceDraft) => { const id = await saveWorkspaceInternal(workspace); await store.switchWorkspace(id); - setRecoveryWorkspace(null); }; useEffect(() => { @@ -212,10 +206,8 @@ export default function WorkspaceSelector() { onSave: handleSave, onSaveAndSwitch: handleSaveAndSwitch, }); - return () => { - remoteSetup.clearHandlers(); - }; - // handleSave/handleSaveAndSwitch close over `store` and `workspaces`; re-register when workspaces change. + // Keep the handlers registered for the app lifetime: the global AddRemoteMachineDialog + // (rendered in MainLayout) stays open across sidebar collapse, which unmounts this component. // eslint-disable-next-line react-hooks/exhaustive-deps }, [workspaces, store]); diff --git a/packages/ui/src/components/brand/BrandWordmark.tsx b/packages/ui/src/components/brand/BrandWordmark.tsx new file mode 100644 index 000000000..b05fa5301 --- /dev/null +++ b/packages/ui/src/components/brand/BrandWordmark.tsx @@ -0,0 +1,9 @@ +import { WORDMARK_CLASS, WORDMARK_TEXT_CLASS } from "#/lib/pageMotion"; + +export function BrandWordmark({ topOffset = "top-[4%]" }: { topOffset?: string }) { + return ( + + ); +} diff --git a/packages/ui/src/components/workspace/RemoteWorkspaceSetup.tsx b/packages/ui/src/components/workspace/RemoteWorkspaceSetup.tsx index a9cc92127..2caaa34ea 100644 --- a/packages/ui/src/components/workspace/RemoteWorkspaceSetup.tsx +++ b/packages/ui/src/components/workspace/RemoteWorkspaceSetup.tsx @@ -1,6 +1,7 @@ import { useEffect, useReducer, useRef, useState } from "react"; import { Icon } from "@iconify/react"; import { createDeviceClient } from "#api/DeviceClient"; +import { useRemoteSetupStore } from "#/stores/ui/remoteSetup"; import { Alert, AlertDescription, AlertTitle } from "#shadcn/components/ui/alert"; import { Button } from "#shadcn/components/ui/button"; import { Input } from "#shadcn/components/ui/input"; @@ -145,6 +146,7 @@ export function RemoteWorkspaceSetup({ onCancel, }: RemoteWorkspaceSetupProps) { const { toast } = useToast(); + const remoteSetup = useRemoteSetupStore(); const [form, dispatchForm] = useReducer(setupFormReducer, { name: "", pairingUrl: "" }); const [connection, setConnection] = useState({ kind: "idle" }); const [pendingWorkspace, setPendingWorkspace] = useState(null); @@ -194,6 +196,7 @@ export function RemoteWorkspaceSetup({ return; } issuedCredentialRef = result.credentialRef; + remoteSetup.setPendingCredentialRef(result.credentialRef); setPendingWorkspace({ name: deriveName(name, result.remoteUrl), remoteUrl: result.remoteUrl, @@ -241,6 +244,7 @@ export function RemoteWorkspaceSetup({ if (pendingWorkspace?.credentialRef) { await window.argos?.workspace?.discardCredential?.(pendingWorkspace.credentialRef); } + remoteSetup.setPendingCredentialRef(null); }; const isReviewing = @@ -301,6 +305,11 @@ export function RemoteWorkspaceSetup({ ); } +const VIEW_TOGGLE_ITEMS: { value: View; label: string; icon: string }[] = [ + { value: "form", label: "Form", icon: "lucide:square-pen" }, + { value: "instructions", label: "Instructions", icon: "lucide:book-open" }, +]; + function ViewToggle({ view, onChange, @@ -311,17 +320,13 @@ function ViewToggle({ connectionKind: ConnectionState["kind"]; }) { if (connectionKind === "checking" || connectionKind === "success") return null; - const items: { value: View; label: string; icon: string }[] = [ - { value: "form", label: "Form", icon: "lucide:square-pen" }, - { value: "instructions", label: "Instructions", icon: "lucide:book-open" }, - ]; return (
    - {items.map((item) => { + {VIEW_TOGGLE_ITEMS.map((item) => { const active = view === item.value; return ( +
    )}
    diff --git a/packages/ui/src/lib/pageMotion.ts b/packages/ui/src/lib/pageMotion.ts new file mode 100644 index 000000000..d98442c26 --- /dev/null +++ b/packages/ui/src/lib/pageMotion.ts @@ -0,0 +1,8 @@ +export const ENTRANCE_CLASS = + "animate-in fade-in slide-in-from-bottom-2 fill-mode-both duration-300 ease-out motion-reduce:animate-none motion-reduce:[animation-delay:0ms]"; + +export const WORDMARK_CLASS = + "pointer-events-none absolute inset-x-0 z-0 text-center select-none animate-in fade-in zoom-in-95 duration-500 fill-mode-both motion-reduce:animate-none motion-reduce:[animation-delay:0ms]"; + +export const WORDMARK_TEXT_CLASS = + "bg-gradient-to-b from-foreground/[0.07] via-foreground/[0.03] to-transparent bg-clip-text font-black lowercase leading-none tracking-[-0.05em] text-transparent text-[clamp(7rem,26vw,15rem)] [mask-image:linear-gradient(to_bottom,black_30%,transparent_85%)]"; diff --git a/packages/ui/src/pages/AgentWelcomePage.tsx b/packages/ui/src/pages/AgentWelcomePage.tsx index 1834ec746..5d1c58923 100644 --- a/packages/ui/src/pages/AgentWelcomePage.tsx +++ b/packages/ui/src/pages/AgentWelcomePage.tsx @@ -1,22 +1,24 @@ import { useMemo } from "react"; -import { useSelector, useStore } from "@tanstack/react-store"; +import { useStore } from "@tanstack/react-store"; import { Icon } from "@iconify/react"; import { agentStore } from "#/stores/ui/agent"; import { themeStore } from "#/stores/theme"; import { createSettingsClient } from "#api/SettingsClient"; +import { BrandWordmark } from "#/components/brand/BrandWordmark"; +import { ENTRANCE_CLASS } from "#/lib/pageMotion"; import AgentAvatar from "#/components/icons/AgentAvatar"; import logo from "#/assets/logo.png"; import logoDark from "#/assets/logo-dark.png"; const settingsClient = createSettingsClient(); -const entranceClass = "animate-in fade-in slide-in-from-bottom-2 fill-mode-both duration-300 ease-out"; - export function AgentWelcomePage() { - const agentState = useSelector(agentStore, (s) => s); - const theme = useStore(themeStore); + const agents = useStore(agentStore, (s) => s.agents); + const isDark = useStore(themeStore, (s) => s.isDark); - const displayedAgents = useMemo(() => agentState.agents.filter((a) => a.enabled).slice(0, 9), [agentState.agents]); + const enabledAgents = useMemo(() => agents.filter((a) => a.enabled), [agents]); + const displayedAgents = useMemo(() => enabledAgents.slice(0, 9), [enabledAgents]); + const hiddenAgentCount = enabledAgents.length - displayedAgents.length; const selectAgent = (agentId: string) => { agentStore.setState((s) => ({ ...s, selectedAgentId: agentId })); @@ -32,19 +34,12 @@ export function AgentWelcomePage() { return (
    - +
    -
    +
    - Argos + Argos

    Select an agent

    @@ -53,7 +48,7 @@ export function AgentWelcomePage() {

    {displayedAgents.length > 0 ? ( -
    +

    Agents

    + + {hiddenAgentCount > 0 && ( + + )}
    ) : (
    diff --git a/packages/ui/src/pages/NewThreadPage.tsx b/packages/ui/src/pages/NewThreadPage.tsx index 953d18949..d25ec9328 100644 --- a/packages/ui/src/pages/NewThreadPage.tsx +++ b/packages/ui/src/pages/NewThreadPage.tsx @@ -12,6 +12,7 @@ import { DropdownMenuTrigger, } from "#shadcn/components/ui/dropdown-menu"; import ChatInputBox from "#/components/chat/ChatInputBox"; +import { BrandWordmark } from "#/components/brand/BrandWordmark"; import { FolderPickerDialog } from "#/components/FolderPicker"; import ChatInputToolbar from "#/components/chat/ChatInputToolbar"; import ChatStatusBar from "#/components/chat/ChatStatusBar"; @@ -50,6 +51,7 @@ const configClient = createConfigClient(); const fileClient = createFileClient(); const modelClient = createModelClient(); const sessionClient = createSessionClient(); +const PROJECT_MENU_LIMIT = 8; type SubmissionModelSelection = { providerId: string; modelId: string }; @@ -737,27 +739,19 @@ function NewThreadPage() {
    - + -
    +
    + {" "}

    New thread

    - {isAcpWorkdirMissing && (
    This agent needs a project. Pick one below to start chatting.
    )} -
    -
    - + Running on {activeMachine?.name ?? "This computer"} @@ -814,24 +802,29 @@ function NewThreadPage() { > {selectedProjectName} {selectedProjectDirectoryInvalid && ( - + )} + + No Project + + Recent Projects - - No Project - - - {projectState.projects.map((project) => ( + {projectState.projects.slice(0, PROJECT_MENU_LIMIT).map((project) => ( {isSelectedInvalidProjectPath(project.path) && ( )} ))} - setFolderPickerOpen(true)}> - Open Folder - + + setFolderPickerOpen(true)}> + Open Folder +
    -
    diff --git a/packages/ui/src/pages/WelcomePage.tsx b/packages/ui/src/pages/WelcomePage.tsx index 1e02dd5b8..24a126d28 100644 --- a/packages/ui/src/pages/WelcomePage.tsx +++ b/packages/ui/src/pages/WelcomePage.tsx @@ -9,6 +9,7 @@ import { createOnboardingClient } from "#api/OnboardingClient"; import { isBrowserMode } from "#api/runtimeKind"; import { persistGuidedOnboardingResumeIntent, type GuidedOnboardingResumeTrigger } from "#/lib/onboardingResume"; import { cn } from "#/lib/utils"; +import { ENTRANCE_CLASS } from "#/lib/pageMotion"; import logo from "#/assets/logo.png"; import logoDark from "#/assets/logo-dark.png"; import { @@ -44,8 +45,6 @@ type SettingsWindowState = Window & { const SETTINGS_SECTION_EVENT = "argos:settings-section"; -const entranceClass = "animate-in fade-in slide-in-from-bottom-2 fill-mode-both duration-300 ease-out"; - function stepStatusIcon(status: GuidedOnboardingStepState["status"], isCurrent: boolean) { if (status === "completed") { return { icon: "lucide:circle-check", className: "text-accent-500" }; @@ -59,6 +58,21 @@ function stepStatusIcon(status: GuidedOnboardingStepState["status"], isCurrent: return { icon: "lucide:circle", className: "text-muted-foreground/40" }; } +const GUIDED_STEP_TITLES: Record = { + "select-provider": "Select a provider", + "provider-api-key": "Add an API key", + "provider-model": "Pick a default model", + "switch-agent": "Choose your agent", + mcp: "Connect MCP servers", + skills: "Install skills", + "switch-model": "Switch models mid-chat", + "first-chat": "Send your first message", +}; + +function guideStepTitle(stepId: GuidedOnboardingStepId): string { + return GUIDED_STEP_TITLES[stepId] ?? stepId; +} + export function WelcomePage() { const navigate = useNavigate(); const theme = useStore(themeStore); @@ -67,29 +81,6 @@ export function WelcomePage() { const guideSteps = useMemo(() => onboardingState?.steps ?? [], [onboardingState]); - const guideStepTitle = (stepId: GuidedOnboardingStepId): string => { - switch (stepId) { - case "select-provider": - return "Select a provider"; - case "provider-api-key": - return "Add an API key"; - case "provider-model": - return "Pick a default model"; - case "switch-agent": - return "Choose your agent"; - case "mcp": - return "Connect MCP servers"; - case "skills": - return "Install skills"; - case "switch-model": - return "Switch models mid-chat"; - case "first-chat": - return "Send your first message"; - default: - return stepId; - } - }; - const currentGuideStepId = useMemo(() => { if (onboardingState?.currentStepId) { return onboardingState.currentStepId; @@ -98,7 +89,7 @@ export function WelcomePage() { }, [onboardingState]); const completedStepCount = useMemo( - () => guideSteps.filter((step) => step.status === "completed").length, + () => guideSteps.filter((step) => step.status === "completed" || step.status === "skipped").length, [guideSteps], ); @@ -230,7 +221,7 @@ export function WelcomePage() {