diff --git a/docs/browser.md b/docs/browser.md index 979656c60..b2350915c 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -123,6 +123,17 @@ is left out, since the copy cannot carry that scope. The cookies cross the conne action is the owner's alone and never automatic. Signing in directly works too: show the agent's browser and sign in there. +An agent that meets a login page asks with `boite ask --open "" "Signed in" Skip`. The question card gets an Open button: it opens the +page as `boite browse` does, in a new tab of the conversation's browser and +its default profile, and shows the panel on the Browser tab, going to the +thread first from the Agents page or another conversation. It calls +`questions.openPage`, which names the question rather than an address, so a +paired phone subscribed to the conversation opens the page too, and the core +reads and checks the address again. The address must +be http or https and carry no user name or password. Such a question never +goes to a delegated agent's parent: only the user can sign in. + A conversation has at most 8 tabs and a machine 24. A page that opens a window, a sign-in popup for instance, adds a tab to the same conversation; past the limit the window is closed. A tab still attaching counts toward both diff --git a/docs/cli.md b/docs/cli.md index 2749ff176..78ad85b1f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -349,7 +349,7 @@ boite diff [file] open the changes surface, or one file's diff boite browse open the url in a new tab of this conversation's browser (http, https) boite open trace|tasks|changes|files [dir] boite status git status: branch, upstream, one row per change -boite ask [option ...] [--multiple] +boite ask [option ...] [--multiple] [--open ] boite task list|add |start |done |remove |clear boite todo list|add |claim boite agents list|inbox @@ -512,7 +512,8 @@ thread does not turn `waiting` and the card stays open after the turn ends. A core restart keeps it too: an open card is read back from the journal at startup, for as long as events are kept (30 days). Each extra argument is an option label; with none the question takes free -text, and `--multiple` lets the user pick several. The answer reaches the agent +text, and `--multiple` lets the user pick several. `--open ` adds an Open +button for a page the user must sign in to (docs/browser.md). The answer reaches the agent as a message that quotes the question, `> question` then the answer: steered into the running turn when the agent takes steering (Codex, pi, Muse, Grok), handed to Claude at the main agent's next tool call through the PostToolUse diff --git a/packages/contracts/src/access.ts b/packages/contracts/src/access.ts index 103ee0148..09dda37f4 100644 --- a/packages/contracts/src/access.ts +++ b/packages/contracts/src/access.ts @@ -139,6 +139,7 @@ export const DEVICE_METHODS: ReadonlySet = new Set 'permissions.answer', 'questions.list', 'questions.answer', + 'questions.openPage', // The address a pending question of the subscribed conversation carries, rechecked by the core. // Paired phones can dismiss the same question cards as the owner's chat. 'questions.skip', // A phone says its own language and experiments, so the agent reads the screen the user reads; nothing the core trusts. diff --git a/packages/contracts/src/delegation-input.ts b/packages/contracts/src/delegation-input.ts index 61a71cb1c..a60313e10 100644 --- a/packages/contracts/src/delegation-input.ts +++ b/packages/contracts/src/delegation-input.ts @@ -1,7 +1,7 @@ import type { DelegationInput, PermissionRequest, QuestionAnswer, QuestionRequest } from './index'; -/** Settings cards are the owner's to decide: they never go to a delegated agent's parent. */ -export const delegationAnswerable = (question: QuestionRequest): boolean => question.setting === undefined && question.agentSetting === undefined; +/** Settings cards and pages to sign in to are the owner's: they never go to a delegated agent's parent. */ +export const delegationAnswerable = (question: QuestionRequest): boolean => question.setting === undefined && question.agentSetting === undefined && question.open === undefined; /** What a delegated child waits on from someone, oldest first. */ export function delegationNeeds(questions: QuestionRequest[], permissions: PermissionRequest[]): DelegationInput[] { diff --git a/packages/contracts/src/messages.ts b/packages/contracts/src/messages.ts index 58263b39f..27cfd3df9 100644 --- a/packages/contracts/src/messages.ts +++ b/packages/contracts/src/messages.ts @@ -197,6 +197,8 @@ export type MessagePart = setting?: SettingAsk; /** An agent setting card: see `QuestionRequest.agentSetting`. */ agentSetting?: AgentSettingAsk; + /** A page to open: see `QuestionRequest.open`. */ + open?: { url: string }; } /** * The agent compacted its context mid-turn: what it held before, what is diff --git a/packages/contracts/src/questions.ts b/packages/contracts/src/questions.ts index 71bed4df4..e98407ade 100644 --- a/packages/contracts/src/questions.ts +++ b/packages/contracts/src/questions.ts @@ -68,6 +68,8 @@ export interface QuestionRequest { * `text` is its reason. Always asynchronous. */ agentSetting?: AgentSettingAsk; + /** A page the card opens in the thread's browser (`boite ask --open`), as for a sign-in. */ + open?: { url: string }; createdAt: Timestamp; } @@ -181,6 +183,15 @@ export interface QuestionsRpcMethods { params: { threadId: ThreadId; questionId: RequestId; optionIds: string[]; text?: string; attachments?: Attachment[] }; result: { ok: true }; }; + /** + * Open a pending question's page (`open`) as a new tab of its conversation's + * browser. The core reads the address from the question, so a paired phone + * subscribed to the conversation opens it without `panel.open`'s surfaces. + */ + 'questions.openPage': { + params: { threadId: ThreadId; questionId: RequestId }; + result: { ok: true }; + }; /** Resolve a pending question without an answer, a steer or a new user message. */ 'questions.skip': { params: { threadId: ThreadId; questionId: RequestId }; @@ -193,7 +204,7 @@ export interface QuestionsRpcMethods { * none makes a free-text question. */ 'questions.ask': { - params: { threadId: ThreadId; text: string; options?: string[]; multiple?: boolean }; + params: { threadId: ThreadId; text: string; options?: string[]; multiple?: boolean; open?: string }; result: { questionId: RequestId }; }; } diff --git a/packages/core/src/agent-guide.ts b/packages/core/src/agent-guide.ts index 64b2ca96e..394c685ac 100644 --- a/packages/core/src/agent-guide.ts +++ b/packages/core/src/agent-guide.ts @@ -19,7 +19,7 @@ export function agentGuide(asyncQuestions: boolean, logAccess = false, skills: r '`boite show [:line]`, `boite diff [file]`, `boite browse ` open the panel. `boite preview ` previews HTML and local assets. `boite attach ` sends files/media into chat (512 MB max).', 'Write a file reference as inline code, `path:line`: a click opens it in the panel.', VIEW_GUIDE_LINE, - ...(asyncQuestions ? ['`boite ask "" [option ...]` asks asynchronously. Keep working on independent tasks; the answer arrives as a message. Silence grants no approval.'] : []), + ...(asyncQuestions ? ['`boite ask [--open ] "" [option ...]` asks asynchronously. Keep working on independent tasks; the answer arrives as a message. Silence grants no approval. Login wall: `--open `, the site and why, options "Signed in" Skip; never in chat.'] : []), '`boite settings [agent] [request|open]` reads Boite settings or a persistent agent\'s, or puts a card in chat for the user to change one.', 'Task tracking is optional. Use it only when steps help you and the user. `boite task add ` / `boite task start|done ` track those steps. `boite todo list|add |claim ` manages project todos; claim marks work done for user confirmation.', '`boite projects` lists projects; `boite projects add ` adds one. `boite thread new ` starts independent work in a project, not a subagent; its first answer comes back.', diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 31a53e098..fca5c6835 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -247,6 +247,17 @@ export function registerAgentMethods(core: Core): void { // rather than believing the user saw something. return { shown: core.subscribers.hasSubscribers(thread.id) }; }); + // The page a pending question carries, for whoever answers it: a paired phone + // names a question, never a surface, and only in the conversation it watches. + core.router.register('questions.openPage', async (params, { connection }) => { + const thread = core.threads.require(params.threadId); + if (connection.identity.principal !== 'owner' && (thread.archived || !connection.subscriptions.has(thread.id))) throw refused('subscribe to the active conversation before opening its page'); + const url = core.threads.listQuestions(thread.id).find((question) => question.id === params.questionId)?.open?.url; + if (url === undefined) throw refused(`questions.openPage: ${params.questionId} is not a pending question with a page`, { questionId: params.questionId }); + const surface = checkSurface(thread.cwd, { kind: 'browser', url }); + if (surface.kind === 'browser') await core.browser.command({ threadId: thread.id, action: { kind: 'open', url: surface.url } }); + return { ok: true } as const; + }); core.router.register('threads.tasks.set', (params) => setTasks(core, params)); core.router.register('threads.tasks.get', (params) => { core.threads.require(params.threadId); diff --git a/packages/core/src/cli-args.ts b/packages/core/src/cli-args.ts index 6784eaca3..a500d233b 100644 --- a/packages/core/src/cli-args.ts +++ b/packages/core/src/cli-args.ts @@ -12,6 +12,8 @@ export interface Parsed { requestId?: string; worktree: boolean; title?: string; + /** `boite ask --open `: the page the card opens. */ + open?: string; name?: string; model?: string; effort?: string; @@ -74,6 +76,7 @@ export function parse(argv: string[]): Parsed { else if (numeric !== undefined) parsed[numeric] = number(arg, next()); else if (arg === '--request-id') parsed.requestId = next(); else if (arg === '--title') parsed.title = next(); + else if (arg === '--open') parsed.open = next(); else if (arg === '--name') parsed.name = next(); else if (arg === '--project') parsed.project = next(); else if (arg === '--why') parsed.why = next(); diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index b2b82a5b0..e770c1ada 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -84,7 +84,9 @@ ${CONTROL_HELP} report a Boite bug on GitHub with that export; show the user the draft before submit ask [option ...] ask the user without stopping; the answer - arrives later as a message (--multiple) + arrives later as a message (--multiple); + --open adds an Open button for a page + the user must sign in to, in your browser settings [key] Boite settings you may ask about: value, page, the name the user's app shows; first, the language the app speaks; experiments last @@ -618,7 +620,7 @@ async function run(parsed: Parsed, io: CliIo, client: CoreClient, threadId: stri if (rest.length === 1 && rest[0] === 'help') { print([USAGE], { help: USAGE }); return; } const text = want(0, 'a question'); const options = rest.slice(1); - const asked = await client.call('questions.ask', { threadId, text, ...(options.length > 0 ? { options } : {}), ...(parsed.multiple ? { multiple: true } : {}) }); + const asked = await client.call('questions.ask', { threadId, text, ...(options.length > 0 ? { options } : {}), ...(parsed.multiple ? { multiple: true } : {}), ...(parsed.open === undefined ? {} : { open: parsed.open }) }); print([`asked: ${asked.questionId}`, 'Keep working. The answer arrives as a message quoting the question; without one, go on with a sensible default.'], asked); }, status: async () => { diff --git a/packages/core/src/drivers/types.ts b/packages/core/src/drivers/types.ts index bb8a2950b..00a1e6207 100644 --- a/packages/core/src/drivers/types.ts +++ b/packages/core/src/drivers/types.ts @@ -71,6 +71,8 @@ export interface QuestionAsk { setting?: SettingAsk; /** An agent setting card (`agents.settings.request`), always asynchronous. */ agentSetting?: AgentSettingAsk; + /** A page the card opens (`boite ask --open`). */ + open?: { url: string }; } /** diff --git a/packages/core/src/threads/cards.ts b/packages/core/src/threads/cards.ts index f9a784530..6f050525d 100644 --- a/packages/core/src/threads/cards.ts +++ b/packages/core/src/threads/cards.ts @@ -22,6 +22,7 @@ import { type ThreadSummary, type Turn, } from '@boite/contracts'; +import { checkSurface } from '../agent.ts'; import { answerText, attachToAnswer } from '../attachments.ts'; import type { Core } from '../core.ts'; import type { PermissionTicket, QuestionAsk, QuestionTicket } from '../drivers/types.ts'; @@ -298,7 +299,7 @@ export class ThreadCards { * turn, or under the last one when the agent asks between turns (from a * background shell of its own, say), in a message of its own. */ - askAsync(params: { threadId: ThreadId; text: string; options?: string[]; multiple?: boolean }): { questionId: RequestId } { + askAsync(params: { threadId: ThreadId; text: string; options?: string[]; multiple?: boolean; open?: string }): { questionId: RequestId } { const thread = this.threads.require(params.threadId); if (thread.archived) throw refused('cannot ask on an archived thread', { threadId: thread.id }); const text = typeof params.text === 'string' ? params.text.trim() : ''; @@ -308,12 +309,17 @@ export class ThreadCards { throw refused('options must be at most 12 labels of 1 to 200 characters', { field: 'options' }); } if (params.multiple !== undefined && typeof params.multiple !== 'boolean') throw refused('multiple must be a boolean', { field: 'multiple' }); + // The page opens as `boite browse` would open it; a user name or password in it would sit on the card and in the journal. + const page = params.open === undefined ? undefined : checkSurface(thread.cwd, { kind: 'browser', url: params.open }); + const credentials = page?.kind === 'browser' ? new URL(page.url) : null; + if (credentials && (credentials.username || credentials.password)) throw refused('open: an address with a user name or password in it is refused', { field: 'open' }); return this.drawAsync(thread, { text, options: labels.map((label, index) => ({ id: String(index + 1), label: label.trim() })), allowText: true, multiple: params.multiple === true && labels.length > 1, async: true, + ...(page?.kind === 'browser' ? { open: { url: page.url } } : {}), }); } @@ -392,6 +398,7 @@ export class ThreadCards { ...(ask.async === true ? { async: true } : {}), ...(ask.setting === undefined ? {} : { setting: ask.setting }), ...(ask.agentSetting === undefined ? {} : { agentSetting: ask.agentSetting }), + ...(ask.open === undefined ? {} : { open: ask.open }), createdAt: Date.now(), }; let resolve: (answer: QuestionAnswer | null) => void = () => undefined; diff --git a/packages/core/test/ask-open.test.ts b/packages/core/test/ask-open.test.ts new file mode 100644 index 000000000..f28a36953 --- /dev/null +++ b/packages/core/test/ask-open.test.ts @@ -0,0 +1,45 @@ +import { afterEach, expect, test } from 'bun:test'; +import { connect } from '../src/client.ts'; +import { echoThread, startTestCore, waitFor, type TestCore } from './harness.ts'; + +let h: TestCore; +afterEach(async () => { await h?.stop(); }); + +test('boite ask --open keeps the page on the card, and refuses an address that is not a web page or carries a login', async () => { + h = await startTestCore(); + const owner = await h.connect(); + const { threadId } = await echoThread(h, owner); + await owner.call('turns.start', { threadId, prompt: 'hello' }); + await waitFor(() => h.core.journal.lastTurn(threadId) !== null); + + const ask = (open: string) => owner.call('questions.ask', { threadId, text: 'billing.example: to download the invoices', options: ['Signed in', 'Skip'], open }); + const { questionId } = await ask('https://billing.example/login?next=%2Finvoices'); + const card = h.core.journal.listMessages(threadId).flatMap(message => message.parts).find(part => part.type === 'question' && part.questionId === questionId); + expect(card).toMatchObject({ open: { url: 'https://billing.example/login?next=%2Finvoices' }, async: true }); + + await expect(ask('file:///etc/passwd')).rejects.toThrow('http or https'); + await expect(ask('https://me:hunter2@billing.example/')).rejects.toThrow('user name or password'); +}); + +test('a paired phone opens the page of a pending question in the conversation it watches, and nothing else', async () => { + h = await startTestCore(); + const owner = await h.connect(); + const { threadId } = await echoThread(h, owner); + await owner.call('turns.start', { threadId, prompt: 'hello' }); + await waitFor(() => h.core.journal.lastTurn(threadId) !== null); + const { questionId } = await owner.call('questions.ask', { threadId, text: 'ci.example: to read the failed run', options: ['Signed in', 'Skip'], open: 'https://ci.example/login' }); + const { questionId: plain } = await owner.call('questions.ask', { threadId, text: 'Which branch?', options: ['main', 'dev'] }); + const opened: string[] = []; + h.core.browser.command = (async ({ action }: { action: { url: string } }) => { opened.push(action.url); return { value: { ok: true } }; }) as typeof h.core.browser.command; + const { grant } = await owner.call('pairing.grant', {}); + const phone = await connect(h.url, '', { grant, client: { name: 'pwa', version: 'test' } }); + try { + await expect(phone.call('questions.openPage', { threadId, questionId })).rejects.toThrow('subscribe to the active conversation'); + await phone.call('threads.subscribe', { threadId }); + await phone.call('questions.openPage', { threadId, questionId }); + expect(opened).toEqual(['https://ci.example/login']); + await expect(phone.call('questions.openPage', { threadId, questionId: plain })).rejects.toThrow('not a pending question with a page'); + await expect(phone.call('panel.open', { threadId, surface: { kind: 'browser', url: 'https://ci.example/login' } })).rejects.toThrow('for the owner only'); + expect(opened).toHaveLength(1); + } finally { phone.close(); } +}); diff --git a/packages/ui/src/components/AssistantMessage.svelte b/packages/ui/src/components/AssistantMessage.svelte index 794f2a229..e847f2396 100644 --- a/packages/ui/src/components/AssistantMessage.svelte +++ b/packages/ui/src/components/AssistantMessage.svelte @@ -11,6 +11,7 @@ import PermissionCard from './PermissionCard.svelte'; import PlanCard from './PlanCard.svelte'; import QuestionCard from './QuestionCard.svelte'; + import { openQuestionPage } from '../lib/question-page'; import SettingRequestCard from './SettingRequestCardLoader.svelte'; import AgentSettingCard from './AgentSettingCardLoader.svelte'; import Prose from './Prose.svelte'; @@ -178,6 +179,8 @@ submit={() => asked !== undefined && sendAnswer(store, asked)} skip={() => store.skipQuestion(message.threadId, part.questionId)} onwrite={() => writeAnswer(store, part.questionId)} + page={part.open?.url} + onopen={() => openQuestionPage(store, message.threadId, part.questionId)} /> {:else if part.type === 'compaction'}
diff --git a/packages/ui/src/components/QuestionCard.svelte b/packages/ui/src/components/QuestionCard.svelte index ecce3170b..0c342743e 100644 --- a/packages/ui/src/components/QuestionCard.svelte +++ b/packages/ui/src/components/QuestionCard.svelte @@ -18,7 +18,9 @@ replying = false, submit, skip, - onwrite + onwrite, + page, + onopen }: { text: string; options: QuestionOption[]; @@ -43,6 +45,9 @@ skip?: () => unknown; /** Hands the composer to this question, when another one or an ordinary message has it. */ onwrite?: () => void; + /** The page asked with `boite ask --open`, and what its Open button does. */ + page?: string; + onopen?: () => unknown; } = $props(); let sent = $state(false); @@ -165,6 +170,9 @@ {#if pending}
+ {#if page && onopen} + + {/if} {#if skip} {/if} diff --git a/packages/ui/src/components/ThreadActivity.svelte b/packages/ui/src/components/ThreadActivity.svelte index 825683716..3bc089e86 100644 --- a/packages/ui/src/components/ThreadActivity.svelte +++ b/packages/ui/src/components/ThreadActivity.svelte @@ -9,6 +9,7 @@ import { dockRoom, questionDock } from '../lib/question-dock.svelte'; import { ignoreQuestions, releaseWaiting, replyOf, repliesOf, sendAnswer, writeAnswer } from '../lib/question-reply.svelte'; import QuestionCard from './QuestionCard.svelte'; + import { openQuestionPage } from '../lib/question-page'; import SettingRequestCard from './SettingRequestCardLoader.svelte'; import { settingHeading } from '../lib/setting-requests'; import AgentSettingCard from './AgentSettingCardLoader.svelte'; @@ -244,6 +245,8 @@ submit={() => sendAnswer(store, question)} skip={() => store.skipQuestion(question.threadId, question.id)} onwrite={child ? undefined : () => writeAnswer(store, question.id)} + page={question.open?.url} + onopen={() => openQuestionPage(store, question.threadId, question.id)} /> {/if}
diff --git a/packages/ui/src/components/agents/AgentPendingQuestions.svelte b/packages/ui/src/components/agents/AgentPendingQuestions.svelte index e33929e3a..4a941e7a4 100644 --- a/packages/ui/src/components/agents/AgentPendingQuestions.svelte +++ b/packages/ui/src/components/agents/AgentPendingQuestions.svelte @@ -6,6 +6,7 @@ import AgentSettingCard from '../AgentSettingCardLoader.svelte'; import SettingRequestCard from '../SettingRequestCardLoader.svelte'; import AgentAvatar from './AgentAvatar.svelte'; + import { openQuestionPage } from '../../lib/question-page'; /** * The cards a persistent agent's hidden session threads wait on, drawn in its @@ -61,6 +62,9 @@

{question.text}

{ e.preventDefault(); void send(question, chosen); }}> + {#if question.open} + + {/if} {#each question.options as option (option.id)} diff --git a/packages/ui/src/lib/fake-client/agents-demo.ts b/packages/ui/src/lib/fake-client/agents-demo.ts index 99931f4a8..e2b35898f 100644 --- a/packages/ui/src/lib/fake-client/agents-demo.ts +++ b/packages/ui/src/lib/fake-client/agents-demo.ts @@ -1,5 +1,6 @@ import type { AgentMessageFile, ArtifactContent, Message, Turn } from '@boite/contracts'; import { requestAgentSetting } from './agent-settings'; +import { askAsync } from './requests'; import type { FakeContext } from './context'; import { FAKE_MEDIA } from './files'; @@ -57,4 +58,5 @@ function askForAnotherProject(ctx: FakeContext): void { const turn: Turn = { id: `turn-agents-demo-${++ctx.seq}`, threadId: thread.id, status: 'done', queuedAt: at, startedAt: at, finishedAt: at + 60_000, usage: null, error: null }; thread.turns.push(turn); requestAgentSetting(ctx, thread, turn, { threadId: thread.id, key: 'projects', value: { add: [next.id] }, reason: `${next.name} has threads nobody follows. Entrust it to me and I will watch them the way I watch the others.` }); + askAsync(ctx, thread, turn, null, 'dashboard.example.com: to read the failed deploy logs on the staging dashboard.', ['Signed in', 'Skip'], false, 'https://dashboard.example.com/login'); } diff --git a/packages/ui/src/lib/fake-client/requests.ts b/packages/ui/src/lib/fake-client/requests.ts index 5f701512d..8197457cb 100644 --- a/packages/ui/src/lib/fake-client/requests.ts +++ b/packages/ui/src/lib/fake-client/requests.ts @@ -108,6 +108,7 @@ export function settleQuestion( ...(request.async === true ? { async: true } : {}), ...(request.setting === undefined ? {} : { setting: request.setting }), ...(request.agentSetting === undefined ? {} : { agentSetting: request.agentSetting }), + ...(request.open === undefined ? {} : { open: request.open }), answer } }); @@ -119,8 +120,8 @@ export function settleQuestion( * assistant message of the last turn, and nothing waits on it. The answer * comes back as a prompt quoting the question, once the thread is free. */ -export function askAsync(ctx: FakeContext, thread: Thread, turn: Turn, running: Message | null, text: string, labels: string[], multiple: boolean): string { - return drawAsync(ctx, thread, turn, running, { text, options: labels.map((label, index) => ({ id: String(index + 1), label })), allowText: true, multiple }); +export function askAsync(ctx: FakeContext, thread: Thread, turn: Turn, running: Message | null, text: string, labels: string[], multiple: boolean, open?: string): string { + return drawAsync(ctx, thread, turn, running, { text, options: labels.map((label, index) => ({ id: String(index + 1), label })), allowText: true, multiple, ...(open === undefined ? {} : { open: { url: open } }) }); } /** The core's `requestSetting`: a settings card, drawn and answered like any card asked without stopping. */ @@ -170,7 +171,7 @@ function drawAsync( thread: Thread, turn: Turn, running: Message | null, - asked: { text: string; options: QuestionOption[]; allowText: boolean; multiple: boolean; setting?: SettingAsk; agentSetting?: AgentSettingAsk } + asked: { text: string; options: QuestionOption[]; allowText: boolean; multiple: boolean; setting?: SettingAsk; agentSetting?: AgentSettingAsk; open?: { url: string } } ): string { const questionId = `qst-${++ctx.seq}`; const text = asked.text; @@ -318,7 +319,7 @@ export function requestMethods(ctx: FakeContext) { data: { threadId: params.threadId } }); } - return { questionId: askAsync(ctx, thread, turn, null, asked.text, asked.labels, asked.multiple) }; + return { questionId: askAsync(ctx, thread, turn, null, asked.text, asked.labels, asked.multiple, params.open) }; }, 'settings.request': async (params) => { const thread = ctx.thread(params.threadId); diff --git a/packages/ui/src/lib/fake-client/workdir.ts b/packages/ui/src/lib/fake-client/workdir.ts index 3e728b87f..c59e44f85 100644 --- a/packages/ui/src/lib/fake-client/workdir.ts +++ b/packages/ui/src/lib/fake-client/workdir.ts @@ -221,6 +221,14 @@ export function workdirMethods(ctx: FakeContext, all: () => Pick { + const thread = ctx.thread(threadId); + const question = ctx.pendingQuestions.get(questionId)?.request; + if (question?.threadId !== thread.id || !question.open) throw refusal(`questions.openPage: ${questionId} is not a pending question with a page`); + const surface = checkSurface(ctx, thread.cwd, { kind: 'browser', url: question.open.url }); + if (surface.kind === 'browser') await all()['browser.command']({ threadId: thread.id, action: { kind: 'open', url: surface.url } }); + return { ok: true as const }; + }, 'git.status': async (params) => { const thread = ctx.thread(params.threadId); const branch = thread.branch ?? 'main'; diff --git a/packages/ui/src/lib/question-page.test.ts b/packages/ui/src/lib/question-page.test.ts new file mode 100644 index 000000000..089671568 --- /dev/null +++ b/packages/ui/src/lib/question-page.test.ts @@ -0,0 +1,67 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import { mount, unmount } from 'svelte'; +import { setExperiment } from './experiments'; +import { FakeClient } from './fake-client'; +import { rightPanel } from './right-panel.svelte'; +import { Store } from './store.svelte'; +import type { AgentsView } from './agents.svelte'; +import ThreadActivity from '../components/ThreadActivity.svelte'; +import AgentPendingQuestions from '../components/agents/AgentPendingQuestions.svelte'; + +let mounted: ReturnType | undefined; +afterEach(async () => { + if (mounted) await unmount(mounted); + mounted = undefined; + document.body.innerHTML = ''; + setExperiment('resident-agents', false); +}); + +async function setup(): Promise<{ client: FakeClient; store: Store }> { + const client = new FakeClient({ delayMs: 0 }); + const store = new Store(); + store.attach(client); + await store.connect(); + await store.open('t-trace'); + return { client, store }; +} + +const ask = (client: FakeClient, threadId: string, url: string) => + client.call('questions.ask', { threadId, text: 'To read the build logs', options: ['Signed in', 'Skip'], open: url }); + +/** The thread's panel shows its browser, on that page. */ +async function shown(client: FakeClient, store: Store, threadId: string, url: string): Promise { + const panel = rightPanel.for(store.threadKey(threadId)); + await vi.waitFor(() => expect(panel.active?.kind).toBe('agent-browser')); + expect(panel.isOpen).toBe(true); + expect((await client.call('browser.remoteStatus', { threadId })).tabs).toMatchObject([{ url, active: true }]); +} + +test('in the thread, Open on a question asked with --open shows its page in the panel, on the Browser tab', async () => { + const { client, store } = await setup(); + // The dock above the composer holds the card the thread waits on: the seeded one is skipped first. + for (const question of store.pendingQuestions.filter((pending) => pending.threadId === 't-trace')) await store.skipQuestion('t-trace', question.id); + await ask(client, 't-trace', 'https://ci.example/login'); + mounted = mount(ThreadActivity, { target: document.body, props: { store } }); + await vi.waitFor(() => expect(document.querySelector('[data-testid=question-open]')).not.toBeNull()); + const open = document.querySelector('[data-testid=question-open]')!; + expect([open.textContent, open.title]).toEqual(['Open', 'https://ci.example/login']); + open.click(); + await shown(client, store, 't-trace', 'https://ci.example/login'); +}); + +test('on the Agents page, Open goes to the session thread and shows its page in that thread\'s panel', async () => { + setExperiment('resident-agents', true); + const { client, store } = await setup(); + // A persistent agent's session thread, not the one open in the chat. + const hidden = store.threads.find((thread) => thread.id !== 't-trace' && !thread.archived)!.id; + await ask(client, hidden, 'https://ci.example/login'); + store.showAgents(); + const scope = { kind: 'agent' as const, id: 'a-ada' }; + const view = { store, snapshot: { profiles: [], groups: [], work: [], sessions: [{ id: 's-1', agentId: 'a-ada', scope, threadId: hidden }] } }; + mounted = mount(AgentPendingQuestions, { target: document.body, props: { view: view as unknown as AgentsView, scope } }); + await vi.waitFor(() => expect(document.querySelector('[data-testid=question-open]')).not.toBeNull()); + document.querySelector('[data-testid=question-open]')!.click(); + await shown(client, store, hidden, 'https://ci.example/login'); + expect(store.page).toBe('chat'); + expect(store.openThread?.id).toBe(hidden); +}); diff --git a/packages/ui/src/lib/question-page.ts b/packages/ui/src/lib/question-page.ts new file mode 100644 index 000000000..3bb1077c7 --- /dev/null +++ b/packages/ui/src/lib/question-page.ts @@ -0,0 +1,25 @@ +import { errorText } from './errors'; +import { liveViews } from './live-view.svelte'; +import { rightPanel } from './right-panel.svelte'; +import type { Store } from './store.svelte'; + +/** + * Open on a question asked with `boite ask --open`: the page opens in the + * thread's browser, as `boite browse` opens it, and the thread's panel shows + * it on the Browser tab, live. From the Agents page or another thread, the + * thread opens first, so its own panel is the one that shows the page. + */ +export async function openQuestionPage(store: Store, threadId: string, questionId: string): Promise { + const client = store.client; + if (!client) return; + try { + if (store.page !== 'chat' || store.openThread?.id !== threadId) { store.showChat(); await store.open(threadId); } + // Not `panel.open`, which is the owner's: a paired phone opens the page too. + await client.call('questions.openPage', { threadId, questionId }); + const key = store.threadKey(threadId); + liveViews.show('agent-browser', key); + const panel = rightPanel.for(key); + const tab = panel.surfaces.find((surface) => surface.kind === 'agent-browser'); + if (tab) panel.activate(tab.id); else panel.open('agent-browser'); + } catch (cause) { store.error = errorText(cause); } +} diff --git a/packages/ui/src/lib/strings/en/chat.ts b/packages/ui/src/lib/strings/en/chat.ts index b32392305..5a2e4f1e2 100644 --- a/packages/ui/src/lib/strings/en/chat.ts +++ b/packages/ui/src/lib/strings/en/chat.ts @@ -167,6 +167,7 @@ export const strings = { questionAnswered: 'Answered', questionCancelled: 'No longer waiting for an answer', questionSkip: 'Skip', + questionOpenPage: 'Open', /** The composer is the free field of the question; the card points there. */ questionReplying: 'Write your answer in the message box below, with any photos or files.', questionWrite: 'Answer in writing', diff --git a/packages/ui/src/lib/strings/fr/chat.ts b/packages/ui/src/lib/strings/fr/chat.ts index 7780bf8c8..54b7b4a85 100644 --- a/packages/ui/src/lib/strings/fr/chat.ts +++ b/packages/ui/src/lib/strings/fr/chat.ts @@ -143,6 +143,7 @@ export const fr: Translation = { questionAnswered: 'Répondu', questionCancelled: 'Cette question n’attend plus de réponse', questionSkip: 'Passer', + questionOpenPage: 'Ouvrir', questionReplying: 'Écrivez votre réponse dans le champ de message en bas, avec photos ou fichiers si besoin.', questionWrite: 'Répondre par écrit', questionDocked: 'Attend votre réponse au-dessus du champ de message',