diff --git a/README.md b/README.md index 1f9634d7..aedcbfdf 100644 --- a/README.md +++ b/README.md @@ -13,22 +13,16 @@

- Continue your coding sessions on the go. Self-hosted workspaces, accessible over Tailscale. -

- -

- Terminal -     - Chat + Self-hosted workspaces with AI coding agents, accessible from anywhere over Tailscale.

## Overview -Perry is an agent (agent P) designed to run as a daemon on your machine. It allows your clients - other machines through CLI, web, or mobile app - to connect directly to your workspaces over the Tailscale network. +Perry is an agent (agent P) designed to run as a daemon on your machine. It auto-registers containerized workspaces on your Tailscale network so your CLI, web UI, or SSH clients can connect directly. It can be connected directly to your host, or it can create docker containers so that your work can be fully isolated. -Continue your sessions on the go! +Continue your sessions from any device on your tailnet. **[Get Started →](https://gricha.github.io/perry/docs/getting-started)** @@ -36,8 +30,7 @@ Continue your sessions on the go! - **AI Coding Agents** - Claude Code, OpenCode, Codex CLI pre-installed - **Self-Hosted** - Run on your own hardware, full control -- **Remote Access** - Use from anywhere via Tailscale, CLI, web, or SSH -- **Web UI** - Manage workspaces from your browser +- **Remote Access** - Use from anywhere via CLI, web, or SSH over Tailscale - **Isolated Environments** - Each workspace runs in its own container ## Setup @@ -54,32 +47,39 @@ curl -fsSL https://raw.githubusercontent.com/gricha/perry/main/install.sh | bash perry agent run ``` -Web UI: **http://localhost:7391** (or your Tailscale host) +## Access From Anywhere -### Create & Use Workspaces +Once your agent is running, connect from any device on your Tailscale network. -**Via CLI:** +### CLI + +The fastest way to access workspaces from any machine: ```bash -# Create workspace -perry create myproject +# Point to your agent (one-time setup) +perry config agent -# Or clone a repo -perry create myproject --clone git@github.com:user/repo.git +# Create workspace and clone a repo +perry start my-proj --clone git@github.com:user/repo.git -# Shell into workspace -perry shell myproject +# Shell into the workspace +perry shell my-proj -# Manage workspaces -perry start myproject -perry stop myproject -perry delete myproject -perry list +# Or attach an AI coding agent directly +opencode attach http://my-proj:4096 ``` -**Via Web UI:** +### Web UI + +Open `http://:7391` to manage workspaces from your browser. + +

+ Web UI Demo +

+ +### Remote Access -Open http://localhost:7391 (or your Tailscale host) and click "+" to create a workspace. +Each workspace is registered on your tailnet, so you can connect directly using CLI, web UI, or SSH.

