mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 09:24:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c17bd1e614 | ||
|
|
f898537233 |
@@ -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.
|
||||
|
||||
+2
-11
@@ -179,17 +179,8 @@ This is the Android counterpart of the iOS local-simulator flow in [development.
|
||||
`modules/paseo-scroll` replaces React Native's vertical scroll view with `PaseoScrollView`, which
|
||||
keeps React Native's scroll manager interface and corrects `maintainVisibleContentPosition` for the
|
||||
chat timeline. [agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections)
|
||||
lists the content-position corrections and the React Native issues they work around.
|
||||
|
||||
The timeline's `nativeID` (`agent-chat-scroll-native-virtualized`) also identifies it to the native
|
||||
scroll owner: a nested panel's edge overshoot and unconsumed fling must leave the timeline still.
|
||||
Ordinary scroll parents retain Android's handoff. Keep that identity in sync with
|
||||
`agent-stream/strategy-native.tsx`; the end pin is absent while reading history, so it cannot
|
||||
identify the timeline for this rule.
|
||||
|
||||
`VisibleContentPositionClampTest` mounts through React Native's own mounting manager;
|
||||
`PanelEdgeTest` covers both panel edges, flings, interior/direct scrolling, and ordinary-parent
|
||||
handoff with the real compiled scroll views:
|
||||
lists the corrections and the React Native issues they work around. Its instrumentation test,
|
||||
`VisibleContentPositionClampTest`, mounts through React Native's own mounting manager:
|
||||
|
||||
```bash
|
||||
cd packages/app/android
|
||||
|
||||
+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).
|
||||
|
||||
@@ -235,7 +235,6 @@ async function beginTimelineRowStabilityCheck(
|
||||
lastTop: initialRow.getBoundingClientRect().top,
|
||||
largestDownwardShift: 0,
|
||||
sawMissing: false,
|
||||
sawHidden: false,
|
||||
};
|
||||
const windowState = window as unknown as Record<string, unknown>;
|
||||
windowState.__messageSubmissionTimelineRowStability = state;
|
||||
@@ -246,9 +245,6 @@ async function beginTimelineRowStabilityCheck(
|
||||
requestAnimationFrame(checkFrame);
|
||||
return;
|
||||
}
|
||||
if (!initialRow.checkVisibility({ opacityProperty: true, visibilityProperty: true })) {
|
||||
state.sawHidden = true;
|
||||
}
|
||||
const top = initialRow.getBoundingClientRect().top;
|
||||
state.largestDownwardShift = Math.max(state.largestDownwardShift, top - state.lastTop);
|
||||
state.lastTop = top;
|
||||
@@ -261,24 +257,14 @@ async function beginTimelineRowStabilityCheck(
|
||||
const result = await page.evaluate(() => {
|
||||
const windowState = window as unknown as Record<string, unknown>;
|
||||
const state = windowState.__messageSubmissionTimelineRowStability as
|
||||
| {
|
||||
active: boolean;
|
||||
largestDownwardShift: number;
|
||||
sawMissing: boolean;
|
||||
sawHidden: boolean;
|
||||
}
|
||||
| { active: boolean; largestDownwardShift: number; sawMissing: boolean }
|
||||
| undefined;
|
||||
if (!state) throw new Error("Timeline-row stability check was not started");
|
||||
state.active = false;
|
||||
delete windowState.__messageSubmissionTimelineRowStability;
|
||||
return {
|
||||
largestDownwardShift: state.largestDownwardShift,
|
||||
sawMissing: state.sawMissing,
|
||||
sawHidden: state.sawHidden,
|
||||
};
|
||||
return { largestDownwardShift: state.largestDownwardShift, sawMissing: state.sawMissing };
|
||||
});
|
||||
expect(result.sawMissing).toBe(false);
|
||||
expect(result.sawHidden).toBe(false);
|
||||
expect(result.largestDownwardShift).toBeLessThanOrEqual(2);
|
||||
};
|
||||
}
|
||||
@@ -1192,13 +1178,7 @@ test.describe("Agent message submission", () => {
|
||||
test.setTimeout(120_000);
|
||||
const toasts = await recordPanelToasts(page);
|
||||
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
|
||||
// The created agent takes over the draft's timeline: the same row stays mounted and painted.
|
||||
const finishTimelineRowStabilityCheck = await beginTimelineRowStabilityCheck(
|
||||
page,
|
||||
pending.userMessage,
|
||||
);
|
||||
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
|
||||
await finishTimelineRowStabilityCheck();
|
||||
// A chat this client just created is current by construction; it is never out of date.
|
||||
await toasts.expectNeverShown("agent-updating-toast");
|
||||
});
|
||||
|
||||
@@ -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()] });
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
-158
@@ -1,158 +0,0 @@
|
||||
package sh.paseo.scroll
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
||||
import com.facebook.react.uimanager.DisplayMetricsHolder
|
||||
import com.facebook.react.uimanager.ThemedReactContext
|
||||
import com.facebook.react.views.scroll.ReactScrollView
|
||||
import com.facebook.react.views.scroll.ReactScrollViewManager
|
||||
import com.facebook.react.views.view.ReactViewGroup
|
||||
import com.facebook.soloader.SoLoader
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/** Forward-order parent, as used by the current Android timeline, and a nested panel at its edge. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PanelEdgeTest {
|
||||
private val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
private var activity: Activity? = null
|
||||
private lateinit var manager: ReactScrollViewManager
|
||||
|
||||
@After fun finish() {
|
||||
instrumentation.runOnMainSync { activity?.finish() }
|
||||
}
|
||||
|
||||
@Test fun panelBottomEdgeLeavesTimelineStill() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
child.scrollTo(0, 600)
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedScroll(0, 0, 0, 100, null)
|
||||
child.stopNestedScroll()
|
||||
assertEquals("Panel stays at its bottom", 600, child.scrollY)
|
||||
assertEquals("Panel overshoot must leave timeline still", 400, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun panelTopEdgeLeavesTimelineStill() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
child.scrollTo(0, 0)
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedScroll(0, 0, 0, -100, null)
|
||||
child.stopNestedScroll()
|
||||
assertEquals("Panel stays at its top", 0, child.scrollY)
|
||||
assertEquals("Panel overshoot must leave timeline still", 400, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun panelFlingLeavesTimelineStill() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
child.scrollTo(0, 600)
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedFling(0f, 1500f, false)
|
||||
child.stopNestedScroll()
|
||||
}
|
||||
SystemClock.sleep(500)
|
||||
instrumentation.runOnMainSync {
|
||||
assertEquals("Panel fling must leave timeline still", 400, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun disablingChildNestedScrollPreventsHandoff() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
child.isNestedScrollingEnabled = false
|
||||
assertEquals(false, child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedScroll(0, 0, 0, 100, null)
|
||||
assertEquals(400, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun ordinaryParentStillConsumesNestedOvershoot() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
manager.setNativeId(parent, "ordinary-scroll-parent")
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedScroll(0, 0, 0, 100, null)
|
||||
child.stopNestedScroll()
|
||||
assertEquals("Ordinary parent retains framework handoff", 500, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun ordinaryParentStillConsumesUnconsumedFling() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
manager.setNativeId(parent, "ordinary-scroll-parent")
|
||||
child.scrollTo(0, 600)
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.dispatchNestedFling(0f, 1500f, false)
|
||||
child.stopNestedScroll()
|
||||
}
|
||||
SystemClock.sleep(500)
|
||||
instrumentation.runOnMainSync { assertTrue(parent.scrollY > 400) }
|
||||
}
|
||||
|
||||
@Test fun consumedPanelFlingDoesNotStartParentFling() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
assertEquals(false, child.dispatchNestedFling(0f, 1500f, true))
|
||||
child.stopNestedScroll()
|
||||
assertEquals(400, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun panelInteriorScrollingLeavesTimelineStill() {
|
||||
val (parent, child) = openPanel()
|
||||
instrumentation.runOnMainSync {
|
||||
child.scrollTo(0, 100)
|
||||
assertEquals(100, child.scrollY)
|
||||
assertEquals(400, parent.scrollY)
|
||||
parent.scrollTo(0, 500)
|
||||
assertEquals("Direct timeline scrolling remains available", 500, parent.scrollY)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPanel(): Pair<ReactScrollView, ReactScrollView> {
|
||||
val started = instrumentation.startActivitySync(
|
||||
Intent(instrumentation.targetContext, Activity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
activity = started
|
||||
lateinit var parent: ReactScrollView
|
||||
lateinit var child: ReactScrollView
|
||||
instrumentation.runOnMainSync {
|
||||
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(started)
|
||||
SoLoader.init(started, OpenSourceMergedSoMapping)
|
||||
val react = ScrollTestContext(started.applicationContext)
|
||||
val context = ThemedReactContext(react, started)
|
||||
manager = PaseoScrollPackage().createViewManager(react, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
|
||||
parent = manager.createViewInstance(context)
|
||||
manager.setNativeId(parent, "agent-chat-scroll-native-virtualized")
|
||||
child = manager.createViewInstance(context)
|
||||
child.isNestedScrollingEnabled = true
|
||||
val content = ReactViewGroup(context)
|
||||
content.addView(child, FrameLayout.LayoutParams(300, 200).apply { topMargin = 400 })
|
||||
parent.addView(content)
|
||||
val childContent = ReactViewGroup(context)
|
||||
child.addView(childContent)
|
||||
started.setContentView(FrameLayout(started).apply { addView(parent, FrameLayout.LayoutParams(300, 400)) })
|
||||
parent.layout(0, 0, 300, 400)
|
||||
content.layout(0, 0, 300, 2000)
|
||||
child.layout(0, 400, 300, 600)
|
||||
childContent.layout(0, 0, 300, 800)
|
||||
parent.scrollTo(0, 400)
|
||||
}
|
||||
return Pair(parent, child)
|
||||
}
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
package sh.paseo.scroll;
|
||||
|
||||
import android.content.Context;
|
||||
import com.facebook.react.bridge.BridgeReactContext;
|
||||
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
|
||||
import com.facebook.react.uimanager.events.EventDispatcher;
|
||||
import com.facebook.react.uimanager.events.EventDispatcherProvider;
|
||||
|
||||
/** Native-only tests use RN's own event sink for a context without JavaScript. */
|
||||
final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider {
|
||||
ScrollTestContext(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EventDispatcher getEventDispatcher() {
|
||||
return BlackHoleEventDispatcher.INSTANCE;
|
||||
}
|
||||
}
|
||||
+1
-24
@@ -12,13 +12,10 @@ import com.facebook.react.views.scroll.ReactScrollView;
|
||||
import com.facebook.react.views.scroll.ReactScrollViewHelper;
|
||||
|
||||
/**
|
||||
* React Native's vertical scroll view with timeline corrections. Java, because the content-position
|
||||
* React Native's vertical scroll view with two corrections. Java, because the content-position
|
||||
* config type is internal to React Native's Kotlin module.
|
||||
*/
|
||||
final class PaseoScrollView extends ReactScrollView {
|
||||
// The chat timeline, packages/app/src/agent-stream/strategy-native.tsx.
|
||||
private static final String TIMELINE_NATIVE_ID = "agent-chat-scroll-native-virtualized";
|
||||
|
||||
// The chat timeline's end pin, packages/app/src/agent-stream/native-end-pin.tsx.
|
||||
private static final String END_PIN_NATIVE_ID = "paseo-end-pin";
|
||||
|
||||
@@ -35,26 +32,6 @@ final class PaseoScrollView extends ReactScrollView {
|
||||
super(context);
|
||||
}
|
||||
|
||||
// A panel owns its gesture through its edges; remaining child motion must not move the chat
|
||||
// behind it. The timeline's native identity stays present when its end pin is removed for
|
||||
// reading history. Ordinary parents retain Android's nested-scroll handoff.
|
||||
@Override
|
||||
public void onNestedScroll(
|
||||
View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
|
||||
if (isTimeline()) return;
|
||||
super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) {
|
||||
if (isTimeline() && !consumed) return true;
|
||||
return super.onNestedFling(target, velocityX, velocityY, consumed);
|
||||
}
|
||||
|
||||
private boolean isTimeline() {
|
||||
return TIMELINE_NATIVE_ID.equals(getTag(R.id.view_tag_native_id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMaintainVisibleContentPosition(
|
||||
@Nullable MaintainVisibleScrollPositionHelper.Config config) {
|
||||
|
||||
@@ -65,6 +65,7 @@ interface BottomAnchorControllerDriver {
|
||||
pendingVerification: AttemptContext | null;
|
||||
blockedReason: BottomAnchorBlockedReason | null;
|
||||
};
|
||||
resetForAgent: () => void;
|
||||
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
|
||||
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
|
||||
beginUserScroll: () => void;
|
||||
@@ -479,6 +480,17 @@ function createBottomAnchorControllerDriver(
|
||||
blockedReason,
|
||||
};
|
||||
},
|
||||
resetForAgent() {
|
||||
lastRouteRequestKey = null;
|
||||
pendingRequest = null;
|
||||
blockedReason = null;
|
||||
cancelPendingAttempt();
|
||||
stickyMeasurementRevision = 0;
|
||||
lastVerifiedStickyMeasurementRevision = 0;
|
||||
isUserScrollActive = false;
|
||||
mode = "sticky-bottom";
|
||||
input.onModeChange("sticky-bottom");
|
||||
},
|
||||
applyRouteRequest(request) {
|
||||
if (!request) {
|
||||
return;
|
||||
@@ -729,6 +741,10 @@ export function useBottomAnchorController(input: {
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
driverRef.current?.resetForAgent();
|
||||
}, [input.agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
driverRef.current?.applyRouteRequest(input.routeRequest);
|
||||
}, [input.routeRequest]);
|
||||
|
||||
@@ -129,8 +129,7 @@ function mountList(heldRowId: string) {
|
||||
}
|
||||
return position - state.scroll;
|
||||
};
|
||||
const rowSize = (rowId: string) => listRef.current?.getState().sizes.get(rowId);
|
||||
return { render, scrollTo, heldRowTop, rowSize };
|
||||
return { render, scrollTo, heldRowTop };
|
||||
}
|
||||
|
||||
it("anchors a data change on a held row whose top is above the viewport", async () => {
|
||||
@@ -170,21 +169,3 @@ it("anchors a size change on a held row whose top is above the viewport", async
|
||||
|
||||
expect(list.heldRowTop()).toBe(-20);
|
||||
});
|
||||
|
||||
it("keeps measured row sizes while the list is not laid out", async () => {
|
||||
// A hidden retained tab is display: none. Its rows still render, and a row measured there
|
||||
// reports 0 x 0. When the tab shows again, a row whose frame did not change gets no new layout
|
||||
// event, so a 0 recorded while hidden would stay and the next row would overlap it.
|
||||
const rows: Row[] = Array.from({ length: 3 }, (_, index) => ({ id: `row-${index}`, height: 40 }));
|
||||
const list = mountList("row-0");
|
||||
list.render(rows);
|
||||
await wait(150);
|
||||
expect(rows.map((row) => list.rowSize(row.id))).toEqual([40, 40, 40]);
|
||||
|
||||
host.style.display = "none";
|
||||
await wait(150);
|
||||
list.render(rows.map((row) => ({ ...row })));
|
||||
await wait(150);
|
||||
|
||||
expect(rows.map((row) => list.rowSize(row.id))).toEqual([40, 40, 40]);
|
||||
});
|
||||
|
||||
@@ -137,8 +137,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
} = props;
|
||||
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
|
||||
const listRef = useRef<LegendListRef>(null);
|
||||
// LegendList places the rows once, behind a hidden reveal, when the list mounts.
|
||||
const [isListPlaced, setIsListPlaced] = useState(false);
|
||||
// LegendList places the rows of each agent once, behind a hidden reveal (`dataKey`).
|
||||
const [placedAgentId, setPlacedAgentId] = useState<string | null>(null);
|
||||
const isListPlaced = placedAgentId === agentId;
|
||||
const isListPlacedRef = useRef(isListPlaced);
|
||||
isListPlacedRef.current = isListPlaced;
|
||||
const streamViewportMetricsRef = useRef({
|
||||
@@ -383,6 +384,23 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
streamViewportMetricsRef.current = {
|
||||
containerKey: "native-virtualized",
|
||||
contentHeight: 0,
|
||||
viewportWidth: 0,
|
||||
viewportHeight: 0,
|
||||
offsetY: 0,
|
||||
viewportMeasuredForKey: null,
|
||||
contentMeasuredForKey: null,
|
||||
};
|
||||
scrollOffsetYRef.current = 0;
|
||||
isUserScrollActiveRef.current = false;
|
||||
clearPendingUserScrollEnd();
|
||||
disclosureHold.cancel();
|
||||
historyStartReadyRef.current = false;
|
||||
const initialHistoryStartState = createHistoryStartPaginationState();
|
||||
historyStartPaginationStateRef.current = initialHistoryStartState;
|
||||
setHistoryStartPaginationState(initialHistoryStartState);
|
||||
const frame = requestAnimationFrame(() => {
|
||||
historyStartReadyRef.current = true;
|
||||
evaluateHistoryStart();
|
||||
@@ -393,7 +411,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
historyStartSettleSchedulerRef.current?.cancel();
|
||||
historyStartSettleSchedulerRef.current = null;
|
||||
};
|
||||
}, [clearPendingUserScrollEnd, evaluateHistoryStart]);
|
||||
}, [agentId, clearPendingUserScrollEnd, disclosureHold, evaluateHistoryStart]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle: StreamViewportHandle = {
|
||||
@@ -605,7 +623,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
|
||||
const handleListPlaced = useStableEvent(() => {
|
||||
isListPlacedRef.current = true;
|
||||
setIsListPlaced(true);
|
||||
setPlacedAgentId(agentId);
|
||||
streamViewportMetricsRef.current = {
|
||||
...streamViewportMetricsRef.current,
|
||||
contentMeasuredForKey: "native-virtualized",
|
||||
@@ -675,7 +693,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
extraData={renderHistoryMountedRow}
|
||||
keyExtractor={keyExtractor}
|
||||
testID="agent-chat-scroll"
|
||||
// The Android scroll owner identifies the timeline to contain nested panel gestures.
|
||||
nativeID="agent-chat-scroll-native-virtualized"
|
||||
ListHeaderComponent={historyStartContent}
|
||||
ListFooterComponent={liveAuxiliaryContent ?? undefined}
|
||||
@@ -694,6 +711,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
onItemSizeChanged={handleItemSizeChanged}
|
||||
onReady={handleListPlaced}
|
||||
dataKey={agentId}
|
||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||
renderScrollComponent={renderScrollComponent}
|
||||
recycleItems={false}
|
||||
|
||||
@@ -839,6 +839,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const initialHistoryStartState = createHistoryStartPaginationState();
|
||||
historyStartPaginationStateRef.current = initialHistoryStartState;
|
||||
setHistoryStartPaginationState(initialHistoryStartState);
|
||||
readingAnchor.reset();
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
historyStartReadyRef.current = true;
|
||||
evaluateHistoryStart();
|
||||
@@ -849,7 +853,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
historyStartSettleSchedulerRef.current?.cancel();
|
||||
historyStartSettleSchedulerRef.current = null;
|
||||
};
|
||||
}, [evaluateHistoryStart]);
|
||||
}, [evaluateHistoryStart, props.agentId, readingAnchor]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActiveRef.current || !isActivationReady) {
|
||||
@@ -1391,7 +1395,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): StreamStrategy {
|
||||
return createStreamStrategy({
|
||||
render: (renderInput) => (
|
||||
<WebStreamViewport {...renderInput} isMobileBreakpoint={input.isMobileBreakpoint} />
|
||||
<WebStreamViewport
|
||||
key={renderInput.agentId}
|
||||
{...renderInput}
|
||||
isMobileBreakpoint={input.isMobileBreakpoint}
|
||||
/>
|
||||
),
|
||||
orderTailReverse: false,
|
||||
orderHeadReverse: false,
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("useStreamHistoryWindow", () => {
|
||||
]).flat();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useStreamHistoryWindow({ items, loadRemoteOlder: vi.fn() }),
|
||||
useStreamHistoryWindow({ agentId: "agent-1", items, loadRemoteOlder: vi.fn() }),
|
||||
);
|
||||
|
||||
expect(result.current.start).toBe(2);
|
||||
@@ -35,6 +35,7 @@ describe("useStreamHistoryWindow", () => {
|
||||
const loadRemoteOlder = vi.fn(async () => true);
|
||||
const { result } = renderHook(() =>
|
||||
useStreamHistoryWindow({
|
||||
agentId: "agent-1",
|
||||
items: [
|
||||
message("u1", "user_message"),
|
||||
message("a1", "assistant_message"),
|
||||
@@ -66,7 +67,8 @@ describe("useStreamHistoryWindow", () => {
|
||||
message("a2", "assistant_message"),
|
||||
];
|
||||
const { result, rerender } = renderHook(
|
||||
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
|
||||
({ history }) =>
|
||||
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
|
||||
{ initialProps: { history: items } },
|
||||
);
|
||||
|
||||
@@ -89,7 +91,8 @@ describe("useStreamHistoryWindow", () => {
|
||||
message("a2", "assistant_message"),
|
||||
];
|
||||
const { result, rerender } = renderHook(
|
||||
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
|
||||
({ history }) =>
|
||||
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
|
||||
{ initialProps: { history: items } },
|
||||
);
|
||||
|
||||
@@ -105,7 +108,8 @@ describe("useStreamHistoryWindow", () => {
|
||||
configurable: true,
|
||||
});
|
||||
const { result, rerender } = renderHook(
|
||||
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
|
||||
({ history }) =>
|
||||
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
|
||||
{
|
||||
initialProps: {
|
||||
history: [
|
||||
@@ -138,6 +142,7 @@ describe("useStreamHistoryWindow", () => {
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
useStreamHistoryWindow({
|
||||
agentId: "agent-1",
|
||||
items: [
|
||||
message("u1", "user_message"),
|
||||
message("a1", "assistant_message"),
|
||||
|
||||
@@ -28,17 +28,22 @@ function findEarlierHistoryWindowStart(input: {
|
||||
}
|
||||
|
||||
interface HistoryWindowBoundary {
|
||||
agentId: string;
|
||||
boundaryItemId: string | null;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
function reconcileHistoryWindow(input: {
|
||||
current: HistoryWindowBoundary;
|
||||
agentId: string;
|
||||
boundaryIndex: number;
|
||||
initialBoundaryItemId: string | null;
|
||||
hasItems: boolean;
|
||||
}): HistoryWindowBoundary {
|
||||
const { current, boundaryIndex, initialBoundaryItemId, hasItems } = input;
|
||||
const { current, agentId, boundaryIndex, initialBoundaryItemId, hasItems } = input;
|
||||
if (current.agentId !== agentId) {
|
||||
return { agentId, boundaryItemId: initialBoundaryItemId, initialized: hasItems };
|
||||
}
|
||||
if (!current.initialized && hasItems) {
|
||||
return { ...current, boundaryItemId: initialBoundaryItemId, initialized: true };
|
||||
}
|
||||
@@ -49,26 +54,28 @@ function reconcileHistoryWindow(input: {
|
||||
}
|
||||
|
||||
export function useStreamHistoryWindow(input: {
|
||||
agentId: string;
|
||||
items: StreamItem[];
|
||||
loadRemoteOlder: () => boolean | Promise<boolean>;
|
||||
}) {
|
||||
const { items, loadRemoteOlder } = input;
|
||||
const { agentId, items, loadRemoteOlder } = input;
|
||||
const initialStart = useMemo(
|
||||
() => findMountedWindowStart({ items, minMountedCount: getMountedRecentStreamItems() }),
|
||||
[items],
|
||||
);
|
||||
const initialBoundaryItemId = initialStart === 0 ? null : (items[initialStart]?.id ?? null);
|
||||
const [window, setWindow] = useState(() => ({
|
||||
agentId,
|
||||
boundaryItemId: initialBoundaryItemId,
|
||||
initialized: items.length > 0,
|
||||
}));
|
||||
const boundaryItemId = window.boundaryItemId;
|
||||
const boundaryItemId = window.agentId === agentId ? window.boundaryItemId : null;
|
||||
const boundaryIndex = useMemo(
|
||||
() => (boundaryItemId === null ? -1 : items.findIndex((item) => item.id === boundaryItemId)),
|
||||
[boundaryItemId, items],
|
||||
);
|
||||
let start = initialStart;
|
||||
if (window.initialized) {
|
||||
if (window.agentId === agentId && window.initialized) {
|
||||
if (window.boundaryItemId === null) {
|
||||
start = 0;
|
||||
} else if (boundaryIndex >= 0) {
|
||||
@@ -80,12 +87,13 @@ export function useStreamHistoryWindow(input: {
|
||||
setWindow((current) =>
|
||||
reconcileHistoryWindow({
|
||||
current,
|
||||
agentId,
|
||||
boundaryIndex,
|
||||
initialBoundaryItemId,
|
||||
hasItems: items.length > 0,
|
||||
}),
|
||||
);
|
||||
}, [boundaryIndex, initialBoundaryItemId, items.length]);
|
||||
}, [agentId, boundaryIndex, initialBoundaryItemId, items.length]);
|
||||
|
||||
const revealLoadedHistory = useCallback(
|
||||
(itemId?: string): boolean => {
|
||||
@@ -98,12 +106,13 @@ export function useStreamHistoryWindow(input: {
|
||||
return false;
|
||||
}
|
||||
setWindow({
|
||||
agentId,
|
||||
boundaryItemId: items[nextStart]?.id ?? null,
|
||||
initialized: true,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[items, start],
|
||||
[agentId, items, start],
|
||||
);
|
||||
const loadOlder = useCallback(async (): Promise<boolean> => {
|
||||
return revealLoadedHistory() || (await loadRemoteOlder());
|
||||
|
||||
@@ -430,6 +430,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
loadOlder: historyPagination.onLoadOlder,
|
||||
}
|
||||
: agentHistoryPagination;
|
||||
useEffect(() => {
|
||||
setExpandedInlineToolCallIds(new Set());
|
||||
setExpandedTurnHostIds(new Set());
|
||||
}, [agentId]);
|
||||
|
||||
const isActive = useRetainedPanelActive();
|
||||
const openInlineFile = useStableEvent(
|
||||
@@ -472,7 +476,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
useEffect(() => {
|
||||
if (!isActive) inlinePathOpener.cancel();
|
||||
return () => inlinePathOpener.cancel();
|
||||
}, [inlinePathOpener, isActive]);
|
||||
}, [inlinePathOpener, isActive, agentId]);
|
||||
const handleInlinePathPress = useStableEvent(
|
||||
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
|
||||
if (isActive) void inlinePathOpener.open(target, disposition);
|
||||
@@ -547,6 +551,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
revealLoadedHistory,
|
||||
loadOlder,
|
||||
} = useStreamHistoryWindow({
|
||||
agentId,
|
||||
items: presentation.tail,
|
||||
loadRemoteOlder,
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
@@ -41,17 +40,6 @@ 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";
|
||||
@@ -67,7 +55,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 +79,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 +410,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>
|
||||
@@ -990,12 +937,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,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],
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -60,6 +60,17 @@ describe("useDraftAgentCreateFlow", () => {
|
||||
|
||||
expect(result.current.isSubmitting).toBe(true);
|
||||
expect(result.current.draftAgent).toEqual({ currentAttempt: attempt });
|
||||
expect(result.current.submittedStreamItems).toEqual([
|
||||
{
|
||||
kind: "user_message",
|
||||
id: "msg-prepared",
|
||||
clientMessageId: "msg-prepared",
|
||||
text: "build this",
|
||||
timestamp: attempt.timestamp,
|
||||
images: [image],
|
||||
attachments: [attachment],
|
||||
},
|
||||
]);
|
||||
expect(createRequest).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -8,8 +8,15 @@ import {
|
||||
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { createUserMessage, type UserMessageImageAttachment } from "@/types/stream";
|
||||
import {
|
||||
createUserMessage,
|
||||
type StreamItem,
|
||||
type UserMessageImageAttachment,
|
||||
} from "@/types/stream";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { PendingMessageSubmission } from "@/composer/submission/model";
|
||||
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
|
||||
interface CreateAttempt {
|
||||
clientMessageId: string;
|
||||
@@ -146,6 +153,38 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
|
||||
const isSubmitting = machine.tag === "creating";
|
||||
|
||||
const submittedStreamItems = useMemo<StreamItem[]>(() => {
|
||||
if (machine.tag !== "creating") {
|
||||
return EMPTY_STREAM_ITEMS;
|
||||
}
|
||||
|
||||
if (
|
||||
!machine.attempt.text &&
|
||||
(!machine.attempt.images || machine.attempt.images.length === 0) &&
|
||||
(!machine.attempt.attachments || machine.attempt.attachments.length === 0)
|
||||
) {
|
||||
return EMPTY_STREAM_ITEMS;
|
||||
}
|
||||
|
||||
return [
|
||||
createUserMessage({
|
||||
clientMessageId: machine.attempt.clientMessageId,
|
||||
text: machine.attempt.text,
|
||||
timestamp: machine.attempt.timestamp,
|
||||
images: machine.attempt.images,
|
||||
attachments: machine.attempt.attachments,
|
||||
}),
|
||||
];
|
||||
}, [machine]);
|
||||
const pendingMessageSubmissions = useMemo<readonly PendingMessageSubmission[]>(() => {
|
||||
if (machine.tag !== "creating") return [];
|
||||
return [
|
||||
{
|
||||
clientMessageId: machine.attempt.clientMessageId,
|
||||
},
|
||||
];
|
||||
}, [machine]);
|
||||
|
||||
const draftAgent = machine.tag === "creating" ? machine.draftAgent : null;
|
||||
const startCreateAttempt = useCallback(
|
||||
(attempt: CreateAttempt) => {
|
||||
@@ -326,6 +365,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
machine,
|
||||
formErrorMessage,
|
||||
isSubmitting,
|
||||
submittedStreamItems,
|
||||
pendingMessageSubmissions,
|
||||
draftAgent,
|
||||
handleCreateFromInput,
|
||||
continueCreateFromAttempt,
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { PendingCreateAttempt } from "@/stores/create-flow-store";
|
||||
import type { UserMessageImageAttachment } from "@/types/stream";
|
||||
import { DRAFT_CAPABILITIES, resolveDraftTimeline } from "./timeline";
|
||||
|
||||
const image: UserMessageImageAttachment = {
|
||||
id: "image-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "image-key",
|
||||
createdAt: 123,
|
||||
};
|
||||
const attachment = {
|
||||
type: "review",
|
||||
cwd: "/repo",
|
||||
summary: "Review",
|
||||
} as unknown as AgentAttachment;
|
||||
|
||||
function pendingAttempt(overrides: Partial<PendingCreateAttempt> = {}): PendingCreateAttempt {
|
||||
return {
|
||||
draftId: "draft-1",
|
||||
serverId: "server-1",
|
||||
agentId: null,
|
||||
clientMessageId: "draft-1:initial-message",
|
||||
text: "build this",
|
||||
timestamp: Date.parse("2026-05-25T00:00:00.000Z"),
|
||||
lifecycle: "active",
|
||||
images: [image],
|
||||
attachments: [attachment],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveDraftTimeline", () => {
|
||||
it("shows the sent prompt pending with the turn working", () => {
|
||||
const timeline = resolveDraftTimeline({
|
||||
pending: pendingAttempt(),
|
||||
serverId: "server-1",
|
||||
tabId: "tab-1",
|
||||
cwd: "/repo",
|
||||
});
|
||||
|
||||
expect(timeline?.agent).toEqual({
|
||||
serverId: "server-1",
|
||||
id: "tab-1",
|
||||
status: "running",
|
||||
cwd: "/repo",
|
||||
capabilities: DRAFT_CAPABILITIES,
|
||||
});
|
||||
expect(timeline?.streamItems).toEqual([
|
||||
{
|
||||
kind: "user_message",
|
||||
id: "draft-1:initial-message",
|
||||
clientMessageId: "draft-1:initial-message",
|
||||
text: "build this",
|
||||
timestamp: new Date("2026-05-25T00:00:00.000Z"),
|
||||
images: [image],
|
||||
attachments: [attachment],
|
||||
},
|
||||
]);
|
||||
expect(timeline?.pendingMessageSubmissions).toEqual([
|
||||
{ clientMessageId: "draft-1:initial-message" },
|
||||
]);
|
||||
expect(timeline?.turnPresentation.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the prompt once the agent is created, until the tab retargets", () => {
|
||||
const timeline = resolveDraftTimeline({
|
||||
pending: pendingAttempt({ lifecycle: "sent", agentId: "agent-1" }),
|
||||
serverId: "server-1",
|
||||
tabId: "tab-1",
|
||||
cwd: "/repo",
|
||||
});
|
||||
|
||||
expect(timeline?.streamItems).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows only the working turn for a prompt without content", () => {
|
||||
const timeline = resolveDraftTimeline({
|
||||
pending: pendingAttempt({ text: "", images: undefined, attachments: undefined }),
|
||||
serverId: "server-1",
|
||||
tabId: "tab-1",
|
||||
cwd: "/repo",
|
||||
});
|
||||
|
||||
expect(timeline?.streamItems).toEqual([]);
|
||||
expect(timeline?.turnPresentation.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it("shows no timeline before a prompt is sent, after it fails, or on another host", () => {
|
||||
const base = { serverId: "server-1", tabId: "tab-1", cwd: "/repo" };
|
||||
|
||||
expect(resolveDraftTimeline({ ...base, pending: undefined })).toBeNull();
|
||||
expect(
|
||||
resolveDraftTimeline({ ...base, pending: pendingAttempt({ lifecycle: "abandoned" }) }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveDraftTimeline({ ...base, pending: pendingAttempt({ serverId: "server-2" }) }),
|
||||
).toBeNull();
|
||||
expect(resolveDraftTimeline({ ...base, cwd: null, pending: pendingAttempt() })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
||||
import type { PendingMessageSubmission } from "@/composer/submission/model";
|
||||
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||
import { type PendingCreateAttempt, useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
resolveTurnPresentation,
|
||||
TURN_LIVENESS_IDLE,
|
||||
type TurnPresentation,
|
||||
} from "@/timeline/turn-liveness";
|
||||
import { createUserMessage, type StreamItem } from "@/types/stream";
|
||||
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
|
||||
import { resolveDraftWorkingDirectory } from "@/composer/draft/workspace-tab-core";
|
||||
|
||||
export const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
};
|
||||
|
||||
export interface DraftConversation {
|
||||
tabId: string;
|
||||
workspaceId: string;
|
||||
draftId: string;
|
||||
setup?: WorkspaceDraftTabSetup;
|
||||
}
|
||||
|
||||
/** The timeline a draft shows while its first prompt creates the agent. */
|
||||
export interface DraftTimeline {
|
||||
agent: AgentScreenAgent;
|
||||
streamItems: StreamItem[];
|
||||
pendingMessageSubmissions: readonly PendingMessageSubmission[];
|
||||
turnPresentation: TurnPresentation;
|
||||
}
|
||||
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
|
||||
/**
|
||||
* The sent prompt, pending, with the turn working. The created agent's timeline carries the same
|
||||
* row (same client message id), so the handoff only changes who owns it.
|
||||
*/
|
||||
export function resolveDraftTimeline(input: {
|
||||
pending: PendingCreateAttempt | undefined;
|
||||
serverId: string;
|
||||
tabId: string;
|
||||
cwd: string | null;
|
||||
}): DraftTimeline | null {
|
||||
const { pending, serverId, tabId, cwd } = input;
|
||||
if (!pending || pending.serverId !== serverId || pending.lifecycle === "abandoned" || !cwd) {
|
||||
return null;
|
||||
}
|
||||
const hasContent =
|
||||
pending.text.length > 0 ||
|
||||
(pending.images?.length ?? 0) > 0 ||
|
||||
(pending.attachments?.length ?? 0) > 0;
|
||||
return {
|
||||
agent: { serverId, id: tabId, status: "running", cwd, capabilities: DRAFT_CAPABILITIES },
|
||||
streamItems: hasContent
|
||||
? [
|
||||
createUserMessage({
|
||||
clientMessageId: pending.clientMessageId,
|
||||
text: pending.text,
|
||||
timestamp: new Date(pending.timestamp),
|
||||
images: pending.images,
|
||||
attachments: pending.attachments,
|
||||
}),
|
||||
]
|
||||
: EMPTY_STREAM_ITEMS,
|
||||
pendingMessageSubmissions: [{ clientMessageId: pending.clientMessageId }],
|
||||
turnPresentation: resolveTurnPresentation(TURN_LIVENESS_IDLE, true),
|
||||
};
|
||||
}
|
||||
|
||||
export function useDraftTimeline(input: {
|
||||
serverId: string;
|
||||
draft: DraftConversation | null;
|
||||
}): DraftTimeline | null {
|
||||
const { serverId, draft } = input;
|
||||
const pending = useCreateFlowStore((state) =>
|
||||
draft ? state.pendingByDraftId[draft.draftId] : undefined,
|
||||
);
|
||||
const workspaceDirectory = useWorkspaceFields(
|
||||
serverId,
|
||||
draft?.workspaceId ?? null,
|
||||
(workspace) => workspace.workspaceDirectory,
|
||||
);
|
||||
const cwd = draft
|
||||
? resolveDraftWorkingDirectory({
|
||||
workspaceDirectory: workspaceDirectory || null,
|
||||
initialSetup: draft.setup ?? null,
|
||||
})
|
||||
: null;
|
||||
const tabId = draft?.tabId ?? "";
|
||||
return useMemo(
|
||||
() => resolveDraftTimeline({ pending, serverId, tabId, cwd }),
|
||||
[cwd, pending, serverId, tabId],
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { resolveSubmissionReadiness } from "@/provider-selection/provider-selection";
|
||||
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
|
||||
|
||||
export interface WorkspaceDraftAutoSubmitConfig {
|
||||
provider: string;
|
||||
@@ -51,13 +50,3 @@ export function validateDraftSubmission(input: {
|
||||
});
|
||||
return readiness.ok ? null : (readiness.reason ?? null);
|
||||
}
|
||||
|
||||
export function resolveDraftWorkingDirectory(input: {
|
||||
workspaceDirectory: string | null;
|
||||
initialSetup: WorkspaceDraftTabSetup | null;
|
||||
}): string | null {
|
||||
if (input.initialSetup) {
|
||||
return input.initialSetup.cwd;
|
||||
}
|
||||
return input.workspaceDirectory;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Keyboard, StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
||||
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ComposerDock } from "@/composer/dock";
|
||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Composer } from "@/composer";
|
||||
import { type ComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session";
|
||||
import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session";
|
||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import { ComposerImportPill } from "@/composer/draft/import-pill";
|
||||
import { COMPOSER_PILL_CLEARANCE } from "@/composer/pill-styles";
|
||||
import { AgentStreamView } from "@/agent-stream/view";
|
||||
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
|
||||
import { useAgentInputDraft } from "@/composer/draft/input-draft";
|
||||
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||
import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "@/composer/draft/create-flow";
|
||||
import { resolveTurnPresentation, TURN_LIVENESS_IDLE } from "@/timeline/turn-liveness";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
@@ -21,13 +25,13 @@ import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||
import {
|
||||
resolveDraftWorkingDirectory,
|
||||
shouldAllowEmptyDraftText,
|
||||
validateDraftSubmission,
|
||||
} from "@/composer/draft/workspace-tab-core";
|
||||
import { DRAFT_CAPABILITIES } from "@/composer/draft/timeline";
|
||||
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
@@ -46,6 +50,16 @@ import {
|
||||
import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
};
|
||||
|
||||
interface AutoSubmitConfig {
|
||||
provider: string;
|
||||
modeId: string | null;
|
||||
@@ -246,15 +260,25 @@ function buildDraftInitialValues(input: {
|
||||
};
|
||||
}
|
||||
|
||||
interface WorkspaceDraftComposerProps {
|
||||
function resolveDraftWorkingDirectory(input: {
|
||||
workspaceDirectory: string | null;
|
||||
initialSetup: WorkspaceDraftTabSetup | null;
|
||||
}): string | null {
|
||||
if (input.initialSetup) {
|
||||
return input.initialSetup.cwd;
|
||||
}
|
||||
return input.workspaceDirectory;
|
||||
}
|
||||
|
||||
interface WorkspaceDraftAgentTabProps {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
tabId: string;
|
||||
draftId: string;
|
||||
initialSetup?: WorkspaceDraftTabSetup;
|
||||
isPaneFocused: boolean;
|
||||
editingSession: ComposerEditingSession;
|
||||
onCreated: (snapshot: AgentSnapshotPayload) => void;
|
||||
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void;
|
||||
onOpenImportSheet?: () => void;
|
||||
}
|
||||
|
||||
@@ -268,21 +292,17 @@ function resolveImportPillPress(
|
||||
return onOpenImportSheet ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the agent from the draft's first prompt. The conversation panel shows that prompt in
|
||||
* the timeline from the create-flow store while the agent is created.
|
||||
*/
|
||||
export function WorkspaceDraftComposer({
|
||||
export function WorkspaceDraftAgentTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
tabId,
|
||||
draftId,
|
||||
initialSetup = undefined,
|
||||
isPaneFocused,
|
||||
editingSession,
|
||||
onCreated,
|
||||
onOpenWorkspaceFile,
|
||||
onOpenImportSheet,
|
||||
}: WorkspaceDraftComposerProps) {
|
||||
}: WorkspaceDraftAgentTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -367,6 +387,7 @@ export function WorkspaceDraftComposer({
|
||||
}, [pendingCreateAttempt]);
|
||||
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const editingSession = useComposerEditingSession("chat");
|
||||
const isComposerFullscreen = useComposerFullscreen(editingSession);
|
||||
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
|
||||
COMPACT_CONTENT_WIDTH,
|
||||
@@ -400,71 +421,82 @@ export function WorkspaceDraftComposer({
|
||||
[isCompactFormFactor, openInSidePane, serverId, workspaceId],
|
||||
);
|
||||
|
||||
const { formErrorMessage, isSubmitting, handleCreateFromInput, continueCreateFromAttempt } =
|
||||
useDraftAgentCreateFlow<Agent, AgentSnapshotPayload>({
|
||||
draftId,
|
||||
getPendingServerId: () => serverId,
|
||||
initialAttempt: initialCreateAttempt,
|
||||
allowEmptyText: allowsEmptyAutoSubmit,
|
||||
validateBeforeSubmit: ({ text, attachments }) => {
|
||||
const allowsEmptyDraftText = shouldAllowEmptyDraftText({
|
||||
allowsEmptyAutoSubmit,
|
||||
attachments,
|
||||
});
|
||||
return validateDraftSubmission({
|
||||
text,
|
||||
allowsEmptyAutoSubmit: allowsEmptyDraftText,
|
||||
composerState,
|
||||
autoSubmitConfig,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
isConnected,
|
||||
});
|
||||
},
|
||||
onBeforeSubmit: async () => {
|
||||
await composerState.persistFormPreferences();
|
||||
if (isWeb) {
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
}
|
||||
Keyboard.dismiss();
|
||||
},
|
||||
buildDraftAgent: (attempt) =>
|
||||
buildDraftAgentSnapshot({
|
||||
attempt,
|
||||
serverId,
|
||||
tabId,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
autoSubmitConfig,
|
||||
composerState,
|
||||
selectModelMessage: t("workspaceSetup.errors.selectModel"),
|
||||
}),
|
||||
createRequest: async ({ attempt, text, images, attachments, cwd }) => {
|
||||
if (pendingAutoSubmit?.agentCreation) {
|
||||
const result = await pendingAutoSubmit.agentCreation.result;
|
||||
return { agentId: result.id, result };
|
||||
}
|
||||
return submitDraftCreateRequest({
|
||||
draftId,
|
||||
attempt,
|
||||
text,
|
||||
images,
|
||||
attachments,
|
||||
cwd,
|
||||
client,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
workspaceId: workspaceFields?.id ?? null,
|
||||
autoSubmitConfig,
|
||||
composerState,
|
||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||
selectModelMessage: t("workspaceSetup.errors.selectModel"),
|
||||
});
|
||||
},
|
||||
onCreateSuccess: ({ result }) => {
|
||||
clearDraftInput("sent");
|
||||
clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey });
|
||||
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
|
||||
onCreated(result);
|
||||
},
|
||||
});
|
||||
const {
|
||||
formErrorMessage,
|
||||
isSubmitting,
|
||||
submittedStreamItems,
|
||||
pendingMessageSubmissions,
|
||||
draftAgent,
|
||||
handleCreateFromInput,
|
||||
continueCreateFromAttempt,
|
||||
} = useDraftAgentCreateFlow<Agent, AgentSnapshotPayload>({
|
||||
draftId,
|
||||
getPendingServerId: () => serverId,
|
||||
initialAttempt: initialCreateAttempt,
|
||||
allowEmptyText: allowsEmptyAutoSubmit,
|
||||
validateBeforeSubmit: ({ text, attachments }) => {
|
||||
const allowsEmptyDraftText = shouldAllowEmptyDraftText({
|
||||
allowsEmptyAutoSubmit,
|
||||
attachments,
|
||||
});
|
||||
return validateDraftSubmission({
|
||||
text,
|
||||
allowsEmptyAutoSubmit: allowsEmptyDraftText,
|
||||
composerState,
|
||||
autoSubmitConfig,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
isConnected,
|
||||
});
|
||||
},
|
||||
onBeforeSubmit: async () => {
|
||||
await composerState.persistFormPreferences();
|
||||
if (isWeb) {
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
}
|
||||
Keyboard.dismiss();
|
||||
},
|
||||
buildDraftAgent: (attempt) =>
|
||||
buildDraftAgentSnapshot({
|
||||
attempt,
|
||||
serverId,
|
||||
tabId,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
autoSubmitConfig,
|
||||
composerState,
|
||||
selectModelMessage: t("workspaceSetup.errors.selectModel"),
|
||||
}),
|
||||
createRequest: async ({ attempt, text, images, attachments, cwd }) => {
|
||||
if (pendingAutoSubmit?.agentCreation) {
|
||||
const result = await pendingAutoSubmit.agentCreation.result;
|
||||
return { agentId: result.id, result };
|
||||
}
|
||||
return submitDraftCreateRequest({
|
||||
draftId,
|
||||
attempt,
|
||||
text,
|
||||
images,
|
||||
attachments,
|
||||
cwd,
|
||||
client,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
workspaceId: workspaceFields?.id ?? null,
|
||||
autoSubmitConfig,
|
||||
composerState,
|
||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||
selectModelMessage: t("workspaceSetup.errors.selectModel"),
|
||||
});
|
||||
},
|
||||
onCreateSuccess: ({ result }) => {
|
||||
clearDraftInput("sent");
|
||||
clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey });
|
||||
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
|
||||
onCreated(result);
|
||||
},
|
||||
});
|
||||
const turnPresentation = useMemo(
|
||||
() => resolveTurnPresentation(TURN_LIVENESS_IDLE, pendingMessageSubmissions.length > 0),
|
||||
[pendingMessageSubmissions],
|
||||
);
|
||||
useAgentControlCommandCenterActions({
|
||||
sourceId: `draft:${serverId}:${tabId}`,
|
||||
enabled: isPaneFocused && !isSubmitting,
|
||||
@@ -568,49 +600,76 @@ export function WorkspaceDraftComposer({
|
||||
}),
|
||||
[composerState.agentControls, handleDropdownCloseFocus, isSubmitting],
|
||||
);
|
||||
return (
|
||||
<View style={animatedStaticStyles.inputAreaWrapper} onLayout={onInputAreaLayout}>
|
||||
{formErrorMessage ? (
|
||||
<View style={styles.formErrorRow}>
|
||||
<View style={styles.formError}>
|
||||
<Text style={styles.errorText}>{formErrorMessage}</Text>
|
||||
</View>
|
||||
const dockContent = (
|
||||
<View style={styles.contentContainer}>
|
||||
{isSubmitting && draftAgent ? (
|
||||
<View style={styles.streamContainer}>
|
||||
<AgentStreamView
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
context={draftAgent}
|
||||
streamItems={submittedStreamItems}
|
||||
pendingMessageSubmissions={pendingMessageSubmissions}
|
||||
turnPresentation={turnPresentation}
|
||||
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
{importPillPress && !isComposerFullscreen ? (
|
||||
<View style={styles.importPillRow}>
|
||||
<View style={styles.importPillContent}>
|
||||
<ComposerImportPill onPress={importPillPress} />
|
||||
) : (
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={styles.configScrollContent}>
|
||||
<View style={styles.configSection}>
|
||||
{formErrorMessage ? (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{formErrorMessage}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
<Composer
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
textSource={draftInput.textSource}
|
||||
onChangeText={draftInput.editText}
|
||||
textReplacement={draftInput.textReplacement}
|
||||
attachments={draftInput.attachments}
|
||||
attachmentScopeKeys={attachmentScopeKeys}
|
||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||
onChangeAttachments={draftInput.setAttachments}
|
||||
cwd={composerState.workingDir}
|
||||
clearDraft={draftInput.clear}
|
||||
editingSession={editingSession}
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
|
||||
onFocusInput={handleFocusInputCallback}
|
||||
commandDraft={composerState.commandDraft}
|
||||
agentControls={composerAgentControls}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<FileDropZone style={styles.container}>
|
||||
<ComposerDock fullscreen={isComposerFullscreen}>
|
||||
{dockContent}
|
||||
<View style={animatedStaticStyles.inputAreaWrapper} onLayout={onInputAreaLayout}>
|
||||
{importPillPress && !isComposerFullscreen ? (
|
||||
<View style={styles.importPillRow}>
|
||||
<View style={styles.importPillContent}>
|
||||
<ComposerImportPill onPress={importPillPress} />
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
<Composer
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
textSource={draftInput.textSource}
|
||||
onChangeText={draftInput.editText}
|
||||
textReplacement={draftInput.textReplacement}
|
||||
attachments={draftInput.attachments}
|
||||
attachmentScopeKeys={attachmentScopeKeys}
|
||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||
onChangeAttachments={draftInput.setAttachments}
|
||||
cwd={composerState.workingDir}
|
||||
clearDraft={draftInput.clear}
|
||||
editingSession={editingSession}
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
|
||||
onFocusInput={handleFocusInputCallback}
|
||||
commandDraft={composerState.commandDraft}
|
||||
agentControls={composerAgentControls}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
</View>
|
||||
</ComposerDock>
|
||||
</FileDropZone>
|
||||
);
|
||||
}
|
||||
|
||||
const animatedStaticStyles = RNStyleSheet.create({
|
||||
@@ -623,10 +682,27 @@ const animatedStaticStyles = RNStyleSheet.create({
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
formErrorRow: {
|
||||
container: {
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
streamContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
configScrollContent: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
paddingTop: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[6],
|
||||
},
|
||||
configSection: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
importPillRow: {
|
||||
width: "100%",
|
||||
@@ -646,10 +722,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
maxWidth: theme.contentMaxWidth,
|
||||
flexDirection: "row",
|
||||
},
|
||||
formError: {
|
||||
width: "100%",
|
||||
maxWidth: theme.contentMaxWidth,
|
||||
marginBottom: theme.spacing[2],
|
||||
errorContainer: {
|
||||
marginTop: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1236,7 +1236,6 @@ export const ar: TranslationResources = {
|
||||
hosts: "المضيفون",
|
||||
settings: "إعدادات",
|
||||
closeSidebar: "إغلاق الشريط الجانبي",
|
||||
moreActions: "المزيد من الإجراءات",
|
||||
},
|
||||
footer: {
|
||||
usage: "الاستخدام",
|
||||
|
||||
@@ -1244,7 +1244,6 @@ export const en = {
|
||||
hosts: "Hosts",
|
||||
settings: "Settings",
|
||||
closeSidebar: "Close sidebar",
|
||||
moreActions: "More actions",
|
||||
},
|
||||
footer: {
|
||||
usage: "Usage",
|
||||
|
||||
@@ -1272,7 +1272,6 @@ export const es: TranslationResources = {
|
||||
hosts: "Hosts",
|
||||
settings: "Ajustes",
|
||||
closeSidebar: "Cerrar barra lateral",
|
||||
moreActions: "Más acciones",
|
||||
},
|
||||
footer: {
|
||||
usage: "Uso",
|
||||
|
||||
@@ -1266,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",
|
||||
|
||||
@@ -1250,7 +1250,6 @@ export const ja: TranslationResources = {
|
||||
hosts: "ホスト",
|
||||
settings: "設定",
|
||||
closeSidebar: "サイドバーを閉じる",
|
||||
moreActions: "その他のアクション",
|
||||
},
|
||||
footer: {
|
||||
usage: "使用状況",
|
||||
|
||||
@@ -1243,7 +1243,6 @@ export const ko: TranslationResources = {
|
||||
hosts: "호스트",
|
||||
settings: "설정",
|
||||
closeSidebar: "사이드바 닫기",
|
||||
moreActions: "추가 작업",
|
||||
},
|
||||
footer: {
|
||||
usage: "사용량",
|
||||
|
||||
@@ -1262,7 +1262,6 @@ export const ptBR: TranslationResources = {
|
||||
hosts: "Hosts",
|
||||
settings: "Configurações",
|
||||
closeSidebar: "Fechar barra lateral",
|
||||
moreActions: "Mais ações",
|
||||
},
|
||||
footer: {
|
||||
usage: "Uso",
|
||||
|
||||
@@ -1254,7 +1254,6 @@ export const ru: TranslationResources = {
|
||||
hosts: "Хосты",
|
||||
settings: "Настройки",
|
||||
closeSidebar: "Закрыть боковую панель",
|
||||
moreActions: "Дополнительные действия",
|
||||
},
|
||||
footer: {
|
||||
usage: "Использование",
|
||||
|
||||
@@ -1228,7 +1228,6 @@ export const zhCN: TranslationResources = {
|
||||
hosts: "Hosts",
|
||||
settings: "设置",
|
||||
closeSidebar: "关闭侧边栏",
|
||||
moreActions: "更多操作",
|
||||
},
|
||||
footer: {
|
||||
usage: "使用情况",
|
||||
|
||||
@@ -78,8 +78,7 @@ import {
|
||||
deriveRouteBottomAnchorIntent,
|
||||
deriveRouteBottomAnchorRequest,
|
||||
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
||||
import { WorkspaceDraftComposer } from "@/composer/draft/workspace-tab";
|
||||
import { type DraftConversation, useDraftTimeline } from "@/composer/draft/timeline";
|
||||
import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
|
||||
import { AgentTracks } from "@/panels/agent-tracks";
|
||||
import { ParentAgentCallout, useParentAgentId } from "@/subagents/parent";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
@@ -91,13 +90,14 @@ import {
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey, type WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { StreamItem, TodoEntry } from "@/types/stream";
|
||||
import type { ViewedTimelineStatus, ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync";
|
||||
import { useArchiveFinishedSubagents, useSubagentsForParent } from "@/subagents";
|
||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
|
||||
@@ -225,29 +225,6 @@ function AgentLoadErrorView({ message, onRetry }: { message: string; onRetry: ()
|
||||
);
|
||||
}
|
||||
|
||||
/** A draft has no agent to wait for; an agent shows its loading and error states until ready. */
|
||||
function renderConversationNonReadyView(args: {
|
||||
draft: DraftConversation | null;
|
||||
agentId: string | undefined;
|
||||
cwd: string | null;
|
||||
viewState: AgentScreenViewState;
|
||||
effectiveAgent: AgentScreenAgent | null;
|
||||
t: TFunction;
|
||||
onRetryLoad: () => void;
|
||||
}): React.ReactElement | null {
|
||||
if (args.draft) {
|
||||
return null;
|
||||
}
|
||||
const nonReadyView = renderChatAgentNonReadyView(args);
|
||||
if (nonReadyView) {
|
||||
return nonReadyView;
|
||||
}
|
||||
invariant(args.agentId, "agent id is defined when agent content is ready");
|
||||
invariant(args.effectiveAgent, "effectiveAgent is defined when the non-ready view is absent");
|
||||
invariant(args.cwd, "agent cwd is defined when agent content is ready");
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderChatAgentNonReadyView(args: {
|
||||
viewState: AgentScreenViewState;
|
||||
effectiveAgent: AgentScreenAgent | null;
|
||||
@@ -326,31 +303,17 @@ function storeFetchedAgentDetail(input: {
|
||||
return hydrated;
|
||||
}
|
||||
|
||||
type ConversationTarget = Extract<WorkspaceTabTarget, { kind: "agent" } | { kind: "draft" }>;
|
||||
|
||||
/** The agent a conversation shows; a draft has none until its first prompt creates one. */
|
||||
function resolveConversationAgentId(target: ConversationTarget): string | undefined {
|
||||
return target.kind === "agent" ? target.agentId.trim() || undefined : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drafts and agents render through one chain so the timeline stays mounted when the draft's
|
||||
* first prompt creates the agent and the tab retargets. The agent gates are idle for a draft.
|
||||
*/
|
||||
export function AgentConversationPanel() {
|
||||
function AgentPanel() {
|
||||
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
|
||||
const { isInteractive } = usePaneFocus();
|
||||
invariant(
|
||||
target.kind === "agent" || target.kind === "draft",
|
||||
"AgentConversationPanel requires an agent or draft target",
|
||||
);
|
||||
invariant(target.kind === "agent", "AgentPanel requires agent target");
|
||||
|
||||
return (
|
||||
<RetainedChatContent>
|
||||
<AgentPanelContent
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
target={target}
|
||||
agentId={target.agentId}
|
||||
isPaneFocused={isInteractive}
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
/>
|
||||
@@ -358,6 +321,55 @@ export function AgentConversationPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
function DraftPanel() {
|
||||
const {
|
||||
serverId,
|
||||
workspaceId,
|
||||
tabId,
|
||||
target,
|
||||
openFileInWorkspace,
|
||||
openImportSheet,
|
||||
retargetCurrentTab,
|
||||
} = usePaneContext();
|
||||
const { isInteractive } = usePaneFocus();
|
||||
invariant(target.kind === "draft", "DraftPanel requires draft target");
|
||||
|
||||
const handleCreated = useCallback(
|
||||
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => {
|
||||
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
|
||||
const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized });
|
||||
getHostRuntimeStore().acceptAgentSnapshot(serverId, agent);
|
||||
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
|
||||
},
|
||||
[retargetCurrentTab, serverId],
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceDraftAgentTab
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
tabId={tabId}
|
||||
draftId={target.draftId}
|
||||
initialSetup={target.setup}
|
||||
isPaneFocused={isInteractive}
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
onCreated={handleCreated}
|
||||
onOpenImportSheet={openImportSheet}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentConversationPanel() {
|
||||
const { target } = usePaneContext();
|
||||
if (target.kind === "draft") {
|
||||
return <DraftPanel />;
|
||||
}
|
||||
if (target.kind === "agent") {
|
||||
return <AgentPanel />;
|
||||
}
|
||||
invariant(false, "AgentConversationPanel requires an agent or draft target");
|
||||
}
|
||||
|
||||
export const agentPanelRegistration = definePanel("agent", {
|
||||
component: AgentConversationPanel,
|
||||
useDescriptor: useAgentDescriptor,
|
||||
@@ -432,18 +444,18 @@ type AgentLookupState =
|
||||
function AgentPanelContent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target,
|
||||
agentId,
|
||||
isPaneFocused,
|
||||
onOpenWorkspaceFile,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
target: ConversationTarget;
|
||||
agentId: string;
|
||||
isPaneFocused: boolean;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedAgentId = resolveConversationAgentId(target);
|
||||
const resolvedAgentId = agentId.trim() || undefined;
|
||||
const resolvedServerId = serverId.trim() || undefined;
|
||||
const daemons = useHosts();
|
||||
const runtimeServerId = resolvedServerId ?? "";
|
||||
@@ -472,7 +484,7 @@ function AgentPanelContent({
|
||||
: runtimeConnectionStatus;
|
||||
const lastConnectionError = runtimeLastError;
|
||||
|
||||
if (target.kind === "agent" && (!resolvedServerId || (!runtimeClient && !hasCachedAgent))) {
|
||||
if (!resolvedServerId || (!runtimeClient && !hasCachedAgent)) {
|
||||
return (
|
||||
<AgentSessionUnavailableState
|
||||
serverLabel={serverLabel}
|
||||
@@ -486,9 +498,9 @@ function AgentPanelContent({
|
||||
|
||||
return (
|
||||
<AgentPanelBody
|
||||
serverId={resolvedServerId ?? serverId}
|
||||
serverId={resolvedServerId}
|
||||
workspaceId={workspaceId}
|
||||
target={target}
|
||||
agentId={resolvedAgentId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
client={runtimeClient}
|
||||
isConnected={runtimeIsConnected}
|
||||
@@ -500,7 +512,7 @@ function AgentPanelContent({
|
||||
function AgentPanelBody({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target,
|
||||
agentId,
|
||||
isPaneFocused,
|
||||
client,
|
||||
isConnected,
|
||||
@@ -508,14 +520,13 @@ function AgentPanelBody({
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
target: ConversationTarget;
|
||||
agentId?: string;
|
||||
isPaneFocused: boolean;
|
||||
client: ReturnType<typeof useHostRuntimeClient>;
|
||||
isConnected: boolean;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const agentId = resolveConversationAgentId(target);
|
||||
const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent();
|
||||
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
||||
const projectPlacement = useStoreWithEqualityFn(
|
||||
@@ -630,7 +641,7 @@ function AgentPanelBody({
|
||||
}
|
||||
: null;
|
||||
|
||||
if (target.kind === "agent" && !agent) {
|
||||
if (!agent) {
|
||||
return (
|
||||
<View style={styles.container} testID="agent-loading">
|
||||
<View style={styles.errorContainer}>
|
||||
@@ -644,7 +655,7 @@ function AgentPanelBody({
|
||||
<ChatAgentContent
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
target={target}
|
||||
agentId={agentId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
client={client}
|
||||
isConnected={isConnected}
|
||||
@@ -656,7 +667,7 @@ function AgentPanelBody({
|
||||
function ChatAgentContent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target,
|
||||
agentId,
|
||||
isPaneFocused,
|
||||
client,
|
||||
isConnected,
|
||||
@@ -664,15 +675,13 @@ function ChatAgentContent({
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
target: ConversationTarget;
|
||||
agentId?: string;
|
||||
isPaneFocused: boolean;
|
||||
client: ReturnType<typeof useHostRuntimeClient>;
|
||||
isConnected: boolean;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { tabId } = usePaneContext();
|
||||
const agentId = resolveConversationAgentId(target);
|
||||
const isPaneVisible = useRetainedPanelActive();
|
||||
const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost();
|
||||
const { isArchivingAgent } = useArchiveAgent();
|
||||
@@ -979,23 +988,17 @@ function ChatAgentContent({
|
||||
visibilityCatchUpStatus,
|
||||
]);
|
||||
|
||||
const draft = useMemo<DraftConversation | null>(
|
||||
() =>
|
||||
target.kind === "draft"
|
||||
? { tabId, workspaceId, draftId: target.draftId, setup: target.setup }
|
||||
: null,
|
||||
[tabId, target, workspaceId],
|
||||
);
|
||||
const nonReadyView = renderConversationNonReadyView({
|
||||
draft,
|
||||
agentId,
|
||||
cwd: agentState.cwd,
|
||||
const nonReadyView = renderChatAgentNonReadyView({
|
||||
viewState,
|
||||
effectiveAgent,
|
||||
t,
|
||||
onRetryLoad: retryAgentLoad,
|
||||
});
|
||||
if (nonReadyView) return nonReadyView;
|
||||
invariant(agentId, "agent id is defined when agent content is ready");
|
||||
invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent");
|
||||
const agentCwd = agentState.cwd;
|
||||
invariant(agentCwd, "agent cwd is defined when agent content is ready");
|
||||
const showHistorySyncOverlay =
|
||||
viewState.tag === "ready" &&
|
||||
viewState.sync.status === "catching_up" &&
|
||||
@@ -1010,8 +1013,7 @@ function ChatAgentContent({
|
||||
<ChatAgentReadyContent
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={agentId ?? tabId}
|
||||
draft={draft}
|
||||
agentId={agentId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
||||
agentState={agentState}
|
||||
@@ -1029,7 +1031,7 @@ function ChatAgentContent({
|
||||
showHistorySyncOverlay={showHistorySyncOverlay}
|
||||
showHistorySyncError={showHistorySyncError}
|
||||
isRetryingHistorySync={isRetryingHistorySync}
|
||||
cwd={agentState.cwd ?? ""}
|
||||
cwd={agentCwd}
|
||||
retryTimelineSync={retryTimelineSync}
|
||||
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
||||
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
||||
@@ -1042,7 +1044,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
draft,
|
||||
isPaneFocused,
|
||||
isArchivingCurrentAgent,
|
||||
agentState,
|
||||
@@ -1068,13 +1069,11 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
/** The agent's id, or the tab id while a draft has no agent yet. */
|
||||
agentId: string;
|
||||
draft: DraftConversation | null;
|
||||
isPaneFocused: boolean;
|
||||
isArchivingCurrentAgent: boolean;
|
||||
agentState: ChatAgentSelectedState;
|
||||
effectiveAgent: AgentScreenAgent | null;
|
||||
effectiveAgent: AgentScreenAgent;
|
||||
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
||||
hasAppliedAuthoritativeHistory: boolean;
|
||||
toastApi: ToastApi;
|
||||
@@ -1096,11 +1095,19 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// The workspace sheet is for looking; Open as tab brings the agent back with its composer.
|
||||
const paneHost = usePaneContext().host;
|
||||
const showsComposer = draft !== null || paneHost !== "sheet";
|
||||
const showsComposer = usePaneContext().host !== "sheet";
|
||||
// The dock lays out fullscreen for the same session the composer edits in.
|
||||
const composerEditingSession = useComposerEditingSession("chat");
|
||||
const isComposerFullscreen = useComposerFullscreen(composerEditingSession);
|
||||
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
|
||||
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
|
||||
state.sessions[serverId]?.agentTasks.get(agentId),
|
||||
);
|
||||
const archiveFinishedSubagents = useArchiveFinishedSubagents({
|
||||
serverId,
|
||||
parentAgentId: agentId,
|
||||
rows: subagentRows,
|
||||
});
|
||||
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
|
||||
const parentAgentId = useParentAgentId(serverId, agentId);
|
||||
// With a composer the parent is a pill above it; without one, the box takes the composer's place.
|
||||
@@ -1154,7 +1161,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
const parentCallout = parentAgentId ? (
|
||||
<ParentAgentCallout serverId={serverId} parentAgentId={parentAgentId} />
|
||||
) : null;
|
||||
const agentComposerSection = showsComposer ? (
|
||||
const composerSection = showsComposer ? (
|
||||
<RenderProfile id={`AgentComposerSection:${agentId}`}>
|
||||
<AgentComposerSection
|
||||
agentId={agentId}
|
||||
@@ -1175,16 +1182,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
) : (
|
||||
parentCallout
|
||||
);
|
||||
const composerSection = draft ? (
|
||||
<DraftComposerSection
|
||||
serverId={serverId}
|
||||
draft={draft}
|
||||
isPaneFocused={isPaneFocused}
|
||||
editingSession={composerEditingSession}
|
||||
/>
|
||||
) : (
|
||||
agentComposerSection
|
||||
);
|
||||
const streamContent = (
|
||||
<View style={animatedStaticStyles.content}>
|
||||
{showsComposer ? null : <SheetAgentFacts serverId={serverId} agentId={agentId} />}
|
||||
@@ -1194,7 +1191,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
agent={effectiveAgent}
|
||||
draft={draft}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||
hasActiveComposer={hasActiveComposer}
|
||||
@@ -1202,12 +1198,16 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
</RenderProfile>
|
||||
{hasActiveComposer && !draft ? (
|
||||
{hasActiveComposer ? (
|
||||
<AgentTracks
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={agentId}
|
||||
parentAgentId={parentPillAgentId}
|
||||
subagentRows={subagentRows}
|
||||
tasks={tasks}
|
||||
archiveFinishedStatus={archiveFinishedSubagents.status}
|
||||
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -1263,7 +1263,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
</RewindComposerRestoreProvider>
|
||||
);
|
||||
return wrapAgentCommandProvider({
|
||||
enabled: showsComposer && hasActiveComposer && !draft,
|
||||
enabled: showsComposer && hasActiveComposer,
|
||||
serverId,
|
||||
agentId,
|
||||
editingSession: composerEditingSession,
|
||||
@@ -1355,7 +1355,6 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
serverId,
|
||||
agentId,
|
||||
agent,
|
||||
draft,
|
||||
routeBottomAnchorRequest,
|
||||
hasAppliedAuthoritativeHistory,
|
||||
hasActiveComposer,
|
||||
@@ -1365,8 +1364,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
|
||||
serverId: string;
|
||||
agentId?: string;
|
||||
agent: AgentScreenAgent | null;
|
||||
draft: DraftConversation | null;
|
||||
agent: AgentScreenAgent;
|
||||
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
||||
hasAppliedAuthoritativeHistory: boolean;
|
||||
hasActiveComposer: boolean;
|
||||
@@ -1424,31 +1422,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
}
|
||||
return new Map(pendingPermissionList.map((permission) => [permission.key, permission]));
|
||||
}, [pendingPermissionList]);
|
||||
// A draft's timeline exists only while its first prompt creates the agent. The created agent's
|
||||
// timeline then replaces this input in the same mounted view.
|
||||
const draftTimeline = useDraftTimeline({ serverId, draft });
|
||||
|
||||
if (draftTimeline) {
|
||||
return (
|
||||
<AgentStreamView
|
||||
ref={streamViewRef}
|
||||
agentId={draftTimeline.agent.id}
|
||||
serverId={serverId}
|
||||
context={draftTimeline.agent}
|
||||
streamItems={draftTimeline.streamItems}
|
||||
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
|
||||
bottomOverlayTailClearance={bottomOverlayTailClearance}
|
||||
bottomOverlayControlClearance={bottomOverlayControlClearance}
|
||||
toast={toast}
|
||||
pendingMessageSubmissions={draftTimeline.pendingMessageSubmissions}
|
||||
turnPresentation={draftTimeline.turnPresentation}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<AgentStreamView
|
||||
ref={streamViewRef}
|
||||
@@ -1469,44 +1443,6 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
);
|
||||
});
|
||||
|
||||
/** The draft's composer, which creates the agent and retargets this tab to it. */
|
||||
function DraftComposerSection({
|
||||
serverId,
|
||||
draft,
|
||||
isPaneFocused,
|
||||
editingSession,
|
||||
}: {
|
||||
serverId: string;
|
||||
draft: DraftConversation;
|
||||
isPaneFocused: boolean;
|
||||
editingSession: ComposerEditingSession;
|
||||
}) {
|
||||
const { openImportSheet, retargetCurrentTab } = usePaneContext();
|
||||
const handleCreated = useCallback(
|
||||
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => {
|
||||
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
|
||||
const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized });
|
||||
getHostRuntimeStore().acceptAgentSnapshot(serverId, agent);
|
||||
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
|
||||
},
|
||||
[retargetCurrentTab, serverId],
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceDraftComposer
|
||||
serverId={serverId}
|
||||
workspaceId={draft.workspaceId}
|
||||
tabId={draft.tabId}
|
||||
draftId={draft.draftId}
|
||||
initialSetup={draft.setup}
|
||||
isPaneFocused={isPaneFocused}
|
||||
editingSession={editingSession}
|
||||
onCreated={handleCreated}
|
||||
onOpenImportSheet={openImportSheet}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const AgentComposerSection = memo(function AgentComposerSection({
|
||||
agentId,
|
||||
serverId,
|
||||
|
||||
@@ -9,11 +9,11 @@ import { useSettings } from "@/hooks/use-settings";
|
||||
import { PluginComposerPills } from "@/plugins";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
useArchiveFinishedSubagents,
|
||||
type ArchiveFinishedStatus,
|
||||
useArchiveSubagent,
|
||||
useDetachSubagent,
|
||||
useSubagentSheetTab,
|
||||
useSubagentsForParent,
|
||||
type SubagentRow,
|
||||
} from "@/subagents";
|
||||
import { ParentAgentPill } from "@/subagents/parent";
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
@@ -35,21 +35,20 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
workspaceId,
|
||||
agentId,
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
onArchiveFinished,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
parentAgentId: string | null;
|
||||
subagentRows: SubagentRow[];
|
||||
tasks: TodoEntry[] | undefined;
|
||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||
onArchiveFinished: () => void;
|
||||
}): ReactElement {
|
||||
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
|
||||
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
|
||||
state.sessions[serverId]?.agentTasks.get(agentId),
|
||||
);
|
||||
const archiveFinishedSubagents = useArchiveFinishedSubagents({
|
||||
serverId,
|
||||
parentAgentId: agentId,
|
||||
rows: subagentRows,
|
||||
});
|
||||
const { tabId } = usePaneContext();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const openInSidePane = useSettings((settings) => settings.openInSidePane);
|
||||
@@ -120,8 +119,8 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
onOpenProviderSubagent={handleOpenProviderSubagent}
|
||||
sheetTabFor={sheetTabFor}
|
||||
onArchiveSubagent={archiveSubagent}
|
||||
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
|
||||
archiveFinishedStatus={archiveFinishedSubagents.status}
|
||||
onArchiveFinished={onArchiveFinished}
|
||||
archiveFinishedStatus={archiveFinishedStatus}
|
||||
onDetachSubagent={canDetachSubagents ? detachSubagent : undefined}
|
||||
/>
|
||||
<PluginComposerPills
|
||||
|
||||
@@ -390,39 +390,4 @@ describe("AgentDirectoryReplica", () => {
|
||||
expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true);
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
it("keeps unchanged agent fields when an update changes only the status", () => {
|
||||
const serverId = "agent-replica-unchanged-fields";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const replica = new AgentDirectoryReplica(
|
||||
serverId,
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
const agentInStore = () => useSessionStore.getState().sessions[serverId]?.agents.get("agent");
|
||||
replica.commitSnapshot([entry(payload("agent"))], []);
|
||||
const before = agentInStore();
|
||||
|
||||
replica.applyDelta({
|
||||
kind: "upsert",
|
||||
agent: { ...payload("agent"), status: "running", updatedAt: "2026-07-17T00:02:00.000Z" },
|
||||
project: entry(payload("agent")).project,
|
||||
});
|
||||
const afterDelta = agentInStore();
|
||||
replica.commitSnapshot(
|
||||
[entry({ ...payload("agent"), status: "idle", updatedAt: "2026-07-17T00:03:00.000Z" })],
|
||||
[],
|
||||
);
|
||||
const afterSnapshot = agentInStore();
|
||||
|
||||
expect(afterDelta?.status).toBe("running");
|
||||
expect(afterSnapshot?.status).toBe("idle");
|
||||
for (const agent of [afterDelta, afterSnapshot]) {
|
||||
expect(agent?.capabilities).toBe(before?.capabilities);
|
||||
expect(agent?.availableModes).toBe(before?.availableModes);
|
||||
expect(agent?.labels).toBe(before?.labels);
|
||||
expect(agent?.projectPlacement).toBe(before?.projectPlacement);
|
||||
}
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,26 +21,6 @@ function withoutAgent(agentId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every update arrives as a whole new agent, so each nested value (capabilities, modes, labels) is
|
||||
* a new object even when it did not change. Keep the stored value for every field that is equal,
|
||||
* and the stored agent when nothing changed, so a reader of one field sees a new value only when
|
||||
* that field changes.
|
||||
*/
|
||||
function keepUnchangedFields(stored: Agent, incoming: Agent): Agent {
|
||||
const next = { ...incoming };
|
||||
let changed = Object.keys(stored).length !== Object.keys(next).length;
|
||||
for (const key of Object.keys(next) as (keyof Agent)[]) {
|
||||
if (next[key] === stored[key]) continue;
|
||||
if (key in stored && equal(next[key], stored[key])) {
|
||||
Object.assign(next, { [key]: stored[key] });
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : stored;
|
||||
}
|
||||
|
||||
function mergeSnapshotTurn(previous: Agent | undefined, incoming: Agent): Agent {
|
||||
if (!previous) return incoming;
|
||||
const activeTurn =
|
||||
@@ -79,8 +59,7 @@ export class AgentStoreProjection {
|
||||
let accepted = agent;
|
||||
useSessionStore.getState().setAgents(this.serverId, (current) => {
|
||||
const previous = current.get(agent.id);
|
||||
const winner = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent));
|
||||
accepted = previous ? keepUnchangedFields(previous, winner) : winner;
|
||||
accepted = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent));
|
||||
if (accepted === previous) return current;
|
||||
const next = new Map(current);
|
||||
next.set(agent.id, accepted);
|
||||
@@ -138,7 +117,7 @@ export class AgentStoreProjection {
|
||||
for (const [agentId, fetchedAgent] of fetched) {
|
||||
const existing = current.get(agentId);
|
||||
const merged = mergeSnapshotTurn(existing, fetchedAgent);
|
||||
agents.set(agentId, existing ? keepUnchangedFields(existing, merged) : merged);
|
||||
agents.set(agentId, existing && equal(existing, merged) ? existing : merged);
|
||||
if (fetchedAgent.archivedAt) {
|
||||
clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -870,12 +870,8 @@ function WorkspaceDocumentTitleEffectSlot({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The header floats over a conversation's timeline. A draft and the agent it creates share one
|
||||
* timeline, so their surfaces match and the timeline does not move when the tab retargets.
|
||||
*/
|
||||
function floatsHeaderOverContent(activeTab: WorkspaceTabDescriptor | null): boolean {
|
||||
return supportsStreamTopOverlay && (activeTab?.kind === "agent" || activeTab?.kind === "draft");
|
||||
return supportsStreamTopOverlay && activeTab?.kind === "agent";
|
||||
}
|
||||
|
||||
/** A fullscreen composer covers the compact header; the column lays out without it. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -126,23 +126,6 @@ describe("agent directory reconciliation", () => {
|
||||
}).toEqual({ title: "newer page", status: "running", projectName: "repo" });
|
||||
});
|
||||
|
||||
it("takes the project of a newer buffered upsert", () => {
|
||||
const snapshotEntry = entry("agent", "idle");
|
||||
const newerProject = { ...snapshotEntry.project, projectName: "renamed project" };
|
||||
const result = reconcileAgentDirectory({
|
||||
snapshot: [snapshotEntry],
|
||||
deltas: [
|
||||
{
|
||||
kind: "upsert",
|
||||
agent: { ...snapshot("agent", "idle"), updatedAt: "2026-07-12T11:00:00.000Z" },
|
||||
project: newerProject,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result[0]?.project.projectName).toBe("renamed project");
|
||||
});
|
||||
|
||||
it("clears a snapshot stop when a newer buffered upsert is running", () => {
|
||||
const result = reconcileAgentDirectory({
|
||||
snapshot: [entry("agent", "idle")],
|
||||
|
||||
@@ -4189,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",
|
||||
|
||||
@@ -33,9 +33,9 @@ async function main(): Promise<void> {
|
||||
|
||||
const apiKey = requireEnv("OPENAI_API_KEY");
|
||||
const transcriptionModel = process.env.STT_MODEL ?? "gpt-4o-transcribe";
|
||||
const prompt =
|
||||
process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT ??
|
||||
"Transcribe only what the speaker says. Do not add words. Preserve punctuation and casing. If the audio is silence or non-speech noise, return an empty transcript.";
|
||||
// Mirrors the daemon: no built-in prompt, only an explicit override. Whisper
|
||||
// treats `prompt` as preceding transcript, so a default would bias the baseline.
|
||||
const prompt = process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT?.trim() || undefined;
|
||||
|
||||
const openai = new OpenAI({ apiKey });
|
||||
|
||||
@@ -43,7 +43,7 @@ async function main(): Promise<void> {
|
||||
file: await import("node:fs").then((fs) => fs.createReadStream(audioPath)),
|
||||
language: "en",
|
||||
model: transcriptionModel,
|
||||
prompt,
|
||||
...(prompt ? { prompt } : {}),
|
||||
response_format: "json",
|
||||
// Aim for determinism.
|
||||
temperature: 0,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -57,11 +57,13 @@ class FakeRealtimeSession extends EventEmitter implements StreamingTranscription
|
||||
class FakeSttProvider implements SpeechToTextProvider {
|
||||
public readonly id = "fake";
|
||||
public lastLanguage?: string;
|
||||
public lastPrompt?: string;
|
||||
constructor(private readonly session: FakeRealtimeSession) {}
|
||||
createSession(
|
||||
params: Parameters<SpeechToTextProvider["createSession"]>[0],
|
||||
): StreamingTranscriptionSession {
|
||||
this.lastLanguage = params.language;
|
||||
this.lastPrompt = params.prompt;
|
||||
return this.session;
|
||||
}
|
||||
}
|
||||
@@ -594,3 +596,58 @@ it("closes every dictation stream when one provider cleanup fails", async () =>
|
||||
expect(second.closed).toBe(true);
|
||||
expect(manager.hasDemand).toBe(false);
|
||||
});
|
||||
|
||||
describe("DictationStreamManager (transcription prompt)", () => {
|
||||
const originalPromptEnv = process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPromptEnv === undefined) {
|
||||
delete process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT;
|
||||
} else {
|
||||
process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT = originalPromptEnv;
|
||||
}
|
||||
});
|
||||
|
||||
const startDictation = async (
|
||||
options: { language?: string } = {},
|
||||
): Promise<{ provider: FakeSttProvider; manager: DictationStreamManager }> => {
|
||||
const session = new FakeRealtimeSession();
|
||||
const provider = new FakeSttProvider(session);
|
||||
const manager = new DictationStreamManager({
|
||||
logger: pino({ level: "silent" }),
|
||||
emit: () => {},
|
||||
sessionId: "prompt",
|
||||
stt: provider,
|
||||
...(options.language ? { language: options.language } : {}),
|
||||
});
|
||||
await manager.handleStart("d1", "audio/pcm;rate=24000;bits=16");
|
||||
manager.cleanupAll();
|
||||
return { provider, manager };
|
||||
};
|
||||
|
||||
it("sends no prompt by default", async () => {
|
||||
delete process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT;
|
||||
const { provider } = await startDictation();
|
||||
expect(provider.lastPrompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends no prompt for non-English dictation by default", async () => {
|
||||
delete process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT;
|
||||
const { provider } = await startDictation({ language: "zh" });
|
||||
expect(provider.lastLanguage).toBe("zh");
|
||||
expect(provider.lastPrompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes an explicit prompt override through", async () => {
|
||||
process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT =
|
||||
" Vocabulary: Paseo, Dokploy, Kubernetes. ";
|
||||
const { provider } = await startDictation();
|
||||
expect(provider.lastPrompt).toBe("Vocabulary: Paseo, Dokploy, Kubernetes.");
|
||||
});
|
||||
|
||||
it.each(["", " "])("treats a blank override (%j) as no prompt", async (value) => {
|
||||
process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT = value;
|
||||
const { provider } = await startDictation();
|
||||
expect(provider.lastPrompt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,12 @@ function parseNonNegativeNumber(value: string | undefined): number | null {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** Unset, empty, and whitespace-only overrides all mean "send no prompt". */
|
||||
function readTranscriptionPromptOverride(env: NodeJS.ProcessEnv): string | undefined {
|
||||
const value = env.PASEO_DICTATION_TRANSCRIPTION_PROMPT?.trim();
|
||||
return value ? value : undefined;
|
||||
}
|
||||
|
||||
function convertPCMToWavBuffer(
|
||||
pcmBuffer: Buffer,
|
||||
sampleRate: number,
|
||||
@@ -185,16 +191,16 @@ export class DictationStreamManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const transcriptionPrompt =
|
||||
process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT ??
|
||||
"Transcribe only what the speaker says. Do not add words. Preserve punctuation and casing. If the audio is silence or non-speech noise, return an empty transcript.";
|
||||
// Whisper uses the prompt as transcript context, not silence instructions.
|
||||
// Keep context opt-in so it does not bias speech or introduce unspoken text.
|
||||
const transcriptionPrompt = readTranscriptionPromptOverride(process.env);
|
||||
|
||||
let stt: ReturnType<SpeechToTextProvider["createSession"]>;
|
||||
try {
|
||||
stt = sttProvider.createSession({
|
||||
logger: this.logger.child({ dictationId }),
|
||||
language: this.language,
|
||||
prompt: transcriptionPrompt,
|
||||
...(transcriptionPrompt ? { prompt: transcriptionPrompt } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -270,16 +270,6 @@ describe.skipIf(isPlatform("win32"))("terminal worker reaper", () => {
|
||||
await reapTerminalWorkers({ paseoHome: home, inspector, gracefulMs: 0 });
|
||||
|
||||
expect(isAlive(unrelated.pid!)).toBe(true);
|
||||
// Completion means the recorded process is gone, even if its PID is occupied.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
!isAlive(original.pid) ||
|
||||
classifyTerminalProgramProcess(original, await inspector.inspect(original.pid)).status ===
|
||||
"unrelated",
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toBe(true);
|
||||
expect(existsSync(recordPath)).toBe(false);
|
||||
expect(
|
||||
classifyTerminalProgramProcess(original, await inspector.inspect(original.pid)),
|
||||
|
||||
@@ -867,18 +867,7 @@ it.each([false, true])(
|
||||
writeFileSync(exitRequestPath, "");
|
||||
await waitForCondition(() => first.getTerminal(ending.id) === undefined, 10000);
|
||||
expect(existsSync(exitObservedPath)).toBe(true);
|
||||
const originalEnding = record!.terminals.find((entry) => entry.id === ending.id)!;
|
||||
// Exit delivery does not reserve the PID. Wait for the original process to
|
||||
// disappear, allowing Windows to reuse its PID just as in the restart check below.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
!isProcessAlive(endingPid) ||
|
||||
classifyTerminalProgramProcess(originalEnding, await inspector.inspect(endingPid))
|
||||
.status === "unrelated",
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toBe(true);
|
||||
expect(isProcessAlive(endingPid)).toBe(false);
|
||||
} finally {
|
||||
if (recordFd !== null) closeSync(recordFd);
|
||||
}
|
||||
|
||||
@@ -25,9 +25,6 @@
|
||||
# 10. A size change keeps only a row shouldRestorePosition accepts in place (prepareMVCP), as a
|
||||
# data change already does, so a disclosure that only grows (a todo card) holds the cut-off
|
||||
# row instead of the first row in view.
|
||||
# 11. A 0 x 0 measurement is not a size (updateItemSizesBatch): rows of a hidden retained tab
|
||||
# (display: none) still render and measure 0 x 0. Recording it collapsed the row, and a row
|
||||
# whose frame was unchanged when the tab showed again got no layout event to correct it.
|
||||
#
|
||||
# Upgrading: drop each hunk whose fix the new version ships, regenerate with
|
||||
# `npx patch-package @legendapp/list`, and copy this header back on top.
|
||||
@@ -48,7 +45,7 @@ index 983e937..970ba51 100644
|
||||
* Returns a stable item type used for pooling and size estimation.
|
||||
*/
|
||||
diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js
|
||||
index eeb3fb0..5fe940d 100644
|
||||
index eeb3fb0..b1689ad 100644
|
||||
--- a/node_modules/@legendapp/list/react-native.js
|
||||
+++ b/node_modules/@legendapp/list/react-native.js
|
||||
@@ -1497,7 +1497,15 @@ function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, no
|
||||
@@ -208,19 +205,7 @@ index eeb3fb0..5fe940d 100644
|
||||
}
|
||||
|
||||
// src/core/updateItemSizes.ts
|
||||
@@ -5291,6 +5335,11 @@ function updateItemSizesBatch(ctx, measurements) {
|
||||
const result = {};
|
||||
const previousTotalSize = state.totalSize;
|
||||
for (const measurement of measurements) {
|
||||
+ // Paseo patch: a view inside a display: none ancestor measures 0 x 0. That is no measurement: a
|
||||
+ // laid-out row is as wide as the list even when it has no height. Recording 0 there collapsed
|
||||
+ // the row, and when the list showed again a row whose frame had not changed got no layout event
|
||||
+ // to correct it, so the next row overlapped it.
|
||||
+ if (measurement.size.width === 0 && measurement.size.height === 0) continue;
|
||||
const ownsMeasuredItem = measurement.containerId === void 0 || isContainerItemCurrent(ctx, measurement.containerId, measurement.itemKey);
|
||||
if (ownsMeasuredItem) {
|
||||
const index = state.indexByKey.get(measurement.itemKey);
|
||||
@@ -5414,7 +5463,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
|
||||
@@ -5414,7 +5458,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
|
||||
state.fixedItemSizeKeys.add(itemKey);
|
||||
}
|
||||
const prevSizeKnown = sizesKnown.get(itemKey);
|
||||
@@ -229,7 +214,7 @@ index eeb3fb0..5fe940d 100644
|
||||
return 0;
|
||||
}
|
||||
const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize);
|
||||
@@ -6283,6 +6332,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
|
||||
@@ -6283,6 +6327,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
|
||||
|
||||
// src/components/Containers.native.tsx
|
||||
var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0;
|
||||
@@ -240,7 +225,7 @@ index eeb3fb0..5fe940d 100644
|
||||
var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
children,
|
||||
freshDataTransitionEpoch,
|
||||
@@ -6290,7 +6343,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
@@ -6290,7 +6338,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
}) {
|
||||
const ctx = useStateContext();
|
||||
const columnWrapperStyle = ctx.columnWrapperStyle;
|
||||
@@ -249,7 +234,7 @@ index eeb3fb0..5fe940d 100644
|
||||
const minSize = useCommittedMinSize();
|
||||
const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
|
||||
const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch);
|
||||
@@ -6513,6 +6566,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
|
||||
@@ -6513,6 +6561,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
|
||||
null
|
||||
);
|
||||
});
|
||||
@@ -264,7 +249,7 @@ index eeb3fb0..5fe940d 100644
|
||||
var ListComponent = typedMemo(function ListComponent2({
|
||||
canRender,
|
||||
freshDataTransitionEpoch,
|
||||
@@ -6651,7 +6712,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
@@ -6651,7 +6707,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style
|
||||
},
|
||||
/* @__PURE__ */ React2__namespace.createElement(ScrollAdjust, null),
|
||||
@@ -273,7 +258,7 @@ index eeb3fb0..5fe940d 100644
|
||||
ListEmptyComponent && getComponent(ListEmptyComponent),
|
||||
shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2__namespace.createElement(AlignItemsAtEndSpacer, { horizontal }),
|
||||
canRender && !ListEmptyComponent && /* @__PURE__ */ React2__namespace.createElement(
|
||||
@@ -6665,7 +6726,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
@@ -6665,7 +6721,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
stickyHeaderConfig
|
||||
}
|
||||
),
|
||||
@@ -282,7 +267,7 @@ index eeb3fb0..5fe940d 100644
|
||||
IS_DEV && ENABLE_DEVMODE
|
||||
);
|
||||
});
|
||||
@@ -7886,7 +7947,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
|
||||
@@ -7886,7 +7942,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
|
||||
const state = ctx.state;
|
||||
if (stylePaddingTop !== void 0) {
|
||||
const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0;
|
||||
@@ -294,7 +279,7 @@ index eeb3fb0..5fe940d 100644
|
||||
let prevTotalSize = peek$(ctx, "totalSize") || 0;
|
||||
set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop);
|
||||
state.timeoutSetPaddingTop = setTimeout(() => {
|
||||
@@ -7989,6 +8053,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
@@ -7989,6 +8048,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
estimatedListSize,
|
||||
extraData,
|
||||
getFixedItemSize,
|
||||
@@ -302,7 +287,7 @@ index eeb3fb0..5fe940d 100644
|
||||
getItemType,
|
||||
horizontal,
|
||||
rtl,
|
||||
@@ -8282,6 +8347,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
@@ -8282,6 +8342,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
dataVersion,
|
||||
drawDistance,
|
||||
estimatedItemSize,
|
||||
@@ -311,7 +296,7 @@ index eeb3fb0..5fe940d 100644
|
||||
getItemType: useWrapIfItem(getItemType),
|
||||
hasExternalScroll,
|
||||
diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs
|
||||
index fe5f415..7f1b9dd 100644
|
||||
index fe5f415..e61945e 100644
|
||||
--- a/node_modules/@legendapp/list/react-native.mjs
|
||||
+++ b/node_modules/@legendapp/list/react-native.mjs
|
||||
@@ -1476,7 +1476,15 @@ function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, no
|
||||
@@ -471,19 +456,7 @@ index fe5f415..7f1b9dd 100644
|
||||
}
|
||||
|
||||
// src/core/updateItemSizes.ts
|
||||
@@ -5270,6 +5314,11 @@ function updateItemSizesBatch(ctx, measurements) {
|
||||
const result = {};
|
||||
const previousTotalSize = state.totalSize;
|
||||
for (const measurement of measurements) {
|
||||
+ // Paseo patch: a view inside a display: none ancestor measures 0 x 0. That is no measurement: a
|
||||
+ // laid-out row is as wide as the list even when it has no height. Recording 0 there collapsed
|
||||
+ // the row, and when the list showed again a row whose frame had not changed got no layout event
|
||||
+ // to correct it, so the next row overlapped it.
|
||||
+ if (measurement.size.width === 0 && measurement.size.height === 0) continue;
|
||||
const ownsMeasuredItem = measurement.containerId === void 0 || isContainerItemCurrent(ctx, measurement.containerId, measurement.itemKey);
|
||||
if (ownsMeasuredItem) {
|
||||
const index = state.indexByKey.get(measurement.itemKey);
|
||||
@@ -5393,7 +5442,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
|
||||
@@ -5393,7 +5437,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
|
||||
state.fixedItemSizeKeys.add(itemKey);
|
||||
}
|
||||
const prevSizeKnown = sizesKnown.get(itemKey);
|
||||
@@ -492,7 +465,7 @@ index fe5f415..7f1b9dd 100644
|
||||
return 0;
|
||||
}
|
||||
const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize);
|
||||
@@ -6262,6 +6311,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
|
||||
@@ -6262,6 +6306,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
|
||||
|
||||
// src/components/Containers.native.tsx
|
||||
var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0;
|
||||
@@ -503,7 +476,7 @@ index fe5f415..7f1b9dd 100644
|
||||
var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
children,
|
||||
freshDataTransitionEpoch,
|
||||
@@ -6269,7 +6322,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
@@ -6269,7 +6317,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
}) {
|
||||
const ctx = useStateContext();
|
||||
const columnWrapperStyle = ctx.columnWrapperStyle;
|
||||
@@ -512,7 +485,7 @@ index fe5f415..7f1b9dd 100644
|
||||
const minSize = useCommittedMinSize();
|
||||
const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
|
||||
const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch);
|
||||
@@ -6492,6 +6545,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
|
||||
@@ -6492,6 +6540,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
|
||||
null
|
||||
);
|
||||
});
|
||||
@@ -527,7 +500,7 @@ index fe5f415..7f1b9dd 100644
|
||||
var ListComponent = typedMemo(function ListComponent2({
|
||||
canRender,
|
||||
freshDataTransitionEpoch,
|
||||
@@ -6630,7 +6691,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
@@ -6630,7 +6686,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style
|
||||
},
|
||||
/* @__PURE__ */ React2.createElement(ScrollAdjust, null),
|
||||
@@ -536,7 +509,7 @@ index fe5f415..7f1b9dd 100644
|
||||
ListEmptyComponent && getComponent(ListEmptyComponent),
|
||||
shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2.createElement(AlignItemsAtEndSpacer, { horizontal }),
|
||||
canRender && !ListEmptyComponent && /* @__PURE__ */ React2.createElement(
|
||||
@@ -6644,7 +6705,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
@@ -6644,7 +6700,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
stickyHeaderConfig
|
||||
}
|
||||
),
|
||||
@@ -545,7 +518,7 @@ index fe5f415..7f1b9dd 100644
|
||||
IS_DEV && ENABLE_DEVMODE
|
||||
);
|
||||
});
|
||||
@@ -7865,7 +7926,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
|
||||
@@ -7865,7 +7921,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
|
||||
const state = ctx.state;
|
||||
if (stylePaddingTop !== void 0) {
|
||||
const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0;
|
||||
@@ -557,7 +530,7 @@ index fe5f415..7f1b9dd 100644
|
||||
let prevTotalSize = peek$(ctx, "totalSize") || 0;
|
||||
set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop);
|
||||
state.timeoutSetPaddingTop = setTimeout(() => {
|
||||
@@ -7968,6 +8032,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
@@ -7968,6 +8027,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
estimatedListSize,
|
||||
extraData,
|
||||
getFixedItemSize,
|
||||
@@ -565,7 +538,7 @@ index fe5f415..7f1b9dd 100644
|
||||
getItemType,
|
||||
horizontal,
|
||||
rtl,
|
||||
@@ -8261,6 +8326,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
@@ -8261,6 +8321,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
dataVersion,
|
||||
drawDistance,
|
||||
estimatedItemSize,
|
||||
|
||||
@@ -142,8 +142,19 @@ Keys and base URLs follow the fallback rule above.
|
||||
- `PASEO_VOICE_LOCAL_STT_MODEL`, `PASEO_VOICE_LOCAL_TTS_MODEL`, local voice STT/TTS model IDs
|
||||
- `PASEO_DICTATION_LANGUAGE`, dictation STT language
|
||||
- `PASEO_VOICE_LANGUAGE`, voice mode STT language; falls back to `PASEO_DICTATION_LANGUAGE` when unset
|
||||
- `PASEO_DICTATION_TRANSCRIPTION_PROMPT`, optional OpenAI dictation transcription prompt (including compatible servers). Unset by default; empty or whitespace-only is treated as unset
|
||||
- `PASEO_VOICE_LOCAL_TTS_SPEAKER_ID`, `PASEO_VOICE_LOCAL_TTS_SPEED`, optional local voice TTS tuning
|
||||
|
||||
## Transcription Prompt
|
||||
|
||||
With the OpenAI provider, including compatible servers, Paseo sends no transcription prompt by default. Whisper-family models use `prompt` as transcript context, so instruction text can distort the transcript or bias its language. Omitting it does not prevent every hallucination on silence or noise.
|
||||
|
||||
For OpenAI dictation, set `PASEO_DICTATION_TRANSCRIPTION_PROMPT` to send a prompt explicitly, for example to bias toward project vocabulary. An empty or whitespace-only value means no prompt. Local providers ignore this setting, and voice mode never sends a prompt.
|
||||
|
||||
```bash
|
||||
PASEO_DICTATION_TRANSCRIPTION_PROMPT="Vocabulary: Paseo, TypeScript."
|
||||
```
|
||||
|
||||
## Operational Notes
|
||||
|
||||
Voice mode can launch and control agents. Treat voice prompts with the same care as direct agent instructions, especially when specifying working directories or destructive operations.
|
||||
|
||||
@@ -120,8 +120,19 @@ Paseo 在配置的 OpenAI base URL 下使用以下路径:
|
||||
- `PASEO_VOICE_LOCAL_STT_MODEL`、`PASEO_VOICE_LOCAL_TTS_MODEL`:本地语音 STT/TTS 模型 ID
|
||||
- `PASEO_DICTATION_LANGUAGE`:口述 STT 语言(仅限 OpenAI STT;本地 Parakeet 会忽略)
|
||||
- `PASEO_VOICE_LANGUAGE`:语音模式 STT 语言;未设置时退回到 `PASEO_DICTATION_LANGUAGE`(仅限 OpenAI STT;本地 Parakeet 会忽略)
|
||||
- `PASEO_DICTATION_TRANSCRIPTION_PROMPT`:可选的 OpenAI 口述转录提示词(包括兼容服务器)。默认不设置;空值或仅含空白字符的值视为未设置
|
||||
- `PASEO_VOICE_LOCAL_TTS_SPEAKER_ID`、`PASEO_VOICE_LOCAL_TTS_SPEED`:可选的本地语音 TTS 调优
|
||||
|
||||
## 转录提示词 {#transcription-prompt}
|
||||
|
||||
使用 OpenAI Provider(包括兼容服务器)进行口述时,Paseo 默认不发送转录提示词。Whisper 系列模型把 `prompt` 当作转录上下文,因此指令文本可能使转录失真或影响输出语言。不发送提示词也无法消除静音或噪声中的所有幻觉。
|
||||
|
||||
对于 OpenAI 口述,可以设置 `PASEO_DICTATION_TRANSCRIPTION_PROMPT` 来显式发送提示词,例如提示项目术语。空值或仅含空白字符的值表示不发送提示词。本地 Provider 会忽略此设置,语音模式也不会发送提示词。
|
||||
|
||||
```bash
|
||||
PASEO_DICTATION_TRANSCRIPTION_PROMPT="Vocabulary: Paseo, TypeScript."
|
||||
```
|
||||
|
||||
## 使用须知 {#operational-notes}
|
||||
|
||||
语音模式可以启动和控制 Agent。请像对待直接的 Agent 指令一样谨慎对待语音提示词,尤其是在指定工作目录或执行破坏性操作时。
|
||||
|
||||
Reference in New Issue
Block a user