Skip to content
Merged
182 changes: 171 additions & 11 deletions packages/app/e2e/regression/btw-sidebar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,149 @@ import { expectSessionTitle } from "../utils/waits"

test.use({ viewport: { width: 1440, height: 900 } })

test("keeps separate one-shot /btw answers and restores them after reload", async ({ page }, testInfo) => {
const prompts: unknown[] = []
const generations: { sessionID: string; prompt: string }[] = []

const { editor } = await openSession(page, {
name: "BtwQuestions",
onPrompt: (input) => prompts.push(input),
generate: (input) => {
generations.push(input)

return {
text: input.prompt.includes("second question")
? "Second answer"
: `First answer\n\n${"A detailed explanation of the first question.\n\n".repeat(100)}End of first answer`,
}
},
})

const panel = page.locator('[data-slot="session-btw-panel"]')
await editor.fill("/btw first question")
await editor.press("Enter")
await expect(panel.getByText("First answer", { exact: true })).toBeVisible()
await expect(panel.getByRole("textbox")).toHaveCount(0)
await expect(panel.getByRole("button", { name: "Send", exact: true })).toHaveCount(0)
await panel.getByText("End of first answer", { exact: true }).scrollIntoViewIfNeeded()
await expect(panel.getByText("End of first answer", { exact: true })).toBeInViewport()
await editor.fill("/btw second question")
await editor.press("Enter")
await expect(panel.getByText("Second answer", { exact: true })).toBeVisible()
const first = page.getByRole("tab", { name: "first question", exact: true })
const second = page.getByRole("tab", { name: "second question", exact: true })
await expect(first).toBeVisible()
await expect(second).toHaveAttribute("data-selected", "")
await page.screenshot({ path: testInfo.outputPath("separate-tabs.png") })
expect(generations).toHaveLength(2)
expect(generations[1]?.prompt).not.toContain("first question")
expect(generations[1]?.prompt).not.toContain("First answer")

await page.reload()
await expect(first).toBeVisible()
await expect(second).toHaveAttribute("data-selected", "")
await expect(panel.getByText("Second answer", { exact: true })).toBeVisible()
await first.click()
await expect(panel.getByText("First answer", { exact: true })).toBeVisible()
await expect(panel.getByText("Second answer", { exact: true })).toHaveCount(0)
await expect(panel.getByRole("textbox")).toHaveCount(0)
await page.screenshot({ path: testInfo.outputPath("restored-answer.png") })

// Closing a tab forgets its question; the other tab and its answer survive a reload.
await first.click({ button: "middle" })
await expect(first).toHaveCount(0)
await expect(second).toHaveAttribute("data-selected", "")
await expect(panel.getByText("Second answer", { exact: true })).toBeVisible()
await page.reload()
await expect(second).toHaveAttribute("data-selected", "")
await expect(panel.getByText("Second answer", { exact: true })).toBeVisible()
await expect(first).toHaveCount(0)
expect(generations).toHaveLength(2)
expect(prompts).toEqual([])
})

test("isolates concurrent side questions and makes an interrupted reload retryable", async ({ page }) => {
const held = Promise.withResolvers<void>()
const attempts: string[] = []
const prompts: unknown[] = []

const { editor } = await openSession(page, {
name: "BtwConcurrent",
onPrompt: (input) => prompts.push(input),
generate: async (input) => {
attempts.push(input.prompt)

if (input.prompt.includes("slow question") && attempts.length === 1) {
await held.promise

return { text: "Abandoned answer" }
}

return { text: input.prompt.includes("fast question") ? "Fast answer" : "Retried answer" }
},
})

const panel = page.locator('[data-slot="session-btw-panel"]')
await editor.fill("/btw slow question")
await editor.press("Enter")
await expect(panel.getByRole("status")).toContainText("Working")
await editor.fill("/btw fast question")
await editor.press("Enter")
await expect(panel.getByText("Fast answer", { exact: true })).toBeVisible()
await page.getByRole("tab", { name: "slow question", exact: true }).click()
await expect(panel.getByRole("status")).toContainText("Working")
await expect(panel.getByText("Fast answer", { exact: true })).toHaveCount(0)

await page.reload()
await expect(panel.getByText("Couldn’t answer that question", { exact: true })).toBeVisible()
await expect(panel.getByRole("status")).toHaveCount(0)
expect(attempts).toHaveLength(2)
await panel.getByRole("button", { name: "Retry", exact: true }).click()
await expect(panel.getByText("Retried answer", { exact: true })).toBeVisible()
expect(attempts).toHaveLength(3)
expect(attempts[2]).toContain("slow question")
expect(attempts[2]).not.toContain("fast question")
held.resolve()
await page.getByRole("tab", { name: "fast question", exact: true }).click()
await expect(panel.getByText("Fast answer", { exact: true })).toBeVisible()
await page.getByRole("tab", { name: "slow question", exact: true }).click()
await expect(panel.getByText("Retried answer", { exact: true })).toBeVisible()
await expect(panel.getByText("Abandoned answer", { exact: true })).toHaveCount(0)
await expect(panel.getByRole("textbox")).toHaveCount(0)
expect(prompts).toEqual([])
})

