+
{
const dialog = page.getByRole("dialog", { name: "Quick Chat" });
await expect(dialog).toBeVisible({ timeout: 10_000 });
- const agentPicker = dialog.getByText("Choose an agent to start chatting");
- if (!(await agentPicker.isVisible({ timeout: 1_000 }).catch(() => false))) {
+ const setup = dialog.getByTestId("quick-chat-setup");
+ if (!(await setup.isVisible({ timeout: 1_000 }).catch(() => false))) {
await dialog.getByLabel("Start new chat").click();
}
- await expect(agentPicker).toBeVisible({ timeout: 5_000 });
+ await expect(setup).toBeVisible({ timeout: 5_000 });
- const agentCard = dialog
- .locator("button")
- .filter({ has: page.locator(".rounded-md.border") })
- .first();
- await expect(agentCard).toBeVisible({ timeout: 5_000 });
- await agentCard.click();
+ const agentSelector = dialog.getByTestId("agent-profile-selector");
+ if (
+ await agentSelector
+ .getByText("Select agent", { exact: false })
+ .isVisible()
+ .catch(() => false)
+ ) {
+ await agentSelector.click();
+ await page.getByRole("option").first().click();
+ }
+ await dialog.getByTestId("quick-chat-start").click();
// Wait for chat input to appear AND become editable. Eager init means the
// agent starts during the picker → tab transition; the input is briefly
diff --git a/apps/web/e2e/tests/chat/mobile-quick-chat-repository.spec.ts b/apps/web/e2e/tests/chat/mobile-quick-chat-repository.spec.ts
new file mode 100644
index 0000000000..9dcd6d32ff
--- /dev/null
+++ b/apps/web/e2e/tests/chat/mobile-quick-chat-repository.spec.ts
@@ -0,0 +1,43 @@
+import { test, expect } from "../../fixtures/test-base";
+
+async function assertNoHorizontalOverflow(page: import("@playwright/test").Page) {
+ const dimensions = await page.evaluate(() => ({
+ scrollWidth: document.documentElement.scrollWidth,
+ clientWidth: document.documentElement.clientWidth,
+ }));
+ expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth + 1);
+}
+
+test.describe("Quick Chat repository context on mobile", () => {
+ test("selects an agent and repository branch without hidden actions", async ({ testPage }) => {
+ await testPage.goto("/");
+ await testPage.waitForLoadState("networkidle");
+ const modifier = process.platform === "darwin" ? "Meta" : "Control";
+ await testPage.keyboard.press(`${modifier}+Shift+q`);
+
+ const dialog = testPage.getByRole("dialog", { name: "Quick Chat" });
+ await expect(dialog.getByTestId("quick-chat-setup")).toBeVisible({ timeout: 10_000 });
+ await expect(dialog.getByTestId("quick-chat-resize-left")).not.toBeVisible();
+ await expect(dialog.getByTestId("quick-chat-resize-right")).not.toBeVisible();
+ await expect(
+ dialog.getByText("Chat with an agent about an idea, question, or codebase."),
+ ).toBeVisible();
+
+ const agentSelector = dialog.getByTestId("agent-profile-selector");
+ if (await agentSelector.getByText("Select agent", { exact: false }).isVisible()) {
+ await agentSelector.click();
+ await testPage.getByRole("option").first().click();
+ }
+ await dialog.getByTestId("add-repository").click();
+ await dialog.getByTestId("repo-chip-trigger").click();
+ await testPage.getByRole("option").first().click();
+ await expect(dialog.getByTestId("branch-chip-trigger")).toContainText("main", {
+ timeout: 10_000,
+ });
+ await assertNoHorizontalOverflow(testPage);
+
+ await dialog.getByTestId("quick-chat-start").click();
+ await expect(dialog.locator(".tiptap.ProseMirror")).toBeVisible({ timeout: 30_000 });
+ await assertNoHorizontalOverflow(testPage);
+ });
+});
diff --git a/apps/web/e2e/tests/chat/quick-chat.spec.ts b/apps/web/e2e/tests/chat/quick-chat.spec.ts
index 3a14e0026b..8c6475ab5f 100644
--- a/apps/web/e2e/tests/chat/quick-chat.spec.ts
+++ b/apps/web/e2e/tests/chat/quick-chat.spec.ts
@@ -1,4 +1,6 @@
import { type Locator, type Page } from "@playwright/test";
+import { execFileSync } from "node:child_process";
+import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
import { attachAvailableCommandsCapture } from "../../helpers/ws-capture";
@@ -6,7 +8,7 @@ import { attachAvailableCommandsCapture } from "../../helpers/ws-capture";
* Quick Chat E2E tests: basic flow, enhance prompt, queued messages, multi-tab.
*/
-async function openQuickChatWithAgent(page: Page): Promise {
+async function openQuickChatSetup(page: Page): Promise {
await page.goto("/");
await page.waitForLoadState("networkidle");
@@ -18,20 +20,31 @@ async function openQuickChatWithAgent(page: Page): Promise {
const dialog = page.getByRole("dialog", { name: "Quick Chat" });
await expect(dialog).toBeVisible({ timeout: 10_000 });
- // If a stale session tab is showing, click "+" to start a fresh agent picker.
- const agentPicker = dialog.getByText("Choose an agent to start chatting");
- if (!(await agentPicker.isVisible({ timeout: 1_000 }).catch(() => false))) {
+ // If a stale session tab is showing, click "+" to start a fresh setup form.
+ const setup = dialog.getByTestId("quick-chat-setup");
+ if (!(await setup.isVisible({ timeout: 1_000 }).catch(() => false))) {
await dialog.getByLabel("Start new chat").click();
}
- await expect(agentPicker).toBeVisible({ timeout: 5_000 });
+ await expect(setup).toBeVisible({ timeout: 5_000 });
+ return dialog;
+}
- // Click the first agent profile card.
- const agentCard = dialog
- .locator("button")
- .filter({ has: page.locator(".rounded-md.border") })
- .first();
- await expect(agentCard).toBeVisible({ timeout: 5_000 });
- await agentCard.click();
+async function selectAgentIfNeeded(dialog: Locator, page: Page) {
+ const selector = dialog.getByTestId("agent-profile-selector");
+ if (
+ await selector
+ .getByText("Select agent", { exact: false })
+ .isVisible()
+ .catch(() => false)
+ ) {
+ await selector.click();
+ await page.getByRole("option").first().click();
+ }
+}
+
+async function startQuickChatFromSetup(dialog: Locator, page: Page) {
+ await selectAgentIfNeeded(dialog, page);
+ await dialog.getByTestId("quick-chat-start").click();
// Wait for chat input to appear AND become editable. Eager init means the
// agent starts during the HTTP request, so the input is briefly disabled
@@ -39,7 +52,11 @@ async function openQuickChatWithAgent(page: Page): Promise {
const editor = dialog.locator(".tiptap.ProseMirror");
await expect(editor).toBeVisible({ timeout: 15_000 });
await expect(editor).toHaveAttribute("contenteditable", "true", { timeout: 30_000 });
+}
+async function openQuickChatWithAgent(page: Page): Promise {
+ const dialog = await openQuickChatSetup(page);
+ await startQuickChatFromSetup(dialog, page);
return dialog;
}
@@ -59,7 +76,167 @@ async function sendQuickChatMessage(dialog: Locator, page: Page, text: string) {
}).toPass({ timeout: 30_000, intervals: [250, 500, 1_000] });
}
+async function waitForQuickChatWidth(dialog: Locator) {
+ await expect
+ .poll(() =>
+ dialog.evaluate((element) => {
+ const preferred = Number.parseFloat(
+ getComputedStyle(element).getPropertyValue("--quick-chat-width"),
+ );
+ return Math.abs(element.getBoundingClientRect().width - preferred);
+ }),
+ )
+ .toBeLessThan(2);
+}
+
test.describe("Quick Chat", () => {
+ test("resizes from either edge, restores width, and keeps tab actions adjacent", async ({
+ testPage,
+ }) => {
+ const dialog = await openQuickChatSetup(testPage);
+ await waitForQuickChatWidth(dialog);
+ const initialBox = await dialog.boundingBox();
+ expect(initialBox).not.toBeNull();
+
+ const rightHandle = dialog.getByTestId("quick-chat-resize-right");
+ await expect(rightHandle).toBeVisible();
+ const rightBox = await rightHandle.boundingBox();
+ expect(rightBox).not.toBeNull();
+ await testPage.mouse.move(
+ rightBox!.x + rightBox!.width / 2,
+ rightBox!.y + rightBox!.height / 2,
+ );
+ await testPage.mouse.down();
+ await expect.poll(() => testPage.evaluate(() => document.body.style.cursor)).toBe("ew-resize");
+ await testPage.mouse.move(
+ rightBox!.x + rightBox!.width / 2 + 50,
+ rightBox!.y + rightBox!.height / 2,
+ );
+ await testPage.mouse.up();
+ await waitForQuickChatWidth(dialog);
+
+ const rightResizedBox = await dialog.boundingBox();
+ expect(rightResizedBox!.width).toBeGreaterThan(initialBox!.width + 80);
+
+ const leftHandle = dialog.getByTestId("quick-chat-resize-left");
+ const leftBox = await leftHandle.boundingBox();
+ expect(leftBox).not.toBeNull();
+ await leftHandle.hover();
+ const leftHighlightBox = await leftHandle.locator("span").boundingBox();
+ expect(leftHighlightBox).not.toBeNull();
+ expect(leftHighlightBox!.x).toBeCloseTo(rightResizedBox!.x, 0);
+ await testPage.mouse.move(leftBox!.x + leftBox!.width / 2, leftBox!.y + leftBox!.height / 2);
+ await testPage.mouse.down();
+ await testPage.mouse.move(
+ leftBox!.x + leftBox!.width / 2 - 40,
+ leftBox!.y + leftBox!.height / 2,
+ );
+ await testPage.mouse.up();
+ await waitForQuickChatWidth(dialog);
+
+ const finalBox = await dialog.boundingBox();
+ const finalPreferredWidth = await dialog.evaluate((element) =>
+ Number.parseFloat(getComputedStyle(element).getPropertyValue("--quick-chat-width")),
+ );
+ expect(finalBox!.width).toBeGreaterThan(rightResizedBox!.width + 50);
+ expect(finalBox!.x + finalBox!.width / 2).toBeCloseTo(
+ (await testPage.evaluate(() => window.innerWidth)) / 2,
+ 0,
+ );
+
+ const tab = dialog.getByTestId("quick-chat-tab").last();
+ const newChat = dialog.getByLabel("Start new chat");
+ const tabBox = await tab.boundingBox();
+ const newChatBox = await newChat.boundingBox();
+ expect(newChatBox!.x - (tabBox!.x + tabBox!.width)).toBeLessThanOrEqual(8);
+
+ const setupSurfaces = await dialog.evaluate((element) => {
+ const setup = element.querySelector('[data-testid="quick-chat-setup"]');
+ const footer = element.querySelector('[data-testid="quick-chat-setup-footer"]');
+ return {
+ dialog: getComputedStyle(element).backgroundColor,
+ setup: setup ? getComputedStyle(setup).backgroundColor : null,
+ footer: footer ? getComputedStyle(footer).backgroundColor : null,
+ };
+ });
+ expect(setupSurfaces.setup).toBe(setupSurfaces.dialog);
+ expect(setupSurfaces.footer).toBe(setupSurfaces.setup);
+
+ await startQuickChatFromSetup(dialog, testPage);
+ const surfaces = await dialog.evaluate((element) => {
+ const messages = element.querySelector('[data-testid="quick-chat-messages"]');
+ const input = element.querySelector('[data-testid="chat-input-area"]');
+ return {
+ dialog: getComputedStyle(element).backgroundColor,
+ messages: messages ? getComputedStyle(messages).backgroundColor : null,
+ input: input ? getComputedStyle(input).backgroundColor : null,
+ };
+ });
+ expect(surfaces.messages).toBe(surfaces.dialog);
+ expect(surfaces.input).toBe(surfaces.messages);
+
+ await testPage.keyboard.press("Escape");
+ await expect(dialog).not.toBeVisible();
+ await testPage.reload();
+ await testPage.waitForLoadState("networkidle");
+ const modifier = process.platform === "darwin" ? "Meta" : "Control";
+ await testPage.keyboard.press(`${modifier}+Shift+q`);
+ await expect(dialog).toBeVisible();
+ await waitForQuickChatWidth(dialog);
+ const restoredBox = await dialog.boundingBox();
+ const restoredPreferredWidth = await dialog.evaluate((element) =>
+ Number.parseFloat(getComputedStyle(element).getPropertyValue("--quick-chat-width")),
+ );
+ expect(restoredPreferredWidth).toBe(finalPreferredWidth);
+ expect(Math.abs(restoredBox!.width - finalBox!.width)).toBeLessThan(2);
+ });
+
+ test("explains quick chat and starts with repository context", async ({
+ testPage,
+ seedData,
+ backend,
+ }) => {
+ const sourceRepo = path.join(backend.tmpDir, "repos", "e2e-repo");
+ const contextBranch = "quick-chat-context-branch";
+ execFileSync("git", ["branch", "-f", contextBranch], { cwd: sourceRepo });
+ try {
+ const dialog = await openQuickChatSetup(testPage);
+ await expect(dialog.getByTestId("quick-chat-introduction")).toContainText(
+ "Chat with an agent about an idea, question, or codebase.",
+ );
+ await expect(
+ dialog.getByText("Add repository context to focus on specific code and branches."),
+ ).toBeVisible();
+ await selectAgentIfNeeded(dialog, testPage);
+
+ await dialog.getByTestId("add-repository").click();
+ await dialog.getByTestId("repo-chip-trigger").click();
+ await testPage.getByRole("option").first().click();
+ await dialog.getByTestId("branch-chip-trigger").click();
+ await testPage.getByRole("option", { name: contextBranch }).click();
+
+ const startRequest = testPage.waitForRequest(
+ (request) => request.url().includes("/quick-chat") && request.method() === "POST",
+ );
+ await dialog.getByTestId("quick-chat-start").click();
+ const payload = (await startRequest).postDataJSON() as {
+ repositories?: Array<{ repository_id: string; base_branch: string }>;
+ };
+ expect(payload.repositories).toEqual([
+ { repository_id: seedData.repositoryId, base_branch: contextBranch },
+ ]);
+ await expect(dialog.locator(".tiptap.ProseMirror")).toBeVisible({ timeout: 30_000 });
+ expect(
+ execFileSync("git", ["branch", "--show-current"], {
+ cwd: sourceRepo,
+ encoding: "utf8",
+ }).trim(),
+ ).toBe("main");
+ } finally {
+ execFileSync("git", ["branch", "-D", contextBranch], { cwd: sourceRepo });
+ }
+ });
+
test("opens quick chat, selects agent, sends message and receives response", async ({
testPage,
}) => {
@@ -186,20 +363,10 @@ test.describe("Quick Chat", () => {
const newChatBtn = dialog.getByLabel("Start new chat");
await newChatBtn.click();
- // Agent picker should appear.
- await expect(dialog.getByText("Choose an agent to start chatting")).toBeVisible({
- timeout: 5_000,
- });
-
- // Select agent for the new tab.
- const agentCard = dialog
- .locator("button")
- .filter({ has: testPage.locator(".rounded-md.border") })
- .first();
- await agentCard.click();
-
- // Wait for chat input in new tab.
- await expect(dialog.locator(".tiptap.ProseMirror")).toBeVisible({ timeout: 15_000 });
+ // Setup should appear without the first-use introduction once a chat exists.
+ await expect(dialog.getByTestId("quick-chat-setup")).toBeVisible({ timeout: 5_000 });
+ await expect(dialog.getByTestId("quick-chat-introduction")).not.toBeVisible();
+ await startQuickChatFromSetup(dialog, testPage);
// Send a message in the second tab using script mode.
await sendQuickChatMessage(dialog, testPage, 'e2e:message("second tab response")');
diff --git a/apps/web/e2e/tests/chat/slash-command-composer.spec.ts b/apps/web/e2e/tests/chat/slash-command-composer.spec.ts
index 451705b51e..6c12ab87f4 100644
--- a/apps/web/e2e/tests/chat/slash-command-composer.spec.ts
+++ b/apps/web/e2e/tests/chat/slash-command-composer.spec.ts
@@ -65,18 +65,23 @@ async function openQuickChatWithAgent(page: Page): Promise {
const dialog = page.getByRole("dialog", { name: "Quick Chat" });
await expect(dialog).toBeVisible({ timeout: 10_000 });
- const agentPicker = dialog.getByText("Choose an agent to start chatting");
- if (!(await agentPicker.isVisible({ timeout: 1_000 }).catch(() => false))) {
+ const setup = dialog.getByTestId("quick-chat-setup");
+ if (!(await setup.isVisible({ timeout: 1_000 }).catch(() => false))) {
await dialog.getByLabel("Start new chat").click();
}
- await expect(agentPicker).toBeVisible({ timeout: 5_000 });
+ await expect(setup).toBeVisible({ timeout: 5_000 });
- const agentCard = dialog
- .locator("button")
- .filter({ has: page.locator(".rounded-md.border") })
- .first();
- await expect(agentCard).toBeVisible({ timeout: 5_000 });
- await agentCard.click();
+ const agentSelector = dialog.getByTestId("agent-profile-selector");
+ if (
+ await agentSelector
+ .getByText("Select agent", { exact: false })
+ .isVisible()
+ .catch(() => false)
+ ) {
+ await agentSelector.click();
+ await page.getByRole("option").first().click();
+ }
+ await dialog.getByTestId("quick-chat-start").click();
const editor = chatEditor(dialog);
await expect(editor).toBeVisible({ timeout: 15_000 });
diff --git a/apps/web/e2e/tests/task/create-task-remote-repo.spec.ts b/apps/web/e2e/tests/task/create-task-remote-repo.spec.ts
index 25fd598285..964b499b8f 100644
--- a/apps/web/e2e/tests/task/create-task-remote-repo.spec.ts
+++ b/apps/web/e2e/tests/task/create-task-remote-repo.spec.ts
@@ -186,6 +186,9 @@ test.describe("Task creation from Remote tab (chip picker)", () => {
// survives the truncation, so assert on that.
const trigger = testPage.getByTestId("remote-repo-chip-trigger").first();
await expect(trigger).toContainText("paste-repo", { timeout: 5_000 });
+ await expect(testPage.getByTestId("remote-branch-chip-trigger").first()).toContainText("main", {
+ timeout: 10_000,
+ });
await testPage.getByTestId("task-title-input").fill("Remote paste happy path");
await testPage.getByTestId("task-description-input").fill("test");
diff --git a/apps/web/e2e/tests/terminal/terminal-agent.spec.ts b/apps/web/e2e/tests/terminal/terminal-agent.spec.ts
index a34669e5bd..93a4302b58 100644
--- a/apps/web/e2e/tests/terminal/terminal-agent.spec.ts
+++ b/apps/web/e2e/tests/terminal/terminal-agent.spec.ts
@@ -3,7 +3,7 @@ import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
-import { TimeoutError, type Page } from "@playwright/test";
+import { errors, type Page } from "@playwright/test";
// ---------------------------------------------------------------------------
// Helpers shared across TUI passthrough tests
@@ -113,7 +113,7 @@ test.describe("Terminal agent (TUI passthrough)", () => {
await session.waitForPassthroughLoading();
} catch (error) {
// Keep tolerance for "didn't appear in time", but preserve real failures.
- if (!(error instanceof TimeoutError)) throw error;
+ if (!(error instanceof errors.TimeoutError)) throw error;
}
// Once connected, loading overlay disappears and terminal content is visible
diff --git a/apps/web/hooks/domains/workspace/use-repository-branches.test.ts b/apps/web/hooks/domains/workspace/use-repository-branches.test.ts
new file mode 100644
index 0000000000..4407c87ab0
--- /dev/null
+++ b/apps/web/hooks/domains/workspace/use-repository-branches.test.ts
@@ -0,0 +1,69 @@
+import { cleanup, renderHook, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const listBranchesMock = vi.fn();
+const setRepositoryBranchesMock = vi.fn();
+const setRepositoryBranchesLoadingMock = vi.fn();
+
+const mockState = {
+ repositoryBranches: {
+ itemsByRepositoryId: {} as Record,
+ loadedByRepositoryId: {} as Record,
+ loadingByRepositoryId: {} as Record,
+ },
+ setRepositoryBranches: setRepositoryBranchesMock,
+ setRepositoryBranchesLoading: setRepositoryBranchesLoadingMock,
+};
+
+vi.mock("@/components/state-provider", () => ({
+ useAppStore: (selector: (state: typeof mockState) => unknown) => selector(mockState),
+}));
+
+vi.mock("@/lib/api", () => ({
+ listBranches: (...args: unknown[]) => listBranchesMock(...args),
+ listRepositoryBranches: vi.fn(),
+}));
+
+import { useBranches, type BranchSource } from "./use-repository-branches";
+
+const WORKSPACE_ID = "workspace-1";
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+describe("useBranches", () => {
+ it("loads a new repository while the prior repository request is still pending", async () => {
+ listBranchesMock
+ .mockImplementationOnce(() => new Promise(() => undefined))
+ .mockResolvedValueOnce({ branches: [{ name: "main", type: "remote" }] });
+
+ const sourceA: BranchSource = {
+ kind: "id",
+ workspaceId: WORKSPACE_ID,
+ repositoryId: "repo-a",
+ };
+ const sourceB: BranchSource = {
+ kind: "id",
+ workspaceId: WORKSPACE_ID,
+ repositoryId: "repo-b",
+ };
+ const { rerender } = renderHook(({ source }: { source: BranchSource }) => useBranches(source), {
+ initialProps: { source: sourceA },
+ });
+
+ await waitFor(() =>
+ expect(listBranchesMock).toHaveBeenCalledWith(WORKSPACE_ID, {
+ repositoryId: "repo-a",
+ }),
+ );
+ rerender({ source: sourceB });
+
+ await waitFor(() =>
+ expect(listBranchesMock).toHaveBeenCalledWith(WORKSPACE_ID, {
+ repositoryId: "repo-b",
+ }),
+ );
+ });
+});
diff --git a/apps/web/hooks/domains/workspace/use-repository-branches.ts b/apps/web/hooks/domains/workspace/use-repository-branches.ts
index 7a37dd2dd3..f37681584d 100644
--- a/apps/web/hooks/domains/workspace/use-repository-branches.ts
+++ b/apps/web/hooks/domains/workspace/use-repository-branches.ts
@@ -54,12 +54,12 @@ export function useBranches(source: BranchSource | null, enabled = true): UseBra
);
const setRepositoryBranches = useAppStore((state) => state.setRepositoryBranches);
const setRepositoryBranchesLoading = useAppStore((state) => state.setRepositoryBranchesLoading);
- const inFlightRef = useRef(false);
+ const inFlightKeysRef = useRef>(new Set());
useEffect(() => {
if (!enabled || !source) return;
- if (isLoaded || inFlightRef.current) return;
- inFlightRef.current = true;
+ if (isLoaded || inFlightKeysRef.current.has(key)) return;
+ inFlightKeysRef.current.add(key);
setRepositoryBranchesLoading(key, true);
const promise =
@@ -71,7 +71,7 @@ export function useBranches(source: BranchSource | null, enabled = true): UseBra
.then((response) => setRepositoryBranches(key, response.branches))
.catch(() => setRepositoryBranches(key, []))
.finally(() => {
- inFlightRef.current = false;
+ inFlightKeysRef.current.delete(key);
setRepositoryBranchesLoading(key, false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- key encodes source identity; listing every field re-fires on every render
diff --git a/apps/web/hooks/use-quick-chat-width.test.ts b/apps/web/hooks/use-quick-chat-width.test.ts
new file mode 100644
index 0000000000..eaf80fd103
--- /dev/null
+++ b/apps/web/hooks/use-quick-chat-width.test.ts
@@ -0,0 +1,123 @@
+import { act, renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, it } from "vitest";
+import {
+ clampQuickChatWidth,
+ QUICK_CHAT_WIDTH_LIMITS,
+ useQuickChatWidth,
+} from "./use-quick-chat-width";
+
+const WIDTH_PROPERTY = "--quick-chat-width";
+
+function mouseDown(clientX: number, dialog: HTMLElement) {
+ const handle = document.createElement("button");
+ dialog.appendChild(handle);
+ return {
+ clientX,
+ currentTarget: handle,
+ preventDefault: () => {},
+ } as unknown as React.MouseEvent;
+}
+
+function mouseMove(clientX: number) {
+ document.dispatchEvent(new MouseEvent("mousemove", { clientX, bubbles: true }));
+}
+
+function mouseUp() {
+ document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
+}
+
+describe("quick chat dialog width", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 1200 });
+ document.body.style.cursor = "";
+ document.body.style.userSelect = "";
+ });
+
+ it("defaults to 80 percent of the viewport", () => {
+ const { result } = renderHook(() => useQuickChatWidth());
+
+ expect(result.current.width).toBe(960);
+ });
+
+ it("restores and clamps a persisted width", () => {
+ window.localStorage.setItem(QUICK_CHAT_WIDTH_LIMITS.storageKey, JSON.stringify(5000));
+ const { result } = renderHook(() => useQuickChatWidth());
+
+ expect(result.current.width).toBe(1200 - QUICK_CHAT_WIDTH_LIMITS.viewportMargin);
+ });
+
+ it("grows symmetrically when the right edge moves right", () => {
+ const { result } = renderHook(() => useQuickChatWidth());
+ const dialog = document.createElement("div");
+
+ act(() => result.current.rightResizeHandleProps.onMouseDown(mouseDown(900, dialog)));
+ act(() => mouseMove(950));
+
+ expect(dialog.style.getPropertyValue(WIDTH_PROPERTY)).toBe("1060px");
+ expect(result.current.width).toBe(960);
+ act(() => mouseUp());
+ expect(result.current.width).toBe(1060);
+ });
+
+ it("grows symmetrically when the left edge moves left", () => {
+ const { result } = renderHook(() => useQuickChatWidth());
+ const dialog = document.createElement("div");
+
+ act(() => result.current.leftResizeHandleProps.onMouseDown(mouseDown(300, dialog)));
+ act(() => mouseMove(250));
+
+ expect(dialog.style.getPropertyValue(WIDTH_PROPERTY)).toBe("1060px");
+ expect(result.current.width).toBe(960);
+ act(() => mouseUp());
+ expect(result.current.width).toBe(1060);
+ });
+
+ it("persists the final width on mouseup and restores it after remount", () => {
+ const first = renderHook(() => useQuickChatWidth());
+ const dialog = document.createElement("div");
+ act(() => first.result.current.rightResizeHandleProps.onMouseDown(mouseDown(900, dialog)));
+ act(() => mouseMove(950));
+ act(() => mouseUp());
+ first.unmount();
+
+ const second = renderHook(() => useQuickChatWidth());
+ expect(second.result.current.width).toBe(1060);
+ });
+
+ it("clamps a drag on viewport resize without rendering until mouseup", () => {
+ const { result } = renderHook(() => useQuickChatWidth());
+ const dialog = document.createElement("div");
+
+ act(() => result.current.rightResizeHandleProps.onMouseDown(mouseDown(900, dialog)));
+ act(() => mouseMove(950));
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 1000 });
+ act(() => window.dispatchEvent(new Event("resize")));
+
+ expect(dialog.style.getPropertyValue(WIDTH_PROPERTY)).toBe("968px");
+ expect(result.current.width).toBe(960);
+ act(() => mouseUp());
+ expect(result.current.width).toBe(968);
+ });
+
+ it("rebases an active drag after the viewport changes", () => {
+ const { result } = renderHook(() => useQuickChatWidth());
+ const dialog = document.createElement("div");
+
+ act(() => result.current.rightResizeHandleProps.onMouseDown(mouseDown(900, dialog)));
+ act(() => mouseMove(950));
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 1000 });
+ act(() => window.dispatchEvent(new Event("resize")));
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 1200 });
+ act(() => window.dispatchEvent(new Event("resize")));
+ act(() => mouseMove(951));
+
+ expect(dialog.style.getPropertyValue(WIDTH_PROPERTY)).toBe("970px");
+ act(() => mouseUp());
+ });
+
+ it("clamps to the minimum and current viewport", () => {
+ expect(clampQuickChatWidth(100, 1200)).toBe(QUICK_CHAT_WIDTH_LIMITS.minWidth);
+ expect(clampQuickChatWidth(5000, 900)).toBe(900 - QUICK_CHAT_WIDTH_LIMITS.viewportMargin);
+ });
+});
diff --git a/apps/web/hooks/use-quick-chat-width.ts b/apps/web/hooks/use-quick-chat-width.ts
new file mode 100644
index 0000000000..b3098c15c5
--- /dev/null
+++ b/apps/web/hooks/use-quick-chat-width.ts
@@ -0,0 +1,135 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { getLocalStorage, setLocalStorage } from "@/lib/local-storage";
+
+const STORAGE_KEY = "kandev.quickChat.dialogWidth";
+const DEFAULT_VIEWPORT_RATIO = 0.8;
+const MIN_WIDTH = 480;
+const VIEWPORT_MARGIN = 32;
+
+export const QUICK_CHAT_WIDTH_LIMITS = {
+ storageKey: STORAGE_KEY,
+ defaultViewportRatio: DEFAULT_VIEWPORT_RATIO,
+ minWidth: MIN_WIDTH,
+ viewportMargin: VIEWPORT_MARGIN,
+} as const;
+
+function viewportWidth(): number {
+ return typeof window === "undefined" ? 1200 : window.innerWidth;
+}
+
+export function clampQuickChatWidth(width: number, availableWidth = viewportWidth()): number {
+ const maximum = Math.max(0, availableWidth - VIEWPORT_MARGIN);
+ const minimum = Math.min(MIN_WIDTH, maximum);
+ return Math.round(Math.min(Math.max(width, minimum), maximum));
+}
+
+function readInitialWidth(): number {
+ const availableWidth = viewportWidth();
+ const stored = getLocalStorage(STORAGE_KEY, null);
+ const preferred =
+ typeof stored === "number" && Number.isFinite(stored)
+ ? stored
+ : availableWidth * DEFAULT_VIEWPORT_RATIO;
+ return clampQuickChatWidth(preferred, availableWidth);
+}
+
+function restoreBodyStyles() {
+ document.body.style.cursor = "";
+ document.body.style.userSelect = "";
+}
+
+type ResizeEdge = "left" | "right";
+
+export function useQuickChatWidth() {
+ const [width, setWidth] = useState(readInitialWidth);
+ const widthRef = useRef(width);
+ const dragRef = useRef<{
+ edge: ResizeEdge;
+ startX: number;
+ lastX: number;
+ startWidth: number;
+ dialog: HTMLElement;
+ } | null>(null);
+
+ const updateWidth = useCallback((nextWidth: number) => {
+ widthRef.current = nextWidth;
+ setWidth(nextWidth);
+ }, []);
+
+ const startResize = useCallback((edge: ResizeEdge, event: React.MouseEvent) => {
+ event.preventDefault();
+ const dialog = event.currentTarget.parentElement;
+ if (!dialog) return;
+ dragRef.current = {
+ edge,
+ startX: event.clientX,
+ lastX: event.clientX,
+ startWidth: widthRef.current,
+ dialog,
+ };
+ document.body.style.cursor = "ew-resize";
+ document.body.style.userSelect = "none";
+ }, []);
+
+ const handleLeftMouseDown = useCallback(
+ (event: React.MouseEvent) => startResize("left", event),
+ [startResize],
+ );
+ const handleRightMouseDown = useCallback(
+ (event: React.MouseEvent) => startResize("right", event),
+ [startResize],
+ );
+
+ useEffect(() => {
+ const handleMouseMove = (event: MouseEvent) => {
+ const drag = dragRef.current;
+ if (!drag) return;
+ drag.lastX = event.clientX;
+ const pointerDelta = event.clientX - drag.startX;
+ const widthDelta = (drag.edge === "right" ? pointerDelta : -pointerDelta) * 2;
+ const nextWidth = clampQuickChatWidth(drag.startWidth + widthDelta);
+ widthRef.current = nextWidth;
+ drag.dialog.style.setProperty("--quick-chat-width", `${nextWidth}px`);
+ };
+ const handleMouseUp = () => {
+ if (!dragRef.current) return;
+ dragRef.current = null;
+ restoreBodyStyles();
+ setWidth(widthRef.current);
+ setLocalStorage(STORAGE_KEY, widthRef.current);
+ };
+ const handleWindowResize = () => {
+ const nextWidth = clampQuickChatWidth(widthRef.current);
+ const drag = dragRef.current;
+ if (drag) {
+ widthRef.current = nextWidth;
+ drag.startWidth = nextWidth;
+ drag.startX = drag.lastX;
+ drag.dialog.style.setProperty("--quick-chat-width", `${nextWidth}px`);
+ return;
+ }
+ updateWidth(nextWidth);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ window.addEventListener("resize", handleWindowResize);
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ window.removeEventListener("resize", handleWindowResize);
+ if (dragRef.current) {
+ dragRef.current = null;
+ restoreBodyStyles();
+ }
+ };
+ }, [updateWidth]);
+
+ return {
+ width,
+ leftResizeHandleProps: { onMouseDown: handleLeftMouseDown },
+ rightResizeHandleProps: { onMouseDown: handleRightMouseDown },
+ };
+}
diff --git a/apps/web/lib/api/domains/github-api.test.ts b/apps/web/lib/api/domains/github-api.test.ts
index f099f44f61..660888f3ae 100644
--- a/apps/web/lib/api/domains/github-api.test.ts
+++ b/apps/web/lib/api/domains/github-api.test.ts
@@ -9,6 +9,8 @@ import {
createTaskPR,
fetchAccessibleRepos,
fetchIssueInfo,
+ fetchPRInfo,
+ fetchRepoBranches,
getTaskCIAutomationOptions,
GitHubUnavailableError,
linkTaskIssue,
@@ -46,6 +48,29 @@ function lastCallUrl(): string {
return String(call[0]);
}
+describe("remote repository reads", () => {
+ it("retries a transient network failure when loading branches", async () => {
+ fetchSpy.mockRejectedValueOnce(new TypeError("fetch failed"));
+ fetchSpy.mockResolvedValueOnce(jsonResponse({ branches: [{ name: "main" }] }));
+
+ await expect(fetchRepoBranches("acme", "site")).resolves.toEqual({
+ branches: [{ name: "main" }],
+ });
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it("retries a transient network failure when loading PR title metadata", async () => {
+ fetchSpy.mockRejectedValueOnce(new TypeError("fetch failed"));
+ fetchSpy.mockResolvedValueOnce(jsonResponse({ number: 42, title: "Recovered" }));
+
+ await expect(fetchPRInfo("acme", "site", 42)).resolves.toMatchObject({
+ number: 42,
+ title: "Recovered",
+ });
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
+ });
+});
+
describe("fetchAccessibleRepos — URL & parsing", () => {
it("builds the correct URL with both q and limit", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ repos: [] }));
diff --git a/apps/web/lib/api/domains/github-api.ts b/apps/web/lib/api/domains/github-api.ts
index 692a2213f1..80d32b1cf5 100644
--- a/apps/web/lib/api/domains/github-api.ts
+++ b/apps/web/lib/api/domains/github-api.ts
@@ -1,4 +1,4 @@
-import { fetchJson, ApiError, type ApiRequestOptions } from "../client";
+import { fetchJson, fetchJsonWithRetry, ApiError, type ApiRequestOptions } from "../client";
import type {
GitHubStatusResponse,
GitHubOrg,
@@ -443,7 +443,7 @@ export async function fetchPRInfo(
number: number,
options?: ApiRequestOptions,
) {
- return fetchJson(
+ return fetchJsonWithRetry(
`/api/v1/github/prs/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/info`,
options,
);
@@ -456,7 +456,7 @@ export async function fetchIssueInfo(
number: number,
options?: ApiRequestOptions,
) {
- return fetchJson(
+ return fetchJsonWithRetry(
`/api/v1/github/issues/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/info`,
options,
);
@@ -464,7 +464,7 @@ export async function fetchIssueInfo(
// Remote repo branches
export async function fetchRepoBranches(owner: string, repo: string, options?: ApiRequestOptions) {
- return fetchJson<{ branches: { name: string }[] }>(
+ return fetchJsonWithRetry<{ branches: { name: string }[] }>(
`/api/v1/github/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`,
options,
);
diff --git a/apps/web/lib/api/domains/workspace-api.ts b/apps/web/lib/api/domains/workspace-api.ts
index 1e99d46f91..878027672f 100644
--- a/apps/web/lib/api/domains/workspace-api.ts
+++ b/apps/web/lib/api/domains/workspace-api.ts
@@ -85,18 +85,39 @@ export async function listRepositoryScripts(repositoryId: string, options?: ApiR
}
// Quick Chat operations
-export type StartQuickChatRequest = {
+type StartQuickChatCommon = {
title?: string;
- repository_id?: string;
agent_profile_id?: string;
executor_id?: string;
prompt?: string;
+};
+
+type StartQuickChatLegacyRepository = {
+ repositories?: never;
+ repository_id?: string;
local_path?: string;
repository_name?: string;
default_branch?: string;
base_branch?: string;
};
+type StartQuickChatRepositories = {
+ repositories: QuickChatRepositoryInput[];
+ repository_id?: never;
+ local_path?: never;
+ repository_name?: never;
+ default_branch?: never;
+ base_branch?: never;
+};
+
+export type StartQuickChatRequest = StartQuickChatCommon &
+ (StartQuickChatLegacyRepository | StartQuickChatRepositories);
+
+export type QuickChatRepositoryInput = {
+ repository_id: string;
+ base_branch: string;
+};
+
export type StartQuickChatResponse = {
task_id: string;
session_id: string;
diff --git a/docs/decisions/0038-quick-chat-repository-isolation.md b/docs/decisions/0038-quick-chat-repository-isolation.md
new file mode 100644
index 0000000000..f7acc13163
--- /dev/null
+++ b/docs/decisions/0038-quick-chat-repository-isolation.md
@@ -0,0 +1,42 @@
+# 0038: Quick Chat Repository Isolation
+
+**Status:** accepted
+**Date:** 2026-07-14
+**Area:** backend, frontend
+
+## Context
+
+Quick chat already creates an ephemeral task and can associate one repository, but it resolves
+the workspace default executor. When that executor is local, selecting another branch can
+switch the branch in the user's source checkout. Adding multiple repositories amplifies that
+risk, while cloning every repository into a new quick-chat-specific directory would duplicate
+the existing task materialization and cleanup systems.
+
+## Decision
+
+Repo-backed quick chats use the built-in worktree executor and the existing multi-repository
+task preparation pipeline. The quick-chat request accepts an ordered repository list and the
+backend enforces the worktree executor whenever that list is non-empty. Repo-less chats retain
+the current scratch workspace and resolved default executor.
+
+The worktrees remain owned by the ephemeral task and use the existing task environment,
+session worktree, rollback, deletion, expiration, and garbage-collection paths. The selected
+base branch supplies committed repository state; uncommitted source-checkout changes are not
+copied.
+
+## Consequences
+
+Concurrent chats can target the same repository and base branch without mutating the user's
+checkout. Materialization stays smaller and faster than a full clone, and no second cleanup
+system is introduced. A chat does not see uncommitted local changes, and repository-backed
+quick chats always run through the local worktree executor rather than a workspace default
+remote executor in this initial version.
+
+## Alternatives Considered
+
+- **Clone every repository under `quick-chat/`.** Rejected because it duplicates clone,
+ credential, preparation, persistence, and cleanup behavior already owned by task worktrees.
+- **Use the workspace default executor unchanged.** Rejected because a local executor may
+ checkout the requested branch in the user's source directory.
+- **Copy the current working directory.** Rejected because it silently includes uncommitted or
+ ignored files and has unclear performance and privacy behavior.
diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md
index a268a89a04..b7ff1853ec 100644
--- a/docs/decisions/INDEX.md
+++ b/docs/decisions/INDEX.md
@@ -43,3 +43,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 0035 | [Version AgentReady events by prompt generation](0035-version-agent-ready-events-by-prompt-generation.md) | accepted | backend | 2026-07-14 |
| 0036 | [Normalize ACP shell output at the adapter boundary](0036-normalize-acp-shell-output-at-adapter-boundary.md) | accepted | backend, frontend, protocol | 2026-07-14 |
| 0037 | [Resource-aware frontend unit tests](0037-resource-aware-frontend-unit-tests.md) | accepted | frontend, infra | 2026-07-14 |
+| 0038 | [Quick Chat Repository Isolation](0038-quick-chat-repository-isolation.md) | accepted | backend, frontend | 2026-07-14 |
diff --git a/docs/plans/quick-chat-repository-context/plan.md b/docs/plans/quick-chat-repository-context/plan.md
new file mode 100644
index 0000000000..233bd169c5
--- /dev/null
+++ b/docs/plans/quick-chat-repository-context/plan.md
@@ -0,0 +1,55 @@
+---
+spec: docs/specs/tasks/quick-chat-repository-context.md
+created: 2026-07-14
+status: complete
+---
+
+# Implementation Plan: Quick Chat Repository Context
+
+## Overview
+
+Extend quick chat's existing ephemeral-task contract to carry ordered repository inputs and
+enforce isolated worktree preparation. Then extract the controlled workspace repo/branch chips
+from task creation, replace the agent-card picker with a responsive setup form, and verify the
+desktop and mobile flows.
+
+## Backend
+
+- Extend `httpStartQuickChatRequest` in
+ `apps/backend/internal/task/handlers/task_http_handlers.go` with plural repository inputs.
+- Normalize plural or legacy singleton fields into `[]service.TaskRepositoryInput`, reject mixed
+ shapes, and force `models.ExecutorIDWorktree` for repo-backed starts.
+- Preserve eager launch and rollback through `Service.DeleteTask`.
+
+## Frontend
+
+- Add the plural request type in `apps/web/lib/api/domains/workspace-api.ts`.
+- Extract a controlled workspace repository/branch chip component from
+ `apps/web/components/task-create-dialog-repo-chips.tsx` without moving task-only URL/folder and
+ fresh-branch modes.
+- Add `apps/web/components/quick-chat/quick-chat-setup.tsx`; use the shared agent selector,
+ repository chips, approved helper copy, and responsive footer.
+- Keep start supersession and orphan cleanup in `use-quick-chat-modal.ts`.
+
+## Tests
+
+- Backend handler tests cover plural normalization, compatibility, isolation, and validation.
+- Frontend unit tests cover payload construction/default selection logic and supersession.
+- Existing task chip tests guard the extraction against regressions.
+
+## E2E Tests
+
+- Extend `apps/web/e2e/tests/chat/quick-chat.spec.ts` for setup helper copy and repo-backed start.
+- Add a `mobile-*.spec.ts` flow for the same user value and narrow layout.
+
+## Implementation Waves
+
+Wave 1:
+- [x] [task-01-backend-contract](task-01-backend-contract.md)
+- [x] [task-02-shared-repository-chips](task-02-shared-repository-chips.md)
+
+Wave 2:
+- [x] [task-03-quick-chat-setup](task-03-quick-chat-setup.md)
+
+Wave 3:
+- [x] [task-04-e2e-verification](task-04-e2e-verification.md)
diff --git a/docs/plans/quick-chat-repository-context/task-01-backend-contract.md b/docs/plans/quick-chat-repository-context/task-01-backend-contract.md
new file mode 100644
index 0000000000..d7af371d16
--- /dev/null
+++ b/docs/plans/quick-chat-repository-context/task-01-backend-contract.md
@@ -0,0 +1,23 @@
+---
+id: "01-backend-contract"
+title: "Backend quick-chat repository contract"
+status: done
+wave: 1
+depends_on: []
+plan: "plan.md"
+spec: "../../specs/tasks/quick-chat-repository-context.md"
+---
+
+# Task 01: Backend quick-chat repository contract
+
+**Acceptance:** plural repository inputs preserve order and branches; legacy singleton input
+remains accepted; repo-backed starts always launch with the worktree executor.
+
+**Verification:** `make -C apps/backend test` from the repository root.
+
+**Files likely touched:** `apps/backend/internal/task/handlers/task_http_handlers.go`,
+`apps/backend/internal/task/handlers/task_http_handlers_test.go`.
+
+**Dependencies:** None.
+
+**Output contract:** summarize contract changes, tests run, files changed, risks, and status.
diff --git a/docs/plans/quick-chat-repository-context/task-02-shared-repository-chips.md b/docs/plans/quick-chat-repository-context/task-02-shared-repository-chips.md
new file mode 100644
index 0000000000..bd3db1c9cb
--- /dev/null
+++ b/docs/plans/quick-chat-repository-context/task-02-shared-repository-chips.md
@@ -0,0 +1,23 @@
+---
+id: "02-shared-repository-chips"
+title: "Shared controlled repository chips"
+status: done
+wave: 1
+depends_on: []
+plan: "plan.md"
+spec: "../../specs/tasks/quick-chat-repository-context.md"
+---
+
+# Task 02: Shared controlled repository chips
+
+**Acceptance:** task creation and quick chat can use one controlled workspace repo/branch chip
+surface; task-only source modes remain unchanged; quick chat can exclude duplicate repos.
+
+**Verification:** run the focused repository-chip Vitest files from `apps/`.
+
+**Files likely touched:** `apps/web/components/task-create-dialog-repo-chips.tsx`, new shared
+component and tests.
+
+**Dependencies:** None.
+
+**Output contract:** summarize extraction, tests run, files changed, risks, and status.
diff --git a/docs/plans/quick-chat-repository-context/task-03-quick-chat-setup.md b/docs/plans/quick-chat-repository-context/task-03-quick-chat-setup.md
new file mode 100644
index 0000000000..ff9e8eb6e3
--- /dev/null
+++ b/docs/plans/quick-chat-repository-context/task-03-quick-chat-setup.md
@@ -0,0 +1,22 @@
+---
+id: "03-quick-chat-setup"
+title: "Responsive quick-chat setup"
+status: done
+wave: 2
+depends_on: ["01-backend-contract", "02-shared-repository-chips"]
+plan: "plan.md"
+spec: "../../specs/tasks/quick-chat-repository-context.md"
+---
+
+# Task 03: Responsive quick-chat setup
+
+**Acceptance:** users select an agent and optional repository branches before starting; first-use
+and field helper copy render correctly; failed/superseded starts preserve current cleanup rules.
+
+**Verification:** run focused quick-chat Vitest tests and web typecheck from `apps/web`.
+
+**Files likely touched:** quick-chat modal/hook/setup files and workspace API types.
+
+**Dependencies:** Tasks 01 and 02.
+
+**Output contract:** summarize UI/state changes, tests run, files changed, risks, and status.
diff --git a/docs/plans/quick-chat-repository-context/task-04-e2e-verification.md b/docs/plans/quick-chat-repository-context/task-04-e2e-verification.md
new file mode 100644
index 0000000000..56e6beb0dc
--- /dev/null
+++ b/docs/plans/quick-chat-repository-context/task-04-e2e-verification.md
@@ -0,0 +1,27 @@
+---
+id: "04-e2e-verification"
+title: "Quick-chat repository E2E verification"
+status: done
+wave: 3
+depends_on: ["03-quick-chat-setup"]
+plan: "plan.md"
+spec: "../../specs/tasks/quick-chat-repository-context.md"
+---
+
+# Task 04: Quick-chat repository E2E verification
+
+**Acceptance:** desktop and mobile flows start a repo-backed quick chat; source checkout remains
+unchanged; controls fit narrow viewports.
+
+**Verification:** from `apps/web`, run
+`pnpm e2e:run --host --no-build --project chromium -- tests/chat/quick-chat.spec.ts`
+(6 passed) and
+`pnpm e2e:run --host --no-build --project mobile-chrome -- tests/chat/mobile-quick-chat-repository.spec.ts`
+(1 passed). From the repository root, run `make fmt`, `make typecheck`, `make test`, and
+`make lint`; all completed successfully.
+
+**Files likely touched:** quick-chat desktop/mobile E2E specs.
+
+**Dependencies:** Task 03.
+
+**Output contract:** report scenarios, commands, results, artifacts, risks, and status.
diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md
index 10fc99c586..9585cf089f 100644
--- a/docs/specs/INDEX.md
+++ b/docs/specs/INDEX.md
@@ -51,6 +51,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su
| [wip-limit-pull-system](tasks/wip-limit-pull-system.md) | building |
| [multi-branch](tasks/multi-branch/spec.md) | shipped |
| [quick-chat-expiration](tasks/quick-chat-expiration.md) | draft |
+| [quick-chat-repository-context](tasks/quick-chat-repository-context.md) | shipped |
| [parent-child-message-interrupt](tasks/parent-child-message-interrupt.md) | shipped |
## agents/ — agent governance
diff --git a/docs/specs/tasks/quick-chat-repository-context.md b/docs/specs/tasks/quick-chat-repository-context.md
new file mode 100644
index 0000000000..91c3a74b55
--- /dev/null
+++ b/docs/specs/tasks/quick-chat-repository-context.md
@@ -0,0 +1,115 @@
+---
+status: shipped
+created: 2026-07-14
+owner: kandev
+---
+
+# Quick Chat Repository Context
+
+## Why
+
+Quick chat is useful for focused questions, but its agent-only picker does not explain the
+surface or let a user ground the conversation in repository code. Users need optional code
+context without changing their checked-out branches or creating a kanban task.
+
+## What
+
+- A new quick chat presents a searchable agent-profile selector and requires an agent before
+ it can start.
+- A user may add zero, one, or multiple imported workspace repositories. Each selected
+ repository has one explicit base branch and can appear at most once in a chat.
+- Selecting a repository automatically fills its preferred branch (`main`, `master`, or the
+ first available branch), including when another repository lookup is still pending.
+- A workspace with no existing quick chats explains that quick chat is for discussing an idea,
+ question, or codebase outside the task board.
+- Agent and repository fields include concise helper copy. Repository copy explains that the
+ optional selection focuses the conversation on specific code and branches.
+- Repo-backed quick chats run in Kandev-owned isolated worktrees. They never switch or modify
+ the branch checked out in the user's source directory.
+- Repository context contains committed branch state. Uncommitted files from the user's
+ working directory are not copied into the chat.
+- Repo-less quick chats retain their current scratch workspace and default-executor behavior.
+- The desktop quick-chat dialog stays horizontally centered and can be resized from either
+ horizontal edge. Its last user-selected width is restored across browser sessions and clamped
+ to the current viewport.
+- The quick-chat message history and composer retain the dialog's popover surface color. The setup
+ footer uses that same continuous surface, and the new chat action appears immediately after the
+ last chat tab.
+- Quick-chat tabs remain in creation order across reloads; later activity does not reorder them.
+- Hovering either resize target highlights the dialog's outer border, not an inset line.
+- The setup flow supports desktop and mobile layouts without horizontal page scrolling or
+ hover-only actions. Mobile quick chat remains full-screen and does not expose resize handles.
+
+Decision: [ADR 0038](../../decisions/0038-quick-chat-repository-isolation.md).
+
+## API surface
+
+`POST /api/v1/workspaces/:id/quick-chat` accepts:
+
+```json
+{
+ "title": "Codex - Chat 1",
+ "agent_profile_id": "profile-id",
+ "repositories": [
+ { "repository_id": "repo-1", "base_branch": "main" },
+ { "repository_id": "repo-2", "base_branch": "develop" }
+ ]
+}
+```
+
+`agent_profile_id` is required after workspace-default resolution. `repositories` is optional
+and ordered. The legacy singleton repository fields remain accepted, but callers cannot mix
+the singleton and plural shapes in one request.
+
+The response remains:
+
+```json
+{ "task_id": "task-id", "session_id": "session-id" }
+```
+
+## Failure modes
+
+- An incomplete repository row keeps Start Chat disabled.
+- A missing agent, foreign repository, duplicate repository, invalid branch, or mixed request
+ shape fails validation and does not launch an agent.
+- If repository preparation or agent launch fails, the backend deletes the ephemeral task and
+ its materialized worktrees. The setup remains visible with its selections and reports the
+ error.
+- A superseded in-flight start still deletes the completed orphan task.
+
+## Persistence guarantees
+
+Quick-chat task/session rows and their isolated worktrees survive backend restarts under the
+existing task runtime rules. Closing, deleting, workspace deletion, or idle expiration cleans
+them through the existing quick-chat task deletion path.
+
+## Scenarios
+
+- **GIVEN** a workspace with no quick chats, **WHEN** the user opens Quick Chat, **THEN** the
+ setup explains its purpose and shows agent and repository helper copy.
+- **GIVEN** an agent and no repositories, **WHEN** the user starts a chat, **THEN** the chat uses
+ the existing repo-less scratch workspace.
+- **GIVEN** an agent and two repository/branch selections, **WHEN** the user starts a chat,
+ **THEN** the agent workspace contains both repositories at committed state from those branches.
+- **GIVEN** a repository with an empty branch selection, **WHEN** its branch list loads, **THEN**
+ the preferred branch is selected automatically even if a prior repository request is pending.
+- **GIVEN** the user's source checkout is on another branch or dirty, **WHEN** a repo-backed chat
+ starts, **THEN** the source checkout and its uncommitted files remain unchanged.
+- **GIVEN** one repo-backed chat is active, **WHEN** another chat targets the same repository and
+ base branch, **THEN** both start in distinct Kandev-owned worktrees.
+- **GIVEN** an existing quick chat, **WHEN** the user opens a new chat tab, **THEN** the long
+ first-use introduction is omitted while field helper copy remains.
+- **GIVEN** a mobile viewport, **WHEN** the user selects an agent and repository branch, **THEN**
+ all controls remain touch-accessible and the page has no horizontal overflow.
+- **GIVEN** a desktop quick-chat dialog, **WHEN** the user drags either horizontal edge, **THEN**
+ the centered dialog resizes within viewport limits and restores that width the next time it
+ opens.
+- **GIVEN** one or more quick-chat tabs, **WHEN** the tab strip renders, **THEN** the new-chat
+ action sits directly after the final tab rather than at the far edge of the dialog.
+
+## Out of scope
+
+- Remote repository URL entry or arbitrary local folders.
+- Including uncommitted working-directory changes.
+- Selecting the same repository more than once in a single quick chat.
+- Choosing an executor in the quick-chat setup.