Web UI Demo diff --git a/docs/docs/configuration/tailscale.md b/docs/docs/configuration/tailscale.md index 5fefc7c0..02c365a9 100644 --- a/docs/docs/configuration/tailscale.md +++ b/docs/docs/configuration/tailscale.md @@ -109,9 +109,9 @@ perry shell myproject - `https://your-machine.tail-scale.ts.net` (with HTTPS) - `http://your-machine.tail-scale.ts.net:7391` (without HTTPS) -**From mobile:** +**From another device:** -Access the Web UI from your phone's browser while connected to your tailnet. +Access the Web UI from any browser on your tailnet. ## Part 2: Workspace Networking @@ -133,7 +133,7 @@ http://myproject:3000 # Works from any device on your tailnet **Use cases:** -- Test your mobile app against a dev server running in a workspace +- Test a dev server running in a workspace from another device - Share a preview URL with a teammate: "Check out `http://myproject:3000`" - Access databases, Redis, or any service running in a workspace - Connect your IDE on one machine to a workspace running on another diff --git a/docs/docs/introduction.md b/docs/docs/introduction.md index 62676594..64033888 100644 --- a/docs/docs/introduction.md +++ b/docs/docs/introduction.md @@ -12,16 +12,36 @@ Perry is a self-hosted daemon that: - **Spawns sandboxed containers** for isolated development workspaces - **Runs AI coding agents** (Claude Code, OpenCode, Codex) against your workspaces—or directly on the host -- **Provides remote access** via Tailscale through a responsive web app or native mobile app +- **Provides remote access** via CLI, web UI, or SSH over Tailscale Think of it as your personal development environment manager that you can access from anywhere. +## Access From Anywhere + +Perry is designed for remote access. Once your agent is running, you can connect from any device on your Tailscale network. + +**CLI** — The fastest way to access workspaces: +```bash +# Create and clone a repo +perry start my-proj --clone git@github.com:user/repo.git + +# Shell into the workspace +perry shell my-proj + +# Or attach an AI coding agent directly +opencode attach http://my-proj:4096 +``` + +**Web UI** — Full workspace management at `http://:7391` + +**SSH** — Connect directly to registered workspaces on your tailnet + ## How It Works ``` ┌─────────────────────────────────────────────────────────────┐ │ Your Devices │ -│ Browser (Web UI) • Mobile App • CLI • SSH │ +│ Browser (Web UI) • CLI • SSH │ └─────────────────────────┬───────────────────────────────────┘ │ Tailscale / Local ▼ diff --git a/docs/docs/web-ui.md b/docs/docs/web-ui.md index 593e04d8..ef9c62f5 100644 --- a/docs/docs/web-ui.md +++ b/docs/docs/web-ui.md @@ -2,9 +2,38 @@ sidebar_position: 6 --- -# Web & Mobile +# Remote Access -Perry includes a responsive web UI and a native mobile app, giving you access to your workspaces from anywhere. +Perry gives you access to your workspaces from anywhere via CLI, web UI, or SSH. + +## CLI + +The CLI is the fastest way to work with Perry workspaces from any machine. + +### Setup + +Install Perry on your client machine and point it to your agent: + +```bash +perry config agent +``` + +Replace `` with your Tailscale hostname or IP (e.g., `myserver` or `myserver.tail1234.ts.net`). + +### Usage + +```bash +# Create workspace and clone a repo +perry start my-proj --clone git@github.com:user/repo.git + +# Shell into the workspace +perry shell my-proj + +# Or attach an AI coding agent directly +opencode attach http://my-proj:4096 +``` + +See [CLI Reference](./cli.md) for all available commands. ## Web UI @@ -38,40 +67,12 @@ By default, Perry also provides direct access to your host machine (not just con Disable with `perry agent run --no-host-access` if you only want container access. -## Mobile App - -The Perry mobile app provides the same capabilities as the web UI in a native experience optimized for iOS and Android. - -![Mobile App](/img/demo-terminal-mobile.gif) - -### Building for Your Device - -The app is built with Expo. To run it on your own device: - -**Prerequisites:** -- Node.js or Bun -- Xcode (iOS) or Android Studio (Android) -- [Expo CLI](https://docs.expo.dev/get-started/installation/) +## SSH Access -**Development build:** +Every workspace is reachable directly on your tailnet using its Tailscale hostname. ```bash -cd mobile -bun install - -# iOS (requires Mac with Xcode) -bun run ios - -# Android -bun run android +ssh ubuntu@ ``` -This builds and installs a development version directly to your connected device or simulator. - -**Connecting to your agent:** - -The app needs your agent's address. In the app settings, enter your Tailscale hostname (e.g., `myserver:7391` or `myserver.tail1234.ts.net`). - -### App Store - -An iOS App Store version is coming soon. Follow the [GitHub repository](https://github.com/gricha/perry) for updates. +If you prefer local tooling, you can also use `perry shell` to hop into a workspace from any client machine on your tailnet. diff --git a/docs/docs/workflows.md b/docs/docs/workflows.md index 157298b1..fe0c3197 100644 --- a/docs/docs/workflows.md +++ b/docs/docs/workflows.md @@ -24,14 +24,6 @@ perry clone myproject myproject-experiment 4. Enter a name for the new workspace 5. Click "Clone Workspace" -### Via Mobile App - -1. Open the workspace details -2. Go to Settings -3. Tap "Clone Workspace" in the Clone section -4. Enter a name for the new workspace -5. Tap "Clone" - ### What Gets Cloned - All files in `/home/workspace` (your code, configurations, etc.) @@ -128,7 +120,7 @@ perry shell my-app claude ``` -Or use the Web UI's chat interface directly. +Or launch the agent directly in the Web UI terminal. ### OpenCode diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index f1dbed48..1dc3e3d9 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -14,12 +14,12 @@ function HomepageHeader() { Perry -