test("keeps many long-titled side tabs usable at a narrow desktop width", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 900, height: 700 })

const { editor } = await openSession(page, {
name: "BtwOverflow",
generate: () => ({ text: "Saved overflow answer" }),
})

const panel = page.locator('[data-slot="session-btw-panel"]')
const questions = Array.from({ length: 12 }, (_, index) => `Side ${index + 1}: ${"long-title-".repeat(30)}`)

for (const question of questions) {
await editor.fill(`/btw ${question}`)
await editor.press("Enter")
await expect(page.getByRole("tab", { name: question, exact: true })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("Saved overflow answer", { exact: true })).toBeVisible()
}

await expect(page.getByRole("tab", { name: /^Side \d+:/ })).toHaveCount(12)
await expect(panel.getByRole("textbox")).toHaveCount(0)
await page.reload()
await expect(page.getByRole("tab", { name: /^Side \d+:/ })).toHaveCount(12)
await expect(panel.getByText("Saved overflow answer", { exact: true })).toBeVisible()
await page.screenshot({ path: testInfo.outputPath("narrow-many-tabs.png") })
})

test("answers /btw in the side panel without admitting a prompt", async ({ page }) => {
const generations: { sessionID: string; prompt: string }[] = []
const prompts: unknown[] = []
const generated = Promise.withResolvers<void>()
const abandoned = Promise.withResolvers<void>()
const leftBehind = Promise.withResolvers<void>()
const main = { id: "ses_btw_sidebar", title: "Side question session" }
const other = { id: "ses_btw_sidebar_other", title: "Other side question session" }
const ownerWarnings: string[] = []
Expand All @@ -28,7 +166,7 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
if (input.sessionID === other.id) return { text: "This answer belongs to the **other session**." }

if (input.prompt.includes("left behind")) {
await abandoned.promise
await leftBehind.promise

return { text: "This answer arrived after the user left." }
}
Expand All @@ -47,7 +185,6 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
await suggestion.click()
await expect(editor).toHaveText("/btw ")
await editor.press("Enter")

const panel = page.locator('[data-slot="session-btw-panel"]')
await expect(panel).toBeHidden()
await expect(page.getByText("Add a question after /btw", { exact: true })).toBeVisible()
Expand All @@ -59,7 +196,10 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
await expect(panel).toBeVisible()
await expect(panel.getByRole("textbox")).toHaveCount(0)
await expect(panel.getByRole("status")).toContainText("Working")
await expect(page.getByRole("tab", { name: "/btw" })).toHaveAttribute("data-selected", "")
await expect(page.getByRole("tab", { name: "how does the retry loop work?", exact: true })).toHaveAttribute(
"data-selected",
"",
)
generated.resolve()
await expect(panel.getByText("how does the retry loop work?", { exact: true })).toBeVisible()
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
Expand All @@ -75,27 +215,47 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
await editor.fill("/btw what belongs here?")
await editor.press("Enter")
await expect(panel.getByText("other session", { exact: false })).toBeVisible()

await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click()
await expectSessionTitle(page, main.title)
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
await expect(panel.getByText("other session", { exact: false })).toHaveCount(0)

// Leaving a session abandons its in-flight question, so it reads as failed on return.
await editor.fill("/btw is this question left behind?")
await editor.press("Enter")
await expect(panel.getByRole("status")).toContainText("Working")
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(other.id)}"]`).click()
await expectSessionTitle(page, other.title)
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click()
await expectSessionTitle(page, main.title)
await expect(panel.getByText("Couldn’t answer that question", { exact: true })).toBeVisible()
await expect(panel.getByRole("button", { name: "Retry", exact: true })).toBeVisible()
abandoned.resolve()
// Leaving the session does not abandon its question: it is still working, and its answer lands once it arrives.
await expect(panel.getByRole("status")).toContainText("Working")
await page.getByRole("button", { name: "Home", exact: true }).click()
await expect(page).toHaveURL(/\/$/)
leftBehind.resolve()
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click()
await expectSessionTitle(page, main.title)
await expect(panel.getByText("This answer arrived after the user left.", { exact: true })).toBeVisible()
await expect(panel.getByText("Couldn’t answer that question", { exact: true })).toHaveCount(0)
expect(generations.filter((item) => item.prompt.includes("left behind"))).toHaveLength(1)

await page.reload()
await expectSessionTitle(page, main.title)
await expect(page.getByRole("tab", { name: "/btw" })).toHaveCount(0)
await expect(panel).toHaveCount(0)
await expect(page.getByRole("tab", { name: "is this question left behind?", exact: true })).toHaveAttribute(
"data-selected",
"",
)
await expect(panel.getByText("This answer arrived after the user left.", { exact: true })).toBeVisible()
await expect(panel.getByRole("status")).toHaveCount(0)
await page.getByRole("tab", { name: "how does the retry loop work?", exact: true }).click()
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
await page.getByRole("button", { name: "Home", exact: true }).click()
await expect(page).toHaveURL(/\/$/)
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click()
await expectSessionTitle(page, main.title)
await expect(page.getByRole("tab", { name: "how does the retry loop work?", exact: true })).toHaveAttribute(
"data-selected",
"",
)
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
expect(ownerWarnings).toEqual([])
})
5 changes: 3 additions & 2 deletions packages/app/src/composer/submit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ function session(input: {
}

describe("Composer submission", () => {
test("runs a client argument command without admitting it to the session", async () => {
test("applies the selection and runs a client argument command without admitting it to the session", async () => {
const state = createMemoryComposerState().capture()

const image = {
Expand All @@ -169,7 +169,8 @@ describe("Composer submission", () => {
}
}).submit(new Event("submit"))

expect(calls).toEqual(["btw"])
// Client commands such as /btw generate with the session's model, so the composer's selection commits first.
expect(calls).toEqual(["switch-agent", "switch-model", "btw"])
expect(history).toEqual([])
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }, image])
expect(state.context.items()).toHaveLength(1)
Expand Down
45 changes: 31 additions & 14 deletions packages/app/src/composer/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
submitting.add(input.adapter.state)

try {
// Client commands such as /btw answer with the session's model, so apply the composer's selection first,
// following the same steer rule as server commands.
const selection = currentSelection(input)

if (input.adapter.kind === "active-session" && selection && (input.delivery?.(false) ?? "steer") === "steer")
await applySelection(
input.adapter.session(),
selection,
input.adapter.controls().model.selection.trackSessionCommit,
)
clearClientCommand(input, prompt)
await clientCommand()
} catch (error) {
Expand Down Expand Up @@ -272,25 +282,22 @@ function readSubmission(

if (!text.trim() && !prompt.some(isAttachment) && comments === 0) return

const controls = input.adapter.controls()
const model = controls.model.selection.current()
const agent = controls.agents.current
const selection = currentSelection(input)

if (!model || !agent) {
if (!selection) {
input.notify.missingSelection()

return
}

const variant = controls.model.selection.variant.current()
const retry = input.adapter.state.retry.current()

const retryID =
retry &&
retry.agent === agent &&
retry.providerID === model.provider.id &&
retry.modelID === model.id &&
(retry.variant ?? "default") === (variant ?? "default")
retry.agent === selection.agent &&
retry.providerID === selection.model.providerID &&
retry.modelID === selection.model.modelID &&
(retry.variant ?? "default") === (selection.variant ?? "default")
? retry.id
: undefined

Expand All @@ -301,15 +308,25 @@ function readSubmission(
context,
text,
images,
selection: {
agent,
model: { modelID: model.id, providerID: model.provider.id },
variant,
},
selection,
delivery: input.delivery?.(alternate) ?? "steer",
}
}

function currentSelection(input: ComposerSubmitInput): ComposerSelection | undefined {
const controls = input.adapter.controls()
const model = controls.model.selection.current()
const agent = controls.agents.current

if (!model || !agent) return

return {
agent,
model: { modelID: model.id, providerID: model.provider.id },
variant: controls.model.selection.variant.current(),
}
}

function clearSubmission(input: ComposerSubmitInput, submission: ReturnType<typeof createComposerSubmission>) {
submission.clear()
submission.target().mode.set("normal")
Expand Down
13 changes: 12 additions & 1 deletion packages/gui-extensions/src/btw/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import { Extension } from "../sdk"
import { Schema } from "effect"
import { Extension, Store } from "../sdk"
import en from "./i18n/en"

const Chats = Schema.Struct({
chats: Schema.Array(
Schema.Struct({ id: Schema.String, question: Schema.String, answer: Schema.optional(Schema.String) }),
),
})

export default Extension.define({
id: "btw",
stores: {
// Each open /btw tab's chat (its question and answer), kept until its tab closes.
chats: Store.session(Chats, { chats: [] }),
},
i18n: { en },
})
Loading
Loading