diff --git a/agents/dashboard/__tests__/StudioView.newTask.test.tsx b/agents/dashboard/__tests__/StudioView.newTask.test.tsx new file mode 100644 index 00000000..671a031e --- /dev/null +++ b/agents/dashboard/__tests__/StudioView.newTask.test.tsx @@ -0,0 +1,51 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi, beforeAll } from 'vitest' + +// jsdom doesn't implement scrollIntoView; StreamFeed calls it unconditionally +// on mount to follow the tail of the stream. +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn() +}) + +const mockReset = vi.fn() +const mockUseStudioSession = vi.fn() + +vi.mock('@/hooks/useStudioSession', () => ({ + useStudioSession: () => mockUseStudioSession(), + pendingApprovals: () => [], +})) + +import { StudioView } from '../components/views/StudioView' + +function baseSession() { + return { + sessionId: 'cs_1', + status: 'review', + connected: false, + stream: [], + diff: 'diff --git a/x b/x\n+hi', + mergeSha: null, + error: null, + start: vi.fn(), + merge: vi.fn(), + discard: vi.fn(), + reset: mockReset, + respondApproval: vi.fn(), + } +} + +describe('StudioView "New task"', () => { + it('clears the stale prompt text and resets the session', () => { + mockUseStudioSession.mockReturnValue(baseSession()) + render() + + const textarea = screen.getByLabelText('Task description') + fireEvent.change(textarea, { target: { value: 'Add a rate limit to /events' } }) + + const newTaskBtn = screen.getByRole('button', { name: /new task/i }) + fireEvent.click(newTaskBtn) + + expect(mockReset).toHaveBeenCalledTimes(1) + expect(textarea).toHaveValue('') + }) +}) diff --git a/agents/dashboard/components/views/StudioView.tsx b/agents/dashboard/components/views/StudioView.tsx index d6b69370..f525d339 100644 --- a/agents/dashboard/components/views/StudioView.tsx +++ b/agents/dashboard/components/views/StudioView.tsx @@ -1,265 +1 @@ -'use client' - -import React, { useMemo, useState } from 'react' -import { Pane } from '@/components/shell/Pane' -import { StreamFeed } from '@/components/studio/StreamFeed' -import { DiffPanel } from '@/components/studio/DiffPanel' -import { useToast } from '@/components/ui/ToastProvider' -import { useStudioSession, pendingApprovals, type StudioStatus, type StreamItem } from '@/hooks/useStudioSession' - -const STATUS_META: Record = { - idle: { label: 'ready', color: 'var(--text-secondary)', live: false }, - pending: { label: 'starting', color: 'var(--accent-cyan)', live: true }, - running: { label: 'building', color: 'var(--accent-cyan)', live: true }, - review: { label: 'review the diff', color: 'var(--accent-amber)', live: false }, - merged: { label: 'merged', color: 'var(--accent-green)', live: false }, - discarded: { label: 'discarded', color: 'var(--text-secondary)', live: false }, - failed: { label: 'failed', color: 'var(--accent-red)', live: false }, -} - -function slugify(text: string): string { - return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 32) || 'task' -} - -// Per-task model choice. Sonnet is the default — near-Opus on coding at a -// fraction of the cost. The service accepts any valid id; these are the set -// worth offering in the UI. -const MODELS: { id: string; label: string }[] = [ - { id: 'claude-sonnet-5', label: 'Sonnet 5 · balanced (default)' }, - { id: 'claude-opus-4-8', label: 'Opus 4.8 · most capable' }, - { id: 'claude-haiku-4-5', label: 'Haiku 4.5 · fast & cheap' }, - { id: 'claude-fable-5', label: 'Fable 5 · top tier' }, -] - -export function StudioView(): React.JSX.Element { - const [focus, setFocus] = useState(null) - const [prompt, setPrompt] = useState('') - const [model, setModel] = useState(MODELS[0].id) - const [merging, setMerging] = useState(false) - const { toast } = useToast() - const s = useStudioSession() - - const meta = STATUS_META[s.status] - const running = s.status === 'running' || s.status === 'pending' - const pending = useMemo(() => pendingApprovals(s.stream), [s.stream]) - - const gridTemplate = focus - ? `"${focus} ${focus} ${focus}" 1fr / 1fr 1fr 1fr` - : `"task stream diff" 1fr / 340px 1fr 1fr` - - const submit = async () => { - const p = prompt.trim() - if (!p || running) return - toast({ variant: 'info', title: 'Studio', message: 'Handing the task to the agent…' }) - await s.start(p, slugify(p), model) - } - - const merge = async () => { - setMerging(true) - try { - const result = await s.merge() - if (result.ok) { - toast({ variant: 'success', title: 'Merged', message: 'The change landed on the branch. Nice one!' }) - } else { - // e.g. a merge collision — show the service's plain-language reason. - toast({ variant: 'error', title: "Couldn't merge", message: result.detail ?? 'Merge failed.' }) - } - } finally { - setMerging(false) - } - } - - const discard = async () => { - await s.discard() - toast({ variant: 'info', title: 'Discarded', message: 'Worktree thrown away — nothing was written.' }) - } - - return ( -
- setFocus(focus === 'task' ? null : 'task')} - > -
- - -