- Self-hosted dev containers, accessible anywhere -

-

- Docker workspaces with SSH, Web UI, and AI coding tools built in -

+

+ Self-hosted dev containers, auto-registered on your tailnet +

+

+ Docker workspaces with Tailscale, SSH, Web UI, and AI coding tools built in +

- Work from your phone, tablet, or any browser via Tailscale. + Workspaces auto-register on your tailnet for direct access from any device.

-
-
- Terminal on mobile -

Mobile terminal

-
-
- AI chat on mobile -

AI sessions on the go

-
-
Web UI demo -

Full Web UI for desktop

+

Web UI, CLI, and SSH stay in sync

diff --git a/docs/src/pages/privacy.md b/docs/src/pages/privacy.md index ead16b06..5ff7e5d3 100644 --- a/docs/src/pages/privacy.md +++ b/docs/src/pages/privacy.md @@ -4,17 +4,17 @@ ## Overview -Perry is an open-source tool for creating isolated Docker-in-Docker development environments. This privacy policy covers the Perry mobile application. +Perry is an open-source tool for creating isolated Docker-in-Docker development environments. This privacy policy covers the Perry web UI. ## Data Collection **Perry does not collect, store, or transmit any personal data to external servers.** -The Perry mobile app: +The Perry web UI: - Connects only to your self-hosted Perry agent on your local network or via Tailscale - Does not include any analytics, tracking, or telemetry - Does not require user accounts or authentication with external services -- Stores configuration locally on your device only +- Stores configuration locally in your browser only ## Local Network Access @@ -26,7 +26,7 @@ Perry requires local network access to communicate with your Perry agent. This c ## Data Storage All data is stored locally: -- **On your device:** App preferences and agent connection settings +- **In your browser:** UI preferences and agent connection settings - **On your Perry agent:** Workspace configurations and state (self-hosted by you) ## Third-Party Services diff --git a/test/web/workspace.spec.ts b/test/web/workspace.spec.ts index d09794f7..cc97d291 100644 --- a/test/web/workspace.spec.ts +++ b/test/web/workspace.spec.ts @@ -235,7 +235,9 @@ test.describe('Web UI - Terminal', () => { const sessionsTab = page.getByRole('button', { name: /sessions/i }); await sessionsTab.click(); - await expect(page.getByRole('button', { name: /new chat/i })).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole('button', { name: /new session/i })).toBeVisible({ + timeout: 10000, + }); } finally { await agent.api.deleteWorkspace(workspaceName); } @@ -262,7 +264,9 @@ test.describe('Web UI - Sessions', () => { try { await page.goto(`http://127.0.0.1:${agent.port}/workspaces/${workspaceName}?tab=sessions`); - await expect(page.getByRole('button', { name: /new chat/i })).toBeVisible({ timeout: 30000 }); + await expect(page.getByRole('button', { name: /new session/i })).toBeVisible({ + timeout: 30000, + }); } finally { await agent.api.deleteWorkspace(workspaceName); } @@ -288,13 +292,15 @@ test.describe('Web UI - Sessions', () => { try { await page.goto(`http://127.0.0.1:${agent.port}/workspaces/${workspaceName}?tab=sessions`); - await expect(page.getByRole('button', { name: /new chat/i })).toBeVisible({ timeout: 30000 }); + await expect(page.getByRole('button', { name: /new session/i })).toBeVisible({ + timeout: 30000, + }); } finally { await agent.api.deleteWorkspace(workspaceName); } }, 120000); - test('sessions list shows prompt and clicking opens chat directly', async ({ agent, page }) => { + test('sessions list shows prompt and clicking opens terminal', async ({ agent, page }) => { const workspaceName = generateTestWorkspaceName(); const sessionId = `test-session-${Date.now()}`; const filePath = `/home/workspace/.claude/projects/-workspace/${sessionId}.jsonl`; @@ -319,22 +325,20 @@ test.describe('Web UI - Sessions', () => { await sessionItem.click(); - await expect(page.getByText('Claude Code', { exact: true })).toBeVisible({ timeout: 30000 }); - await expect(page.getByPlaceholder('Send a message...')).toBeVisible(); + await expect(page.getByText('Agent Terminal')).toBeVisible({ timeout: 30000 }); + await expect(page.locator('[data-testid="terminal-screen"]')).toBeVisible(); } finally { await agent.api.deleteWorkspace(workspaceName); } }, 120000); - test('clicking session loads conversation history', async ({ agent, page }) => { + test('clicking session opens terminal with resume command', async ({ agent, page }) => { const workspaceName = generateTestWorkspaceName(); const sessionId = `history-test-${Date.now()}`; const filePath = `/home/workspace/.claude/projects/-workspace/${sessionId}.jsonl`; const sessionContent = [ '{"type":"user","message":{"content":"What is 2+2?"},"timestamp":"2026-01-01T00:00:00.000Z"}', '{"type":"assistant","message":{"content":[{"type":"text","text":"2+2 equals 4"}]},"timestamp":"2026-01-01T00:00:01.000Z"}', - '{"type":"user","message":{"content":"Thanks!"},"timestamp":"2026-01-01T00:00:02.000Z"}', - '{"type":"assistant","message":{"content":[{"type":"text","text":"You are welcome!"}]},"timestamp":"2026-01-01T00:00:03.000Z"}', ].join('\n'); await agent.api.createWorkspace({ name: workspaceName }); @@ -353,39 +357,34 @@ test.describe('Web UI - Sessions', () => { await sessionItem.click(); - await expect(page.getByText('Claude Code', { exact: true })).toBeVisible({ timeout: 30000 }); - await expect(page.getByText('2+2 equals 4')).toBeVisible({ timeout: 10000 }); - await expect(page.getByText('Thanks!')).toBeVisible(); - await expect(page.getByText('You are welcome!')).toBeVisible(); + await expect(page.getByText('Agent Terminal')).toBeVisible({ timeout: 30000 }); + await expect(page.locator('[data-testid="terminal-screen"]')).toBeVisible(); } finally { await agent.api.deleteWorkspace(workspaceName); } }, 120000); - test('clicking new chat opens chat UI', async ({ agent, page }) => { + test('clicking new session opens terminal', async ({ agent, page }) => { const workspaceName = generateTestWorkspaceName(); await agent.api.createWorkspace({ name: workspaceName }); try { await page.goto(`http://127.0.0.1:${agent.port}/workspaces/${workspaceName}?tab=sessions`); - await page.getByRole('button', { name: /new chat/i }).click(); + await page.getByRole('button', { name: /new session/i }).click(); await page.getByText('Claude Code').first().click(); - await expect(page.getByPlaceholder('Send a message...')).toBeVisible({ timeout: 30000 }); + await expect(page.getByText('Agent Terminal')).toBeVisible({ timeout: 30000 }); + await expect(page.locator('[data-testid="terminal-screen"]')).toBeVisible(); } finally { await agent.api.deleteWorkspace(workspaceName); } }, 120000); - test('resuming session sends projectPath in WebSocket connect message', async ({ - agent, - page, - }) => { + test('resuming session from project folder opens terminal', async ({ agent, page }) => { const workspaceName = generateTestWorkspaceName(); const sessionId = `project-path-test-${Date.now()}`; const projectDir = '-home-workspace-myproject'; - const expectedProjectPath = '/home/workspace/myproject'; const filePath = `/home/workspace/.claude/projects/${projectDir}/${sessionId}.jsonl`; const sessionContent = [ '{"type":"user","message":{"content":"Test message"},"timestamp":"2026-01-01T00:00:00.000Z"}', @@ -399,21 +398,6 @@ test.describe('Web UI - Sessions', () => { ); try { - let capturedConnectMessage: { sessionId?: string; projectPath?: string } | null = null; - - page.on('websocket', (ws) => { - ws.on('framesent', (frame) => { - try { - const data = JSON.parse(frame.payload as string); - if (data.type === 'connect' && ws.url().includes('/rpc/live/claude/')) { - capturedConnectMessage = data; - } - } catch { - // Ignore non-JSON frames - } - }); - }); - await page.goto(`http://127.0.0.1:${agent.port}/workspaces/${workspaceName}?tab=sessions`); const sessionItem = page @@ -424,13 +408,8 @@ test.describe('Web UI - Sessions', () => { await sessionItem.click(); - await expect(page.getByPlaceholder('Send a message...')).toBeVisible({ timeout: 30000 }); - - await page.waitForTimeout(1000); - - expect(capturedConnectMessage).not.toBeNull(); - expect(capturedConnectMessage?.sessionId).toBeTruthy(); - expect(capturedConnectMessage?.projectPath).toBe(expectedProjectPath); + await expect(page.getByText('Agent Terminal')).toBeVisible({ timeout: 30000 }); + await expect(page.locator('[data-testid="terminal-screen"]')).toBeVisible(); } finally { await agent.api.deleteWorkspace(workspaceName); } diff --git a/web/src/components/Terminal.tsx b/web/src/components/Terminal.tsx index 82fab994..5c26972a 100644 --- a/web/src/components/Terminal.tsx +++ b/web/src/components/Terminal.tsx @@ -1,10 +1,11 @@ -import { useEffect, useRef, useState, useCallback } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web' import { getTerminalUrl } from '@/lib/api' interface TerminalProps { workspaceName: string initialCommand?: string + runId?: string } const MAX_CACHED_TERMINALS = 5 @@ -16,6 +17,7 @@ interface CachedTerminal { ws: WebSocket | null lastUsed: number initialCommandSent: boolean + runId?: string } const terminalCache = new Map() @@ -26,10 +28,10 @@ function evictLRU(): void { let oldest: string | null = null let oldestTime = Infinity - for (const [name, cached] of terminalCache) { + for (const [key, cached] of terminalCache) { if (cached.lastUsed < oldestTime) { oldestTime = cached.lastUsed - oldest = name + oldest = key } } @@ -44,10 +46,10 @@ function evictLRU(): void { } function getOrCreateTerminal( - workspaceName: string, + cacheKey: string, ghosttyFactory: () => Promise ): Promise { - const existing = terminalCache.get(workspaceName) + const existing = terminalCache.get(cacheKey) if (existing) { existing.lastUsed = Date.now() return Promise.resolve(existing) @@ -99,14 +101,14 @@ function getOrCreateTerminal( initialCommandSent: false, } - terminalCache.set(workspaceName, cached) + terminalCache.set(cacheKey, cached) evictLRU() return cached }) } -function TerminalInstance({ workspaceName, initialCommand }: TerminalProps) { +function TerminalInstance({ workspaceName, initialCommand, runId }: TerminalProps) { const terminalRef = useRef(null) const cachedRef = useRef(null) const resizeObserverRef = useRef(null) @@ -114,9 +116,15 @@ function TerminalInstance({ workspaceName, initialCommand }: TerminalProps) { const [isInitialized, setIsInitialized] = useState(false) const [hasReceivedData, setHasReceivedData] = useState(false) + const cacheKey = useMemo(() => `${workspaceName}:${runId ?? 'default'}`, [workspaceName, runId]) + const setupWebSocket = useCallback((cached: CachedTerminal, cancelled: { current: boolean }) => { if (cached.ws && cached.ws.readyState === WebSocket.OPEN) { setIsConnected(true) + if (initialCommand && !cached.initialCommandSent) { + cached.initialCommandSent = true + cached.ws.send(initialCommand + '\n') + } return } @@ -182,11 +190,15 @@ function TerminalInstance({ workspaceName, initialCommand }: TerminalProps) { const connect = async () => { if (!terminalRef.current || cancelled.current) return - const cached = await getOrCreateTerminal(workspaceName, () => Ghostty.load()) + const cached = await getOrCreateTerminal(cacheKey, () => Ghostty.load()) if (cancelled.current) return cachedRef.current = cached cached.lastUsed = Date.now() + if (cached.runId !== runId) { + cached.initialCommandSent = false + cached.runId = runId + } setIsInitialized(true) const term = cached.terminal @@ -264,7 +276,7 @@ function TerminalInstance({ workspaceName, initialCommand }: TerminalProps) { } cachedRef.current = null } - }, [workspaceName, setupWebSocket]) + }, [cacheKey, runId, setupWebSocket, workspaceName]) return ( <> @@ -294,13 +306,14 @@ function TerminalInstance({ workspaceName, initialCommand }: TerminalProps) { ) } -export function Terminal({ workspaceName, initialCommand }: TerminalProps) { +export function Terminal({ workspaceName, initialCommand, runId }: TerminalProps) { return (
) diff --git a/web/src/pages/WorkspaceDetail.tsx b/web/src/pages/WorkspaceDetail.tsx index b05401ba..3757b870 100644 --- a/web/src/pages/WorkspaceDetail.tsx +++ b/web/src/pages/WorkspaceDetail.tsx @@ -33,7 +33,6 @@ import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Terminal } from '@/components/Terminal' -import { Chat } from '@/components/Chat' import { cn } from '@/lib/utils' import { AgentIcon } from '@/components/AgentIcon' import { @@ -58,6 +57,7 @@ import { type TabType = 'sessions' | 'terminal' | 'settings' const AGENT_LABELS: Record = { + all: 'All Agents', 'claude-code': 'Claude Code', opencode: 'OpenCode', @@ -338,7 +338,7 @@ function SessionListItem({ ) } -type ChatMode = { type: 'chat'; sessionId?: string; agentType?: AgentType; projectPath?: string } | { type: 'terminal'; command: string } +type TerminalMode = { type: 'terminal'; command: string; runId?: string } export function WorkspaceDetail() { const { name: rawName } = useParams<{ name: string }>() @@ -351,53 +351,50 @@ export function WorkspaceDetail() { const currentTab = (searchParams.get('tab') as TabType) || 'sessions' const sessionParam = searchParams.get('session') const agentParam = searchParams.get('agent') as AgentType | null + const runIdParam = searchParams.get('runId') - const [projectPathOverride, setProjectPathOverride] = useState(undefined) - - const chatMode: ChatMode | null = useMemo(() => { + const terminalMode: TerminalMode | null = useMemo(() => { if (!sessionParam && !agentParam) return null if (agentParam === 'codex') { - return { type: 'terminal', command: sessionParam ? `codex resume ${sessionParam}` : 'codex' } + return { type: 'terminal', command: sessionParam ? `codex resume ${sessionParam}` : 'codex', runId: runIdParam ?? undefined } + } + if (agentParam && sessionParam) { + return { type: 'terminal', command: `${agentParam} resume ${sessionParam}` } } - return { - type: 'chat', - sessionId: sessionParam || undefined, - agentType: (agentParam || 'claude-code') as AgentType, - projectPath: projectPathOverride, + if (agentParam) { + return { type: 'terminal', command: agentParam, runId: runIdParam ?? undefined } } - }, [sessionParam, agentParam, projectPathOverride]) + return null + }, [sessionParam, agentParam, runIdParam]) - const setChatMode = useCallback((mode: ChatMode | null) => { + const setTerminalMode = useCallback((mode: TerminalMode | null) => { if (!mode) { - setProjectPathOverride(undefined) setSearchParams((prev) => { const next = new URLSearchParams(prev) next.delete('session') next.delete('agent') + next.delete('runId') return next }) - } else if (mode.type === 'chat') { - if (mode.projectPath) { - setProjectPathOverride(mode.projectPath) - } - setSearchParams((prev) => { - const next = new URLSearchParams(prev) - if (mode.sessionId) next.set('session', mode.sessionId) - else next.delete('session') - if (mode.agentType) next.set('agent', mode.agentType) - return next - }) - } else if (mode.type === 'terminal') { - setSearchParams((prev) => { - const next = new URLSearchParams(prev) - next.set('agent', 'codex') - if (mode.command.includes('resume')) { - const match = mode.command.match(/resume\s+(\S+)/) - if (match) next.set('session', match[1]) - } - return next - }) + return } + + setSearchParams((prev) => { + const next = new URLSearchParams(prev) + const resumeMatch = mode.command.match(/^(\S+)\s+resume\s+(\S+)/) + if (resumeMatch) { + next.set('agent', resumeMatch[1]) + next.set('session', resumeMatch[2]) + next.delete('runId') + } else { + next.set('agent', mode.command) + next.delete('session') + if (mode.runId) { + next.set('runId', mode.runId) + } + } + return next + }) }, [setSearchParams]) const [agentFilter, setAgentFilter] = useState('all') @@ -428,20 +425,6 @@ export function WorkspaceDetail() { }) } - const handleSessionId = useCallback((sessionId: string) => { - if (name && chatMode?.type === 'chat') { - const agent = chatMode.agentType || 'claude-code' - api.recordSessionAccess(name, sessionId, agent).catch(() => {}) - queryClient.invalidateQueries({ queryKey: ['sessions', name] }) - setSearchParams((prev) => { - const next = new URLSearchParams(prev) - next.set('session', sessionId) - next.set('agent', agent) - return next - }) - } - }, [name, chatMode, queryClient, setSearchParams]) - const { data: hostInfo, isLoading: hostLoading } = useQuery({ queryKey: ['hostInfo'], queryFn: api.getHostInfo, @@ -479,14 +462,6 @@ export function WorkspaceDetail() { return sessionList.filter((session) => matchingIds.has(session.id)) }, [sessions, debouncedQuery, searchData]) - useEffect(() => { - if (sessionParam && !projectPathOverride && sessions) { - const session = sessions.find(s => s.id === sessionParam) - if (session?.projectPath) { - setProjectPathOverride(session.projectPath) - } - } - }, [sessionParam, projectPathOverride, sessions]) const startMutation = useMutation({ mutationFn: () => api.startWorkspace(name!), @@ -501,7 +476,7 @@ export function WorkspaceDetail() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['workspace', name] }) queryClient.invalidateQueries({ queryKey: ['workspaces'] }) - setChatMode(null) + setTerminalMode(null) }, }) @@ -559,26 +534,17 @@ export function WorkspaceDetail() { }) const handleResume = (session: SessionInfo) => { - if (session.agentType === 'claude-code' || session.agentType === 'opencode') { - setChatMode({ - type: 'chat', - sessionId: session.id, - agentType: session.agentType, - projectPath: session.projectPath, - }) - } else { - const resumeId = session.agentSessionId || session.id - setChatMode({ type: 'terminal', command: `codex resume ${resumeId}` }) + const resumeId = session.agentSessionId || session.id + setTerminalMode({ type: 'terminal', command: `${session.agentType} resume ${resumeId}` }) + if (name) { + api.recordSessionAccess(name, session.id, session.agentType).catch(() => {}) + queryClient.invalidateQueries({ queryKey: ['sessions', name] }) } } - const handleNewChat = (agentType: AgentType = 'claude-code') => { - setProjectPathOverride(undefined) - if (agentType === 'claude-code' || agentType === 'opencode') { - setChatMode({ type: 'chat', agentType }) - } else { - setChatMode({ type: 'terminal', command: 'codex' }) - } + const handleNewSession = (agentType: AgentType = 'claude-code') => { + const sessionId = `${agentType}-${Date.now()}` + setTerminalMode({ type: 'terminal', command: agentType, runId: sessionId }) } if (isLoading) { @@ -772,31 +738,24 @@ export function WorkspaceDetail() {
{!isRunning ? ( renderStartPrompt() - ) : chatMode ? ( - chatMode.type === 'chat' ? ( - setChatMode(null)} - /> - ) : ( -
-
- - Agent Terminal -
-
- -
+ ) : terminalMode ? ( +
+
+ + Agent Terminal +
+
+
- ) +
) : ( <>
@@ -847,19 +806,19 @@ export function WorkspaceDetail() { - handleNewChat('claude-code')}> + handleNewSession('claude-code')}> Claude Code - handleNewChat('opencode')}> + handleNewSession('opencode')}> OpenCode - handleNewChat('codex')}> + handleNewSession('codex')}> Codex @@ -874,25 +833,27 @@ export function WorkspaceDetail() {
) : !sessions || sessions.length === 0 ? (
- -

No sessions yet

+ +

No agent sessions yet

+ - handleNewChat('claude-code')}> + handleNewSession('claude-code')}> Claude Code - handleNewChat('opencode')}> + handleNewSession('opencode')}> OpenCode - handleNewChat('codex')}> + handleNewSession('codex')}> Codex