diff --git a/ui/harness/harness-board.ts b/ui/harness/harness-board.ts index cbbab11f..00564be2 100644 --- a/ui/harness/harness-board.ts +++ b/ui/harness/harness-board.ts @@ -34,7 +34,7 @@ import { KanbanModal } from '../src/board/KanbanModal.js' import { scopeTheme } from '../src/board/workspace/themeScope.js' import { workshopExample } from './workshop-example.js' -import { installWorkspaceNativeURLs, NOTE_FIBERS, WORKSPACE_HOST, workspaceExample } from './workspace-fixtures.js' +import { installWorkspaceNativeURLs, NOTE_FIBERS, WORKSPACE_HOST, WORKSPACE_ID, workspaceExample } from './workspace-fixtures.js' import { openCapture, openStash, openSettings } from '../src/forms/mountForms.js' import { showToast } from '../src/board/utils.js' import type { @@ -79,7 +79,7 @@ let mockMeeting: Record | null = meetingScenario === 'live' || state: 'live', title: 'Shear telecon', mirror_host: 'project-host', - fiber: meetingScenario === 'joined' ? 'work/spt3g_papers/bmodes-2d/run' : null, + fiber: meetingScenario === 'joined' ? (workspaceFixture ? WORKSPACE_ID : 'work/spt3g_papers/bmodes-2d/run') : null, scribe_session_uuid: meetingScenario === 'scribe' ? '6bc045dc-92e0-473a-bf9e-e1cc263223bc' : null, started_at: iso(-13 * 60_000 - 12_000), tail: MOCK_TAIL, diff --git a/ui/src/board/KanbanModal.ts b/ui/src/board/KanbanModal.ts index 20d18509..2b53db44 100644 --- a/ui/src/board/KanbanModal.ts +++ b/ui/src/board/KanbanModal.ts @@ -351,6 +351,8 @@ export class KanbanModal { canJoin: () => meetingJoinable(this.meetingStatus), join: (card, mode, note) => this.joinCardMeeting(card, mode, note), current: () => this.meetingStatus.meeting, + stop: (meeting) => this.stopCurrentMeeting(meeting), + stopRequested: (meeting) => this.meetingStopGuard.isRequested(meeting), }, workerPhase: (card) => findCardColumn(this.lastResponse, card.id) === 'inFlight', }, diff --git a/ui/src/board/workspace/Dock.integration.test.ts b/ui/src/board/workspace/Dock.integration.test.ts index 29e3162c..1d004a36 100644 --- a/ui/src/board/workspace/Dock.integration.test.ts +++ b/ui/src/board/workspace/Dock.integration.test.ts @@ -130,6 +130,35 @@ describe('anchored pickers', () => { meetingDock.reset() }) + it('stops the recording this constitution hosts, by the shared stop path', async () => { + let recording: Record | null = { title: 'Standup', state: 'live', fiber: 'a/task', tail: [] } + let requested = false + const stop = vi.fn(() => { requested = true }) + const control = { canJoin: () => false, current: () => recording, join: vi.fn(), stop, stopRequested: () => requested } + const meetingDock = new Dock('', saved, undefined, undefined, { meeting: control as never }) + const band = meetingDock.bandFor(task()) + document.body.append(band.el) + const button = band.el.querySelector('.kbn-detail-transcript-stop')! + expect([button.hidden, button.textContent, button.disabled]).toEqual([false, 'Stop', false]) + button.click() + expect(stop).toHaveBeenCalledWith(recording) + meetingDock.syncMeeting() + expect(button.disabled).toBe(true) + button.click() + expect(stop).toHaveBeenCalledTimes(1) + + recording = { ...recording, state: 'failed' } + requested = false + meetingDock.syncMeeting() + expect([button.hidden, button.textContent]).toEqual([false, 'Dismiss']) + + // A recording another constitution hosts shows no Stop here. + recording = { title: 'Standup', state: 'live', fiber: 'other', tail: [] } + meetingDock.syncMeeting() + expect(button.closest('section')!.hidden).toBe(true) + meetingDock.reset() + }) + it('keeps the switch on when a meeting fails to start', async () => { const control = { canJoin: () => true, current: () => null, join: vi.fn(async () => ({ error: 'hark unavailable', delivered: false })) } const meetingDock = new Dock('', saved, undefined, undefined, { meeting: control as never }) diff --git a/ui/src/board/workspace/Dock.ts b/ui/src/board/workspace/Dock.ts index 58b031f3..b3919d8f 100644 --- a/ui/src/board/workspace/Dock.ts +++ b/ui/src/board/workspace/Dock.ts @@ -4,7 +4,7 @@ import { CONVERSATION_OPENING_CHANGED } from '../conversationOpening.js' import { hasLiveWorker, hasWorkerToStop, type ColumnKind, type KanbanCard, type ShuttleKind } from '../KanbanTypes.js' import { agentGroups } from '../../forms/agents.js' import { MEETING_MODES, type MeetingMode } from '../../forms/meetingApi.js' -import { meetingHostCard, meetingStateWord, paintTranscript, type MeetingRecord } from '../meeting.js' +import { meetingActions, meetingHostCard, meetingStateWord, paintTranscript, type MeetingRecord } from '../meeting.js' import { defaultSurface, isCodexAgent, persistedSurface, type ExecutionSurface } from '../../forms/executionSurface.js' import { dispatchFailureMessage, isAgentCard, needsProjectDir, postDaemonJson, postForceDispatch, type DispatchFailureBody } from '../KanbanModalShared.js' import { buildProjectDirPrompt } from '../projectDirPrompt.js' @@ -71,6 +71,10 @@ export interface MeetingJoinControl { join(card: KanbanCard, mode: MeetingMode, note: () => Promise): Promise /** The meeting the board last observed, if any. */ current(): MeetingRecord | null + /** Stop (or dismiss, once failed) the recording: the board's one stop path. */ + stop?(meeting: MeetingRecord): void | Promise + /** Whether a stop for `meeting` has been asked and not yet observed. */ + stopRequested?(meeting: MeetingRecord): boolean } export interface SessionWindow { @@ -627,7 +631,16 @@ export class Dock { state.className = 'kbn-detail-transcript-state' const title = document.createElement('span') title.className = 'kbn-detail-transcript-title' - meta.append(dot, state, title) + const stop = document.createElement('button') + stop.type = 'button' + stop.className = 'kbn-detail-transcript-stop' + stop.addEventListener('click', (e) => { + e.stopPropagation() + const current = this.meeting?.current() + if (!current || this.meeting?.stopRequested?.(current)) return + void this.meeting?.stop?.(current) + }) + meta.append(dot, state, title, stop) const list = document.createElement('ol') list.className = 'kbn-detail-transcript-lines' list.setAttribute('aria-label', 'Transcript') @@ -650,8 +663,16 @@ export class Dock { if (!pane || !card) return const meeting = this.meeting?.current() ?? null const hosted = meetingHostCard(meeting, [card]) !== null - pane.hidden = !hosted || meeting === null || meeting.tail.length === 0 + pane.hidden = !hosted || meeting === null if (!meeting || !hosted) return + const actions = this.meeting?.stop ? meetingActions(meeting, this.meeting.stopRequested?.(meeting) ?? false) : null + const stop = pane.querySelector('.kbn-detail-transcript-stop')! + stop.hidden = !actions + if (actions) { + stop.textContent = actions.dismiss ? 'Dismiss' : 'Stop' + stop.disabled = actions.stopDisabled + } + pane.querySelector('.kbn-detail-transcript-lines')!.hidden = meeting.tail.length === 0 for (const st of ['starting', 'loading', 'live', 'stopping', 'failed']) { pane.classList.toggle(`kbn-detail-transcript-${st}`, meeting.state === st) } @@ -1152,6 +1173,7 @@ export class Dock { input.addEventListener('change', () => arm(input.checked)) this.meetingArmed = () => input.checked && !input.disabled + const paint = (): void => { const control = this.meeting if (!control) return diff --git a/ui/src/board/workspace/dock.css b/ui/src/board/workspace/dock.css index ac46dc70..082ab576 100644 --- a/ui/src/board/workspace/dock.css +++ b/ui/src/board/workspace/dock.css @@ -270,6 +270,8 @@ .ws-dock .kbn-detail-transcript-live .kbn-detail-transcript-dot { background: var(--ws-ink); } .ws-dock .kbn-detail-transcript-failed .kbn-detail-transcript-dot { background: var(--ws-red); } .ws-dock .kbn-detail-transcript-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--ws-label-size)/1.2 var(--ws-serif); } +.ws-dock button.kbn-detail-transcript-stop:not(.kbn-card-worker) { margin-left: auto; flex: none; padding: 2px 6px; border: 0; background: transparent; color: var(--ws-stop); font: var(--ws-small-size) var(--ws-mono); cursor: pointer; } +.ws-dock .kbn-detail-transcript-stop:disabled { opacity: .55; cursor: default; } .ws-dock .kbn-detail-transcript-lines { margin: 0; padding: 0; list-style: none; max-height: 9em; overflow-y: auto; overscroll-behavior: contain; font: var(--ws-prose-size)/1.4 var(--ws-serif); overflow-wrap: anywhere; } .ws-dock .kbn-meeting-speaker { margin-right: 5px; color: var(--ws-ink-muted); font: var(--ws-label-size)/1.4 var(--ws-serif); } .ws-dock .kbn-ctl-history-toggle { display: flex; align-items: center; justify-content: space-between; width: 100%; min-height: var(--ctl-h); padding: 4px 0; border: none; background: transparent; color: var(--ws-ink-muted); } diff --git a/ui/src/board/workspace/tokens.css b/ui/src/board/workspace/tokens.css index e335e09a..318b2bcd 100644 --- a/ui/src/board/workspace/tokens.css +++ b/ui/src/board/workspace/tokens.css @@ -39,6 +39,7 @@ --ws-agent: var(--kbn-agent); --ws-verdict: var(--kbn-tempered); --ws-red: var(--ws-owed); + --ws-stop: var(--kbn-error); --ws-serif: var(--font-serif, 'EB Garamond', Georgia, serif); --ws-mono: var(--font-mono, 'IBM Plex Mono', ui-monospace, monospace); diff --git a/ui/src/forms/CaptureForm.tsx b/ui/src/forms/CaptureForm.tsx index afa31bfc..586e198a 100644 --- a/ui/src/forms/CaptureForm.tsx +++ b/ui/src/forms/CaptureForm.tsx @@ -112,7 +112,7 @@ export function CaptureForm({ const [chrome, setChrome] = useState(false) const [surface, setSurface] = useState('cli') const [mobileMeeting] = useState(() => isMobileViewport()) - const [meetingIntent, setMeetingIntent] = useState(() => mobileMeeting) + const [meetingIntent, setMeetingIntent] = useState(false) const [meetingMode, setMeetingMode] = useState(() => mobileMeeting ? 'phone' : null) const [meetingCapabilities, setMeetingCapabilities] = useState({ loading: true, diff --git a/ui/src/forms/captureCapabilities.integration.test.tsx b/ui/src/forms/captureCapabilities.integration.test.tsx index 595cda92..3929bb47 100644 --- a/ui/src/forms/captureCapabilities.integration.test.tsx +++ b/ui/src/forms/captureCapabilities.integration.test.tsx @@ -48,7 +48,7 @@ const yap = () => act(() => { Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(element, 'A thought') element.dispatchEvent(new Event('input', { bubbles: true })) }) -const mount = async (overrides: Partial = {}) => { +const mount = async (overrides: Partial = {}, armed = true) => { const container = document.createElement('div') document.body.append(container) root = createRoot(container) @@ -58,6 +58,8 @@ const mount = async (overrides: Partial = {}) => { phoneAudio={{ begin }} {...overrides} />)) await tick() + const toggle = document.querySelector('.capture-meeting-toggle') + if (mobile && armed && toggle?.getAttribute('aria-pressed') === 'false') { await click('.capture-meeting-toggle'); await tick() } } const meetingReads = () => fetcher.mock.calls.filter(([url]) => String(url).includes('/meeting')) const captureBody = (): Record => { @@ -127,9 +129,12 @@ describe('rendered Capture capabilities', () => { expect(bind).toHaveBeenCalledWith(7, expect.objectContaining({ launch: 'fake-launch' })) }) - it('mobile narrows Mac modes to phone, defaults on, and offers no selector', async () => { + it('mobile narrows Mac modes to phone, starts off, and offers no selector', async () => { mobile = true - await mount() + await mount({}, false) + expect(query('.capture-meeting-toggle').getAttribute('aria-pressed')).toBe('false') + expect(query('.form-submit').textContent).toBe('Spawn') + click('.capture-meeting-toggle') expect(query('.capture-meeting-toggle').getAttribute('aria-pressed')).toBe('true') expect(document.querySelector('[role="radiogroup"]')).toBeNull() expect(document.activeElement?.tagName).not.toBe('TEXTAREA') diff --git a/ui/src/forms/phoneCapture.integration.test.tsx b/ui/src/forms/phoneCapture.integration.test.tsx index 2ca8b524..db975a90 100644 --- a/ui/src/forms/phoneCapture.integration.test.tsx +++ b/ui/src/forms/phoneCapture.integration.test.tsx @@ -44,12 +44,15 @@ const click = (selector: string) => { expect(button).not.toBeNull() act(() => button.click()) } -const mount = async (overrides: Partial = {}) => { +// Meeting starts off; most of these tests exercise it on, so `mount` switches it on unless asked not to. +const mount = async (overrides: Partial = {}, armed = true) => { const host = document.createElement('div') document.body.append(host) root = createRoot(host) await act(async () => root!.render()) await tick() + const toggle = document.querySelector('.capture-meeting-toggle') + if (armed && toggle?.getAttribute('aria-pressed') === 'false') { act(() => toggle.click()); await tick() } } const unmount = async () => { if (root) { await act(async () => root!.unmount()); root = null } } const choices = () => [...document.querySelectorAll('.form-select')].map((select) => select.value) @@ -148,6 +151,18 @@ describe('phone Capture form wiring', () => { expect(phone.session.mic).toBe(opened) }) + it('starts mobile Capture with Meeting off, so the default action is an ordinary new session', async () => { + localStorage.setItem(MEETING_PROJECT_KEY, JSON.stringify(saved)) + await mount({}, false) + expect(document.querySelector('.capture-meeting-toggle')?.getAttribute('aria-pressed')).toBe('false') + expect(document.querySelector('.form-submit')?.textContent).toBe('Spawn') + expect(choices().slice(0, 2)).toEqual([saved.hostId, saved.projectId]) + click('.capture-meeting-toggle') + expect(document.querySelector('.capture-meeting-toggle')?.getAttribute('aria-pressed')).toBe('true') + expect(document.querySelector('[role="radiogroup"]')).toBeNull() + expect(document.querySelector('.form-submit')?.textContent).toBe('Start meeting') + }) + it('defaults mobile Capture to Phone, restores its successful host/project, and leaves the keyboard closed', async () => { localStorage.setItem(MEETING_PROJECT_KEY, JSON.stringify(saved)) await mount() @@ -163,7 +178,7 @@ describe('phone Capture form wiring', () => { it('keeps desktop defaults and lets desktop explicitly select Phone', async () => { mobile(false) localStorage.setItem(MEETING_PROJECT_KEY, JSON.stringify(saved)) - await mount() + await mount({}, false) expect(document.querySelector('.capture-meeting-toggle')?.getAttribute('aria-pressed')).toBe('false') expect(choices().slice(0, 2)).toEqual(['local', 'local:/desk']) expect(document.activeElement?.tagName).toBe('TEXTAREA')