Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ui/harness/harness-board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -79,7 +79,7 @@ let mockMeeting: Record<string, unknown> | 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,
Expand Down
2 changes: 2 additions & 0 deletions ui/src/board/KanbanModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down
29 changes: 29 additions & 0 deletions ui/src/board/workspace/Dock.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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<HTMLButtonElement>('.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 })
Expand Down
28 changes: 25 additions & 3 deletions ui/src/board/workspace/Dock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -71,6 +71,10 @@ export interface MeetingJoinControl {
join(card: KanbanCard, mode: MeetingMode, note: () => Promise<string>): Promise<MeetingJoinResult>
/** 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<void>
/** Whether a stop for `meeting` has been asked and not yet observed. */
stopRequested?(meeting: MeetingRecord): boolean
}

export interface SessionWindow {
Expand Down Expand Up @@ -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')
Expand All @@ -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<HTMLButtonElement>('.kbn-detail-transcript-stop')!
stop.hidden = !actions
if (actions) {
stop.textContent = actions.dismiss ? 'Dismiss' : 'Stop'
stop.disabled = actions.stopDisabled
}
pane.querySelector<HTMLElement>('.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)
}
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions ui/src/board/workspace/dock.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
1 change: 1 addition & 0 deletions ui/src/board/workspace/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion ui/src/forms/CaptureForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export function CaptureForm({
const [chrome, setChrome] = useState<boolean>(false)
const [surface, setSurface] = useState<ExecutionSurface>('cli')
const [mobileMeeting] = useState(() => isMobileViewport())
const [meetingIntent, setMeetingIntent] = useState(() => mobileMeeting)
const [meetingIntent, setMeetingIntent] = useState(false)
const [meetingMode, setMeetingMode] = useState<MeetingMode | null>(() => mobileMeeting ? 'phone' : null)
const [meetingCapabilities, setMeetingCapabilities] = useState({
loading: true,
Expand Down
11 changes: 8 additions & 3 deletions ui/src/forms/captureCapabilities.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<CaptureFormProps> = {}) => {
const mount = async (overrides: Partial<CaptureFormProps> = {}, armed = true) => {
const container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
Expand All @@ -58,6 +58,8 @@ const mount = async (overrides: Partial<CaptureFormProps> = {}) => {
phoneAudio={{ begin }} {...overrides}
/>))
await tick()
const toggle = document.querySelector<HTMLButtonElement>('.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<string, unknown> => {
Expand Down Expand Up @@ -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')
Expand Down
19 changes: 17 additions & 2 deletions ui/src/forms/phoneCapture.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,15 @@ const click = (selector: string) => {
expect(button).not.toBeNull()
act(() => button.click())
}
const mount = async (overrides: Partial<CaptureFormProps> = {}) => {
// Meeting starts off; most of these tests exercise it on, so `mount` switches it on unless asked not to.
const mount = async (overrides: Partial<CaptureFormProps> = {}, armed = true) => {
const host = document.createElement('div')
document.body.append(host)
root = createRoot(host)
await act(async () => root!.render(<CaptureForm projects={projects} hosts={hosts} onSpawned={vi.fn()} onCancel={vi.fn()} onMeetingResult={result} phoneAudio={phone} {...overrides} />))
await tick()
const toggle = document.querySelector<HTMLButtonElement>('.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<HTMLSelectElement>('.form-select')].map((select) => select.value)
Expand Down Expand Up @@ -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()
Expand All @@ -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')
Expand Down
Loading