mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bf7f8b7e7 | ||
|
|
142b2d2bc4 | ||
|
|
7bafd0fdad |
@@ -136,12 +136,6 @@ the marker. Closing a tab sets that client's label to `false`. Any `true` client
|
||||
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
|
||||
normal root agent immediately, and closing its still-open tab archives it.
|
||||
|
||||
Agent-scoped workspace creation records the caller's workspace as `parentWorkspaceId`.
|
||||
This is informational ancestry for future workspace grouping. It does not change placement,
|
||||
visibility, status, or archive behavior. Archiving a parent workspace preserves its children's
|
||||
records and parentage. Unscoped creation and existing workspaces have no parent; ancestry is
|
||||
never inferred from directories, projects, or Git branches.
|
||||
|
||||
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
|
||||
|
||||
Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility.
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
|
||||
|
||||
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
|
||||
|
||||
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. The hit region is not the painted area: a filled toolbar control such as the composer's send or stop keeps the 44pt frame and paints a 36pt disc inside it (`TOOLBAR_DISC_SIZE`), so it does not outweigh its unpainted neighbours. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
|
||||
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
|
||||
|
||||
One gate, `resolveControlSizing`, picks touch or pointer sizing: touch when the layout is compact or the device has a touch screen. An iPad with the sidebar docked is wide and touch-sized; a desktop is pointer-sized; a phone is both compact and touch. The touch-screen fact is read once at startup (`hasTouchScreen` in `hooks/use-fine-pointer.ts`), so attaching a mouse or keyboard to a tablet never shrinks its controls. Stylesheets take sizes from `bySizing({ touch, pointer })`, which resolves to a breakpoint value, so a caller never sees which sizing it got. Rows, icon buttons and icon slots take theirs from `styles/density.ts`, which uses the same gate. An `{ xs, md }` literal for a hit target or glyph is wrong: on an iPad it yields the pointer size.
|
||||
|
||||
|
||||
+2
-3
@@ -226,8 +226,7 @@ its own.
|
||||
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
|
||||
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
|
||||
inactive, and that inactive commit is where the surface unmounts.
|
||||
- Anchoring, flipping, edge clamping, and capping a surface's width to the display live in
|
||||
`components/ui/anchor.ts`, shared with the hover card, and are unit-tested. Clamping moves a
|
||||
surface but cannot shrink it, so the width cap is what keeps a wide menu on a narrow screen. Fix positioning bugs there, not at a call site.
|
||||
- Anchoring, flipping, and edge clamping live in `components/ui/anchor.ts`, shared with the hover
|
||||
card, and are unit-tested. Fix positioning bugs there, not at a call site.
|
||||
- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
|
||||
status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md).
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
import {
|
||||
captureChatHistory,
|
||||
expectRewoundHistoryIsPrivate,
|
||||
rewindForkPrompt,
|
||||
closeChatHistory,
|
||||
followChatHistorySource,
|
||||
openChatHistory,
|
||||
sendChatHistoryFromAnotherClient,
|
||||
setChatHistoryViewport,
|
||||
} from "../support/helpers/chat-history";
|
||||
import { expect, test as base } from "../support/fixtures";
|
||||
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
|
||||
import {
|
||||
getActiveForkAgentId,
|
||||
expectActiveForkAgent,
|
||||
expectChatHistoryAttachment,
|
||||
expectInFlightForkAvailable,
|
||||
expectLiveAssistantText,
|
||||
@@ -50,97 +38,6 @@ const test = base.extend<{
|
||||
test.describe("Assistant fork menu", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("reads fork history before and after sending and opens its source after reload", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}, testInfo) => {
|
||||
const initialPrompt = "emit 1 coalesced agent stream updates for readable fork history.";
|
||||
const session = await seedForkWorkspace({
|
||||
repoPrefix: "assistant-fork-readable-",
|
||||
title: "Original conversation",
|
||||
initialPrompt,
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
await forkMostRecentAssistantTurnToNewTab(page);
|
||||
await openChatHistory(page, "draft", [initialPrompt]);
|
||||
await captureChatHistory(page, testInfo, "history-wide.png");
|
||||
await closeChatHistory(page);
|
||||
await submitMessage(page, "emit 1 coalesced agent stream updates for the fork.");
|
||||
const forkAgentId = await getActiveForkAgentId(page);
|
||||
await session.client.waitForFinish(forkAgentId, 45_000);
|
||||
|
||||
await openAgentRoute(page, { workspaceId: session.workspaceId, agentId: forkAgentId });
|
||||
await page.reload();
|
||||
await setChatHistoryViewport(page, "compact");
|
||||
await openChatHistory(page, "sent", [initialPrompt]);
|
||||
await captureChatHistory(page, testInfo, "history-compact.png");
|
||||
await followChatHistorySource(page);
|
||||
await setChatHistoryViewport(page, "wide");
|
||||
await expectActiveForkAgent(page, session.agentId);
|
||||
|
||||
await openAgentRoute(page, { workspaceId: session.workspaceId, agentId: forkAgentId });
|
||||
await expectActiveForkAgent(page, forkAgentId);
|
||||
await forkMostRecentAssistantTurnToNewTab(page);
|
||||
await openChatHistory(page, "draft", [initialPrompt, "updates for the fork."]);
|
||||
await followChatHistorySource(page);
|
||||
await expectActiveForkAgent(page, forkAgentId);
|
||||
});
|
||||
|
||||
for (const prompt of ["", "Continue the fork"]) {
|
||||
test(`retains inherited context when rewinding the first fork prompt (${prompt || "history only"})`, async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
const initialPrompt = "emit 1 coalesced agent stream updates for rewindable fork history.";
|
||||
const session = await seedForkWorkspace({
|
||||
repoPrefix: "fork-rewind-",
|
||||
title: "Rewind source",
|
||||
initialPrompt,
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
await forkMostRecentAssistantTurnToNewTab(page);
|
||||
await submitMessage(page, prompt);
|
||||
const forkId = await getActiveForkAgentId(page);
|
||||
await session.client.waitForFinish(forkId, 45_000);
|
||||
await page.reload();
|
||||
await openChatHistory(page, "sent", [initialPrompt]);
|
||||
await closeChatHistory(page);
|
||||
await rewindForkPrompt(page);
|
||||
await expectRewoundHistoryIsPrivate(page, session.agentId, forkId);
|
||||
await openChatHistory(page, "draft", [initialPrompt]);
|
||||
await closeChatHistory(page);
|
||||
await submitMessage(page, prompt);
|
||||
await session.client.waitForFinish(forkId, 45_000);
|
||||
await page.reload();
|
||||
await openChatHistory(page, "sent", [initialPrompt]);
|
||||
});
|
||||
}
|
||||
|
||||
test("shows history sent from another connected client without reloading", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
const session = await seedForkWorkspace({
|
||||
repoPrefix: "assistant-fork-live-history-",
|
||||
title: "Live history recipient",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
const history = "[User] Context inherited on another device.";
|
||||
await sendChatHistoryFromAnotherClient(session, history);
|
||||
await openChatHistory(page, "sent", [history]);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
});
|
||||
|
||||
test("forks a failed assistant turn that has no provider message id", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { addProjectFlow } from "../support/helpers/add-project-flow";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
chooseFooterOverflowAction,
|
||||
expectFooterLine,
|
||||
expectFooterMenusInsideViewport,
|
||||
expectFooterNeverClipped,
|
||||
expectFooterOverflowActions,
|
||||
footerOverflowMenu,
|
||||
openFooterOverflow,
|
||||
seedDockedSidebarWidth,
|
||||
watchFooterClipping,
|
||||
} from "../support/helpers/sidebar-footer-overflow";
|
||||
import { addOfflineHostsAndReload } from "../support/helpers/hosts";
|
||||
import { openCompactSidebar } from "../support/helpers/usage-sidebar-item";
|
||||
|
||||
const TOUCH_CONTROL = 44;
|
||||
const MIN_SIDEBAR_WIDTH = 200;
|
||||
const ALL_INLINE = [
|
||||
"sidebar-add-project",
|
||||
"sidebar-usage-icon",
|
||||
"sidebar-hosts-trigger",
|
||||
"sidebar-help",
|
||||
"sidebar-settings",
|
||||
];
|
||||
// Five 44pt buttons and the row's padding need 236pt; 200pt holds three and the caret.
|
||||
const NARROW_TOUCH_LINE = [
|
||||
"sidebar-add-project",
|
||||
"sidebar-hosts-trigger",
|
||||
"sidebar-footer-overflow",
|
||||
"sidebar-settings",
|
||||
];
|
||||
|
||||
test.describe("Sidebar footer on a touch screen", () => {
|
||||
test.use({ viewport: { width: 1280, height: 800 }, hasTouch: true });
|
||||
|
||||
test("a narrow docked sidebar moves the actions it can't fit behind a caret", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedDockedSidebarWidth(page, MIN_SIDEBAR_WIDTH);
|
||||
await watchFooterClipping(page);
|
||||
await gotoAppShell(page);
|
||||
await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL });
|
||||
await expectFooterNeverClipped(page);
|
||||
|
||||
await test.step("Help opens as a page of the overflow menu", async () => {
|
||||
await openFooterOverflow(page);
|
||||
await expectFooterOverflowActions(page, ["Usage", "Help and support"]);
|
||||
await chooseFooterOverflowAction(page, "Help and support");
|
||||
await expect(page.getByRole("menuitem", { name: "What's new" })).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-help-menu")).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(footerOverflowMenu(page)).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step("Usage opens from the overflow menu", async () => {
|
||||
await openFooterOverflow(page);
|
||||
await chooseFooterOverflowAction(page, "Usage");
|
||||
await expect(footerOverflowMenu(page)).toBeHidden();
|
||||
await expect(page.getByTestId("usage-modal")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("usage-modal")).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step("Add project and Settings shortcuts still work", async () => {
|
||||
await page.keyboard.press("Control+o");
|
||||
await expect(addProjectFlow(page)).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(addProjectFlow(page)).toBeHidden();
|
||||
await page.keyboard.press("Control+,");
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
});
|
||||
});
|
||||
|
||||
test("the default sidebar width keeps all five actions inline", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await expectFooterLine(page, { testIDs: ALL_INLINE, controlSize: TOUCH_CONTROL });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Sidebar footer in a compact overlay narrower than the footer", () => {
|
||||
test.use({ viewport: { width: 220, height: 800 }, hasTouch: true });
|
||||
|
||||
test("the overflow menu reaches the hidden actions", async ({ page }) => {
|
||||
await watchFooterClipping(page);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL });
|
||||
await expectFooterNeverClipped(page);
|
||||
await openFooterOverflow(page);
|
||||
await expectFooterOverflowActions(page, ["Usage", "Help and support"]);
|
||||
await expectFooterMenusInsideViewport(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Sidebar footer on a short, narrow phone", () => {
|
||||
// 164pt for buttons holds Settings, Add project and the caret, so Hosts moves behind it.
|
||||
test.use({ viewport: { width: 180, height: 360 }, hasTouch: true });
|
||||
|
||||
test("the overflow Hosts page scrolls to its last host and Add host", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostsAndReload(
|
||||
page,
|
||||
Array.from({ length: 10 }, (_, index) => ({
|
||||
serverId: `overflow-host-${index}`,
|
||||
label: `Overflow host ${index}`,
|
||||
})),
|
||||
);
|
||||
await openCompactSidebar(page);
|
||||
await expectFooterLine(page, {
|
||||
testIDs: ["sidebar-add-project", "sidebar-footer-overflow", "sidebar-settings"],
|
||||
controlSize: TOUCH_CONTROL,
|
||||
});
|
||||
await openFooterOverflow(page);
|
||||
await expectFooterOverflowActions(page, ["Usage", "Hosts", "Help and support"]);
|
||||
await expectFooterMenusInsideViewport(page);
|
||||
await chooseFooterOverflowAction(page, "Hosts");
|
||||
await expectFooterMenusInsideViewport(page);
|
||||
const addHost = page.getByRole("menuitem", { name: "Add host" });
|
||||
await addHost.scrollIntoViewIfNeeded();
|
||||
await expect(page.getByRole("menuitem", { name: "Overflow host 9" })).toBeVisible();
|
||||
await expect(addHost).toBeInViewport();
|
||||
await addHost.click();
|
||||
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
openProjectViaDaemon,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openCompactSettings, openSettingsHostSection } from "../support/helpers/settings";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import {
|
||||
expectFooterItemSetting,
|
||||
expectFooterSeparator,
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
||||
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { buildOpenProjectRoute } from "@/utils/host-routes";
|
||||
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
@@ -71,112 +70,7 @@ async function footerClip(page: Page) {
|
||||
return (await page.locator('[data-testid="sidebar-footer"]:visible').boundingBox())!;
|
||||
}
|
||||
|
||||
async function setPageVisibility(page: Page, visibility: DocumentVisibilityState): Promise<void> {
|
||||
await page.evaluate((state) => {
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state });
|
||||
Object.defineProperty(document, "hidden", {
|
||||
configurable: true,
|
||||
get: () => state === "hidden",
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}, visibility);
|
||||
}
|
||||
|
||||
test.describe("Usage item", () => {
|
||||
test("visible summary refreshes every five minutes without opening Usage", async ({ page }) => {
|
||||
let usedPct = 31;
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
() => [
|
||||
{
|
||||
...claudeAndCodexReports()[0]!,
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [
|
||||
{ id: "five_hour", label: "Session", shortLabel: "5h", summary: true, usedPct },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
await page.clock.install();
|
||||
await page.setViewportSize(WIDE);
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await gotoAppShell(page);
|
||||
await expectPinnedUsage(page, ["31% 5h"]);
|
||||
const startingUrl = page.url();
|
||||
await qaScreenshot(page, "usage-auto-refresh-initial", { kind: "footer" });
|
||||
const requestsBeforeRefresh = usage.listRequests();
|
||||
|
||||
usedPct = 42;
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
await qaScreenshot(page, "usage-auto-refresh-after-interval", { kind: "footer" });
|
||||
await expectPinnedUsage(page, ["42% 5h"]);
|
||||
await expect(usageModal(page)).toHaveCount(0);
|
||||
await expect(page).toHaveURL(startingUrl);
|
||||
expect(usage.listRequests()).toEqual([
|
||||
...requestsBeforeRefresh,
|
||||
{ forceRefresh: false, reportIds: undefined, agentId: undefined },
|
||||
]);
|
||||
|
||||
await setPageVisibility(page, "hidden");
|
||||
const requestsBeforeBackgrounding = usage.listRequests();
|
||||
usedPct = 47;
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
expect(usage.listRequests()).toEqual(requestsBeforeBackgrounding);
|
||||
await expectPinnedUsage(page, ["42% 5h"]);
|
||||
await setPageVisibility(page, "visible");
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
await expectPinnedUsage(page, ["47% 5h"]);
|
||||
expect(usage.listRequests()).toEqual([
|
||||
...requestsBeforeBackgrounding,
|
||||
{ forceRefresh: false, reportIds: undefined, agentId: undefined },
|
||||
]);
|
||||
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(usageItem(page)).toHaveCount(0);
|
||||
const requestsBeforeHiding = usage.listRequests();
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
expect(usage.listRequests()).toEqual(requestsBeforeHiding);
|
||||
|
||||
await openCompactSidebar(page);
|
||||
await expectPinnedUsage(page, ["47% 5h"]);
|
||||
await expect(page.getByTestId("agent-list-backdrop")).toBeVisible();
|
||||
usedPct = 53;
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
await expectPinnedUsage(page, ["53% 5h"]);
|
||||
await expect(usageModal(page)).toHaveCount(0);
|
||||
|
||||
await page.setViewportSize(WIDE);
|
||||
await openSidebarNavSettings(page);
|
||||
await setFooterItemVisible(page, "usage", false);
|
||||
await leaveSettings(page);
|
||||
await expectNoUsageItem(page);
|
||||
const requestsBeforeDisabling = usage.listRequests();
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
expect(usage.listRequests()).toEqual(requestsBeforeDisabling);
|
||||
});
|
||||
|
||||
test("Usage settings do not poll reports while the sidebar is inactive", async ({ page }) => {
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [() => claudeAndCodexReports()],
|
||||
});
|
||||
await page.clock.install();
|
||||
await page.setViewportSize(COMPACT);
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
await openSettingsHostSection(page, getServerId(), "usage");
|
||||
await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible();
|
||||
await expect(usageItem(page)).toHaveCount(0);
|
||||
const requestsBeforeWaiting = usage.listRequests();
|
||||
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
expect(usage.listRequests()).toEqual(requestsBeforeWaiting);
|
||||
await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible();
|
||||
});
|
||||
|
||||
test("both compact footer entry points open Usage as a sheet", async ({ page }) => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
|
||||
@@ -112,21 +112,3 @@ export async function expectLiveAssistantText(page: Page, text: string): Promise
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getActiveForkAgentId(page: Page): Promise<string> {
|
||||
const selectedTab = page
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.getByRole("button")
|
||||
.and(page.locator('[aria-selected="true"]'));
|
||||
await expect(selectedTab).toHaveAttribute("data-testid", /^workspace-tab-agent_/);
|
||||
const tabId = await selectedTab.getAttribute("data-testid");
|
||||
if (!tabId) throw new Error("Expected a selected agent tab");
|
||||
return tabId.replace("workspace-tab-agent_", "");
|
||||
}
|
||||
|
||||
export async function expectActiveForkAgent(page: Page, agentId: string) {
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||
import type { MockAgentWorkspace } from "./mock-agent";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
export function setChatHistoryViewport(page: Page, layout: "wide" | "compact") {
|
||||
return page.setViewportSize(
|
||||
layout === "wide" ? { width: 1280, height: 720 } : { width: 390, height: 844 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function openChatHistory(page: Page, location: "draft" | "sent", contents: string[]) {
|
||||
const testId =
|
||||
location === "draft"
|
||||
? "composer-chat-history-attachment-pill"
|
||||
: "message-chat-history-attachment";
|
||||
const attachment = page.getByTestId(testId).filter({ visible: true }).last();
|
||||
await expect(attachment).toBeVisible({ timeout: 10_000 });
|
||||
await attachment.click();
|
||||
for (const text of contents) {
|
||||
await expect(page.getByTestId("chat-history-content")).toContainText(text);
|
||||
}
|
||||
await expect(page.getByRole("button", { name: "Open source chat" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function closeChatHistory(page: Page) {
|
||||
await page
|
||||
.getByTestId("chat-history-preview")
|
||||
.getByRole("button", { name: "Close", exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
export async function followChatHistorySource(page: Page) {
|
||||
await page.getByRole("button", { name: "Open source chat" }).click();
|
||||
await expect(page.getByTestId("chat-history-preview")).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function captureChatHistory(page: Page, testInfo: TestInfo, name: string) {
|
||||
// Follow the existing QA helpers: settle spring animations and hide Expo-only chrome.
|
||||
await page.waitForTimeout(600);
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
await page.screenshot({ path: testInfo.outputPath(name), animations: "disabled" });
|
||||
}
|
||||
|
||||
export async function sendChatHistoryFromAnotherClient(
|
||||
session: MockAgentWorkspace,
|
||||
history: string,
|
||||
) {
|
||||
const serverId = getServerId();
|
||||
await session.client.sendAgentMessage(session.agentId, "", {
|
||||
attachments: [
|
||||
{
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
text: history,
|
||||
chatHistorySource: { serverId, agentId: "source-agent", title: "Original conversation" },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export async function rewindForkPrompt(page: Page) {
|
||||
const prompt = page.getByTestId("user-message").filter({ visible: true }).last();
|
||||
await prompt.hover();
|
||||
const trigger = prompt.getByRole("button", { name: "Rewind to this message", exact: true });
|
||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||
await trigger.click();
|
||||
const option = page.getByRole("menuitem", { name: "Rewind conversation", exact: true });
|
||||
await expect(option).toBeInViewport({ ratio: 1 });
|
||||
await option.click();
|
||||
await expect(option).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectRewoundHistoryIsPrivate(page: Page, sourceId: string, forkId: string) {
|
||||
await page.getByTestId(`workspace-tab-agent_${sourceId}`).click();
|
||||
const history = page
|
||||
.getByTestId("composer-chat-history-attachment-pill")
|
||||
.filter({ visible: true });
|
||||
await expect(history).toHaveCount(0);
|
||||
await page.getByTestId(`workspace-tab-agent_${forkId}`).click();
|
||||
await expect(history).toBeVisible();
|
||||
}
|
||||
@@ -22,27 +22,15 @@ export async function addOfflineHostAndReload(
|
||||
page: Page,
|
||||
input: { serverId: string; label: string; primaryLabel?: string },
|
||||
): Promise<void> {
|
||||
await addOfflineHostsAndReload(page, [input], { primaryLabel: input.primaryLabel });
|
||||
}
|
||||
|
||||
/** Adds several unreachable hosts with one reload. See `addOfflineHostAndReload`. */
|
||||
export async function addOfflineHostsAndReload(
|
||||
page: Page,
|
||||
hosts: Array<{ serverId: string; label: string }>,
|
||||
options: { primaryLabel?: string } = {},
|
||||
): Promise<void> {
|
||||
const nowIso = new Date().toISOString();
|
||||
const offlineHosts = hosts.map((host) =>
|
||||
buildSeededHost({
|
||||
serverId: host.serverId,
|
||||
label: host.label,
|
||||
endpoint: "127.0.0.1:59999",
|
||||
nowIso,
|
||||
}),
|
||||
);
|
||||
const offlineHost = buildSeededHost({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
endpoint: "127.0.0.1:59999",
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await page.evaluate(
|
||||
({ hosts: added, keys, primaryLabel }) => {
|
||||
({ host, keys, primaryLabel }) => {
|
||||
const nonce = localStorage.getItem(keys.nonce);
|
||||
if (!nonce) {
|
||||
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
|
||||
@@ -52,22 +40,20 @@ export async function addOfflineHostsAndReload(
|
||||
if (primaryLabel && registry[0]) {
|
||||
registry[0].label = primaryLabel;
|
||||
}
|
||||
for (const host of added) {
|
||||
if (!registry.some((entry) => entry.serverId === host.serverId)) {
|
||||
registry.push(host);
|
||||
}
|
||||
if (!registry.some((entry) => entry.serverId === host.serverId)) {
|
||||
registry.push(host);
|
||||
}
|
||||
localStorage.setItem(keys.registry, JSON.stringify(registry));
|
||||
localStorage.setItem(keys.disableSeedOnce, nonce);
|
||||
},
|
||||
{
|
||||
hosts: offlineHosts,
|
||||
host: offlineHost,
|
||||
keys: {
|
||||
registry: REGISTRY_KEY,
|
||||
nonce: SEED_NONCE_KEY,
|
||||
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
},
|
||||
primaryLabel: options.primaryLabel,
|
||||
primaryLabel: input.primaryLabel,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -157,10 +157,7 @@ export interface SeedDaemonClient {
|
||||
sendAgentMessage(
|
||||
agentId: string,
|
||||
text: string,
|
||||
options?: {
|
||||
sourceAgentId?: string;
|
||||
attachments?: import("@getpaseo/protocol/messages").AgentAttachment[];
|
||||
},
|
||||
options?: { sourceAgentId?: string },
|
||||
): Promise<void>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
/** Every footer control that can sit in the icon line, caret included. */
|
||||
const FOOTER_LINE_TEST_IDS = [
|
||||
"sidebar-add-project",
|
||||
"sidebar-usage-icon",
|
||||
"sidebar-hosts-trigger",
|
||||
"sidebar-help",
|
||||
"sidebar-footer-overflow",
|
||||
"sidebar-settings",
|
||||
];
|
||||
|
||||
// Browser layout boxes include floating-point rounding, even for whole-pixel styles.
|
||||
const GEOMETRY_TOLERANCE = 0.01;
|
||||
|
||||
/** Open the app with the docked sidebar at `width`, as if the user had dragged it there. */
|
||||
export async function seedDockedSidebarWidth(page: Page, width: number): Promise<void> {
|
||||
await page.addInitScript((sidebarWidth) => {
|
||||
localStorage.setItem("panel-state", JSON.stringify({ version: 17, state: { sidebarWidth } }));
|
||||
}, width);
|
||||
}
|
||||
|
||||
/**
|
||||
* From the first frame, record any frame in which a footer button reaches past its row, so a row
|
||||
* laid out before it measured shows up even if it lasted one frame.
|
||||
*/
|
||||
export async function watchFooterClipping(page: Page): Promise<void> {
|
||||
await page.addInitScript((testIDs) => {
|
||||
const clippedFrames: string[] = [];
|
||||
Object.assign(window, { __footerClippedFrames: clippedFrames });
|
||||
const sample = () => {
|
||||
for (const row of document.querySelectorAll('[data-testid="sidebar-footer-bottom-line"]')) {
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
if (rowRect.width === 0) continue;
|
||||
for (const testID of testIDs) {
|
||||
const button = row.querySelector(`[data-testid="${testID}"]`);
|
||||
if (button && button.getBoundingClientRect().right > rowRect.right + 0.5) {
|
||||
clippedFrames.push(testID);
|
||||
}
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
}, FOOTER_LINE_TEST_IDS);
|
||||
}
|
||||
|
||||
export async function expectFooterNeverClipped(page: Page): Promise<void> {
|
||||
const clipped = await page.evaluate(
|
||||
() => (window as unknown as { __footerClippedFrames: string[] }).__footerClippedFrames,
|
||||
);
|
||||
expect(clipped).toEqual([]);
|
||||
}
|
||||
|
||||
function footerLine(page: Page): Locator {
|
||||
return page.locator('[data-testid="sidebar-footer-bottom-line"]:visible');
|
||||
}
|
||||
|
||||
export function footerOverflowMenu(page: Page): Locator {
|
||||
return page.getByTestId("sidebar-footer-overflow-menu");
|
||||
}
|
||||
|
||||
/**
|
||||
* The icon line shows exactly `testIDs`, in order, each `controlSize` square and wholly inside the
|
||||
* line's box, so no button is cut off by the sidebar's edge.
|
||||
*/
|
||||
export async function expectFooterLine(
|
||||
page: Page,
|
||||
input: { testIDs: string[]; controlSize: number },
|
||||
): Promise<void> {
|
||||
const line = footerLine(page);
|
||||
await expect(line).toHaveCount(1);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
line
|
||||
.locator(FOOTER_LINE_TEST_IDS.map((testID) => `[data-testid="${testID}"]`).join(","))
|
||||
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-testid"))),
|
||||
)
|
||||
.toEqual(input.testIDs);
|
||||
const { row, buttons } = await line.evaluate((element, testIDs) => {
|
||||
const rect = (node: Element) => {
|
||||
const { x, width, height } = node.getBoundingClientRect();
|
||||
return { x, width, height };
|
||||
};
|
||||
return {
|
||||
row: rect(element),
|
||||
buttons: testIDs.map((testID) => rect(element.querySelector(`[data-testid="${testID}"]`)!)),
|
||||
};
|
||||
}, input.testIDs);
|
||||
for (const button of buttons) {
|
||||
expect(Math.abs(button.width - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE);
|
||||
expect(Math.abs(button.height - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE);
|
||||
expect(button.x).toBeGreaterThanOrEqual(row.x - GEOMETRY_TOLERANCE);
|
||||
expect(button.x + button.width).toBeLessThanOrEqual(row.x + row.width + GEOMETRY_TOLERANCE);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openFooterOverflow(page: Page): Promise<void> {
|
||||
await footerLine(page).getByTestId("sidebar-footer-overflow").click();
|
||||
await expect(footerOverflowMenu(page)).toBeVisible();
|
||||
}
|
||||
|
||||
/** The overflow menu lists exactly these actions, in row order. */
|
||||
export async function expectFooterOverflowActions(page: Page, names: string[]): Promise<void> {
|
||||
await expect(footerOverflowMenu(page).getByRole("menuitem")).toHaveText(names);
|
||||
}
|
||||
|
||||
export async function chooseFooterOverflowAction(page: Page, name: string): Promise<void> {
|
||||
await footerOverflowMenu(page).getByRole("menuitem", { name }).click();
|
||||
}
|
||||
|
||||
/** Every row of the open overflow menu, and of the page it opened, is inside the viewport's width. */
|
||||
export async function expectFooterMenusInsideViewport(page: Page): Promise<void> {
|
||||
const viewport = page.viewportSize()!;
|
||||
const rows = page.getByRole("menuitem");
|
||||
await expect(rows.first()).toBeVisible();
|
||||
const boxes = await rows.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const { x, width } = element.getBoundingClientRect();
|
||||
return { x, width };
|
||||
}),
|
||||
);
|
||||
for (const box of boxes) {
|
||||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ final class WindowLayoutView: ExpoView {
|
||||
private var barGuide: UILayoutGuide?
|
||||
private var barEdge = "none"
|
||||
private var bottomBarGuide: UILayoutGuide?
|
||||
private var topBarGuide: UILayoutGuide?
|
||||
private var occlusions: [CGRect] = []
|
||||
private var measuredBounds = CGRect.zero
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
@@ -38,6 +40,7 @@ final class WindowLayoutView: ExpoView {
|
||||
super.didMoveToWindow()
|
||||
previousPayload = nil
|
||||
bottomBarGuide = nil
|
||||
topBarGuide = nil
|
||||
setNeedsUpdateConstraints()
|
||||
scheduleUpdate()
|
||||
}
|
||||
@@ -62,6 +65,8 @@ final class WindowLayoutView: ExpoView {
|
||||
if #available(iOS 27.1, *) {
|
||||
// UIKit tracks these reads and invalidates constraints when regions change.
|
||||
divisions = reservedRegions(kind: .division).compactMap { $0.isActive ? $0.frame : nil }
|
||||
// Frames already include the system's interactive margins.
|
||||
occlusions = reservedRegions(kind: .occlusion).compactMap { $0.isActive ? $0.frame : nil }
|
||||
let edge: String
|
||||
switch traitCollection.verticalBarEdge {
|
||||
case .leading: edge = "leading"
|
||||
@@ -73,6 +78,11 @@ final class WindowLayoutView: ExpoView {
|
||||
// screen's corners rather than the rectangular safe area.
|
||||
bottomBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.bottom, extent: 44))
|
||||
}
|
||||
if topBarGuide == nil {
|
||||
// Where a top bar rests. Beside a partial status region it can start above the
|
||||
// rectangular safe area; content still keeps out of the occlusions.
|
||||
topBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.top, extent: 44))
|
||||
}
|
||||
if edge != barEdge {
|
||||
barEdge = edge
|
||||
barGuide = edge == "none" ? nil : layoutGuide(
|
||||
@@ -106,11 +116,17 @@ final class WindowLayoutView: ExpoView {
|
||||
if let bottomBar = bottomBarGuide?.layoutFrame, bottomBar.height > 0 {
|
||||
bottomClearance = max(0, bounds.maxY - bottomBar.maxY)
|
||||
}
|
||||
var topBar: Any = NSNull()
|
||||
if let frame = topBarGuide?.layoutFrame, frame.width > 0, frame.height > 0 {
|
||||
topBar = rect(frame)
|
||||
}
|
||||
let payload: [String: Any] = [
|
||||
"bounds": rect(bounds),
|
||||
"verticalBarEdge": barEdge,
|
||||
"verticalBar": barPayload,
|
||||
"bottomClearance": bottomClearance,
|
||||
"topBar": topBar,
|
||||
"occlusions": occlusions.map(rect),
|
||||
"divisions": divisions.map(rect)
|
||||
]
|
||||
guard !NSDictionary(dictionary: payload).isEqual(to: previousPayload ?? [:]) else { return }
|
||||
|
||||
@@ -49,7 +49,7 @@ function getTextAttachmentSubtitle(
|
||||
t: TFunction,
|
||||
): string {
|
||||
if (attachment.contextKind === "chat_history") {
|
||||
return attachment.chatHistorySource?.title ?? t("message.chatHistory.previousConversation");
|
||||
return "Previous conversation";
|
||||
}
|
||||
return t("message.attachments.text");
|
||||
}
|
||||
@@ -87,13 +87,6 @@ export function getAgentAttachmentPillContent(
|
||||
subtitle: `Issue #${attachment.number}`,
|
||||
};
|
||||
case "text":
|
||||
if (attachment.contextKind === "chat_history") {
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: t("message.chatHistory.title"),
|
||||
subtitle: getTextAttachmentSubtitle(attachment, t),
|
||||
};
|
||||
}
|
||||
if (attachment.externalResource) {
|
||||
return {
|
||||
icon: attachmentGithubIssueIcon,
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||
import type { ChatHistoryAttachment as HistoryAttachment } from "@getpaseo/protocol/messages";
|
||||
import { extractChatHistoryBody } from "@getpaseo/protocol/chat-history";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ArrowUpRight } from "lucide-react-native";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
|
||||
import { AttachmentFrame, AttachmentLabel, AttachmentPill } from "@/components/attachment-pill";
|
||||
import { AdaptiveModalSheet, FULL_SHEET_SNAP_POINTS } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
|
||||
interface ChatHistoryAttachmentProps {
|
||||
attachment: Extract<AgentAttachment, { type: "text" }>;
|
||||
onRemove?: () => void;
|
||||
disabled?: boolean;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export function ChatHistoryAttachment({
|
||||
attachment,
|
||||
onRemove,
|
||||
disabled = false,
|
||||
testID,
|
||||
}: ChatHistoryAttachmentProps) {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const open = useCallback(() => setVisible(true), []);
|
||||
const close = useCallback(() => setVisible(false), []);
|
||||
const source = attachment.chatHistorySource;
|
||||
const openSource = useCallback(() => {
|
||||
if (!source) return;
|
||||
close();
|
||||
navigateToAgent({ serverId: source.serverId, agentId: source.agentId });
|
||||
}, [close, source]);
|
||||
const header = useMemo(
|
||||
() => ({
|
||||
title: t("message.chatHistory.title"),
|
||||
subtitle: source?.title ? (
|
||||
<Text style={styles.description} numberOfLines={1}>
|
||||
{source.title}
|
||||
</Text>
|
||||
) : undefined,
|
||||
}),
|
||||
[source?.title, t],
|
||||
);
|
||||
const footer = useMemo(
|
||||
() =>
|
||||
source ? (
|
||||
<Button variant="outline" size="sm" leftIcon={ArrowUpRight} onPress={openSource}>
|
||||
{t("message.chatHistory.openSource")}
|
||||
</Button>
|
||||
) : undefined,
|
||||
[source, openSource, t],
|
||||
);
|
||||
const history = useMemo(() => extractChatHistoryBody(attachment.text), [attachment.text]);
|
||||
const content = getAgentAttachmentPillContent(attachment, t);
|
||||
const label = (
|
||||
<AttachmentLabel icon={content.icon} title={content.title} subtitle={content.subtitle} />
|
||||
);
|
||||
const openLabel = t("message.chatHistory.open");
|
||||
|
||||
return (
|
||||
<>
|
||||
{onRemove ? (
|
||||
<AttachmentPill
|
||||
testID={testID}
|
||||
onOpen={open}
|
||||
onRemove={onRemove}
|
||||
disabled={disabled}
|
||||
openAccessibilityLabel={openLabel}
|
||||
removeAccessibilityLabel={t("message.chatHistory.remove")}
|
||||
>
|
||||
{label}
|
||||
</AttachmentPill>
|
||||
) : (
|
||||
<AttachmentFrame testID={testID} onPress={open} accessibilityLabel={openLabel}>
|
||||
{label}
|
||||
</AttachmentFrame>
|
||||
)}
|
||||
<AdaptiveModalSheet
|
||||
visible={visible}
|
||||
onClose={close}
|
||||
header={header}
|
||||
snapPoints={FULL_SHEET_SNAP_POINTS}
|
||||
desktopMaxWidth={720}
|
||||
desktopHeight="75%"
|
||||
testID="chat-history-preview"
|
||||
contentStyle={styles.content}
|
||||
footer={footer}
|
||||
>
|
||||
<Text style={styles.description}>{t("message.chatHistory.description")}</Text>
|
||||
<Text selectable testID="chat-history-content" style={styles.history}>
|
||||
{history}
|
||||
</Text>
|
||||
</AdaptiveModalSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
content: { gap: theme.spacing[4] },
|
||||
description: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted },
|
||||
history: {
|
||||
fontSize: theme.fontSize.content,
|
||||
lineHeight: theme.fontSize.content * 1.6,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
|
||||
export function restoreChatHistoryAttachment(scopeKey: string, attachment: HistoryAttachment) {
|
||||
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
|
||||
scopeKey,
|
||||
attachment: {
|
||||
kind: "chat_history",
|
||||
id: generateMessageId(),
|
||||
attachment,
|
||||
source: attachment.chatHistorySource,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
AgentAttachment,
|
||||
ChatHistorySource,
|
||||
ForgeSearchItem,
|
||||
UploadedFileAttachment,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
@@ -83,7 +82,9 @@ export interface ChatHistoryContextAttachment {
|
||||
kind: "chat_history";
|
||||
id: string;
|
||||
attachment: Extract<AgentAttachment, { type: "text" }>;
|
||||
source?: ChatHistorySource & {
|
||||
source: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
boundaryMessageId?: string | null;
|
||||
boundaryCursor?: { epoch: string; seq: number } | null;
|
||||
itemCount?: number;
|
||||
|
||||
@@ -21,29 +21,6 @@ function contextAttachment(
|
||||
}
|
||||
|
||||
describe("workspace attachment utilities", () => {
|
||||
it("submits chat history with its source host and agent", () => {
|
||||
const source = { serverId: "original-host", agentId: "original-agent", title: "Original" };
|
||||
expect(
|
||||
workspaceAttachmentToSubmitAttachment({
|
||||
kind: "chat_history",
|
||||
id: "history-draft",
|
||||
attachment: {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
text: "Earlier context",
|
||||
},
|
||||
source,
|
||||
}),
|
||||
).toEqual({
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
text: "Earlier context",
|
||||
chatHistorySource: source,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats pull request context as a workspace attachment", () => {
|
||||
expect(isWorkspaceAttachment(contextAttachment())).toBe(true);
|
||||
});
|
||||
|
||||
@@ -62,12 +62,7 @@ export function workspaceAttachmentToSubmitAttachment(
|
||||
};
|
||||
}
|
||||
if (attachment.kind === "chat_history") {
|
||||
if (!attachment.source) return attachment.attachment;
|
||||
const { serverId, agentId, title } = attachment.source;
|
||||
return {
|
||||
...attachment.attachment,
|
||||
chatHistorySource: { serverId, agentId, ...(title ? { title } : {}) },
|
||||
};
|
||||
return attachment.attachment;
|
||||
}
|
||||
return attachment.kind === "review" ? attachment.attachment : null;
|
||||
}
|
||||
|
||||
@@ -178,19 +178,6 @@ export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScope):
|
||||
}, [cwd, draftId, isDraftScope, serverId, workspaceId]);
|
||||
}
|
||||
|
||||
export function useAgentAttachmentScopeKey(input: { serverId: string; agentId: string }): string {
|
||||
const { serverId, agentId } = input;
|
||||
return useMemo(
|
||||
() =>
|
||||
[
|
||||
"agent-attachments",
|
||||
`server=${encodeScopePart(serverId)}`,
|
||||
`agent=${encodeScopePart(agentId)}`,
|
||||
].join(":"),
|
||||
[serverId, agentId],
|
||||
);
|
||||
}
|
||||
|
||||
export function useDraftWorkspaceAttachmentScopeKey(draftId: string | null | undefined): string {
|
||||
const normalizedDraftId = useMemo(() => draftId?.trim() ?? "", [draftId]);
|
||||
return useMemo(
|
||||
|
||||
@@ -13,11 +13,14 @@ import {
|
||||
mutedIconColorMapping,
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
import { HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
import { useTopBandRow } from "@/window-layout";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
|
||||
const ThemedArrowLeft = withUnistyles(ArrowLeft);
|
||||
|
||||
const NO_SHORTCUT: ShortcutKey[] = [];
|
||||
const ROW_PADDING = SPACING[3];
|
||||
|
||||
interface CompactHeaderProps {
|
||||
/** The leading island: the sidebar menu, or a back button. */
|
||||
@@ -51,21 +54,29 @@ export function CompactHeader({
|
||||
actions,
|
||||
}: CompactHeaderProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
// The trailing side gives way to the occlusion, so it needs no padding of its own.
|
||||
const topBand = useTopBandRow({
|
||||
offset: insets.top + HEADER_TOP_PADDING_MOBILE,
|
||||
height: HEADER_INNER_HEIGHT_MOBILE,
|
||||
// The header stays in the band in every sidebar state; the title gives up width instead.
|
||||
minimumWidth: 0,
|
||||
});
|
||||
const rootStyle = useMemo(
|
||||
() => [
|
||||
styles.root,
|
||||
{
|
||||
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE,
|
||||
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE + topBand.top,
|
||||
paddingRight: Math.max(ROW_PADDING, topBand.trailing),
|
||||
marginLeft: insets.left,
|
||||
marginRight: insets.right,
|
||||
},
|
||||
],
|
||||
[insets.top, insets.left, insets.right],
|
||||
[insets.top, insets.left, insets.right, topBand.top, topBand.trailing],
|
||||
);
|
||||
|
||||
return (
|
||||
<IconButtonChromeProvider value="header-island">
|
||||
<View style={rootStyle}>
|
||||
<View ref={topBand.ref} onLayout={topBand.onLayout} style={rootStyle}>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.island}>
|
||||
{navigation === "menu" ? <SidebarMenuToggle /> : <BackButton onBack={onBack} />}
|
||||
@@ -140,7 +151,7 @@ function BackButton({ onBack }: { onBack?: () => void }) {
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingLeft: ROW_PADDING,
|
||||
},
|
||||
row: {
|
||||
height: HEADER_INNER_HEIGHT_MOBILE,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/constants/layout";
|
||||
import { WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { useTopBandRow } from "@/window-layout";
|
||||
|
||||
interface ScreenHeaderProps {
|
||||
left?: ReactNode;
|
||||
@@ -41,17 +42,33 @@ export function ScreenHeader({
|
||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||
const baseHorizontalPadding = isMobile ? theme.spacing[2] : theme.spacing[3];
|
||||
|
||||
const topBand = useTopBandRow({
|
||||
offset: insets.top + topPadding,
|
||||
height: isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT,
|
||||
// The header stays in the band in every sidebar state; the title gives up width instead.
|
||||
minimumWidth: 0,
|
||||
});
|
||||
const innerStyle = useMemo(
|
||||
() => [styles.inner, { paddingTop: insets.top + topPadding }],
|
||||
[insets.top, topPadding],
|
||||
() => [styles.inner, { paddingTop: insets.top + topPadding + topBand.top }],
|
||||
[insets.top, topPadding, topBand.top],
|
||||
);
|
||||
const rowStyle = useMemo(() => [styles.row, borderless && styles.borderless], [borderless]);
|
||||
const leftCombinedStyle = useMemo(() => [styles.left, leftStyle], [leftStyle]);
|
||||
const rightCombinedStyle = useMemo(() => [styles.right, rightStyle], [rightStyle]);
|
||||
// The occlusion replaces the row's trailing padding rather than adding to it.
|
||||
const rightCombinedStyle = useMemo(
|
||||
() => [
|
||||
styles.right,
|
||||
rightStyle,
|
||||
topBand.trailing > baseHorizontalPadding && {
|
||||
marginRight: topBand.trailing - baseHorizontalPadding,
|
||||
},
|
||||
],
|
||||
[rightStyle, topBand.trailing, baseHorizontalPadding],
|
||||
);
|
||||
|
||||
return (
|
||||
<ComposerDockBackground style={styles.header} testID="composer-dock-header">
|
||||
<View style={innerStyle}>
|
||||
<View ref={topBand.ref} onLayout={topBand.onLayout} style={innerStyle}>
|
||||
<WindowChromeSafeArea
|
||||
placement="inline"
|
||||
horizontalPadding={baseHorizontalPadding}
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
Text,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
} from "react-native";
|
||||
import { Gesture, ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
@@ -30,7 +29,7 @@ import {
|
||||
resolveDesktopSidebarInsetLeft,
|
||||
resolveDesktopSidebarWidth,
|
||||
} from "@/components/desktop-sidebar-layout";
|
||||
import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout";
|
||||
import { useWindowLayout, resolveDesktopDivisionLayout, useTopBandRow } from "@/window-layout";
|
||||
import {
|
||||
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
||||
SIDEBAR_RESIZE_FAIL_OFFSET,
|
||||
@@ -41,23 +40,13 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
|
||||
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
|
||||
import { SidebarFooterIconHighlight } from "@/components/sidebar/sidebar-footer-icon-highlight";
|
||||
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
|
||||
import { SidebarFooterOverflowMenu } from "@/components/sidebar/sidebar-footer-overflow-menu";
|
||||
import {
|
||||
resolveSidebarFooterLayout,
|
||||
SIDEBAR_FOOTER_ACTIONS,
|
||||
type SidebarFooterAction,
|
||||
} from "@/components/sidebar-footer-layout";
|
||||
import {
|
||||
controlSizingForLayout,
|
||||
mapLayout,
|
||||
TOOLBAR_CONTROL_SIZES,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { TOUCH_TARGET } from "@/components/ui/control-sizing";
|
||||
import { isNative as platformIsNative } from "@/constants/platform";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useImportSession } from "@/hooks/use-import-session";
|
||||
@@ -67,7 +56,6 @@ import {
|
||||
type SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { density } from "@/styles/density";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
|
||||
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
@@ -92,9 +80,6 @@ import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
|
||||
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
||||
|
||||
/** The footer icon line's inset; the overflow layout counts it as space the buttons can't use. */
|
||||
const FOOTER_ROW_PADDING = SPACING[2];
|
||||
|
||||
const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null;
|
||||
|
||||
interface SidebarSharedProps {
|
||||
@@ -426,72 +411,35 @@ function SidebarFooter({
|
||||
}) {
|
||||
const newAgentKeys = useShortcutKeys("new-agent");
|
||||
const settingsKeys = useShortcutKeys("toggle-settings");
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const [rowWidth, setRowWidth] = useState(0);
|
||||
const handleRowLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setRowWidth(event.nativeEvent.layout.width);
|
||||
}, []);
|
||||
const { inline, overflow } = useMemo(
|
||||
() =>
|
||||
resolveSidebarFooterLayout({
|
||||
rowWidth,
|
||||
controlSize: TOOLBAR_CONTROL_SIZES[controlSizingForLayout(isCompactLayout)],
|
||||
rowPadding: FOOTER_ROW_PADDING,
|
||||
actions: SIDEBAR_FOOTER_ACTIONS,
|
||||
}),
|
||||
[rowWidth, isCompactLayout],
|
||||
);
|
||||
const isInline = (action: SidebarFooterAction) => inline.includes(action);
|
||||
|
||||
// One line of icons: Add project, Usage, Hosts, then Help and Settings at the end. Actions that
|
||||
// don't fit move behind a caret before Settings.
|
||||
// One line of icons: Add project, Usage, Hosts, then Help and Settings at the end.
|
||||
return (
|
||||
<UsageSidebarRoot>
|
||||
<View style={styles.footerContainer} testID="sidebar-footer">
|
||||
<SidebarFooterRows onBeforeNavigate={onBeforeNavigate} />
|
||||
<View
|
||||
style={styles.sidebarFooter}
|
||||
onLayout={handleRowLayout}
|
||||
testID="sidebar-footer-bottom-line"
|
||||
>
|
||||
{isInline("add-project") ? (
|
||||
<FooterIconButton
|
||||
onPress={handleOpenProject}
|
||||
testID="sidebar-add-project"
|
||||
label={labels.addProject}
|
||||
icon={FolderPlus}
|
||||
shortcutKeys={newAgentKeys}
|
||||
/>
|
||||
) : null}
|
||||
{isInline("usage") ? <SidebarUsageIcon label={labels.usage} /> : null}
|
||||
{isInline("hosts") ? (
|
||||
<SidebarHostPicker
|
||||
label={labels.hosts}
|
||||
onAddHost={handleAddHost}
|
||||
onOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
) : null}
|
||||
<View style={styles.sidebarFooter} testID="sidebar-footer-bottom-line">
|
||||
<FooterIconButton
|
||||
onPress={handleOpenProject}
|
||||
testID="sidebar-add-project"
|
||||
label={labels.addProject}
|
||||
icon={FolderPlus}
|
||||
shortcutKeys={newAgentKeys}
|
||||
/>
|
||||
<SidebarUsageIcon label={labels.usage} />
|
||||
<SidebarHostPicker
|
||||
label={labels.hosts}
|
||||
onAddHost={handleAddHost}
|
||||
onOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
<View style={styles.footerSpacer} />
|
||||
{isInline("help") ? <SidebarHelpMenu /> : null}
|
||||
{overflow.length > 0 ? (
|
||||
<SidebarFooterOverflowMenu
|
||||
actions={overflow}
|
||||
labels={labels}
|
||||
onAddProject={handleOpenProject}
|
||||
onSettings={handleSettings}
|
||||
onAddHost={handleAddHost}
|
||||
onOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
) : null}
|
||||
{isInline("settings") ? (
|
||||
<FooterIconButton
|
||||
onPress={handleSettings}
|
||||
testID="sidebar-settings"
|
||||
label={labels.settings}
|
||||
icon={Settings}
|
||||
shortcutKeys={settingsKeys}
|
||||
/>
|
||||
) : null}
|
||||
<SidebarHelpMenu />
|
||||
<FooterIconButton
|
||||
onPress={handleSettings}
|
||||
testID="sidebar-settings"
|
||||
label={labels.settings}
|
||||
icon={Settings}
|
||||
shortcutKeys={settingsKeys}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</UsageSidebarRoot>
|
||||
@@ -580,6 +528,14 @@ function MobileSidebar({
|
||||
closeSidebar();
|
||||
}, [closeSidebar]);
|
||||
|
||||
// The first nav row carries the close button; the panel slides in, so re-measure once settled.
|
||||
const navBand = useTopBandRow({
|
||||
offset: theme.spacing[2],
|
||||
height: TOUCH_TARGET,
|
||||
minimumWidth: TOUCH_TARGET * 2,
|
||||
settled: active,
|
||||
});
|
||||
const navBandStyle = useMemo(() => ({ paddingTop: navBand.top }), [navBand.top]);
|
||||
const closeButton = useMemo(
|
||||
() => (
|
||||
<SidebarCloseButton
|
||||
@@ -587,10 +543,10 @@ function MobileSidebar({
|
||||
onPress={closeSidebar}
|
||||
testID="sidebar-close"
|
||||
nativeID="sidebar-close"
|
||||
style={styles.mobileCloseButton(insets.right)}
|
||||
style={styles.mobileCloseButton(insets.right, navBand.trailing)}
|
||||
/>
|
||||
),
|
||||
[labels.closeSidebar, closeSidebar, insets.right],
|
||||
[labels.closeSidebar, closeSidebar, insets.right, navBand.trailing],
|
||||
);
|
||||
|
||||
const mobileSidebarInsetStyle = useMemo(
|
||||
@@ -603,11 +559,13 @@ function MobileSidebar({
|
||||
);
|
||||
|
||||
const navRows = (
|
||||
<SidebarNavRows
|
||||
style={styles.mobileNavRowsGroup}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
firstRowTrailing={closeButton}
|
||||
/>
|
||||
<View ref={navBand.ref} onLayout={navBand.onLayout} style={navBandStyle}>
|
||||
<SidebarNavRows
|
||||
style={styles.mobileNavRowsGroup}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
firstRowTrailing={closeButton}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -947,8 +905,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
mobileCloseButton: (rightInset: number) => ({
|
||||
marginRight: theme.spacing[2] + rightInset,
|
||||
mobileCloseButton: (rightInset: number, topBandTrailing: number) => ({
|
||||
marginRight: Math.max(theme.spacing[2] + rightInset, topBandTrailing),
|
||||
}),
|
||||
desktopSidebarBorder: (hasDivision: boolean) => ({
|
||||
borderRightWidth: hasDivision ? 0 : 1,
|
||||
@@ -990,12 +948,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
// Buttons sit edge to edge; their own inset spaces the glyphs.
|
||||
// As tall as its buttons from the first frame, which places none until the row has measured.
|
||||
sidebarFooter: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
minHeight: mapLayout(density.iconButton, (height) => height + theme.spacing[1.5] * 2),
|
||||
paddingHorizontal: FOOTER_ROW_PADDING,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
},
|
||||
// Pushes Help and Settings to the end of the icon line.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ChatHistoryAttachment } from "@/attachments/chat-history";
|
||||
import { useDisclosureHold } from "@/agent-stream/disclosure";
|
||||
import { MessagePreview, MessageSender } from "@/components/user-message";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -34,10 +33,7 @@ import { useTargetAgentTitle } from "@/tool-calls/use-target-agent-title";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import type { AgentCapabilityFlags, ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import type {
|
||||
AgentAttachment,
|
||||
ChatHistoryAttachment as ChatHistorySnapshot,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import MaskedView from "@react-native-masked-view/masked-view";
|
||||
import {
|
||||
Check,
|
||||
@@ -423,12 +419,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
const hasText = message.trim().length > 0;
|
||||
const hasImages = images.length > 0;
|
||||
const hasAttachments = attachments.length > 0;
|
||||
const chatHistory = attachments.find(
|
||||
(attachment): attachment is ChatHistorySnapshot =>
|
||||
attachment.type === "text" && attachment.contextKind === "chat_history",
|
||||
);
|
||||
const showTrailingRow =
|
||||
!isPending && (hasText || Boolean(chatHistory)) && (isCompact || isNative || isHovered);
|
||||
const showTrailingRow = !isPending && hasText && (isCompact || isNative || isHovered);
|
||||
const formattedTimestamp = useMemo(
|
||||
() => formatMessageTimestamp(new Date(timestamp)),
|
||||
[timestamp],
|
||||
@@ -437,12 +428,9 @@ export const UserMessage = memo(function UserMessage({
|
||||
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
const getMessageContent = useCallback(
|
||||
() => message || chatHistory?.text || "",
|
||||
[message, chatHistory],
|
||||
);
|
||||
const getMessageContent = useCallback(() => message, [message]);
|
||||
const handleRewind = useCallback(
|
||||
(input: { mode: RewindMode; rewoundText: string; chatHistory?: ChatHistorySnapshot }) => {
|
||||
(input: { mode: RewindMode; rewoundText: string }) => {
|
||||
return rewindMutation.rewindAgent(input);
|
||||
},
|
||||
[rewindMutation],
|
||||
@@ -508,19 +496,11 @@ export const UserMessage = memo(function UserMessage({
|
||||
{hasAttachments ? (
|
||||
<View style={attachmentPreviewContainerStyle}>
|
||||
{attachments.map((attachment, index) => {
|
||||
const key = `${attachment.type}:${"number" in attachment ? attachment.number : index}`;
|
||||
if (attachment.type === "text" && attachment.contextKind === "chat_history") {
|
||||
return (
|
||||
<ChatHistoryAttachment
|
||||
key={key}
|
||||
attachment={attachment}
|
||||
testID="message-chat-history-attachment"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const content = getAgentAttachmentPillContent(attachment, t);
|
||||
return (
|
||||
<AttachmentFrame key={key}>
|
||||
<AttachmentFrame
|
||||
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
|
||||
>
|
||||
<AttachmentLabel
|
||||
icon={content.icon}
|
||||
title={content.title}
|
||||
@@ -538,7 +518,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
) : null}
|
||||
</MessagePreview>
|
||||
</View>
|
||||
{hasText || chatHistory ? (
|
||||
{hasText ? (
|
||||
<View
|
||||
style={trailingRowStyle}
|
||||
pointerEvents={showTrailingRow ? "auto" : "none"}
|
||||
@@ -552,7 +532,6 @@ export const UserMessage = memo(function UserMessage({
|
||||
capabilities={capabilities}
|
||||
isPending={rewindMutation.isPending}
|
||||
rewoundText={message}
|
||||
chatHistory={chatHistory}
|
||||
onRewind={handleRewind}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1,45 +1,27 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { restoreComposerPromptIfEmpty } from "./composer-restore";
|
||||
import { restoreComposerTextIfEmpty } from "./composer-restore";
|
||||
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
|
||||
|
||||
describe("restoreComposerPromptIfEmpty", () => {
|
||||
describe("restoreComposerTextIfEmpty", () => {
|
||||
test("restores the rewound message when the composer is empty", () => {
|
||||
expect(
|
||||
restoreComposerPromptIfEmpty({
|
||||
restoreComposerTextIfEmpty({
|
||||
currentText: "",
|
||||
rewoundPrompt: { text: "message before rewind" },
|
||||
rewoundText: "message before rewind",
|
||||
}),
|
||||
).toEqual({ text: "message before rewind" });
|
||||
).toBe("message before rewind");
|
||||
});
|
||||
|
||||
test("preserves an existing composer draft", () => {
|
||||
expect(
|
||||
restoreComposerPromptIfEmpty({
|
||||
restoreComposerTextIfEmpty({
|
||||
currentText: "keep this draft",
|
||||
rewoundPrompt: { text: "message before rewind" },
|
||||
rewoundText: "message before rewind",
|
||||
}),
|
||||
).toEqual({ text: "keep this draft" });
|
||||
).toBe("keep this draft");
|
||||
});
|
||||
});
|
||||
|
||||
test.each(["", "Continue here"])("restores inherited history with rewound prompt %s", (text) => {
|
||||
const chatHistory = {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
text: "Earlier context",
|
||||
} as const;
|
||||
expect(
|
||||
restoreComposerPromptIfEmpty({ currentText: "", rewoundPrompt: { text, chatHistory } }),
|
||||
).toEqual({ text, chatHistory });
|
||||
expect(
|
||||
restoreComposerPromptIfEmpty({
|
||||
currentText: "Existing draft",
|
||||
rewoundPrompt: { text, chatHistory },
|
||||
}),
|
||||
).toEqual({ text: "Existing draft" });
|
||||
});
|
||||
|
||||
describe("shouldRestoreComposerForRewindMode", () => {
|
||||
test("restores only conversation-mutating rewind modes", () => {
|
||||
expect(shouldRestoreComposerForRewindMode("conversation")).toBe(true);
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import type { ChatHistoryAttachment } from "@getpaseo/protocol/messages";
|
||||
import { restoreChatHistoryAttachment } from "@/attachments/chat-history";
|
||||
import type { ComposerTextSource } from "@/composer/text-source";
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
|
||||
|
||||
interface RewindComposerRestoreContextValue {
|
||||
completeRewind: (prompt: { text: string; chatHistory?: ChatHistoryAttachment }) => void;
|
||||
completeRewind: (text: string) => void;
|
||||
}
|
||||
|
||||
interface RewindComposerRestoreProviderProps {
|
||||
attachmentScopeKey: string;
|
||||
textSource: ComposerTextSource;
|
||||
setText: (text: string) => void;
|
||||
onRewindComplete: () => void;
|
||||
@@ -17,34 +14,35 @@ interface RewindComposerRestoreProviderProps {
|
||||
|
||||
const RewindComposerRestoreContext = createContext<RewindComposerRestoreContextValue | null>(null);
|
||||
|
||||
export function restoreComposerPromptIfEmpty(input: {
|
||||
export function restoreComposerTextIfEmpty(input: {
|
||||
currentText: string;
|
||||
rewoundPrompt: { text: string; chatHistory?: ChatHistoryAttachment };
|
||||
}): { text: string; chatHistory?: ChatHistoryAttachment } {
|
||||
if (input.currentText.length > 0) return { text: input.currentText };
|
||||
return input.rewoundPrompt;
|
||||
rewoundText: string;
|
||||
}): string {
|
||||
if (input.currentText.length > 0) {
|
||||
return input.currentText;
|
||||
}
|
||||
return input.rewoundText;
|
||||
}
|
||||
|
||||
export function RewindComposerRestoreProvider({
|
||||
attachmentScopeKey,
|
||||
textSource,
|
||||
setText,
|
||||
onRewindComplete,
|
||||
children,
|
||||
}: RewindComposerRestoreProviderProps) {
|
||||
const completeRewind = useCallback(
|
||||
(prompt: { text: string; chatHistory?: ChatHistoryAttachment }) => {
|
||||
(rewoundText: string) => {
|
||||
const currentText = textSource.getSnapshot();
|
||||
const restored = restoreComposerPromptIfEmpty({ currentText, rewoundPrompt: prompt });
|
||||
if (restored.chatHistory) {
|
||||
restoreChatHistoryAttachment(attachmentScopeKey, restored.chatHistory);
|
||||
}
|
||||
if (restored.text !== currentText) {
|
||||
setText(restored.text);
|
||||
const nextText = restoreComposerTextIfEmpty({
|
||||
currentText: currentText,
|
||||
rewoundText,
|
||||
});
|
||||
if (nextText !== currentText) {
|
||||
setText(nextText);
|
||||
}
|
||||
onRewindComplete();
|
||||
},
|
||||
[attachmentScopeKey, onRewindComplete, setText, textSource],
|
||||
[onRewindComplete, setText, textSource],
|
||||
);
|
||||
|
||||
const value = useMemo(() => ({ completeRewind }), [completeRewind]);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ChatHistoryAttachment } from "@getpaseo/protocol/messages";
|
||||
import { memo, useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
@@ -20,12 +19,7 @@ export type { RewindMode };
|
||||
interface RewindMenuProps {
|
||||
capabilities: AgentCapabilityFlags;
|
||||
rewoundText: string;
|
||||
chatHistory?: ChatHistoryAttachment;
|
||||
onRewind: (input: {
|
||||
mode: RewindMode;
|
||||
rewoundText: string;
|
||||
chatHistory?: ChatHistoryAttachment;
|
||||
}) => Promise<void> | void;
|
||||
onRewind: (input: { mode: RewindMode; rewoundText: string }) => Promise<void> | void;
|
||||
isPending?: boolean;
|
||||
testID?: string;
|
||||
}
|
||||
@@ -44,7 +38,6 @@ function getIcon(mode: RewindMode, color: string): ReactElement {
|
||||
export const RewindMenu = memo(function RewindMenu({
|
||||
capabilities,
|
||||
rewoundText,
|
||||
chatHistory,
|
||||
onRewind,
|
||||
isPending: isPendingProp = false,
|
||||
testID = "rewind-menu",
|
||||
@@ -77,7 +70,7 @@ export const RewindMenu = memo(function RewindMenu({
|
||||
if (isLocked) return;
|
||||
setPendingMode(mode);
|
||||
try {
|
||||
await onRewind({ mode, rewoundText, chatHistory });
|
||||
await onRewind({ mode, rewoundText });
|
||||
} catch {
|
||||
// useRewindAgentMutation owns the toast; the menu only owns flow state.
|
||||
} finally {
|
||||
@@ -85,7 +78,7 @@ export const RewindMenu = memo(function RewindMenu({
|
||||
setIsOpen(false);
|
||||
}
|
||||
},
|
||||
[isLocked, onRewind, rewoundText, chatHistory],
|
||||
[isLocked, onRewind, rewoundText],
|
||||
);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ChatHistoryAttachment } from "@getpaseo/protocol/messages";
|
||||
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -21,7 +20,6 @@ interface UseRewindAgentMutationInput {
|
||||
interface RewindAgentInput {
|
||||
mode: RewindMode;
|
||||
rewoundText: string;
|
||||
chatHistory?: ChatHistoryAttachment;
|
||||
}
|
||||
|
||||
export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
|
||||
@@ -54,10 +52,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
|
||||
if (!shouldRestoreComposerForRewindMode(variables.mode)) {
|
||||
return;
|
||||
}
|
||||
composerRestore?.completeRewind({
|
||||
text: variables.rewoundText,
|
||||
chatHistory: variables.chatHistory,
|
||||
});
|
||||
composerRestore?.completeRewind(variables.rewoundText);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t("rewind.errors.failed"));
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveSidebarFooterLayout,
|
||||
SIDEBAR_FOOTER_ACTIONS,
|
||||
} from "@/components/sidebar-footer-layout";
|
||||
import { DEFAULT_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
|
||||
|
||||
const TOUCH = 44;
|
||||
const POINTER = 28;
|
||||
const ROW_PADDING = 8;
|
||||
|
||||
function layout(rowWidth: number, controlSize: number) {
|
||||
return resolveSidebarFooterLayout({
|
||||
rowWidth,
|
||||
controlSize,
|
||||
rowPadding: ROW_PADDING,
|
||||
actions: SIDEBAR_FOOTER_ACTIONS,
|
||||
});
|
||||
}
|
||||
|
||||
describe("sidebar footer layout", () => {
|
||||
it("keeps every action inline at the default width with either sizing", () => {
|
||||
for (const controlSize of [TOUCH, POINTER]) {
|
||||
expect(layout(DEFAULT_SIDEBAR_WIDTH, controlSize)).toEqual({
|
||||
inline: ["add-project", "usage", "hosts", "help", "settings"],
|
||||
overflow: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every action inline at the minimum width with pointer sizing", () => {
|
||||
expect(layout(MIN_SIDEBAR_WIDTH, POINTER).overflow).toEqual([]);
|
||||
});
|
||||
|
||||
// The iPhone Duo report: the folded sidebar column is about the minimum width, and touch
|
||||
// sizing makes five 44pt buttons plus padding 236pt wide.
|
||||
it("moves Help and Usage behind the caret at the minimum width with touch sizing", () => {
|
||||
expect(layout(MIN_SIDEBAR_WIDTH, TOUCH)).toEqual({
|
||||
inline: ["add-project", "hosts", "settings"],
|
||||
overflow: ["usage", "help"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Settings and Add project inline longest", () => {
|
||||
// Three slots: two actions and the caret.
|
||||
expect(layout(3 * TOUCH + 2 * ROW_PADDING, TOUCH)).toEqual({
|
||||
inline: ["add-project", "settings"],
|
||||
overflow: ["usage", "hosts", "help"],
|
||||
});
|
||||
// Two slots: Settings and the caret.
|
||||
expect(layout(2 * TOUCH + 2 * ROW_PADDING, TOUCH)).toEqual({
|
||||
inline: ["settings"],
|
||||
overflow: ["add-project", "usage", "hosts", "help"],
|
||||
});
|
||||
});
|
||||
|
||||
it("puts everything behind the caret when only the caret fits", () => {
|
||||
expect(layout(TOUCH + 2 * ROW_PADDING - 10, TOUCH)).toEqual({
|
||||
inline: [],
|
||||
overflow: ["add-project", "usage", "hosts", "help", "settings"],
|
||||
});
|
||||
});
|
||||
|
||||
it("fits all five actions exactly at their combined width", () => {
|
||||
const exact = 5 * TOUCH + 2 * ROW_PADDING;
|
||||
expect(layout(exact, TOUCH).overflow).toEqual([]);
|
||||
// Browser layout rounds a little below the exact width.
|
||||
expect(layout(exact - 0.4, TOUCH).overflow).toEqual([]);
|
||||
expect(layout(exact - 1, TOUCH).overflow).toEqual(["usage", "help"]);
|
||||
});
|
||||
|
||||
it("only places the actions it is given", () => {
|
||||
expect(
|
||||
resolveSidebarFooterLayout({
|
||||
rowWidth: 3 * TOUCH + 2 * ROW_PADDING,
|
||||
controlSize: TOUCH,
|
||||
rowPadding: ROW_PADDING,
|
||||
actions: ["add-project", "hosts", "settings"],
|
||||
}),
|
||||
).toEqual({ inline: ["add-project", "hosts", "settings"], overflow: [] });
|
||||
});
|
||||
|
||||
// Laying out against a guessed width would paint a clipped row for a frame on narrow sidebars.
|
||||
it("places nothing before the row has been measured", () => {
|
||||
for (const controlSize of [TOUCH, POINTER]) {
|
||||
expect(layout(0, controlSize)).toEqual({ inline: [], overflow: [] });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
/** The sidebar footer's icon line, in row order. */
|
||||
export const SIDEBAR_FOOTER_ACTIONS = [
|
||||
"add-project",
|
||||
"usage",
|
||||
"hosts",
|
||||
"help",
|
||||
"settings",
|
||||
] as const;
|
||||
|
||||
export type SidebarFooterAction = (typeof SIDEBAR_FOOTER_ACTIONS)[number];
|
||||
|
||||
/** The order actions keep their place in a row too narrow for all of them. */
|
||||
const KEEP_ORDER: readonly SidebarFooterAction[] = [
|
||||
"settings",
|
||||
"add-project",
|
||||
"hosts",
|
||||
"usage",
|
||||
"help",
|
||||
];
|
||||
|
||||
/** Measured widths land a hair under the exact sum of their parts. */
|
||||
const MEASUREMENT_TOLERANCE = 0.5;
|
||||
|
||||
export interface SidebarFooterLayout {
|
||||
/** Actions drawn in the row, in row order. */
|
||||
inline: SidebarFooterAction[];
|
||||
/** Actions behind the overflow caret, in row order. Empty when everything fits. */
|
||||
overflow: SidebarFooterAction[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the footer's actions between the row and the overflow menu. Buttons keep their full size;
|
||||
* when they don't all fit, the caret takes one slot and the actions that lose their place move
|
||||
* behind it, Help first and Settings last. Nothing is placed until the row has a measured width.
|
||||
*/
|
||||
export function resolveSidebarFooterLayout(input: {
|
||||
rowWidth: number;
|
||||
controlSize: number;
|
||||
rowPadding: number;
|
||||
actions: readonly SidebarFooterAction[];
|
||||
}): SidebarFooterLayout {
|
||||
const { rowWidth, controlSize, rowPadding, actions } = input;
|
||||
// An unmeasured row stays empty: any guess would paint a clipped row for a frame on a narrow
|
||||
// sidebar before the real width arrives.
|
||||
if (rowWidth <= 0) {
|
||||
return { inline: [], overflow: [] };
|
||||
}
|
||||
const slots = Math.floor(
|
||||
(rowWidth - rowPadding * 2 + MEASUREMENT_TOLERANCE) / Math.max(controlSize, 1),
|
||||
);
|
||||
if (slots >= actions.length) {
|
||||
return { inline: [...actions], overflow: [] };
|
||||
}
|
||||
const inlineCount = Math.max(slots - 1, 0);
|
||||
const kept = new Set(
|
||||
KEEP_ORDER.filter((action) => actions.includes(action)).slice(0, inlineCount),
|
||||
);
|
||||
return {
|
||||
inline: actions.filter((action) => kept.has(action)),
|
||||
overflow: actions.filter((action) => !kept.has(action)),
|
||||
};
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import type { LucideIcon } from "lucide-react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { density } from "@/styles/density";
|
||||
import { SidebarFooterIconHighlight } from "./sidebar-footer-icon-highlight";
|
||||
|
||||
/**
|
||||
* A footer icon that opens a menu. Sized and drawn like the footer's other icon buttons; the
|
||||
* tooltip stays out of the way while the menu is open.
|
||||
*/
|
||||
export function SidebarFooterMenuTrigger({
|
||||
icon,
|
||||
label,
|
||||
menuOpen,
|
||||
testID,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
menuOpen: boolean;
|
||||
testID: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<Tooltip delayDuration={300} enabledOnDesktop={!menuOpen}>
|
||||
<TooltipTrigger asChild>
|
||||
<View>
|
||||
<DropdownMenuTrigger
|
||||
style={styles.trigger}
|
||||
testID={testID}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
>
|
||||
{({ hovered, pressed, open }) => (
|
||||
<>
|
||||
<SidebarFooterIconHighlight visible={pressed || open} />
|
||||
<DensityIcon
|
||||
icon={icon}
|
||||
size="md"
|
||||
color={hovered ? "foreground" : "foregroundMuted"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{label}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
width: density.iconButton,
|
||||
height: density.iconButton,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
}));
|
||||
@@ -1,245 +0,0 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronUp,
|
||||
CircleGauge,
|
||||
CircleHelp,
|
||||
FolderPlus,
|
||||
Plus,
|
||||
Server,
|
||||
Settings,
|
||||
} from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
|
||||
import type { SidebarFooterAction } from "@/components/sidebar-footer-layout";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSubTrigger,
|
||||
type MenuPageDefinition,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { orderHostsLocalFirst } from "@/types/host-connection";
|
||||
import { useOpenSidebarUsage } from "@/usage";
|
||||
import { SidebarFooterMenuTrigger } from "./sidebar-footer-menu-trigger";
|
||||
import { SidebarHelpMenuItems } from "./sidebar-help-menu";
|
||||
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const ThemedFolderPlus = withUnistyles(FolderPlus);
|
||||
const ThemedCircleGauge = withUnistyles(CircleGauge);
|
||||
const ThemedServer = withUnistyles(Server);
|
||||
const ThemedCircleHelp = withUnistyles(CircleHelp);
|
||||
const ThemedSettings = withUnistyles(Settings);
|
||||
const ThemedPlus = withUnistyles(Plus);
|
||||
const addProjectLeading = <ThemedFolderPlus size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
|
||||
const usageLeading = <ThemedCircleGauge size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
|
||||
const hostsLeading = <ThemedServer size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
|
||||
const helpLeading = <ThemedCircleHelp size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
|
||||
const settingsLeading = <ThemedSettings size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
|
||||
const addHostLeading = <ThemedPlus size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
|
||||
|
||||
const HOSTS_PAGE_ID = "hosts";
|
||||
const HELP_PAGE_ID = "help";
|
||||
|
||||
export interface SidebarFooterOverflowMenuProps {
|
||||
/** The footer actions that did not fit in the row, in row order. */
|
||||
actions: readonly SidebarFooterAction[];
|
||||
labels: { addProject: string; hosts: string; settings: string; usage: string };
|
||||
onAddProject: () => void;
|
||||
onSettings: () => void;
|
||||
onAddHost: () => void;
|
||||
onOpenHostSettings: (serverId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The caret at the end of a footer row too narrow for every action. It lists the actions that
|
||||
* lost their place, with the same test IDs they carry in the row; Hosts and Help, menus of their
|
||||
* own in the row, open as pages of this menu.
|
||||
*/
|
||||
export function SidebarFooterOverflowMenu({
|
||||
actions,
|
||||
labels,
|
||||
onAddProject,
|
||||
onSettings,
|
||||
onAddHost,
|
||||
onOpenHostSettings,
|
||||
}: SidebarFooterOverflowMenuProps): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const openUsage = useOpenSidebarUsage();
|
||||
const newAgentKeys = useShortcutKeys("new-agent");
|
||||
const settingsKeys = useShortcutKeys("toggle-settings");
|
||||
const helpLabel = t("sidebar.help.trigger");
|
||||
const addProjectTrailing = useMemo(
|
||||
() => (newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null),
|
||||
[newAgentKeys],
|
||||
);
|
||||
const settingsTrailing = useMemo(
|
||||
() => (settingsKeys ? <Shortcut chord={settingsKeys} /> : null),
|
||||
[settingsKeys],
|
||||
);
|
||||
|
||||
const pages = useMemo((): MenuPageDefinition[] => {
|
||||
const result: MenuPageDefinition[] = [];
|
||||
if (actions.includes("hosts")) {
|
||||
result.push({
|
||||
id: HOSTS_PAGE_ID,
|
||||
title: labels.hosts,
|
||||
content: <HostsPage onAddHost={onAddHost} onOpenHostSettings={onOpenHostSettings} />,
|
||||
});
|
||||
}
|
||||
if (actions.includes("help")) {
|
||||
result.push({ id: HELP_PAGE_ID, title: helpLabel, content: <SidebarHelpMenuItems /> });
|
||||
}
|
||||
return result;
|
||||
}, [actions, helpLabel, labels.hosts, onAddHost, onOpenHostSettings]);
|
||||
|
||||
function renderAction(action: SidebarFooterAction): ReactElement {
|
||||
switch (action) {
|
||||
case "add-project":
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={action}
|
||||
testID="sidebar-add-project"
|
||||
leading={addProjectLeading}
|
||||
trailing={addProjectTrailing}
|
||||
onSelect={onAddProject}
|
||||
>
|
||||
{labels.addProject}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
case "usage":
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={action}
|
||||
testID="sidebar-usage-icon"
|
||||
leading={usageLeading}
|
||||
onSelect={openUsage}
|
||||
>
|
||||
{labels.usage}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
case "hosts":
|
||||
return (
|
||||
<DropdownMenuSubTrigger
|
||||
key={action}
|
||||
id={HOSTS_PAGE_ID}
|
||||
testID="sidebar-hosts-trigger"
|
||||
leading={hostsLeading}
|
||||
>
|
||||
{labels.hosts}
|
||||
</DropdownMenuSubTrigger>
|
||||
);
|
||||
case "help":
|
||||
return (
|
||||
<DropdownMenuSubTrigger
|
||||
key={action}
|
||||
id={HELP_PAGE_ID}
|
||||
testID="sidebar-help"
|
||||
leading={helpLeading}
|
||||
>
|
||||
{helpLabel}
|
||||
</DropdownMenuSubTrigger>
|
||||
);
|
||||
case "settings":
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={action}
|
||||
testID="sidebar-settings"
|
||||
leading={settingsLeading}
|
||||
trailing={settingsTrailing}
|
||||
onSelect={onSettings}
|
||||
>
|
||||
{labels.settings}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<SidebarFooterMenuTrigger
|
||||
icon={ChevronUp}
|
||||
label={t("sidebar.actions.moreActions")}
|
||||
menuOpen={open}
|
||||
testID="sidebar-footer-overflow"
|
||||
/>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="end"
|
||||
offset={8}
|
||||
width={240}
|
||||
pages={pages}
|
||||
scrollable
|
||||
testID="sidebar-footer-overflow-menu"
|
||||
>
|
||||
{actions.map(renderAction)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
/** The host picker's choices as a menu page: each host opens its settings, then Add host. */
|
||||
function HostsPage({
|
||||
onAddHost,
|
||||
onOpenHostSettings,
|
||||
}: {
|
||||
onAddHost: () => void;
|
||||
onOpenHostSettings: (serverId: string) => void;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const hosts = useHosts();
|
||||
const localServerId = useLocalDaemonServerId();
|
||||
const orderedHosts = useMemo(
|
||||
() => orderHostsLocalFirst(hosts, localServerId),
|
||||
[hosts, localServerId],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{orderedHosts.map((host) => (
|
||||
<HostRow
|
||||
key={host.serverId}
|
||||
serverId={host.serverId}
|
||||
label={host.label}
|
||||
onOpenHostSettings={onOpenHostSettings}
|
||||
/>
|
||||
))}
|
||||
{orderedHosts.length > 0 ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem testID="sidebar-host-add" leading={addHostLeading} onSelect={onAddHost}>
|
||||
{t("settings.addHost")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HostRow({
|
||||
serverId,
|
||||
label,
|
||||
onOpenHostSettings,
|
||||
}: {
|
||||
serverId: string;
|
||||
label: string;
|
||||
onOpenHostSettings: (serverId: string) => void;
|
||||
}): ReactElement {
|
||||
const handleSelect = useCallback(
|
||||
() => onOpenHostSettings(serverId),
|
||||
[onOpenHostSettings, serverId],
|
||||
);
|
||||
const leading = useMemo(() => <HostStatusDotSlot serverId={serverId} />, [serverId]);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-host-row-${serverId}`}
|
||||
leading={leading}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Text, View } from "react-native";
|
||||
import { Activity, CircleHelp, Gift, Keyboard } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
@@ -12,13 +12,17 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { SidebarFooterMenuTrigger } from "./sidebar-footer-menu-trigger";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { SidebarFooterIconHighlight } from "./sidebar-footer-icon-highlight";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useAppDiagnosticStore } from "@/diagnostics/store";
|
||||
import { useHardwareKeyboardPresent } from "@/keyboard";
|
||||
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { density } from "@/styles/density";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
@@ -73,32 +77,12 @@ function HostVersionHint({ host }: { host: HostProfile }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The Help menu's trigger, and the menu it opens. */
|
||||
export function SidebarHelpMenu() {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<SidebarFooterMenuTrigger
|
||||
icon={CircleHelp}
|
||||
label={t("sidebar.help.trigger")}
|
||||
menuOpen={open}
|
||||
testID="sidebar-help"
|
||||
/>
|
||||
<DropdownMenuContent side="top" align="end" offset={8} width={280} testID="sidebar-help-menu">
|
||||
<SidebarHelpMenuItems />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
/** The Help menu's rows, shared by the Help menu and the footer's overflow page for it. */
|
||||
export function SidebarHelpMenuItems() {
|
||||
const { t } = useTranslation();
|
||||
const shortcutsAvailable = useHardwareKeyboardPresent();
|
||||
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
|
||||
const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen);
|
||||
const [open, setOpen] = useState(false);
|
||||
const version = formatVersionWithPrefix(resolveAppVersion());
|
||||
const hosts = useHosts();
|
||||
|
||||
@@ -115,65 +99,105 @@ export function SidebarHelpMenuItems() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuLabel>{t("sidebar.help.sectionHelp")}</DropdownMenuLabel>
|
||||
{shortcutsAvailable ? (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<Tooltip delayDuration={300} enabledOnDesktop={!open}>
|
||||
<TooltipTrigger asChild>
|
||||
<View>
|
||||
<DropdownMenuTrigger
|
||||
style={styles.trigger}
|
||||
testID="sidebar-help"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("sidebar.help.trigger")}
|
||||
>
|
||||
{({ hovered, pressed, open: menuOpen }) => (
|
||||
<>
|
||||
<SidebarFooterIconHighlight visible={pressed || menuOpen} />
|
||||
<DensityIcon
|
||||
icon={CircleHelp}
|
||||
size="md"
|
||||
color={hovered ? "foreground" : "foregroundMuted"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{t("sidebar.help.trigger")}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent side="top" align="end" offset={8} width={280} testID="sidebar-help-menu">
|
||||
<DropdownMenuLabel>{t("sidebar.help.sectionHelp")}</DropdownMenuLabel>
|
||||
{shortcutsAvailable ? (
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-shortcuts"
|
||||
leading={shortcutsLeadingIcon}
|
||||
onSelect={openKeyboardShortcuts}
|
||||
>
|
||||
{t("sidebar.help.shortcuts")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-shortcuts"
|
||||
leading={shortcutsLeadingIcon}
|
||||
onSelect={openKeyboardShortcuts}
|
||||
testID="sidebar-help-changelog"
|
||||
leading={changelogLeadingIcon}
|
||||
onSelect={openChangelog}
|
||||
>
|
||||
{t("sidebar.help.shortcuts")}
|
||||
{t("sidebar.help.whatsNew")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-changelog"
|
||||
leading={changelogLeadingIcon}
|
||||
onSelect={openChangelog}
|
||||
>
|
||||
{t("sidebar.help.whatsNew")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-diagnostics"
|
||||
leading={diagnosticLeadingIcon}
|
||||
onSelect={openAppDiagnostic}
|
||||
>
|
||||
{t("sidebar.help.diagnostics")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t("sidebar.help.reportIssue")}</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-discord"
|
||||
leading={discordLeadingIcon}
|
||||
onSelect={openDiscord}
|
||||
>
|
||||
{t("sidebar.help.discord")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-github"
|
||||
leading={githubLeadingIcon}
|
||||
onSelect={openGitHubIssue}
|
||||
>
|
||||
{t("sidebar.help.github")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<View style={styles.versionList}>
|
||||
<DropdownMenuHint
|
||||
style={styles.versionHint}
|
||||
trailing={version}
|
||||
testID="sidebar-help-version"
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-diagnostics"
|
||||
leading={diagnosticLeadingIcon}
|
||||
onSelect={openAppDiagnostic}
|
||||
>
|
||||
{t("sidebar.help.appName")}
|
||||
</DropdownMenuHint>
|
||||
{hosts.map((host) => (
|
||||
<HostVersionHint key={host.serverId} host={host} />
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
{t("sidebar.help.diagnostics")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t("sidebar.help.reportIssue")}</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-discord"
|
||||
leading={discordLeadingIcon}
|
||||
onSelect={openDiscord}
|
||||
>
|
||||
{t("sidebar.help.discord")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-github"
|
||||
leading={githubLeadingIcon}
|
||||
onSelect={openGitHubIssue}
|
||||
>
|
||||
{t("sidebar.help.github")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<View style={styles.versionList}>
|
||||
<DropdownMenuHint
|
||||
style={styles.versionHint}
|
||||
trailing={version}
|
||||
testID="sidebar-help-version"
|
||||
>
|
||||
{t("sidebar.help.appName")}
|
||||
</DropdownMenuHint>
|
||||
{hosts.map((host) => (
|
||||
<HostVersionHint key={host.serverId} host={host} />
|
||||
))}
|
||||
</View>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
width: density.iconButton,
|
||||
height: density.iconButton,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
versionList: {
|
||||
gap: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[2],
|
||||
|
||||
@@ -19,6 +19,8 @@ import type { Theme } from "@/styles/theme";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { TOUCH_TARGET } from "@/components/ui/control-sizing";
|
||||
import { useTopBandRow } from "@/window-layout";
|
||||
import { AlertTriangle, CheckCircle2, Info } from "lucide-react-native";
|
||||
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
|
||||
import {
|
||||
@@ -200,13 +202,30 @@ export function ToastViewport({
|
||||
const topOffset =
|
||||
placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : panelClearance;
|
||||
|
||||
// A centred toast cannot trim one side, so it always moves below an occlusion it meets.
|
||||
const topBand = useTopBandRow({
|
||||
offset: 0,
|
||||
height: TOUCH_TARGET,
|
||||
minimumWidth: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
const widthBoundaryStyle = useMemo(
|
||||
() => [styles.widthBoundary, { paddingTop: topBand.top }],
|
||||
[topBand.top],
|
||||
);
|
||||
|
||||
const content = (
|
||||
<View
|
||||
style={styles.container(placement, topOffset)}
|
||||
pointerEvents="box-none"
|
||||
collapsable={false}
|
||||
>
|
||||
<View style={styles.widthBoundary} pointerEvents="box-none" collapsable={false}>
|
||||
<View
|
||||
ref={topBand.ref}
|
||||
onLayout={topBand.onLayout}
|
||||
style={widthBoundaryStyle}
|
||||
pointerEvents="box-none"
|
||||
collapsable={false}
|
||||
>
|
||||
{toast ? (
|
||||
<Animated.View
|
||||
entering={toastEntering}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computePosition, fitSurfaceWidth, getTransformOrigin, type Rect } from "./anchor";
|
||||
import { computePosition, getTransformOrigin, type Rect } from "./anchor";
|
||||
|
||||
const DISPLAY: Rect = { x: 0, y: 0, width: 1000, height: 800 };
|
||||
const TRIGGER: Rect = { x: 100, y: 100, width: 40, height: 20 };
|
||||
@@ -97,26 +97,3 @@ describe("getTransformOrigin", () => {
|
||||
expect(getTransformOrigin(placement, alignment)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fitSurfaceWidth", () => {
|
||||
it("leaves a surface that fits the display alone, capped at the display", () => {
|
||||
expect(fitSurfaceWidth({ width: 240, minWidth: 180 }, 1000)).toEqual({
|
||||
width: 240,
|
||||
minWidth: 180,
|
||||
maxWidth: 984,
|
||||
});
|
||||
});
|
||||
|
||||
it("shrinks a surface wider than a narrow display to the space inside its edge padding", () => {
|
||||
expect(fitSurfaceWidth({ width: 240, minWidth: 180, maxWidth: 320 }, 220)).toEqual({
|
||||
width: 204,
|
||||
minWidth: 180,
|
||||
maxWidth: 204,
|
||||
});
|
||||
expect(fitSurfaceWidth({ minWidth: 180 }, 150)).toEqual({
|
||||
width: undefined,
|
||||
minWidth: 134,
|
||||
maxWidth: 134,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,27 +120,6 @@ function clampToDisplayArea(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export interface SurfaceWidth {
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Caps a surface's requested width to the display. Position clamping can move a surface but not
|
||||
* shrink it, so a menu wider than a narrow screen would otherwise hang off the far edge.
|
||||
*/
|
||||
export function fitSurfaceWidth(requested: SurfaceWidth, displayWidth: number): SurfaceWidth {
|
||||
const available = Math.max(displayWidth - EDGE_PADDING * 2, 0);
|
||||
const cap = (value: number | undefined) =>
|
||||
typeof value === "number" ? Math.min(value, available) : undefined;
|
||||
return {
|
||||
width: cap(requested.width),
|
||||
minWidth: cap(requested.minWidth),
|
||||
maxWidth: cap(requested.maxWidth) ?? available,
|
||||
};
|
||||
}
|
||||
|
||||
export function computePosition({
|
||||
triggerRect,
|
||||
contentSize,
|
||||
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
getControlInteractionPhase,
|
||||
TOOLBAR_CONTROL_HEIGHT,
|
||||
TOOLBAR_CONTROL_SIZES,
|
||||
TOOLBAR_DISC_SIZE,
|
||||
TOOLBAR_DISC_SIZES,
|
||||
TOUCH_TARGET,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -92,11 +90,6 @@ describe("control sizing gate", () => {
|
||||
// Composer toolbar controls: a full target on touch, unchanged 28 on pointer.
|
||||
expect(TOOLBAR_CONTROL_SIZES).toEqual({ touch: TOUCH_TARGET, pointer: 28 });
|
||||
expect(TOOLBAR_CONTROL_HEIGHT).toEqual({ xs: TOUCH_TARGET, md: 28 });
|
||||
// A filled toolbar control paints a 36pt disc inside its 44pt touch frame, and fills its
|
||||
// 28pt pointer frame exactly, so desktop is unchanged. Its glyph is the `md` glyph.
|
||||
expect(TOOLBAR_DISC_SIZES).toEqual({ touch: 36, pointer: TOOLBAR_CONTROL_SIZES.pointer });
|
||||
expect(TOOLBAR_DISC_SIZE).toEqual({ xs: 36, md: 28 });
|
||||
expect(iconSizeFor("md")).toEqual({ touch: 20, pointer: 16 });
|
||||
for (const size of ["sm", "md"] as const) {
|
||||
expect(controlHeightForLayout(size, true)).toBeGreaterThan(
|
||||
controlHeightForLayout(size, false),
|
||||
|
||||
@@ -93,14 +93,6 @@ export const ICON_BUTTON_SIZES = {
|
||||
export const TOOLBAR_CONTROL_SIZES = { touch: TOUCH_TARGET, pointer: 28 } satisfies SizingValue;
|
||||
export const TOOLBAR_CONTROL_HEIGHT = bySizing(TOOLBAR_CONTROL_SIZES);
|
||||
|
||||
/**
|
||||
* The painted disc of a filled toolbar control, such as the composer's send and stop. The
|
||||
* control keeps the full toolbar hit frame and paints this smaller disc centered inside it, so
|
||||
* touch-sized it does not outweigh its unpainted neighbours. Pointer-sized it fills the frame.
|
||||
*/
|
||||
export const TOOLBAR_DISC_SIZES = { touch: 36, pointer: 28 } satisfies SizingValue;
|
||||
export const TOOLBAR_DISC_SIZE = bySizing(TOOLBAR_DISC_SIZES);
|
||||
|
||||
/**
|
||||
* Glyph sizes. Pointer glyphs sit beside 13-14pt text; touch glyphs sit beside iOS's 17pt body
|
||||
* text, so each step is larger.
|
||||
|
||||
@@ -29,7 +29,6 @@ import type { KeyboardFocusScope } from "@/keyboard/actions";
|
||||
import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
|
||||
import {
|
||||
computePosition,
|
||||
fitSurfaceWidth,
|
||||
getTransformOrigin,
|
||||
measureElement,
|
||||
type Alignment,
|
||||
@@ -284,7 +283,11 @@ export function AnchoredSurface({
|
||||
const { width: screenWidth } = Dimensions.get("window");
|
||||
const resolvedWidthStyle: ViewStyle = fullWidth
|
||||
? { width: screenWidth - horizontalPadding * 2 }
|
||||
: fitSurfaceWidth({ width, minWidth, maxWidth }, screenWidth);
|
||||
: {
|
||||
...(typeof width === "number" ? { width } : null),
|
||||
...(typeof minWidth === "number" ? { minWidth } : null),
|
||||
...(typeof maxWidth === "number" ? { maxWidth } : null),
|
||||
};
|
||||
return [
|
||||
resolvedWidthStyle,
|
||||
{
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ChatHistoryAttachment } from "@/attachments/chat-history";
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
@@ -256,28 +255,12 @@ function WorkspaceAttachmentPill({
|
||||
}: WorkspaceAttachmentPillProps) {
|
||||
const { t } = useTranslation();
|
||||
const content = getWorkspaceAttachmentPillContent(attachment, t);
|
||||
const chatHistory = useMemo(() => {
|
||||
if (attachment.kind !== "chat_history") return null;
|
||||
if (!attachment.source) return attachment.attachment;
|
||||
const { serverId, agentId, title } = attachment.source;
|
||||
return { ...attachment.attachment, chatHistorySource: { serverId, agentId, title } };
|
||||
}, [attachment]);
|
||||
const handleOpen = useCallback(() => {
|
||||
onOpen(attachment);
|
||||
}, [onOpen, attachment]);
|
||||
const handleRemove = useCallback(() => {
|
||||
onRemove(index);
|
||||
}, [onRemove, index]);
|
||||
if (chatHistory) {
|
||||
return (
|
||||
<ChatHistoryAttachment
|
||||
attachment={chatHistory}
|
||||
onRemove={handleRemove}
|
||||
disabled={disabled}
|
||||
testID={getPillTestID(attachment)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AttachmentPill
|
||||
testID={getPillTestID(attachment)}
|
||||
|
||||
@@ -242,10 +242,10 @@ describe("useDraftAgentCreateFlow", () => {
|
||||
expect(createRequest).toHaveBeenCalledWith({
|
||||
attempt: expect.objectContaining({
|
||||
text: "",
|
||||
attachments: [{ ...attachment.attachment, chatHistorySource: attachment.source }],
|
||||
attachments: [attachment.attachment],
|
||||
}),
|
||||
text: "",
|
||||
attachments: [{ ...attachment.attachment, chatHistorySource: attachment.source }],
|
||||
attachments: [attachment.attachment],
|
||||
cwd: "/repo",
|
||||
});
|
||||
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
controlSizingForLayout,
|
||||
iconSizeFor,
|
||||
TOOLBAR_CONTROL_HEIGHT,
|
||||
TOOLBAR_DISC_SIZE,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { usePointerCapabilities } from "@/hooks/pointer-capabilities";
|
||||
@@ -1118,7 +1117,7 @@ function ComposerCancelButton({
|
||||
accessibilityRole="button"
|
||||
style={cancelButtonStyle}
|
||||
>
|
||||
<View style={styles.cancelButtonDisc}>{icon}</View>
|
||||
{icon}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
@@ -2596,21 +2595,14 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
width: "100%",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
// The trigger is the hit frame and stays unpainted; the disc inside it carries the fill.
|
||||
cancelButton: {
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
cancelButtonDisc: {
|
||||
width: TOOLBAR_DISC_SIZE,
|
||||
height: TOOLBAR_DISC_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.palette.red[600],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
rightControls: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
controlSizingForLayout,
|
||||
iconSizeFor,
|
||||
TOOLBAR_CONTROL_HEIGHT,
|
||||
TOOLBAR_DISC_SIZE,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
@@ -350,11 +349,7 @@ function VoiceButtonIcon({
|
||||
buttonIconSize: number;
|
||||
}) {
|
||||
if (isDictating) {
|
||||
return (
|
||||
<View style={styles.voiceButtonRecordingDisc}>
|
||||
<Square size={buttonIconSize} color="white" fill="white" />
|
||||
</View>
|
||||
);
|
||||
return <Square size={buttonIconSize} color="white" fill="white" />;
|
||||
}
|
||||
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
|
||||
if (isMutedRealtime) {
|
||||
@@ -842,14 +837,12 @@ function SendButtonTooltip({
|
||||
testID={submitButtonTestID}
|
||||
style={sendButtonCombinedStyle}
|
||||
>
|
||||
<View style={submitLabel ? styles.sendButtonDiscLabeled : styles.sendButtonDisc}>
|
||||
<SendButtonContent
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
submitIcon={submitIcon}
|
||||
submitLabel={submitLabel}
|
||||
buttonIconSize={buttonIconSize}
|
||||
/>
|
||||
</View>
|
||||
<SendButtonContent
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
submitIcon={submitIcon}
|
||||
submitLabel={submitLabel}
|
||||
buttonIconSize={buttonIconSize}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<SendTooltipBody label={sendTooltipLabel} sendKeys={sendKeys} />
|
||||
@@ -1932,6 +1925,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
styles.voiceButton,
|
||||
Boolean(hovered) && !isDictating && styles.iconButtonHovered,
|
||||
!isDictationStartEnabled && styles.buttonDisabled,
|
||||
isDictating && styles.voiceButtonRecording,
|
||||
],
|
||||
[isDictating, isDictationStartEnabled],
|
||||
);
|
||||
@@ -1980,8 +1974,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
allowEmpty: allowEmptySubmit,
|
||||
});
|
||||
const sendButtonCombinedStyle = useMemo(
|
||||
() => [styles.sendButton, isPresentedSendDisabled && styles.buttonDisabled],
|
||||
[isPresentedSendDisabled],
|
||||
() => [
|
||||
styles.sendButton,
|
||||
submitLabel ? styles.sendButtonLabeled : undefined,
|
||||
isPresentedSendDisabled && styles.buttonDisabled,
|
||||
],
|
||||
[isPresentedSendDisabled, submitLabel],
|
||||
);
|
||||
const overlayContainerStyle = useMemo(
|
||||
() => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }],
|
||||
@@ -2418,38 +2416,23 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
voiceButtonRecordingDisc: {
|
||||
width: TOOLBAR_DISC_SIZE,
|
||||
height: TOOLBAR_DISC_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
voiceButtonRecording: {
|
||||
backgroundColor: theme.colors.destructive,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
// The trigger is the hit frame and stays unpainted; the disc inside it carries the fill.
|
||||
sendButton: {
|
||||
minWidth: TOOLBAR_CONTROL_HEIGHT,
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.accent,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
sendButtonDisc: {
|
||||
width: TOOLBAR_DISC_SIZE,
|
||||
height: TOOLBAR_DISC_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.accent,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
sendButtonDiscLabeled: {
|
||||
minWidth: TOOLBAR_DISC_SIZE,
|
||||
height: TOOLBAR_DISC_SIZE,
|
||||
sendButtonLabeled: {
|
||||
width: "auto",
|
||||
minWidth: TOOLBAR_CONTROL_HEIGHT,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.accent,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
sendButtonLabel: {
|
||||
fontSize: theme.fontSize.base,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type ControlSizing,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTopBandRow } from "@/window-layout";
|
||||
import { useComposerFullscreenExit } from "./fullscreen-exit";
|
||||
import { resolveGlyphRailOffset } from "./state";
|
||||
|
||||
@@ -83,6 +84,12 @@ export function ComposerInputPresentation({
|
||||
// The toolbar row bleeds 6px past the input padding; the inline glyph sits on that edge.
|
||||
const enterStyle = useMemo(() => [styles.enterButton, { right: -6 + railOffset }], [railOffset]);
|
||||
const exitStyle = useMemo(() => [styles.iconButton, { marginRight: railOffset }], [railOffset]);
|
||||
const exitSize = TOOLBAR_CONTROL_SIZES[controlSizing];
|
||||
const topBand = useTopBandRow({ offset: 0, height: exitSize, minimumWidth: exitSize });
|
||||
const exitRowStyle = useMemo(
|
||||
() => [styles.exitRow, { paddingTop: topBand.top, paddingRight: topBand.trailing }],
|
||||
[topBand.top, topBand.trailing],
|
||||
);
|
||||
return (
|
||||
<View
|
||||
ref={exitScopeRef}
|
||||
@@ -90,7 +97,7 @@ export function ComposerInputPresentation({
|
||||
testID={fullscreen ? "composer-fullscreen" : undefined}
|
||||
>
|
||||
{fullscreen ? (
|
||||
<View style={styles.exitRow}>
|
||||
<View ref={topBand.ref} onLayout={topBand.onLayout} style={exitRowStyle}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -68,7 +68,6 @@ export interface UseForkAgentInput {
|
||||
|
||||
function buildChatHistoryAttachment(input: {
|
||||
draftId: string;
|
||||
title: string | null;
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
payload: Awaited<ReturnType<DaemonClient["buildAgentForkContext"]>>;
|
||||
@@ -84,7 +83,6 @@ function buildChatHistoryAttachment(input: {
|
||||
source: {
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
...(input.title ? { title: input.title } : {}),
|
||||
boundaryMessageId: input.payload.boundaryMessageId,
|
||||
boundaryCursor: input.payload.boundaryCursor,
|
||||
itemCount: input.payload.itemCount,
|
||||
@@ -149,7 +147,6 @@ export function useForkAgent(
|
||||
const payload = await client.buildAgentForkContext(agentId, boundary);
|
||||
const attachment = buildChatHistoryAttachment({
|
||||
draftId,
|
||||
title: useSessionStore.getState().sessions[serverId]?.agents.get(agentId)?.title ?? null,
|
||||
serverId,
|
||||
agentId,
|
||||
payload,
|
||||
|
||||
@@ -342,14 +342,6 @@ export const ar: TranslationResources = {
|
||||
openFile: "افتح الملف",
|
||||
copied: "منقول",
|
||||
},
|
||||
chatHistory: {
|
||||
title: "سجل المحادثة",
|
||||
previousConversation: "المحادثة السابقة",
|
||||
open: "فتح مرفق سجل المحادثة",
|
||||
remove: "إزالة مرفق سجل المحادثة",
|
||||
openSource: "فتح المحادثة الأصلية",
|
||||
description: "السياق المنسوخ عند تفريع هذه المحادثة.",
|
||||
},
|
||||
attachments: {
|
||||
dismissImage: "تجاهل الصورة",
|
||||
closeImage: "إغلاق الصورة",
|
||||
@@ -1244,7 +1236,6 @@ export const ar: TranslationResources = {
|
||||
hosts: "المضيفون",
|
||||
settings: "إعدادات",
|
||||
closeSidebar: "إغلاق الشريط الجانبي",
|
||||
moreActions: "المزيد من الإجراءات",
|
||||
},
|
||||
footer: {
|
||||
usage: "الاستخدام",
|
||||
|
||||
@@ -339,14 +339,6 @@ export const en = {
|
||||
openFile: "Open file",
|
||||
copied: "Copied",
|
||||
},
|
||||
chatHistory: {
|
||||
title: "Chat history",
|
||||
previousConversation: "Previous conversation",
|
||||
open: "Open chat history attachment",
|
||||
remove: "Remove chat history attachment",
|
||||
openSource: "Open source chat",
|
||||
description: "Context copied when this chat was forked.",
|
||||
},
|
||||
attachments: {
|
||||
dismissImage: "Dismiss image",
|
||||
closeImage: "Close image",
|
||||
@@ -1252,7 +1244,6 @@ export const en = {
|
||||
hosts: "Hosts",
|
||||
settings: "Settings",
|
||||
closeSidebar: "Close sidebar",
|
||||
moreActions: "More actions",
|
||||
},
|
||||
footer: {
|
||||
usage: "Usage",
|
||||
|
||||
@@ -347,14 +347,6 @@ export const es: TranslationResources = {
|
||||
openFile: "Abrir archivo",
|
||||
copied: "Copiado",
|
||||
},
|
||||
chatHistory: {
|
||||
title: "Historial del chat",
|
||||
previousConversation: "Conversación anterior",
|
||||
open: "Abrir el historial adjunto",
|
||||
remove: "Quitar el historial adjunto",
|
||||
openSource: "Abrir el chat de origen",
|
||||
description: "Contexto copiado al bifurcar este chat.",
|
||||
},
|
||||
attachments: {
|
||||
dismissImage: "Descartar imagen",
|
||||
closeImage: "Cerrar imagen",
|
||||
@@ -1280,7 +1272,6 @@ export const es: TranslationResources = {
|
||||
hosts: "Hosts",
|
||||
settings: "Ajustes",
|
||||
closeSidebar: "Cerrar barra lateral",
|
||||
moreActions: "Más acciones",
|
||||
},
|
||||
footer: {
|
||||
usage: "Uso",
|
||||
|
||||
@@ -347,14 +347,6 @@ export const fr: TranslationResources = {
|
||||
openFile: "Ouvrir le fichier",
|
||||
copied: "Copié",
|
||||
},
|
||||
chatHistory: {
|
||||
title: "Historique du chat",
|
||||
previousConversation: "Conversation précédente",
|
||||
open: "Ouvrir l’historique joint",
|
||||
remove: "Retirer l’historique joint",
|
||||
openSource: "Ouvrir le chat d’origine",
|
||||
description: "Contexte copié lors de la bifurcation de ce chat.",
|
||||
},
|
||||
attachments: {
|
||||
dismissImage: "Ignorer l’image",
|
||||
closeImage: "Fermer l’image",
|
||||
@@ -1274,7 +1266,6 @@ export const fr: TranslationResources = {
|
||||
hosts: "Hôtes",
|
||||
settings: "Paramètres",
|
||||
closeSidebar: "Fermer la barre latérale",
|
||||
moreActions: "Plus d’actions",
|
||||
},
|
||||
footer: {
|
||||
usage: "Utilisation",
|
||||
|
||||
@@ -347,14 +347,6 @@ export const ja: TranslationResources = {
|
||||
openFile: "ファイルを開く",
|
||||
copied: "コピーしました",
|
||||
},
|
||||
chatHistory: {
|
||||
title: "チャット履歴",
|
||||
previousConversation: "以前の会話",
|
||||
open: "チャット履歴の添付を開く",
|
||||
remove: "チャット履歴の添付を削除",
|
||||
openSource: "元のチャットを開く",
|
||||
description: "このチャットを分岐した時点でコピーされたコンテキストです。",
|
||||
},
|
||||
attachments: {
|
||||
dismissImage: "画像を閉じる",
|
||||
closeImage: "画像を閉じる",
|
||||
@@ -1258,7 +1250,6 @@ export const ja: TranslationResources = {
|
||||
hosts: "ホスト",
|
||||
settings: "設定",
|
||||
closeSidebar: "サイドバーを閉じる",
|
||||
moreActions: "その他のアクション",
|
||||
},
|
||||
footer: {
|
||||
usage: "使用状況",
|
||||
|
||||
@@ -343,14 +343,6 @@ export const ko: TranslationResources = {
|
||||
openFile: "파일 열기",
|
||||
copied: "복사됨",
|
||||
},
|
||||
chatHistory: {
|
||||
title: "채팅 기록",
|
||||
previousConversation: "이전 대화",
|
||||
open: "채팅 기록 첨부 열기",
|
||||
remove: "채팅 기록 첨부 제거",
|
||||
openSource: "원본 채팅 열기",
|
||||
description: "이 채팅을 분기할 때 복사된 컨텍스트입니다.",
|
||||
},
|
||||
attachments: {
|
||||
dismissImage: "이미지 닫기",
|
||||
closeImage: "이미지 닫기",
|
||||
@@ -1251,7 +1243,6 @@ export const ko: TranslationResources = {
|
||||
hosts: "호스트",
|
||||
settings: "설정",
|
||||
closeSidebar: "사이드바 닫기",
|
||||
moreActions: "추가 작업",
|
||||
},
|
||||
footer: {
|
||||
usage: "사용량",
|
||||
|
||||
@@ -346,14 +346,6 @@ export const ptBR: TranslationResources = {
|
||||
openFile: "Abrir arquivo",
|
||||
copied: "Copiado",
|
||||
},
|
||||
chatHistory: {
|
||||
title: "Histórico do chat",
|
||||
previousConversation: "Conversa anterior",
|
||||
open: "Abrir histórico anexado",
|
||||
remove: "Remover histórico anexado",
|
||||
openSource: "Abrir chat de origem",
|
||||
description: "Contexto copiado ao ramificar este chat.",
|
||||
},
|
||||
attachments: {
|
||||
dismissImage: "Dispensar imagem",
|
||||
closeImage: "Fechar imagem",
|
||||
@@ -1270,7 +1262,6 @@ export const ptBR: TranslationResources = {
|
||||
hosts: "Hosts",
|
||||
settings: "Configurações",
|
||||
closeSidebar: "Fechar barra lateral",
|
||||
moreActions: "Mais ações",
|
||||
},
|
||||
footer: {
|
||||
usage: "Uso",
|
||||
|
||||
@@ -346,14 +346,6 @@ export const ru: TranslationResources = {
|
||||
openFile: "Открыть файл",
|
||||
copied: "Скопировано",
|
||||
},
|
||||
chatHistory: {
|
||||
title: "История чата",
|
||||
previousConversation: "Предыдущий разговор",
|
||||
open: "Открыть вложенную историю чата",
|
||||
remove: "Удалить вложенную историю чата",
|
||||
openSource: "Открыть исходный чат",
|
||||
description: "Контекст, скопированный при создании этой ветки чата.",
|
||||
},
|
||||
attachments: {
|
||||
dismissImage: "Закрыть изображение",
|
||||
closeImage: "Закрыть изображение",
|
||||
@@ -1262,7 +1254,6 @@ export const ru: TranslationResources = {
|
||||
hosts: "Хосты",
|
||||
settings: "Настройки",
|
||||
closeSidebar: "Закрыть боковую панель",
|
||||
moreActions: "Дополнительные действия",
|
||||
},
|
||||
footer: {
|
||||
usage: "Использование",
|
||||
|
||||
@@ -342,14 +342,6 @@ export const zhCN: TranslationResources = {
|
||||
openFile: "打开文件",
|
||||
copied: "已复制",
|
||||
},
|
||||
chatHistory: {
|
||||
title: "聊天历史",
|
||||
previousConversation: "之前的对话",
|
||||
open: "打开聊天历史附件",
|
||||
remove: "移除聊天历史附件",
|
||||
openSource: "打开来源对话",
|
||||
description: "创建此分叉时复制的上下文。",
|
||||
},
|
||||
attachments: {
|
||||
dismissImage: "关闭图片",
|
||||
closeImage: "关闭图片",
|
||||
@@ -1236,7 +1228,6 @@ export const zhCN: TranslationResources = {
|
||||
hosts: "Hosts",
|
||||
settings: "设置",
|
||||
closeSidebar: "关闭侧边栏",
|
||||
moreActions: "更多操作",
|
||||
},
|
||||
footer: {
|
||||
usage: "使用情况",
|
||||
|
||||
@@ -43,10 +43,7 @@ import { RewindComposerRestoreProvider } from "@/components/rewind/composer-rest
|
||||
import { useAgentDescriptor } from "@/agent-presentation";
|
||||
import { useToastHost, type ToastApi, type ToastState } from "@/components/toast-host";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import {
|
||||
useWorkspaceAttachmentScopeKey,
|
||||
useAgentAttachmentScopeKey,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
import { COMPACT_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
||||
@@ -1115,7 +1112,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
const parentAgentId = useParentAgentId(serverId, agentId);
|
||||
// With a composer the parent is a pill above it; without one, the box takes the composer's place.
|
||||
const parentPillAgentId = showsComposer ? parentAgentId : null;
|
||||
const rewindAttachmentScopeKey = useAgentAttachmentScopeKey({ serverId, agentId });
|
||||
const rawAgentInputDraft = useAgentInputDraft({
|
||||
draftKey: buildDraftStoreKey({
|
||||
serverId,
|
||||
@@ -1249,7 +1245,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
|
||||
const content = (
|
||||
<RewindComposerRestoreProvider
|
||||
attachmentScopeKey={rewindAttachmentScopeKey}
|
||||
textSource={agentInputDraft.textSource}
|
||||
setText={agentInputDraft.replaceText}
|
||||
onRewindComplete={handleRewindComplete}
|
||||
@@ -1546,10 +1541,9 @@ function ActiveAgentComposer({
|
||||
cwd,
|
||||
workspaceId,
|
||||
});
|
||||
const agentAttachmentScopeKey = useAgentAttachmentScopeKey({ serverId, agentId });
|
||||
const attachmentScopeKeys = useMemo(
|
||||
() => [workspaceAttachmentScopeKey, agentAttachmentScopeKey],
|
||||
[workspaceAttachmentScopeKey, agentAttachmentScopeKey],
|
||||
() => [workspaceAttachmentScopeKey],
|
||||
[workspaceAttachmentScopeKey],
|
||||
);
|
||||
const handleOpenWorkspaceAttachment = useCallback(
|
||||
(attachment: WorkspaceComposerAttachment) => {
|
||||
|
||||
@@ -321,26 +321,6 @@ describe("ReplicaCache", () => {
|
||||
expect(restored?.range).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves workspace parentage and its checkpoint across cache reopen", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
const baseline = directory({ workspaces: { generation: "g", afterSeq: 8 } });
|
||||
const workspace = normalizeWorkspaceDescriptor({
|
||||
...workspacePayload(),
|
||||
parentWorkspaceId: "parent",
|
||||
});
|
||||
baseline.workspaces.set(workspace.id, workspace);
|
||||
commitDirectory(writer, SERVER_ID, baseline);
|
||||
await writer.flush();
|
||||
const reader = createCache(storage);
|
||||
const restored = await reader.readDirectory(SERVER_ID);
|
||||
expect(restored.checkpoint).toEqual(baseline.checkpoint);
|
||||
expect(restored.workspaces.get(workspace.id)?.parentWorkspaceId).toBe("parent");
|
||||
expect((await reader.readWorkspace(SERVER_ID, workspace.id))?.workspace.parentWorkspaceId).toBe(
|
||||
"parent",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves workspace sorting timestamps across cache reopen", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
|
||||
@@ -299,7 +299,6 @@ const WorkspaceGitRuntimeSchema = z
|
||||
.optional();
|
||||
|
||||
const StoredWorkspaceSchema = z.strictObject({
|
||||
parentWorkspaceId: z.string().optional(),
|
||||
// Require both keys: a cursor without these values would preserve the wrong sort on reconnect.
|
||||
createdAt: IsoDateSchema.nullable(),
|
||||
lastUserMessageAt: IsoDateSchema.nullable(),
|
||||
@@ -708,7 +707,6 @@ function restoreWorkspace(stored: StoredWorkspace): WorkspaceDescriptor {
|
||||
|
||||
function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace {
|
||||
return {
|
||||
parentWorkspaceId: workspace.parentWorkspaceId,
|
||||
createdAt: workspace.createdAt?.toISOString() ?? null,
|
||||
lastUserMessageAt: workspace.lastUserMessageAt?.toISOString() ?? null,
|
||||
id: workspace.id,
|
||||
|
||||
@@ -16,6 +16,8 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
|
||||
import type { SplitPane } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { TOUCH_TARGET } from "@/components/ui/control-sizing";
|
||||
import { useTopBandRow } from "@/window-layout";
|
||||
|
||||
interface ExplorerSidebarDockProps extends Pick<
|
||||
WorkspaceDesktopTabsRowProps,
|
||||
@@ -117,48 +119,61 @@ export function ExplorerSidebarDock({
|
||||
[onCloseOtherTabs, tabs],
|
||||
);
|
||||
|
||||
const topBand = useTopBandRow({
|
||||
offset: 0,
|
||||
height: TOUCH_TARGET,
|
||||
// Trimming the rail beside the status region truncates its tab labels; start below instead.
|
||||
minimumWidth: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
const railBandStyle = useMemo(
|
||||
() => ({ paddingTop: topBand.top, paddingRight: topBand.trailing }),
|
||||
[topBand.top, topBand.trailing],
|
||||
);
|
||||
|
||||
return (
|
||||
<RetainedPanel active>
|
||||
<WindowChromeRegion corners="top-right">
|
||||
<View style={styles.dock} testID="workspace-explorer-sidebar">
|
||||
<WindowChromeSafeArea placement="inline" style={styles.tabRail}>
|
||||
<TitlebarDragRegion />
|
||||
<View style={styles.tabRow}>
|
||||
<WorkspaceDesktopTabsRow
|
||||
host="explorer"
|
||||
launchPurpose="supporting"
|
||||
isFocused={isWorkspaceFocused}
|
||||
ownsKeyboardShortcuts={false}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyTerminalId={onCopyTerminalId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
focusModeEnabled={false}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
externalDndContext={externalDndContext}
|
||||
paneId={pane.id}
|
||||
tabs={tabItems}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
activeDragTabId={activeDragTabId}
|
||||
tabDropPreviewIndex={
|
||||
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
|
||||
}
|
||||
onNavigateTab={handleSelectTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
onReorderTabs={handleReorderTabs}
|
||||
/>
|
||||
</View>
|
||||
{headerAction ? <View style={styles.headerAction}>{headerAction}</View> : null}
|
||||
<View pointerEvents="none" style={styles.tabRailDivider} />
|
||||
</WindowChromeSafeArea>
|
||||
<View ref={topBand.ref} onLayout={topBand.onLayout} style={railBandStyle}>
|
||||
<WindowChromeSafeArea placement="inline" style={styles.tabRail}>
|
||||
<TitlebarDragRegion />
|
||||
<View style={styles.tabRow}>
|
||||
<WorkspaceDesktopTabsRow
|
||||
host="explorer"
|
||||
launchPurpose="supporting"
|
||||
isFocused={isWorkspaceFocused}
|
||||
ownsKeyboardShortcuts={false}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyTerminalId={onCopyTerminalId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
focusModeEnabled={false}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
externalDndContext={externalDndContext}
|
||||
paneId={pane.id}
|
||||
tabs={tabItems}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
activeDragTabId={activeDragTabId}
|
||||
tabDropPreviewIndex={
|
||||
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
|
||||
}
|
||||
onNavigateTab={handleSelectTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
onReorderTabs={handleReorderTabs}
|
||||
/>
|
||||
</View>
|
||||
{headerAction ? <View style={styles.headerAction}>{headerAction}</View> : null}
|
||||
<View pointerEvents="none" style={styles.tabRailDivider} />
|
||||
</WindowChromeSafeArea>
|
||||
</View>
|
||||
<View style={styles.content}>
|
||||
<WorkspacePanelHost
|
||||
paneId={pane.id}
|
||||
|
||||
@@ -557,33 +557,6 @@ describe("normalizeWorkspaceDescriptor", () => {
|
||||
expect(blank.workspaceDirectory).toBe("");
|
||||
});
|
||||
|
||||
it("preserves informational workspace parentage", () => {
|
||||
const payload = {
|
||||
id: "child",
|
||||
parentWorkspaceId: "parent",
|
||||
projectId: "1",
|
||||
projectDisplayName: "Project 1",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
name: "main",
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
} as WorkspaceDescriptorPayload;
|
||||
|
||||
const workspace = normalizeWorkspaceDescriptor(payload);
|
||||
|
||||
expect(workspace.parentWorkspaceId).toBe("parent");
|
||||
expect(
|
||||
normalizeWorkspaceDescriptor({ ...payload, parentWorkspaceId: undefined }).parentWorkspaceId,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("defaults missing scripts to an empty array", () => {
|
||||
const payload = {
|
||||
id: "1",
|
||||
|
||||
@@ -105,7 +105,6 @@ export interface Agent {
|
||||
}
|
||||
|
||||
export interface WorkspaceDescriptor {
|
||||
parentWorkspaceId?: string;
|
||||
createdAt: Date | null;
|
||||
lastUserMessageAt: Date | null;
|
||||
id: string;
|
||||
@@ -163,7 +162,6 @@ export function normalizeWorkspaceDescriptor(
|
||||
projectKind: payload.projectKind,
|
||||
workspaceKind: payload.workspaceKind,
|
||||
background: payload.background ?? false,
|
||||
parentWorkspaceId: payload.parentWorkspaceId,
|
||||
name: payload.name,
|
||||
title: payload.title ?? null,
|
||||
pinnedAt: payload.pinnedAt ?? null,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
hydrateStreamState,
|
||||
mergeToolCallDetail,
|
||||
reduceStreamUpdate,
|
||||
replaceWithCanonicalStream,
|
||||
streamTimelineItemIdentity,
|
||||
type AgentToolCallItem,
|
||||
type StreamItem,
|
||||
@@ -159,104 +158,6 @@ describe("plugin timeline rows", () => {
|
||||
});
|
||||
|
||||
describe("user message identity", () => {
|
||||
it.each(["", "Continue here"])(
|
||||
"restores fork history onto a cached user message (%s)",
|
||||
(text) => {
|
||||
const cached = createUserMessage({
|
||||
clientMessageId: "fork-client",
|
||||
messageId: "fork-provider",
|
||||
text,
|
||||
timestamp: new Date(0),
|
||||
timelineCursor: { epoch: "timeline", seq: 1 },
|
||||
});
|
||||
const history = {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
text: "[User] Earlier question\n[Assistant] Earlier answer",
|
||||
} as const;
|
||||
const canonical = createUserMessage({
|
||||
...cached,
|
||||
attachments: [history],
|
||||
});
|
||||
const result = replaceWithCanonicalStream({
|
||||
canonical: [canonical],
|
||||
previousTail: [cached],
|
||||
previousHead: [],
|
||||
sendingClientMessageIds: [],
|
||||
preserveContinuity: true,
|
||||
canonicalCoverage: { epoch: "timeline", endSeq: 1 },
|
||||
});
|
||||
expect(result.tail).toHaveLength(1);
|
||||
expect(result.tail[0]).toMatchObject({ text, attachments: [history] });
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["", "Continue from here"])(
|
||||
"hydrates fork history without optimistic state: %s",
|
||||
(text) => {
|
||||
const chatHistory = {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
text: "[User] Earlier question\n[Assistant] Earlier answer",
|
||||
chatHistorySource: { serverId: "source-host", agentId: "source-agent", title: "Original" },
|
||||
} as const;
|
||||
const result = reduceStreamUpdate(
|
||||
[],
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "user_message", text, messageId: "fork-prompt", chatHistory },
|
||||
},
|
||||
new Date(0),
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({ kind: "user_message", text, attachments: [chatHistory] });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ text: "", insert: "tail" },
|
||||
{ text: "Continue", insert: "tail" },
|
||||
{ text: "", insert: "head" },
|
||||
{ text: "Continue", insert: "head" },
|
||||
] as const)("delivers fork history to a live viewer ($insert, $text)", ({ text, insert }) => {
|
||||
const chatHistory = {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
text: "[User] Original question",
|
||||
chatHistorySource: { serverId: "source-host", agentId: "source-agent" },
|
||||
} as const;
|
||||
const event = {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "user_message", text, messageId: "fork-message", chatHistory },
|
||||
} as const;
|
||||
const first = applyStreamEvent({
|
||||
tail: [],
|
||||
head: [],
|
||||
event,
|
||||
timestamp: new Date(0),
|
||||
unmatchedUserMessageInsert: insert,
|
||||
});
|
||||
expect(first[insert]).toHaveLength(1);
|
||||
expect(first[insert][0]).toMatchObject({
|
||||
kind: "user_message",
|
||||
text,
|
||||
attachments: [chatHistory],
|
||||
});
|
||||
const repeated = applyStreamEvent({
|
||||
...first,
|
||||
event,
|
||||
timestamp: new Date(0),
|
||||
unmatchedUserMessageInsert: insert,
|
||||
});
|
||||
expect([...repeated.tail, ...repeated.head]).toHaveLength(1);
|
||||
expect([...repeated.tail, ...repeated.head][0]).toMatchObject({ attachments: [chatHistory] });
|
||||
});
|
||||
|
||||
it("replaces provisional optimistic turn membership with canonical membership", () => {
|
||||
const optimistic = createUserMessage({
|
||||
clientMessageId: "hello-client",
|
||||
|
||||
@@ -263,7 +263,6 @@ function produceUserMessage(
|
||||
clientMessageId: incoming.clientMessageId ?? existing.clientMessageId,
|
||||
messageId: incoming.messageId ?? existing.messageId,
|
||||
timelineCursor: incoming.timelineCursor ?? existing.timelineCursor,
|
||||
attachments: presentation.attachments ?? incoming.attachments,
|
||||
});
|
||||
if (
|
||||
existing.id === merged.id &&
|
||||
@@ -892,18 +891,6 @@ export function handoffCreatedAgentUserMessageToStream(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeUserMessageContent(
|
||||
text: string,
|
||||
chatHistory?: Extract<AgentTimelineItem, { type: "user_message" }>["chatHistory"],
|
||||
) {
|
||||
const normalized = normalizeChunk(text);
|
||||
return {
|
||||
...normalized,
|
||||
hasContent: normalized.hasContent || chatHistory !== undefined,
|
||||
attachments: chatHistory ? [chatHistory] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function appendUserMessage(
|
||||
state: StreamItem[],
|
||||
text: string,
|
||||
@@ -913,9 +900,8 @@ function appendUserMessage(
|
||||
clientMessageId?: string,
|
||||
timelineCursor?: TimelinePosition,
|
||||
turnId?: string,
|
||||
chatHistory?: Extract<AgentTimelineItem, { type: "user_message" }>["chatHistory"],
|
||||
): StreamItem[] {
|
||||
const { chunk, hasContent, attachments } = normalizeUserMessageContent(text, chatHistory);
|
||||
const { chunk, hasContent } = normalizeChunk(text);
|
||||
if (!hasContent) {
|
||||
return state;
|
||||
}
|
||||
@@ -928,7 +914,6 @@ function appendUserMessage(
|
||||
timelineCursor,
|
||||
turnId,
|
||||
text: chunk,
|
||||
attachments,
|
||||
timestamp,
|
||||
});
|
||||
return upsertUserMessage(state, nextItem);
|
||||
@@ -1538,7 +1523,6 @@ function reduceTimelineEvent(
|
||||
item.clientMessageId,
|
||||
timelineCursor,
|
||||
event.turnId,
|
||||
item.chatHistory,
|
||||
);
|
||||
case "assistant_message":
|
||||
return appendAssistantMessage(
|
||||
@@ -1890,7 +1874,7 @@ function applyCanonicalUserMessageEvent(params: {
|
||||
}): ApplyStreamEventResult | null {
|
||||
const { tail, head, event, timestamp, timelineCursor, unmatchedInsert = "tail" } = params;
|
||||
if (event.type !== "timeline" || event.item.type !== "user_message") return null;
|
||||
const normalized = normalizeUserMessageContent(event.item.text, event.item.chatHistory);
|
||||
const normalized = normalizeChunk(event.item.text);
|
||||
|
||||
const flushedTail = head.length > 0 ? flushHeadToTail(tail, head) : tail;
|
||||
const flushedHead = head.length > 0 ? [] : head;
|
||||
@@ -1903,7 +1887,6 @@ function applyCanonicalUserMessageEvent(params: {
|
||||
turnId: event.turnId,
|
||||
timelineCursor,
|
||||
text: normalized.chunk,
|
||||
attachments: normalized.attachments,
|
||||
timestamp,
|
||||
});
|
||||
if (unmatchedInsert === "head") {
|
||||
|
||||
@@ -33,10 +33,9 @@ import {
|
||||
} from "./model";
|
||||
import type { UsageReportEntry, UsageView } from "./types";
|
||||
|
||||
// The daemon caches each report for five minutes, so re-reading it is cheap.
|
||||
// An explicit refresh passes `forceRefresh` to bypass that cache.
|
||||
// The daemon caches each report for five minutes, so re-reading it is cheap. Only
|
||||
// an explicit refresh passes `forceRefresh` and reaches the source's API.
|
||||
const REPORTS_STALE_TIME_MS = 60_000;
|
||||
const REPORTS_REFETCH_INTERVAL_MS = 5 * 60_000;
|
||||
|
||||
/** Every report list of a host: its own and each agent's. */
|
||||
function hostUsageQueryKey(serverId: string) {
|
||||
@@ -165,16 +164,11 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () =
|
||||
|
||||
const NO_REPORTS: UsageReportEntry[] = [];
|
||||
|
||||
interface UsageHostReportsInput {
|
||||
serverId: string | null;
|
||||
shouldPoll?: boolean;
|
||||
}
|
||||
|
||||
/** A host's reports, with periodic refresh enabled only by the visible sidebar summary. */
|
||||
export function useUsageHostReports({
|
||||
serverId,
|
||||
shouldPoll = false,
|
||||
}: UsageHostReportsInput): UsageReportEntry[] {
|
||||
/**
|
||||
* The reports of the sidebar's usage host, which is connected and reports usage; none until they
|
||||
* load, or without a host.
|
||||
*/
|
||||
export function useUsageHostReports(serverId: string | null): UsageReportEntry[] {
|
||||
const setting = useUsageSetting(serverId);
|
||||
const queryClient = useQueryClient();
|
||||
const query = useFetchQuery({
|
||||
@@ -183,7 +177,6 @@ export function useUsageHostReports({
|
||||
queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken,
|
||||
dataShape: "list",
|
||||
staleTimeMs: REPORTS_STALE_TIME_MS,
|
||||
refetchInterval: shouldPoll ? REPORTS_REFETCH_INTERVAL_MS : false,
|
||||
});
|
||||
return setting.enabled ? (query.data ?? NO_REPORTS) : NO_REPORTS;
|
||||
}
|
||||
|
||||
@@ -16,8 +16,6 @@ import {
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useAppVisible } from "@/hooks/use-app-visible";
|
||||
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
|
||||
import { useUsageDisplay } from "./display";
|
||||
import { useUsageInSidebar } from "./in-sidebar";
|
||||
@@ -38,14 +36,9 @@ import { UsageModal } from "./usage-modal";
|
||||
/** Each summary window with data on the usage host, under its source; empty while none has. */
|
||||
export function useUsageSummary(): readonly PinnedUsageSource[] {
|
||||
const { t } = useTranslation();
|
||||
const active = useRetainedPanelActive();
|
||||
const isAppVisible = useAppVisible();
|
||||
const { inSidebar } = useUsageInSidebar();
|
||||
const serverId = useUsageHostId();
|
||||
const reports = useUsageHostReports({
|
||||
serverId: inSidebar ? serverId : null,
|
||||
shouldPoll: active && inSidebar && isAppVisible,
|
||||
});
|
||||
const reports = useUsageHostReports(inSidebar ? serverId : null);
|
||||
const { preferences } = useUsageDisplay(reports);
|
||||
return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { useWindowDimensions } from "react-native";
|
||||
import type { WindowLayout } from "./model";
|
||||
import type { TopBand, WindowLayout } from "./model";
|
||||
|
||||
export const WindowLayoutContext = createContext<WindowLayout | null>(null);
|
||||
|
||||
/** Published by `WindowLayoutProvider` beside the safe-area insets it derives from it. */
|
||||
export const TopBandContext = createContext<TopBand | null>(null);
|
||||
|
||||
export function useWindowLayout(): WindowLayout | null {
|
||||
const layout = useContext(WindowLayoutContext);
|
||||
const { width, height } = useWindowDimensions();
|
||||
|
||||
@@ -2,12 +2,13 @@ import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { StyleSheet, View, type NativeSyntheticEvent, type ViewProps } from "react-native";
|
||||
import { requireNativeViewManager } from "expo-modules-core";
|
||||
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { WindowLayoutContext, useWindowLayout } from "./context";
|
||||
import { resolveSafeAreaInsets, type WindowLayout } from "./model";
|
||||
import { TopBandContext, WindowLayoutContext, useWindowLayout } from "./context";
|
||||
import { resolveSafeAreaInsets, resolveTopBand, type WindowLayout } from "./model";
|
||||
|
||||
export { useWindowLayout } from "./context";
|
||||
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
|
||||
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
|
||||
export { useTopBandRow, type TopBandRow } from "./top-band";
|
||||
export {
|
||||
resolveDesktopDivisionLayout,
|
||||
resolveWorkspaceActionPlacement,
|
||||
@@ -41,12 +42,23 @@ export function WindowLayoutProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The one bottom inset every descendant reads through `useSafeAreaInsets()`. */
|
||||
/**
|
||||
* The one top and bottom inset every descendant reads through `useSafeAreaInsets()`, and
|
||||
* the top band whose occlusions top-edge rows clear with `useTopBandRow()`.
|
||||
*/
|
||||
function WindowSafeAreaInsets({ children }: { children: ReactNode }) {
|
||||
const systemInsets = useSafeAreaInsets();
|
||||
const layout = useWindowLayout();
|
||||
const insets = useMemo(() => resolveSafeAreaInsets(systemInsets, layout), [systemInsets, layout]);
|
||||
return <SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>;
|
||||
const topBand = useMemo(
|
||||
() => resolveTopBand(systemInsets.top, layout),
|
||||
[systemInsets.top, layout],
|
||||
);
|
||||
return (
|
||||
<TopBandContext.Provider value={topBand}>
|
||||
<SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>
|
||||
</TopBandContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({ root: { flex: 1 } });
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
|
||||
export { useWindowLayout } from "./context";
|
||||
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
|
||||
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
|
||||
export { useTopBandRow, type TopBandRow } from "./top-band";
|
||||
export {
|
||||
resolveDesktopDivisionLayout,
|
||||
resolveWorkspaceActionPlacement,
|
||||
|
||||
@@ -1,35 +1,202 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSafeAreaInsets, type WindowLayout } from "./model";
|
||||
import {
|
||||
resolveSafeAreaInsets,
|
||||
resolveTopBand,
|
||||
resolveTopBandClearance,
|
||||
type WindowLayout,
|
||||
type WindowLayoutRect,
|
||||
} from "./model";
|
||||
|
||||
const systemInsets = { top: 59, right: 0, bottom: 34, left: 0 };
|
||||
|
||||
function layout(bottomClearance: number | null | undefined): WindowLayout {
|
||||
function layout(input: Partial<WindowLayout> = {}): WindowLayout {
|
||||
return {
|
||||
bounds: { x: 0, y: 0, width: 402, height: 874 },
|
||||
verticalBarEdge: "none",
|
||||
verticalBar: null,
|
||||
bottomClearance,
|
||||
divisions: [],
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveSafeAreaInsets", () => {
|
||||
it("replaces only the bottom with the measured bar clearance", () => {
|
||||
expect(resolveSafeAreaInsets(systemInsets, layout(24))).toEqual({
|
||||
expect(resolveSafeAreaInsets(systemInsets, layout({ bottomClearance: 24 }))).toEqual({
|
||||
...systemInsets,
|
||||
bottom: 24,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a measured zero instead of falling back to the system inset", () => {
|
||||
expect(resolveSafeAreaInsets(systemInsets, layout(0)).bottom).toBe(0);
|
||||
expect(resolveSafeAreaInsets(systemInsets, layout({ bottomClearance: 0 })).bottom).toBe(0);
|
||||
});
|
||||
|
||||
it.each([null, undefined])("leaves system insets alone without a clearance (%s)", (value) => {
|
||||
expect(resolveSafeAreaInsets(systemInsets, layout(value))).toBe(systemInsets);
|
||||
expect(resolveSafeAreaInsets(systemInsets, layout({ bottomClearance: value }))).toBe(
|
||||
systemInsets,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves system insets alone before the native layout arrives", () => {
|
||||
expect(resolveSafeAreaInsets(systemInsets, null)).toBe(systemInsets);
|
||||
});
|
||||
|
||||
it("starts the top where the top bar rests when it sits above the system top", () => {
|
||||
const insets = resolveSafeAreaInsets(
|
||||
systemInsets,
|
||||
layout({ topBar: { x: 0, y: 12, width: 402, height: 44 } }),
|
||||
);
|
||||
expect(insets).toEqual({ ...systemInsets, top: 12 });
|
||||
});
|
||||
|
||||
it("keeps the system top when the top bar rests at or below it", () => {
|
||||
const insets = resolveSafeAreaInsets(
|
||||
systemInsets,
|
||||
layout({ topBar: { x: 0, y: 59, width: 402, height: 44 } }),
|
||||
);
|
||||
expect(insets).toBe(systemInsets);
|
||||
});
|
||||
});
|
||||
|
||||
// Measured: iPhone Duo inner screen, portrait (669×951), iOS 27.1 simulator unfolded in
|
||||
// DeviceHub. The Live Activity width (202pt) is still an estimate from #7924's ActivityKit QA.
|
||||
const duo = {
|
||||
systemTop: 82,
|
||||
topBar: { x: 16, y: 26, width: 509, height: 44 },
|
||||
statusRegion: { x: 525, y: 0, width: 144, height: 82 },
|
||||
liveActivityRegion: { x: 467, y: 0, width: 202, height: 82 },
|
||||
};
|
||||
|
||||
function duoLayout(occlusions: WindowLayoutRect[]): WindowLayout {
|
||||
return layout({
|
||||
bounds: { x: 0, y: 0, width: 669, height: 951 },
|
||||
topBar: duo.topBar,
|
||||
occlusions,
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveTopBand", () => {
|
||||
it("publishes the top bar's start and the occlusions when the band is in use", () => {
|
||||
expect(resolveTopBand(duo.systemTop, duoLayout([duo.statusRegion]))).toEqual({
|
||||
top: 26,
|
||||
occlusions: [duo.statusRegion],
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores occlusions without a top bar guide (before iOS 27.1 or unresolved)", () => {
|
||||
const withoutGuide = layout({ topBar: null, occlusions: [duo.statusRegion] });
|
||||
expect(resolveTopBand(duo.systemTop, withoutGuide)).toBeNull();
|
||||
expect(resolveTopBand(duo.systemTop, layout({ occlusions: [duo.statusRegion] }))).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores occlusions when the top bar guide sits below the status region", () => {
|
||||
const below = layout({
|
||||
topBar: { x: 16, y: 82, width: 637, height: 44 },
|
||||
occlusions: [duo.statusRegion],
|
||||
});
|
||||
expect(resolveTopBand(duo.systemTop, below)).toBeNull();
|
||||
});
|
||||
|
||||
// Measured: iPhone 17 Pro, iOS 27.2 simulator, portrait.
|
||||
it("is null on a regular iPhone, whose top bar starts at the status bar's bottom", () => {
|
||||
const iphone = layout({
|
||||
topBar: { x: 18, y: 62, width: 366, height: 44 },
|
||||
occlusions: [{ x: 0, y: 0, width: 402, height: 54 }],
|
||||
});
|
||||
expect(resolveTopBand(62, iphone)).toBeNull();
|
||||
});
|
||||
|
||||
// Measured: iPhone Duo outer screen, iOS 27.1 simulator. The status region is a trailing
|
||||
// column, which the vertical bar layout already owns.
|
||||
it("is null on the Duo outer screen, whose top bar starts below a zero system top", () => {
|
||||
const outer = layout({
|
||||
bounds: { x: 0, y: 0, width: 466, height: 678 },
|
||||
verticalBarEdge: "trailing",
|
||||
topBar: { x: 7 / 3, y: 26, width: 1139 / 3, height: 44 },
|
||||
occlusions: [
|
||||
{ x: 1199 / 3, y: 88 / 3, width: 37, height: 37 },
|
||||
{ x: 382, y: 0, width: 84, height: 174 },
|
||||
],
|
||||
});
|
||||
expect(resolveTopBand(0, outer)).toBeNull();
|
||||
});
|
||||
|
||||
it("is null before the native layout arrives", () => {
|
||||
expect(resolveTopBand(duo.systemTop, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTopBandClearance", () => {
|
||||
// The full workspace header with the sidebar closed spans the window.
|
||||
const fullHeader = { x: 0, y: duo.topBar.y, width: 669, height: 44 };
|
||||
// A row beside a 320pt sidebar whose controls need more width than the status region leaves.
|
||||
const narrowRow = { x: 320, y: duo.topBar.y + 8, width: 349, height: 56 };
|
||||
const narrowMinimum = 300;
|
||||
|
||||
it("trims the trailing side to the status region", () => {
|
||||
expect(
|
||||
resolveTopBandClearance({
|
||||
row: fullHeader,
|
||||
occlusions: [duo.statusRegion],
|
||||
minimumWidth: 300,
|
||||
}),
|
||||
).toEqual({ trailing: 144, top: 0 });
|
||||
});
|
||||
|
||||
it("trims further when a Live Activity widens the status region", () => {
|
||||
expect(
|
||||
resolveTopBandClearance({
|
||||
row: fullHeader,
|
||||
occlusions: [duo.liveActivityRegion],
|
||||
minimumWidth: 300,
|
||||
}),
|
||||
).toEqual({ trailing: 202, top: 0 });
|
||||
});
|
||||
|
||||
it("starts below the status region when the controls no longer fit beside it", () => {
|
||||
expect(
|
||||
resolveTopBandClearance({
|
||||
row: narrowRow,
|
||||
occlusions: [duo.statusRegion],
|
||||
minimumWidth: narrowMinimum,
|
||||
}),
|
||||
).toEqual({ trailing: 0, top: 48 });
|
||||
});
|
||||
|
||||
it("starts below the lowest of several overlapping occlusions", () => {
|
||||
expect(
|
||||
resolveTopBandClearance({
|
||||
row: narrowRow,
|
||||
occlusions: [duo.statusRegion, { x: 600, y: 0, width: 69, height: 96 }],
|
||||
minimumWidth: narrowMinimum,
|
||||
}),
|
||||
).toEqual({ trailing: 0, top: 62 });
|
||||
});
|
||||
|
||||
it("leaves rows alone that do not overlap an occlusion", () => {
|
||||
const sidebarRow = { x: 0, y: duo.topBar.y + 8, width: 320, height: 44 };
|
||||
const rowBelow = { x: 0, y: 82, width: 669, height: 44 };
|
||||
for (const row of [sidebarRow, rowBelow]) {
|
||||
expect(
|
||||
resolveTopBandClearance({ row, occlusions: [duo.statusRegion], minimumWidth: 0 }),
|
||||
).toEqual({ trailing: 0, top: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
it("rounds a fractional clearance up so controls never touch the occlusion", () => {
|
||||
expect(
|
||||
resolveTopBandClearance({
|
||||
row: { x: 0, y: 10, width: 669.5, height: 44 },
|
||||
occlusions: [{ x: 525.25, y: 0, width: 144, height: 60.5 }],
|
||||
minimumWidth: Number.POSITIVE_INFINITY,
|
||||
}),
|
||||
).toEqual({ trailing: 0, top: 51 });
|
||||
expect(
|
||||
resolveTopBandClearance({
|
||||
row: { x: 0, y: 10, width: 669.5, height: 44 },
|
||||
occlusions: [{ x: 525.25, y: 0, width: 144, height: 60.5 }],
|
||||
minimumWidth: 0,
|
||||
}).trailing,
|
||||
).toBe(145);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,18 +14,82 @@ export interface WindowLayout {
|
||||
* the system has no bottom bar guide. Zero is a measured value.
|
||||
*/
|
||||
bottomClearance?: number | null;
|
||||
/** Where a top bar rests, or null until the system resolves its top bar guide. */
|
||||
topBar?: WindowLayoutRect | null;
|
||||
/** Active system occlusions (status region, camera, Live Activities), margins included. */
|
||||
occlusions?: WindowLayoutRect[];
|
||||
divisions: WindowLayoutRect[];
|
||||
}
|
||||
|
||||
/** The window's measured bottom clearance replaces the rectangular safe-area bottom. */
|
||||
export function resolveSafeAreaInsets<Insets extends { bottom: number }>(
|
||||
/**
|
||||
* The band above the rectangular safe area that a top bar may use because the system
|
||||
* occupies only part of it. Content outside a top-edge row still keeps out of `occlusions`.
|
||||
*/
|
||||
export interface TopBand {
|
||||
top: number;
|
||||
occlusions: readonly WindowLayoutRect[];
|
||||
}
|
||||
|
||||
/** Null unless the top bar guide starts above the system's rectangular safe area. */
|
||||
export function resolveTopBand(systemTop: number, layout: WindowLayout | null): TopBand | null {
|
||||
const bar = layout?.topBar;
|
||||
if (!layout || !bar || bar.y >= systemTop) return null;
|
||||
return { top: Math.max(0, bar.y), occlusions: layout.occlusions ?? [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The window's measured bars replace the rectangular safe area: the bottom bar's clearance,
|
||||
* and the top bar's start when it sits above the system top.
|
||||
*/
|
||||
export function resolveSafeAreaInsets<Insets extends { top: number; bottom: number }>(
|
||||
systemInsets: Insets,
|
||||
layout: WindowLayout | null,
|
||||
): Insets {
|
||||
const clearance = layout?.bottomClearance;
|
||||
return typeof clearance === "number" && clearance !== systemInsets.bottom
|
||||
? { ...systemInsets, bottom: clearance }
|
||||
: systemInsets;
|
||||
const bottom = typeof clearance === "number" ? clearance : systemInsets.bottom;
|
||||
const top = resolveTopBand(systemInsets.top, layout)?.top ?? systemInsets.top;
|
||||
return top === systemInsets.top && bottom === systemInsets.bottom
|
||||
? systemInsets
|
||||
: { ...systemInsets, top, bottom };
|
||||
}
|
||||
|
||||
export interface TopBandClearance {
|
||||
/** Points the row's trailing controls keep from its trailing edge. */
|
||||
trailing: number;
|
||||
/** Points the row moves down when its controls cannot fit beside the occlusions. */
|
||||
top: number;
|
||||
}
|
||||
|
||||
export const NO_TOP_BAND_CLEARANCE: TopBandClearance = { trailing: 0, top: 0 };
|
||||
|
||||
/**
|
||||
* How a top-edge row keeps its controls out of the system occlusions it overlaps: trim its
|
||||
* trailing side to the leftmost occlusion, or, when `minimumWidth` no longer fits, start
|
||||
* below the occlusions. `row` is the row's frame without any clearance, in window points.
|
||||
*/
|
||||
export function resolveTopBandClearance(input: {
|
||||
row: WindowLayoutRect;
|
||||
occlusions: readonly WindowLayoutRect[];
|
||||
minimumWidth: number;
|
||||
}): TopBandClearance {
|
||||
const { row, occlusions, minimumWidth } = input;
|
||||
const overlapping = occlusions.filter((frame) => overlaps(frame, row));
|
||||
if (overlapping.length === 0) return NO_TOP_BAND_CLEARANCE;
|
||||
const trailing = Math.ceil(row.x + row.width - Math.min(...overlapping.map((frame) => frame.x)));
|
||||
if (row.width - trailing >= minimumWidth) return { trailing, top: 0 };
|
||||
const bottom = Math.max(...overlapping.map((frame) => frame.y + frame.height));
|
||||
return { trailing: 0, top: Math.ceil(bottom - row.y) };
|
||||
}
|
||||
|
||||
function overlaps(a: WindowLayoutRect, b: WindowLayoutRect): boolean {
|
||||
return (
|
||||
a.width > 0 &&
|
||||
a.height > 0 &&
|
||||
a.x < b.x + b.width &&
|
||||
a.x + a.width > b.x &&
|
||||
a.y < b.y + b.height &&
|
||||
a.y + a.height > b.y
|
||||
);
|
||||
}
|
||||
|
||||
export interface DesktopDivisionLayout {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import type { View } from "react-native";
|
||||
import { TopBandContext } from "./context";
|
||||
import {
|
||||
NO_TOP_BAND_CLEARANCE,
|
||||
resolveTopBandClearance,
|
||||
type TopBandClearance,
|
||||
type WindowLayoutRect,
|
||||
} from "./model";
|
||||
|
||||
export interface TopBandRow extends TopBandClearance {
|
||||
/** Attach to the view whose top edge `offset` is measured from. */
|
||||
ref: RefObject<View | null>;
|
||||
onLayout: (() => void) | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clearance for a row that can land in the window's top band. The measured view's frame
|
||||
* must not depend on the clearance: apply `trailing` and `top` as padding inside it.
|
||||
*
|
||||
* - `offset`: points from the measured view's top to the row, without `top`.
|
||||
* - `height`: the row's height.
|
||||
* - `minimumWidth`: the narrowest row that still fits its controls.
|
||||
* - `settled`: re-measures when it changes, for rows whose panel slides into place.
|
||||
*/
|
||||
export function useTopBandRow(input: {
|
||||
offset: number;
|
||||
height: number;
|
||||
minimumWidth: number;
|
||||
settled?: boolean;
|
||||
}): TopBandRow {
|
||||
const { offset, height, minimumWidth, settled } = input;
|
||||
const band = useContext(TopBandContext);
|
||||
const ref = useRef<View | null>(null);
|
||||
const [frame, setFrame] = useState<WindowLayoutRect | null>(null);
|
||||
const measure = useCallback(() => {
|
||||
ref.current?.measureInWindow((x, y, width, measuredHeight) =>
|
||||
setFrame((previous) =>
|
||||
previous &&
|
||||
previous.x === x &&
|
||||
previous.y === y &&
|
||||
previous.width === width &&
|
||||
previous.height === measuredHeight
|
||||
? previous
|
||||
: { x, y, width, height: measuredHeight },
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
const active = band !== null;
|
||||
useEffect(() => {
|
||||
if (active) measure();
|
||||
}, [active, measure, settled]);
|
||||
const clearance = useMemo(
|
||||
() =>
|
||||
band && frame
|
||||
? resolveTopBandClearance({
|
||||
row: { x: frame.x, y: frame.y + offset, width: frame.width, height },
|
||||
occlusions: band.occlusions,
|
||||
minimumWidth,
|
||||
})
|
||||
: NO_TOP_BAND_CLEARANCE,
|
||||
[band, frame, offset, height, minimumWidth],
|
||||
);
|
||||
return {
|
||||
ref,
|
||||
onLayout: active ? measure : undefined,
|
||||
trailing: clearance.trailing,
|
||||
top: clearance.top,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AgentMessage } from "./agent-message.js";
|
||||
import type { AgentAttachment, ChatHistoryAttachment } from "./messages.js";
|
||||
import type { AgentAttachment } from "./messages.js";
|
||||
|
||||
export type AgentProvider = string;
|
||||
|
||||
@@ -373,13 +373,7 @@ export interface AgentTaskItem {
|
||||
}
|
||||
|
||||
export type AgentTimelineItem =
|
||||
| {
|
||||
type: "user_message";
|
||||
text: string;
|
||||
messageId?: string;
|
||||
clientMessageId?: string;
|
||||
chatHistory?: ChatHistoryAttachment;
|
||||
}
|
||||
| { type: "user_message"; text: string; messageId?: string; clientMessageId?: string }
|
||||
| { type: "assistant_message"; text: string; messageId?: string }
|
||||
| { type: "reasoning"; text: string }
|
||||
| ToolCallTimelineItem
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
/** Remove the fork prompt envelope while preserving the snapshot body verbatim. */
|
||||
export function extractChatHistoryBody(text: string): string {
|
||||
const bodyStart = text.indexOf("\n\n");
|
||||
return text.startsWith("<chat-history-summary>\n") &&
|
||||
text.endsWith("\n</chat-history-summary>") &&
|
||||
bodyStart !== -1
|
||||
? text.slice(bodyStart + 2, -"\n</chat-history-summary>".length)
|
||||
: text;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
AgentTimelineItemPayloadSchema,
|
||||
AgentForkContextRequestMessageSchema,
|
||||
AgentForkContextResponseMessageSchema,
|
||||
CreateAgentRequestMessageSchema,
|
||||
@@ -10,24 +9,6 @@ import {
|
||||
} from "./messages.js";
|
||||
|
||||
describe("shared messages attachments", () => {
|
||||
it("round-trips fork history on a timeline row and accepts rows without it", () => {
|
||||
const row = {
|
||||
type: "user_message",
|
||||
text: "",
|
||||
messageId: "fork-prompt",
|
||||
chatHistory: {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
text: "Earlier context",
|
||||
chatHistorySource: { serverId: "source-host", agentId: "source-agent", title: "Original" },
|
||||
},
|
||||
};
|
||||
expect(AgentTimelineItemPayloadSchema.parse(row)).toEqual(row);
|
||||
const legacy = { type: "user_message", text: "Earlier message" };
|
||||
expect(AgentTimelineItemPayloadSchema.parse(legacy)).toEqual(legacy);
|
||||
});
|
||||
|
||||
it("preserves an optional timeline cursor on fork-context messages", () => {
|
||||
const boundaryCursor = { epoch: "timeline-1", seq: 42 };
|
||||
const request = AgentForkContextRequestMessageSchema.parse({
|
||||
@@ -288,29 +269,6 @@ describe("shared messages attachments", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves the source of a fork across message submission", () => {
|
||||
const attachment = {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text: "Earlier context",
|
||||
chatHistorySource: {
|
||||
serverId: "source-host",
|
||||
agentId: "source-agent",
|
||||
title: "Original conversation",
|
||||
},
|
||||
};
|
||||
const parsed = SendAgentMessageRequestSchema.parse({
|
||||
type: "send_agent_message_request",
|
||||
requestId: "req-fork-source",
|
||||
agentId: "fork-agent",
|
||||
text: "Continue",
|
||||
attachments: [attachment],
|
||||
});
|
||||
expect(parsed.attachments).toEqual([attachment]);
|
||||
});
|
||||
|
||||
it("keeps known text attachment context kinds and ignores future ones", () => {
|
||||
const parsed = SendAgentMessageRequestSchema.parse({
|
||||
type: "send_agent_message_request",
|
||||
|
||||
@@ -712,25 +712,6 @@ const ToolCallTimelineItemPayloadSchema: z.ZodType<ToolCallTimelineItem, unknown
|
||||
ToolCallCanceledPayloadSchema,
|
||||
]);
|
||||
|
||||
export const ChatHistorySourceSchema = z.object({
|
||||
serverId: z.string(),
|
||||
agentId: z.string(),
|
||||
title: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ChatHistorySource = z.infer<typeof ChatHistorySourceSchema>;
|
||||
|
||||
export const ChatHistoryAttachmentSchema = z.object({
|
||||
type: z.literal("text"),
|
||||
mimeType: z.literal("text/plain"),
|
||||
contextKind: z.literal("chat_history"),
|
||||
title: z.string().nullable().optional(),
|
||||
text: z.string(),
|
||||
chatHistorySource: ChatHistorySourceSchema.optional(),
|
||||
});
|
||||
|
||||
export type ChatHistoryAttachment = z.infer<typeof ChatHistoryAttachmentSchema>;
|
||||
|
||||
// zod-aot 0.20.4 miscompiles this as a nested discriminated union by omitting
|
||||
// the inner tool_call branch from the generated outer dispatch.
|
||||
export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem, unknown> = z.union([
|
||||
@@ -739,7 +720,6 @@ export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem, unknow
|
||||
text: z.string(),
|
||||
messageId: z.string().optional(),
|
||||
clientMessageId: z.string().optional(),
|
||||
chatHistory: ChatHistoryAttachmentSchema.optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("assistant_message"),
|
||||
@@ -1200,7 +1180,6 @@ export const TextAttachmentSchema = z
|
||||
title: z.string().nullable().optional(),
|
||||
text: z.string(),
|
||||
externalResource: ExternalResourceAttachmentMetadataSchema.optional(),
|
||||
chatHistorySource: ChatHistorySourceSchema.optional(),
|
||||
})
|
||||
.transform(({ contextKind, ...attachment }) => ({
|
||||
...attachment,
|
||||
@@ -4210,8 +4189,6 @@ export const WorkspaceGitHubRuntimePayloadSchema = z
|
||||
|
||||
export const WorkspaceDescriptorPayloadSchema = z
|
||||
.object({
|
||||
// Informational creation ancestry; independent of placement and lifecycle.
|
||||
parentWorkspaceId: z.string().optional(),
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
projectDisplayName: z.string(),
|
||||
|
||||
@@ -634,26 +634,6 @@ describe("workspace message schemas", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves informational workspace parentage in directory updates", () => {
|
||||
const workspace = {
|
||||
id: "child",
|
||||
parentWorkspaceId: "parent",
|
||||
projectId: "project",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: "child",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
};
|
||||
expect(WorkspaceDescriptorPayloadSchema.parse(workspace).parentWorkspaceId).toBe("parent");
|
||||
expect(
|
||||
WorkspaceDescriptorPayloadSchema.parse({ ...workspace, parentWorkspaceId: undefined })
|
||||
.parentWorkspaceId,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("parses legacy workspace descriptors without workspaceDirectory", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "workspace_update",
|
||||
|
||||
@@ -572,57 +572,3 @@ it("preserves every message in fork history below the character budget", () => {
|
||||
`<chat-history-summary>\nChat history from a previous Paseo agent.\n\n[User] ${text}\n</chat-history-summary>`,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["", "Continue in B"])("carries inherited history through repeated forks (%s)", (text) => {
|
||||
const original = buildAgentForkContextAttachment({
|
||||
rows: [
|
||||
row(1, { type: "user_message", text: "Question in A" }),
|
||||
row(2, { type: "assistant_message", text: "Answer in A" }),
|
||||
],
|
||||
});
|
||||
const child = buildAgentForkContextAttachment({
|
||||
rows: [
|
||||
row(1, {
|
||||
type: "user_message",
|
||||
text,
|
||||
chatHistory: { ...original.attachment, contextKind: "chat_history" },
|
||||
}),
|
||||
row(2, { type: "assistant_message", text: "Answer in B" }),
|
||||
],
|
||||
});
|
||||
const grandchild = buildAgentForkContextAttachment({
|
||||
rows: [
|
||||
row(1, {
|
||||
type: "user_message",
|
||||
text: "Continue in C",
|
||||
chatHistory: { ...child.attachment, contextKind: "chat_history" },
|
||||
}),
|
||||
row(2, { type: "assistant_message", text: "Answer in C" }),
|
||||
],
|
||||
});
|
||||
expect(grandchild.attachment.text).toContain("[User] Question in A\n[Assistant] Answer in A");
|
||||
expect(grandchild.attachment.text).toContain("[Assistant] Answer in B");
|
||||
expect(grandchild.attachment.text).toContain("[User] Continue in C\n[Assistant] Answer in C");
|
||||
expect(grandchild.attachment.text.match(/<chat-history-summary>/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the fork budget when inherited history exceeds the remaining space", () => {
|
||||
const result = buildAgentForkContextAttachment({
|
||||
rows: [
|
||||
row(1, {
|
||||
type: "user_message",
|
||||
text: "Current question",
|
||||
chatHistory: {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
text: "Old ".repeat(30_000),
|
||||
},
|
||||
}),
|
||||
row(2, { type: "assistant_message", text: "Latest answer" }),
|
||||
],
|
||||
});
|
||||
expect(result.attachment.text.length).toBeLessThanOrEqual(100_000);
|
||||
expect(result.attachment.text).toContain("[Earlier chat history omitted]");
|
||||
expect(result.attachment.text).toContain("[User] Current question\n[Assistant] Latest answer");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { extractChatHistoryBody } from "@getpaseo/protocol/chat-history";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
@@ -22,7 +21,6 @@ const FORK_CONTEXT_KINDS: readonly AgentTimelineItem["type"][] = [
|
||||
];
|
||||
|
||||
interface ActivityCuratorOptions {
|
||||
includeChatHistory?: boolean;
|
||||
maxItems?: number;
|
||||
labelAssistantMessages?: boolean;
|
||||
includeKinds?: readonly AgentTimelineItem["type"][];
|
||||
@@ -39,17 +37,6 @@ function activityEntry(text: string): ActivityEntry {
|
||||
return { text };
|
||||
}
|
||||
|
||||
function appendUserEntry(
|
||||
entries: ActivityEntry[],
|
||||
item: Extract<AgentTimelineItem, { type: "user_message" }>,
|
||||
options?: ActivityCuratorOptions,
|
||||
) {
|
||||
if (options?.includeChatHistory && item.chatHistory) {
|
||||
entries.push(activityEntry(extractChatHistoryBody(item.chatHistory.text)));
|
||||
}
|
||||
entries.push(activityEntry(`[User] ${item.text.trim()}`));
|
||||
}
|
||||
|
||||
function appendTextEntry(
|
||||
entries: ActivityEntry[],
|
||||
item: Extract<AgentTimelineItem, { type: "assistant_message" | "reasoning" }>,
|
||||
@@ -171,7 +158,7 @@ function curateProjectedActivityEntries(
|
||||
|
||||
switch (item.type) {
|
||||
case "user_message":
|
||||
appendUserEntry(entries, item, options);
|
||||
entries.push(activityEntry(`[User] ${item.text.trim()}`));
|
||||
break;
|
||||
case "assistant_message":
|
||||
case "reasoning":
|
||||
@@ -374,7 +361,6 @@ export function buildAgentForkContextAttachment(input: {
|
||||
});
|
||||
const entries = curateProjectedActivityEntries(selected.items, {
|
||||
maxItems: 0,
|
||||
includeChatHistory: true,
|
||||
labelAssistantMessages: true,
|
||||
includeKinds: FORK_CONTEXT_KINDS,
|
||||
includeExternalToolInput: false,
|
||||
|
||||
@@ -729,159 +729,6 @@ test("uses an injected timeline store without making it a production requirement
|
||||
}
|
||||
});
|
||||
|
||||
test.each([true, false])(
|
||||
"fork history survives storage-backed replay (provider echo: %s)",
|
||||
async (echo) => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-fork-history-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const attachment = {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
text: "<chat-history-summary>\nChat history from a previous Paseo agent.\n\n[User] Original question\n[Assistant] Original answer\n</chat-history-summary>",
|
||||
chatHistorySource: { serverId: "source-host", agentId: "source-agent", title: "Original" },
|
||||
} as const;
|
||||
const clientMessageId = "fork-prompt";
|
||||
let hasHistory = false;
|
||||
let includeFork = true;
|
||||
class HistorySession extends TestAgentSession {
|
||||
override async startTurn(): Promise<{ turnId: string }> {
|
||||
hasHistory = true;
|
||||
if (echo)
|
||||
this.pushEvent({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: attachment.text,
|
||||
messageId: "native-fork",
|
||||
clientMessageId,
|
||||
},
|
||||
});
|
||||
return super.startTurn();
|
||||
}
|
||||
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
if (!hasHistory) return;
|
||||
if (includeFork)
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "user_message", text: attachment.text, messageId: "native-fork" },
|
||||
};
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "user_message", text: attachment.text, messageId: "native-later" },
|
||||
};
|
||||
}
|
||||
}
|
||||
class HistoryClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new HistorySession(config);
|
||||
}
|
||||
override async resumeSession(): Promise<AgentSession> {
|
||||
return new HistorySession({ provider: "codex", cwd: workdir });
|
||||
}
|
||||
}
|
||||
const client = new HistoryClient();
|
||||
let storage = new AgentStorage(storagePath, logger);
|
||||
let manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
await manager.closeAgent(agent.id);
|
||||
await manager.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
});
|
||||
await drainAsyncGenerator(manager.streamAgent(agent.id, [attachment], { clientMessageId }));
|
||||
expect((await manager.getTimelineRows(agent.id))[0]?.item).toMatchObject({
|
||||
type: "user_message",
|
||||
text: "",
|
||||
chatHistory: attachment,
|
||||
});
|
||||
await manager.closeAgent(agent.id);
|
||||
await manager.flush();
|
||||
|
||||
storage = new AgentStorage(storagePath, logger);
|
||||
manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
await ensureAgentLoaded(agent.id, { agentManager: manager, agentStorage: storage, logger });
|
||||
await manager.hydrateTimelineFromProvider(agent.id);
|
||||
expect((await manager.getTimelineRows(agent.id)).map((row) => row.item)).toEqual([
|
||||
{ type: "user_message", text: "", messageId: "native-fork", chatHistory: attachment },
|
||||
{ type: "user_message", text: attachment.text, messageId: "native-later" },
|
||||
]);
|
||||
// A provider rewind must not recreate a removed fork prompt.
|
||||
includeFork = false;
|
||||
await manager.hydrateTimelineFromProvider(agent.id, { force: true });
|
||||
expect((await manager.getTimelineRows(agent.id)).map((row) => row.item)).toEqual([
|
||||
{ type: "user_message", text: attachment.text, messageId: "native-later" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([undefined, "native-replay-id"])(
|
||||
"restores fork history after a local submission echo and replay ID %s",
|
||||
async (replayId) => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "fork-local-echo-"));
|
||||
const clientMessageId = "local-fork-prompt";
|
||||
const attachment = {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
text: "[User] Original question",
|
||||
} as const;
|
||||
class LocalEchoSession extends TestAgentSession {
|
||||
override async startTurn(): Promise<{ turnId: string }> {
|
||||
this.pushEvent({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: attachment.text,
|
||||
messageId: clientMessageId,
|
||||
clientMessageId,
|
||||
},
|
||||
});
|
||||
return super.startTurn();
|
||||
}
|
||||
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: attachment.text,
|
||||
...(replayId ? { messageId: replayId } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
class LocalEchoClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new LocalEchoSession(config);
|
||||
}
|
||||
}
|
||||
const manager = new AgentManager({ clients: { codex: new LocalEchoClient() }, logger });
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
try {
|
||||
await drainAsyncGenerator(manager.streamAgent(agent.id, [attachment], { clientMessageId }));
|
||||
await manager.hydrateTimelineFromProvider(agent.id, { force: true });
|
||||
expect((await manager.getTimelineRows(agent.id))[0]?.item).toMatchObject({
|
||||
type: "user_message",
|
||||
text: "",
|
||||
chatHistory: attachment,
|
||||
});
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
await manager.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("refreshing an agent replaces the injected timeline store instead of appending a second copy", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-rehydrate-"));
|
||||
const store = new RecordingTimelineStore();
|
||||
|
||||
@@ -441,7 +441,6 @@ interface HandleStreamEventOptions {
|
||||
}
|
||||
|
||||
interface ManagedAgentBase {
|
||||
forkContext?: StoredAgentRecord["forkContext"];
|
||||
id: string;
|
||||
provider: AgentProvider;
|
||||
cwd: string;
|
||||
@@ -2702,15 +2701,11 @@ export class AgentManager {
|
||||
if (event.type === "timeline") {
|
||||
this.touchUpdatedAt(agent);
|
||||
const row = this.recordTimeline(agent.id, event.item);
|
||||
this.dispatchStream(
|
||||
agent.id,
|
||||
{ ...event, item: row.item },
|
||||
{
|
||||
seq: row.seq,
|
||||
epoch: this.timelineStore.getEpoch(agent.id),
|
||||
timestamp: row.timestamp,
|
||||
},
|
||||
);
|
||||
this.dispatchStream(agent.id, event, {
|
||||
seq: row.seq,
|
||||
epoch: this.timelineStore.getEpoch(agent.id),
|
||||
timestamp: row.timestamp,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() });
|
||||
@@ -3924,8 +3919,6 @@ export class AgentManager {
|
||||
options,
|
||||
});
|
||||
|
||||
await this.restoreForkContext(managed);
|
||||
|
||||
// Read history before publishing the agent: a provider failure must leave the
|
||||
// session unregistered so the registration catch closes it.
|
||||
const startupHistory: AgentStreamEvent[] = [];
|
||||
@@ -4498,15 +4491,11 @@ export class AgentManager {
|
||||
event.timestamp ? { timestamp: event.timestamp } : undefined,
|
||||
);
|
||||
if (broadcastTimeline) {
|
||||
this.dispatchStream(
|
||||
agent.id,
|
||||
{ ...event, item: row.item },
|
||||
{
|
||||
seq: row.seq,
|
||||
epoch: this.timelineStore.getEpoch(agent.id),
|
||||
timestamp: row.timestamp,
|
||||
},
|
||||
);
|
||||
this.dispatchStream(agent.id, event, {
|
||||
seq: row.seq,
|
||||
epoch: this.timelineStore.getEpoch(agent.id),
|
||||
timestamp: row.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
@@ -4571,17 +4560,13 @@ export class AgentManager {
|
||||
event.timestamp ? { timestamp: event.timestamp } : undefined,
|
||||
);
|
||||
if (deferredBroadcast) {
|
||||
timelineEvents.push({ event: { ...event, item: row.item }, row });
|
||||
timelineEvents.push({ event, row });
|
||||
} else if (broadcast) {
|
||||
this.dispatchStream(
|
||||
agent.id,
|
||||
{ ...event, item: row.item },
|
||||
{
|
||||
seq: row.seq,
|
||||
epoch: this.timelineStore.getEpoch(agent.id),
|
||||
timestamp: row.timestamp,
|
||||
},
|
||||
);
|
||||
this.dispatchStream(agent.id, event, {
|
||||
seq: row.seq,
|
||||
epoch: this.timelineStore.getEpoch(agent.id),
|
||||
timestamp: row.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
agent.historyPrimed = true;
|
||||
@@ -5116,7 +5101,7 @@ export class AgentManager {
|
||||
const row = this.recordTimeline(agentId, item, { ...options, turnId });
|
||||
const event: AgentStreamEvent = {
|
||||
type: "timeline",
|
||||
item: row.item,
|
||||
item,
|
||||
provider,
|
||||
...(turnId !== undefined ? { turnId } : {}),
|
||||
};
|
||||
@@ -5148,25 +5133,9 @@ export class AgentManager {
|
||||
outcome: "accepted" | "rejected",
|
||||
options?: { messageId?: string; providerMessageId?: string; turnId?: string },
|
||||
): void {
|
||||
const text = submittedPromptText(prompt);
|
||||
if (typeof prompt !== "string") {
|
||||
const attachment = prompt.find(
|
||||
(block) =>
|
||||
block.type === "text" && "contextKind" in block && block.contextKind === "chat_history",
|
||||
);
|
||||
if (attachment?.type === "text" && "mimeType" in attachment) {
|
||||
agent.forkContext = {
|
||||
clientMessageId,
|
||||
providerMessageId:
|
||||
options?.providerMessageId !== clientMessageId ? options?.providerMessageId : undefined,
|
||||
text,
|
||||
attachment: { ...attachment, contextKind: "chat_history" },
|
||||
};
|
||||
}
|
||||
}
|
||||
const items = projectAgentMessages({
|
||||
type: "user_message",
|
||||
text,
|
||||
text: submittedPromptText(prompt),
|
||||
clientMessageId,
|
||||
...(options?.messageId ? { messageId: options.messageId } : {}),
|
||||
});
|
||||
@@ -5214,10 +5183,6 @@ export class AgentManager {
|
||||
}
|
||||
if (!existing || existing.item.type !== "user_message") return null;
|
||||
if (messageId) {
|
||||
if (agent.forkContext?.clientMessageId === clientMessageId && messageId !== clientMessageId) {
|
||||
agent.forkContext.providerMessageId = messageId;
|
||||
this.enqueueBackgroundPersist(agent);
|
||||
}
|
||||
const enriched = this.timelineStore.enrichSubmittedUserMessage(
|
||||
agent.id,
|
||||
clientMessageId,
|
||||
@@ -5282,10 +5247,6 @@ export class AgentManager {
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
private async restoreForkContext(agent: ManagedAgent): Promise<void> {
|
||||
agent.forkContext = (await this.registry?.get(agent.id))?.forkContext;
|
||||
}
|
||||
|
||||
private recordTimeline(
|
||||
agentId: string,
|
||||
item: AgentTimelineItem,
|
||||
@@ -5295,29 +5256,6 @@ export class AgentManager {
|
||||
turnId?: string;
|
||||
},
|
||||
): AgentTimelineRow {
|
||||
const agent = this.agents.get(agentId);
|
||||
const context = agent?.forkContext;
|
||||
if (
|
||||
context &&
|
||||
item.type === "user_message" &&
|
||||
(item.clientMessageId === context.clientMessageId ||
|
||||
(context.providerMessageId
|
||||
? item.messageId === context.providerMessageId
|
||||
: context.attachment.text.length > 0 && item.text.includes(context.attachment.text)))
|
||||
) {
|
||||
// Providers without client IDs can be matched once by the exact snapshot.
|
||||
// Remember their native ID so a later quotation is not mistaken for the fork.
|
||||
if (
|
||||
agent &&
|
||||
!context.providerMessageId &&
|
||||
item.messageId &&
|
||||
item.messageId !== context.clientMessageId
|
||||
) {
|
||||
context.providerMessageId = item.messageId;
|
||||
this.enqueueBackgroundPersist(agent);
|
||||
}
|
||||
item = { ...item, text: context.text, chatHistory: context.attachment };
|
||||
}
|
||||
item = limitAgentTimelineItemContent(item);
|
||||
const row = this.timelineStore.append(agentId, item, options);
|
||||
this.enqueueDurableTimelineAppend(agentId, row);
|
||||
|
||||
@@ -92,7 +92,6 @@ export function toStoredAgentRecord(
|
||||
lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null,
|
||||
title: options?.title ?? null,
|
||||
labels: agent.labels,
|
||||
forkContext: agent.forkContext,
|
||||
lastStatus: agent.lifecycle,
|
||||
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
|
||||
config: config ?? null,
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
ToolPolicy,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import type { AgentAttachment, ChatHistoryAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { PaseoToolCatalog } from "./tools/types.js";
|
||||
|
||||
export type {
|
||||
@@ -398,13 +398,7 @@ export interface PluginTimelineItem {
|
||||
}
|
||||
|
||||
export type AgentTimelineItem =
|
||||
| {
|
||||
type: "user_message";
|
||||
text: string;
|
||||
messageId?: string;
|
||||
clientMessageId?: string;
|
||||
chatHistory?: ChatHistoryAttachment;
|
||||
}
|
||||
| { type: "user_message"; text: string; messageId?: string; clientMessageId?: string }
|
||||
| { type: "assistant_message"; text: string; messageId?: string }
|
||||
| { type: "reasoning"; text: string }
|
||||
| ToolCallTimelineItem
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from "zod";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { writeJsonFileAtomic } from "../atomic-file.js";
|
||||
import { AgentFeatureSchema, AgentStatusSchema, ChatHistoryAttachmentSchema } from "../messages.js";
|
||||
import { AgentFeatureSchema, AgentStatusSchema } from "../messages.js";
|
||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
||||
@@ -53,15 +53,6 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
lastUserMessageAt: z.string().nullable().optional(),
|
||||
title: z.string().nullable().optional(),
|
||||
labels: z.record(z.string(), z.string()).default({}),
|
||||
// Provider transcripts retain the rendered prompt, not attachment metadata.
|
||||
forkContext: z
|
||||
.object({
|
||||
clientMessageId: z.string(),
|
||||
providerMessageId: z.string().optional(),
|
||||
text: z.string(),
|
||||
attachment: ChatHistoryAttachmentSchema,
|
||||
})
|
||||
.optional(),
|
||||
lastStatus: AgentStatusSchema.default("closed"),
|
||||
resumeOnStartup: z.boolean().optional(),
|
||||
lastModeId: z.string().nullable().optional(),
|
||||
|
||||
@@ -1171,7 +1171,7 @@ describe("Suite E: Workspace Tools", () => {
|
||||
.callTool({ name: "archive_workspace", args: { workspaceId } })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
test("agent-scoped workspace creation inherits visibility and permits an explicit false override", async () => {
|
||||
const parentWorkspace = await callToolStructured(topLevelClient, "create_workspace", {
|
||||
@@ -1197,43 +1197,12 @@ describe("Suite E: Workspace Tools", () => {
|
||||
});
|
||||
ownedWorkspaces.push(str(inherited.workspaceId));
|
||||
expect(inherited.background).toBe(true);
|
||||
expect(inherited.parentWorkspaceId).toBe(parentWorkspaceId);
|
||||
const visible = await callToolStructured(scoped, "create_workspace", {
|
||||
isolation: "local",
|
||||
background: false,
|
||||
});
|
||||
ownedWorkspaces.push(str(visible.workspaceId));
|
||||
expect(visible.background).toBe(false);
|
||||
expect(visible.parentWorkspaceId).toBe(parentWorkspaceId);
|
||||
const worktree = await callToolStructured(scoped, "create_workspace", {
|
||||
isolation: "worktree",
|
||||
branchName: "parentage-child",
|
||||
baseBranch: "main",
|
||||
background: false,
|
||||
});
|
||||
ownedWorkspaces.push(str(worktree.workspaceId));
|
||||
expect(worktree.parentWorkspaceId).toBe(parentWorkspaceId);
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemonHandle.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: worktreeRepoCwd },
|
||||
callerAgentId: str(parent.agentId),
|
||||
background: false,
|
||||
});
|
||||
const workspaceId = str(created.workspace?.id);
|
||||
ownedWorkspaces.push(workspaceId);
|
||||
expect(created.workspace?.parentWorkspaceId).toBe(parentWorkspaceId);
|
||||
const listed = await client.fetchWorkspaces();
|
||||
expect(listed.entries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: workspaceId,
|
||||
parentWorkspaceId: parentWorkspaceId,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
} finally {
|
||||
await scoped?.close();
|
||||
for (const workspaceId of ownedWorkspaces.toReversed())
|
||||
@@ -1241,7 +1210,7 @@ describe("Suite E: Workspace Tools", () => {
|
||||
.callTool({ name: "archive_workspace", args: { workspaceId } })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
test("a background agent's children and workspaces are background unless it overrides", async () => {
|
||||
const visibleAgentId = await createTopLevelAgent();
|
||||
|
||||
@@ -188,7 +188,6 @@ interface ProviderSummary {
|
||||
}
|
||||
|
||||
const WorkspaceAutomationSummarySchema = z.object({
|
||||
parentWorkspaceId: z.string().optional(),
|
||||
workspaceId: z.string(),
|
||||
projectId: z.string(),
|
||||
cwd: z.string(),
|
||||
@@ -207,7 +206,6 @@ function toWorkspaceAutomationSummary(workspace: PersistedWorkspaceRecord) {
|
||||
kind: workspace.kind,
|
||||
title: workspace.title,
|
||||
background: workspace.background,
|
||||
parentWorkspaceId: workspace.parentWorkspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5900,7 +5900,6 @@ export class Session {
|
||||
pinnedAt: workspace.pinnedAt,
|
||||
...(workspace.labels && workspace.labels.length > 0 ? { labels: workspace.labels } : {}),
|
||||
background: workspace.background,
|
||||
parentWorkspaceId: workspace.parentWorkspaceId,
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
@@ -5989,7 +5988,6 @@ export class Session {
|
||||
projectKind: projectRecord?.kind ?? "git",
|
||||
workspaceKind: result.workspace.kind,
|
||||
background: result.workspace.background,
|
||||
parentWorkspaceId: result.workspace.parentWorkspaceId,
|
||||
name: resolveWorkspaceName({
|
||||
title: result.workspace.title,
|
||||
derivedDisplayName: result.worktree.branchName || result.workspace.displayName,
|
||||
|
||||
-76
@@ -205,82 +205,6 @@ test("fresh non-git directory creates a directory workspace at the exact path",
|
||||
expect(workspace.cwd).toBe(dir);
|
||||
});
|
||||
|
||||
test("workspace parentage survives reload and parent archive without changing placement", async () => {
|
||||
const cwd = path.join(tmpDir, "plain");
|
||||
const parent = await provisioning.createWorkspaceForDirectory(cwd);
|
||||
const child = await provisioning.createWorkspaceForDirectory(cwd, null, undefined, {
|
||||
callerWorkspaceId: parent.workspaceId,
|
||||
background: false,
|
||||
});
|
||||
const grandchild = await provisioning.createWorkspaceForDirectory(
|
||||
path.join(tmpDir, "other"),
|
||||
null,
|
||||
undefined,
|
||||
{
|
||||
callerWorkspaceId: child.workspaceId,
|
||||
},
|
||||
);
|
||||
expect(parent.parentWorkspaceId).toBeUndefined();
|
||||
expect(child).toMatchObject({ parentWorkspaceId: parent.workspaceId, cwd });
|
||||
expect(grandchild).toMatchObject({ parentWorkspaceId: child.workspaceId });
|
||||
await workspaceRegistry.archive(parent.workspaceId, ARCHIVED_AT);
|
||||
const reloaded = new FileBackedWorkspaceRegistry(
|
||||
path.join(tmpDir, "projects", "workspaces.json"),
|
||||
logger,
|
||||
);
|
||||
await reloaded.initialize();
|
||||
expect(await reloaded.get(child.workspaceId)).toMatchObject({
|
||||
parentWorkspaceId: parent.workspaceId,
|
||||
archivedAt: null,
|
||||
});
|
||||
expect(await reloaded.get(grandchild.workspaceId)).toMatchObject({
|
||||
parentWorkspaceId: child.workspaceId,
|
||||
archivedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("worktree workspace creation records its caller independently of its source project", async () => {
|
||||
const sourceCwd = path.join(tmpDir, "repo");
|
||||
gitRoots.add(sourceCwd);
|
||||
const parent = await provisioning.createWorkspaceForDirectory(path.join(tmpDir, "other"));
|
||||
const child = await provisioning.createWorkspaceForWorktree({
|
||||
sourceCwd,
|
||||
repoRoot: sourceCwd,
|
||||
cwd: path.join(tmpDir, "worktree"),
|
||||
worktreeRoot: path.join(tmpDir, "worktree"),
|
||||
branch: "child",
|
||||
baseBranch: "main",
|
||||
title: null,
|
||||
callerWorkspaceId: parent.workspaceId,
|
||||
});
|
||||
expect(child.parentWorkspaceId).toBe(parent.workspaceId);
|
||||
expect(child.projectId).not.toBe(parent.projectId);
|
||||
expect(await workspaceRegistry.get(child.workspaceId)).toMatchObject({
|
||||
parentWorkspaceId: parent.workspaceId,
|
||||
});
|
||||
});
|
||||
|
||||
test("workspace creation rejects an unknown caller even with explicit visibility", async () => {
|
||||
await expect(
|
||||
provisioning.createWorkspaceForDirectory(path.join(tmpDir, "plain"), null, undefined, {
|
||||
callerWorkspaceId: "missing",
|
||||
background: false,
|
||||
}),
|
||||
).rejects.toThrow("Caller workspace missing not found");
|
||||
});
|
||||
|
||||
test("workspace creation cannot overwrite its caller with a self relationship", async () => {
|
||||
const cwd = path.join(tmpDir, "plain");
|
||||
const parent = await provisioning.createWorkspaceForDirectory(cwd);
|
||||
await expect(
|
||||
provisioning.createWorkspaceForDirectory(cwd, null, undefined, {
|
||||
callerWorkspaceId: parent.workspaceId,
|
||||
workspaceId: parent.workspaceId,
|
||||
}),
|
||||
).rejects.toThrow("A workspace cannot be its own parent");
|
||||
expect(await workspaceRegistry.get(parent.workspaceId)).toEqual(parent);
|
||||
});
|
||||
|
||||
test("background workspace creation preserves hooks, inheritance and durable visibility", async () => {
|
||||
const dir = path.join(tmpDir, "plain");
|
||||
const events: string[] = [];
|
||||
|
||||
+7
-13
@@ -227,21 +227,15 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
return project;
|
||||
}
|
||||
|
||||
async function resolveCreationMetadata(input?: {
|
||||
workspaceId?: string;
|
||||
async function resolveBackground(input?: {
|
||||
background?: boolean;
|
||||
callerWorkspaceId?: string;
|
||||
}): Promise<{ background: boolean; parentWorkspaceId?: string }> {
|
||||
if (!input?.callerWorkspaceId) return { background: input?.background ?? false };
|
||||
if (input.workspaceId === input.callerWorkspaceId) {
|
||||
throw new Error("A workspace cannot be its own parent");
|
||||
}
|
||||
}): Promise<boolean> {
|
||||
if (input?.background !== undefined) return input.background;
|
||||
if (!input?.callerWorkspaceId) return false;
|
||||
const callerWorkspace = await workspaceRegistry.get(input.callerWorkspaceId);
|
||||
if (!callerWorkspace) throw new Error(`Caller workspace ${input.callerWorkspaceId} not found`);
|
||||
return {
|
||||
background: input.background ?? callerWorkspace.background,
|
||||
parentWorkspaceId: callerWorkspace.workspaceId,
|
||||
};
|
||||
return callerWorkspace.background;
|
||||
}
|
||||
|
||||
async function createWorkspaceForDirectory(
|
||||
@@ -275,7 +269,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
title: title?.trim() || null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
...(await resolveCreationMetadata(context)),
|
||||
background: await resolveBackground(context),
|
||||
});
|
||||
await workspaceRegistry.upsert(workspace, {
|
||||
expectsInitialAgent: context?.expectsInitialAgent,
|
||||
@@ -316,7 +310,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
...(input.untrustedSource ? { untrustedSource: input.untrustedSource } : {}),
|
||||
...(await resolveCreationMetadata(input)),
|
||||
background: await resolveBackground(input),
|
||||
});
|
||||
await workspaceRegistry.upsert(workspace, {
|
||||
expectsInitialAgent: input.expectsInitialAgent,
|
||||
|
||||
@@ -49,7 +49,6 @@ const PersistedProjectRecordSchema = z.object({
|
||||
});
|
||||
|
||||
const PersistedWorkspaceRecordSchema = z.object({
|
||||
parentWorkspaceId: z.string().optional(),
|
||||
workspaceId: z.string(),
|
||||
projectId: z.string(),
|
||||
cwd: z.string(),
|
||||
@@ -680,7 +679,6 @@ export function resolveProjectDisplayName(record: PersistedProjectRecord): strin
|
||||
}
|
||||
|
||||
export function createPersistedWorkspaceRecord(input: {
|
||||
parentWorkspaceId?: string;
|
||||
workspaceId: string;
|
||||
projectId: string;
|
||||
cwd: string;
|
||||
|
||||
Reference in New Issue
Block a user