mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 18:22:38 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c532f32227 | ||
|
|
31f9657679 | ||
|
|
72e3316f4f | ||
|
|
566218d682 | ||
|
|
b8bb335c56 | ||
|
|
e01a38d943 | ||
|
|
a25a8ac676 | ||
|
|
c08657d22a |
+1
-1
@@ -1 +1 @@
|
||||
sha256-n6m3m0541/mBbMN1xrh/2uBCRQc62yicK1cdvyyUmmU=
|
||||
sha256-DZOPOf81PkUxdD65BrfkTZhs6/DkfDWCFZ/SU+QrZbg=
|
||||
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
} from "../support/helpers/settings";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
async function openWorkspace(
|
||||
page: import("@playwright/test").Page,
|
||||
@@ -53,6 +55,74 @@ async function openWorkspace(
|
||||
}
|
||||
|
||||
test.describe("Settings sidebar navigation", () => {
|
||||
test("opening Settings closes the composer model selector until it is opened again", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "settings-model-selector-" });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
|
||||
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
|
||||
const popover = page.getByTestId("combobox-desktop-container");
|
||||
await selector.click();
|
||||
await expect(popover).toBeVisible();
|
||||
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+,`);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await page.screenshot({ path: testInfo.outputPath("settings-after-model-selector.png") });
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(selector).toBeVisible();
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await selector.click();
|
||||
await expect(popover).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(popover).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("opening Settings closes an existing chat's model selector until it is opened again", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "settings-existing-model-",
|
||||
title: "Settings model selector",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
|
||||
const popover = page.getByTestId("combobox-desktop-container");
|
||||
await selector.click();
|
||||
await expect(popover).toBeVisible();
|
||||
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+,`);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("settings-after-existing-model-selector.png"),
|
||||
});
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(selector).toBeVisible();
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await selector.click();
|
||||
await expect(popover).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(popover).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
@@ -147,6 +217,42 @@ test.describe("Settings sidebar navigation", () => {
|
||||
test.describe("Settings — compact master-detail", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("the compact tab switcher reopens after Settings hides its workspace", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "settings-tab-switcher-",
|
||||
title: "Compact tab switcher",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const switcher = page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true });
|
||||
const newAgent = page.getByTestId("workspace-tab-switcher-new-agent");
|
||||
await switcher.click();
|
||||
await expect(newAgent).toBeInViewport();
|
||||
|
||||
// Navigate without dismissing the sheet through its backdrop first.
|
||||
await page
|
||||
.getByTestId("menu-button")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.dispatchEvent("click");
|
||||
await page.getByTestId("sidebar-settings").first().dispatchEvent("click");
|
||||
await expectAppRoute(page, buildSettingsRoute());
|
||||
await expect(newAgent).toHaveCount(0);
|
||||
|
||||
await closeCompactSettings(page);
|
||||
await expect(switcher).toBeVisible();
|
||||
await expect(newAgent).toHaveCount(0);
|
||||
await switcher.click();
|
||||
await expect(newAgent).toBeInViewport();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opens compact app and host details and returns through the settings list", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
@@ -910,13 +911,9 @@ function resolveInitialActiveIndex(
|
||||
return selectedIndex >= 0 ? selectedIndex : fallbackIndex;
|
||||
}
|
||||
|
||||
type BottomSheetVisibility = ReturnType<typeof useIsolatedBottomSheetVisibility>;
|
||||
|
||||
interface MobileBodyProps {
|
||||
bottomSheetRef: BottomSheetVisibility["sheetRef"];
|
||||
isOpen: boolean;
|
||||
snapPoints: string[];
|
||||
handleSheetChange: BottomSheetVisibility["handleSheetChange"];
|
||||
handleSheetDismiss: BottomSheetVisibility["handleSheetDismiss"];
|
||||
handleIndicatorStyle: { backgroundColor: string };
|
||||
titleColor: string;
|
||||
title: string;
|
||||
@@ -944,7 +941,22 @@ interface MobileBodyProps {
|
||||
safeAreaBottom: number;
|
||||
}
|
||||
|
||||
function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
||||
function MobileComboboxBody(props: MobileBodyProps): ReactElement | null {
|
||||
const active = useRetainedPanelActive();
|
||||
if (!active) return null;
|
||||
// The visibility tracker must leave the tree with its sheet.
|
||||
return <MobileComboboxSheet {...props} />;
|
||||
}
|
||||
|
||||
function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
|
||||
const {
|
||||
sheetRef: bottomSheetRef,
|
||||
handleSheetChange,
|
||||
handleSheetDismiss,
|
||||
} = useIsolatedBottomSheetVisibility({
|
||||
visible: props.isOpen,
|
||||
onClose: props.onClose,
|
||||
});
|
||||
const comboboxTitleStyle = useMemo(
|
||||
() => [styles.comboboxTitle, { color: props.titleColor }],
|
||||
[props.titleColor],
|
||||
@@ -979,13 +991,13 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
||||
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={props.bottomSheetRef}
|
||||
ref={bottomSheetRef}
|
||||
contextBridge={null}
|
||||
snapPoints={props.snapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={props.handleSheetChange}
|
||||
onDismiss={props.handleSheetDismiss}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropOpacity={0.45}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={ComboboxSheetBackground}
|
||||
@@ -1168,7 +1180,8 @@ function DesktopComboboxOptionsBody(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
|
||||
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null {
|
||||
const active = useRetainedPanelActive();
|
||||
const handleDesktopKey = props.handleDesktopKey;
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
@@ -1190,6 +1203,9 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
|
||||
[props.refs, setWebOverlayScope],
|
||||
);
|
||||
|
||||
// Remove the portal before a retained chat can freeze its closing update.
|
||||
if (!active) return null;
|
||||
|
||||
const overlay = (
|
||||
<OverlayLayerProvider layer={props.overlayLayer}>
|
||||
<View
|
||||
@@ -1309,6 +1325,7 @@ export function Combobox({
|
||||
const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch");
|
||||
const resolvedTitle = title ?? t("common.actions.select");
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const active = useRetainedPanelActive();
|
||||
const floatingLayer = useOverlayLayer("floating");
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const titleColor = theme.colors.foreground;
|
||||
@@ -1348,6 +1365,10 @@ export function Combobox({
|
||||
bumpSearchResetKey();
|
||||
}, [setOpen, setSearchQueryWithCallback]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active && isOpen) handleClose();
|
||||
}, [active, isOpen, handleClose]);
|
||||
|
||||
useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey);
|
||||
|
||||
const collisionPadding = useMemo(computeCollisionPadding, []);
|
||||
@@ -1413,16 +1434,6 @@ export function Combobox({
|
||||
referenceWidth,
|
||||
});
|
||||
|
||||
const {
|
||||
sheetRef: bottomSheetRef,
|
||||
handleSheetChange,
|
||||
handleSheetDismiss,
|
||||
} = useIsolatedBottomSheetVisibility({
|
||||
visible: isOpen,
|
||||
isEnabled: isMobile,
|
||||
onClose: handleClose,
|
||||
});
|
||||
|
||||
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
|
||||
const sanitizedSearchValue = searchQuery.trim();
|
||||
const showCustomOption = useMemo(
|
||||
@@ -1572,10 +1583,8 @@ export function Combobox({
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileComboboxBody
|
||||
bottomSheetRef={bottomSheetRef}
|
||||
isOpen={isOpen}
|
||||
snapPoints={snapPoints}
|
||||
handleSheetChange={handleSheetChange}
|
||||
handleSheetDismiss={handleSheetDismiss}
|
||||
handleIndicatorStyle={handleIndicatorStyle}
|
||||
titleColor={titleColor}
|
||||
title={resolvedTitle}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
resolveTerminalGridMetricsMeasurement,
|
||||
resolveMeasuredTerminalCellMetrics,
|
||||
resolveTerminalCursorOffset,
|
||||
resolveTerminalTextLetterSpacing,
|
||||
} from "./terminal-grid-metrics";
|
||||
|
||||
describe("native terminal grid metrics", () => {
|
||||
@@ -63,6 +64,39 @@ describe("native terminal grid metrics", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([1, 2, 2.75, 3, 3.5])(
|
||||
"spaces font glyphs so text ends on the pixel-snapped cell grid at density %s",
|
||||
(density) => {
|
||||
const measurement = {
|
||||
measuredTextWidth: 72.246,
|
||||
measuredTextHeight: 16.2,
|
||||
measureTextLength: 10,
|
||||
roundToNearestPixel: (value: number) => Math.round(value * density) / density,
|
||||
};
|
||||
const { cellWidth } = resolveMeasuredTerminalCellMetrics(measurement);
|
||||
const letterSpacing = resolveTerminalTextLetterSpacing({
|
||||
...measurement,
|
||||
cellWidth,
|
||||
cellsPerGlyph: 1,
|
||||
});
|
||||
const glyphAdvance = measurement.measuredTextWidth / measurement.measureTextLength;
|
||||
|
||||
expect(80 * (glyphAdvance + letterSpacing)).toBeCloseTo(80 * cellWidth, 10);
|
||||
},
|
||||
);
|
||||
|
||||
it("spaces wide fallback glyphs to exactly two cells each", () => {
|
||||
const cellWidth = 22 / 3;
|
||||
const letterSpacing = resolveTerminalTextLetterSpacing({
|
||||
measuredTextWidth: 103.8,
|
||||
measureTextLength: 10,
|
||||
cellWidth,
|
||||
cellsPerGlyph: 2,
|
||||
});
|
||||
|
||||
expect(24 * (10.38 + letterSpacing)).toBeCloseTo(48 * cellWidth, 10);
|
||||
});
|
||||
|
||||
it("emits measured metrics once until the measurement changes", () => {
|
||||
const measured = { cellWidth: 7.2, cellHeight: 16 };
|
||||
|
||||
|
||||
@@ -44,6 +44,21 @@ export function resolveMeasuredTerminalCellMetrics(
|
||||
};
|
||||
}
|
||||
|
||||
export interface TerminalTextLetterSpacingInput {
|
||||
measuredTextWidth: number;
|
||||
measureTextLength: number;
|
||||
cellWidth: number;
|
||||
cellsPerGlyph: 1 | 2;
|
||||
}
|
||||
|
||||
// Cells are snapped to the pixel grid but glyphs advance by their font's natural width, so a run
|
||||
// of N glyphs ends N * (cells * cellWidth - advance) away from its cells and the cursor. The
|
||||
// letter spacing makes up that difference per glyph.
|
||||
export function resolveTerminalTextLetterSpacing(input: TerminalTextLetterSpacingInput): number {
|
||||
const textLength = Math.max(1, input.measureTextLength);
|
||||
return input.cellsPerGlyph * input.cellWidth - input.measuredTextWidth / textLength;
|
||||
}
|
||||
|
||||
export function resolveTerminalGridMetricsMeasurement(
|
||||
previous: TerminalGridCellMetrics | null,
|
||||
next: TerminalGridCellMetrics,
|
||||
|
||||
@@ -25,14 +25,18 @@ import {
|
||||
resolveTerminalCustomGlyphCellTransform,
|
||||
resolveTerminalGridMetricsMeasurement,
|
||||
resolveTerminalCursorOffset,
|
||||
resolveTerminalTextLetterSpacing,
|
||||
type TerminalGridCellMetrics,
|
||||
} from "./terminal-grid-metrics";
|
||||
import {
|
||||
buildRows,
|
||||
TERMINAL_WIDE_GLYPH_CLASSES,
|
||||
type TerminalCustomGlyphCell,
|
||||
type TerminalCustomGlyphRun,
|
||||
type TerminalRowModel,
|
||||
type TerminalRun,
|
||||
type TerminalTextRun,
|
||||
type TerminalWideGlyphClass,
|
||||
} from "./terminal-row-model";
|
||||
import type { TerminalSelectionRange } from "./terminal-selection";
|
||||
|
||||
@@ -40,12 +44,17 @@ const MEASURE_TEXT = "mmmmmmmmmm";
|
||||
const DEFAULT_FONT_SIZE = 12;
|
||||
const INITIAL_CELL_WIDTH_RATIO = 0.62;
|
||||
const INITIAL_CELL_HEIGHT_RATIO = 1.35;
|
||||
// Keep native line layout clear of the cell boundary: rounding and trailing letter spacing can
|
||||
// otherwise wrap a short run. The run box clips ordinary text; isolated fallback glyphs can spill.
|
||||
const TEXT_LAYOUT_OVERFLOW_CELLS = 2;
|
||||
|
||||
interface CellMetrics {
|
||||
cellWidth: number;
|
||||
cellHeight: number;
|
||||
}
|
||||
|
||||
type TerminalWideGlyphStyles = Readonly<Record<TerminalWideGlyphClass, TextStyle>>;
|
||||
|
||||
interface TerminalGridViewport {
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -57,6 +66,8 @@ interface TerminalGridRowProps {
|
||||
cellHeight: number;
|
||||
fontFamily?: string;
|
||||
fontSize: number;
|
||||
letterSpacing: number;
|
||||
wideGlyphStyles: TerminalWideGlyphStyles;
|
||||
styleEpoch: string;
|
||||
}
|
||||
|
||||
@@ -65,6 +76,7 @@ interface TerminalGridRunProps {
|
||||
cellWidth: number;
|
||||
cellHeight: number;
|
||||
textStyle: StyleProp<TextStyle>;
|
||||
wideGlyphStyles: TerminalWideGlyphStyles;
|
||||
}
|
||||
|
||||
export interface TerminalGridViewProps {
|
||||
@@ -99,21 +111,55 @@ function resolveVisibleCols(input: {
|
||||
return Math.min(input.gridCols, Math.max(1, Math.floor(input.viewportWidth / input.cellWidth)));
|
||||
}
|
||||
|
||||
function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGridRunProps) {
|
||||
function renderTerminalTextSegments(
|
||||
run: TerminalTextRun,
|
||||
wideGlyphStyles: TerminalWideGlyphStyles,
|
||||
) {
|
||||
if (!run.segments.some((segment) => segment.wideGlyphClass)) {
|
||||
return run.text;
|
||||
}
|
||||
let offset = 0;
|
||||
return run.segments.map((segment) => {
|
||||
const key = `${offset}:${segment.wideGlyphClass ?? "font"}`;
|
||||
offset += segment.text.length;
|
||||
return (
|
||||
<Text
|
||||
key={key}
|
||||
style={segment.wideGlyphClass ? wideGlyphStyles[segment.wideGlyphClass] : undefined}
|
||||
>
|
||||
{segment.text}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function TerminalGridRun({
|
||||
run,
|
||||
cellWidth,
|
||||
cellHeight,
|
||||
textStyle,
|
||||
wideGlyphStyles,
|
||||
}: TerminalGridRunProps) {
|
||||
const isolated = run.renderKind === "text" && run.isolated;
|
||||
const runStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [
|
||||
styles.run,
|
||||
isolated ? styles.isolatedRun : styles.run,
|
||||
{
|
||||
backgroundColor: run.style.backgroundColor,
|
||||
height: cellHeight,
|
||||
width: run.cellCount * cellWidth,
|
||||
},
|
||||
],
|
||||
[cellHeight, cellWidth, run.cellCount, run.style.backgroundColor],
|
||||
[cellHeight, cellWidth, isolated, run.cellCount, run.style.backgroundColor],
|
||||
);
|
||||
const runTextStyle = useMemo<StyleProp<TextStyle>>(
|
||||
() => [textStyle, run.style],
|
||||
[run.style, textStyle],
|
||||
() => [
|
||||
textStyle,
|
||||
run.style,
|
||||
styles.runText,
|
||||
{ width: (run.cellCount + TEXT_LAYOUT_OVERFLOW_CELLS) * cellWidth },
|
||||
],
|
||||
[cellWidth, run.cellCount, run.style, textStyle],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -121,8 +167,8 @@ function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGrid
|
||||
{run.renderKind === "custom-glyph" ? (
|
||||
<TerminalGridCustomGlyphRun run={run} cellWidth={cellWidth} cellHeight={cellHeight} />
|
||||
) : (
|
||||
<Text numberOfLines={1} style={runTextStyle}>
|
||||
{run.text}
|
||||
<Text numberOfLines={1} ellipsizeMode="clip" style={runTextStyle}>
|
||||
{renderTerminalTextSegments(run, wideGlyphStyles)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -225,7 +271,8 @@ const MemoTerminalGridRun = memo(TerminalGridRun, (previous, next) => {
|
||||
previous.run === next.run &&
|
||||
previous.cellWidth === next.cellWidth &&
|
||||
previous.cellHeight === next.cellHeight &&
|
||||
previous.textStyle === next.textStyle
|
||||
previous.textStyle === next.textStyle &&
|
||||
previous.wideGlyphStyles === next.wideGlyphStyles
|
||||
);
|
||||
});
|
||||
|
||||
@@ -235,6 +282,8 @@ function TerminalGridRow({
|
||||
cellHeight,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
letterSpacing,
|
||||
wideGlyphStyles,
|
||||
}: TerminalGridRowProps) {
|
||||
const rowStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.row, { height: cellHeight }],
|
||||
@@ -248,9 +297,10 @@ function TerminalGridRow({
|
||||
lineHeight: cellHeight,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
letterSpacing,
|
||||
},
|
||||
],
|
||||
[cellHeight, fontFamily, fontSize],
|
||||
[cellHeight, fontFamily, fontSize, letterSpacing],
|
||||
);
|
||||
const accessibilityLabel = useMemo(
|
||||
() =>
|
||||
@@ -275,6 +325,7 @@ function TerminalGridRow({
|
||||
cellWidth={cellWidth}
|
||||
cellHeight={cellHeight}
|
||||
textStyle={textStyle}
|
||||
wideGlyphStyles={wideGlyphStyles}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -288,10 +339,38 @@ const MemoTerminalGridRow = memo(TerminalGridRow, (previous, next) => {
|
||||
previous.cellHeight === next.cellHeight &&
|
||||
previous.fontFamily === next.fontFamily &&
|
||||
previous.fontSize === next.fontSize &&
|
||||
previous.letterSpacing === next.letterSpacing &&
|
||||
previous.wideGlyphStyles === next.wideGlyphStyles &&
|
||||
previous.styleEpoch === next.styleEpoch
|
||||
);
|
||||
});
|
||||
|
||||
interface TerminalWideGlyphMeasureProps {
|
||||
glyphClass: TerminalWideGlyphClass;
|
||||
sample: string;
|
||||
style: StyleProp<TextStyle>;
|
||||
onMeasure: (glyphClass: TerminalWideGlyphClass, measuredTextWidth: number) => void;
|
||||
}
|
||||
|
||||
// Measures one wide script's advance in the terminal font's fallback, like MEASURE_TEXT does for
|
||||
// the terminal font itself.
|
||||
function TerminalWideGlyphMeasure({
|
||||
glyphClass,
|
||||
sample,
|
||||
style,
|
||||
onMeasure,
|
||||
}: TerminalWideGlyphMeasureProps) {
|
||||
const handleLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => onMeasure(glyphClass, event.nativeEvent.layout.width),
|
||||
[glyphClass, onMeasure],
|
||||
);
|
||||
return (
|
||||
<Text onLayout={handleLayout} pointerEvents="none" style={style}>
|
||||
{sample.repeat(MEASURE_TEXT.length)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function TerminalGridView({
|
||||
state,
|
||||
xtermTheme = DEFAULT_TERMINAL_THEME,
|
||||
@@ -303,6 +382,10 @@ export function TerminalGridView({
|
||||
}: TerminalGridViewProps) {
|
||||
const [metrics, setMetrics] = useState<CellMetrics>(() => estimateCellMetrics(fontSize));
|
||||
const measuredMetricsRef = useRef<TerminalGridCellMetrics | null>(null);
|
||||
const [letterSpacing, setLetterSpacing] = useState(0);
|
||||
const [wideGlyphWidths, setWideGlyphWidths] = useState<
|
||||
Partial<Record<TerminalWideGlyphClass, number>>
|
||||
>({});
|
||||
const [viewport, setViewport] = useState<TerminalGridViewport | null>(null);
|
||||
const resolvedFontFamily = useMemo(
|
||||
() => resolveNativeTerminalFontFamily(fontFamily),
|
||||
@@ -381,6 +464,14 @@ export function TerminalGridView({
|
||||
measureTextLength: MEASURE_TEXT.length,
|
||||
roundToNearestPixel: (value) => PixelRatio.roundToNearestPixel(value),
|
||||
});
|
||||
setLetterSpacing(
|
||||
resolveTerminalTextLetterSpacing({
|
||||
measuredTextWidth: event.nativeEvent.layout.width,
|
||||
measureTextLength: MEASURE_TEXT.length,
|
||||
cellWidth: nextMetrics.cellWidth,
|
||||
cellsPerGlyph: 1,
|
||||
}),
|
||||
);
|
||||
const changedMetrics = resolveTerminalGridMetricsMeasurement(
|
||||
measuredMetricsRef.current,
|
||||
nextMetrics,
|
||||
@@ -395,6 +486,35 @@ export function TerminalGridView({
|
||||
[onCellMetricsChange],
|
||||
);
|
||||
|
||||
const handleWideGlyphMeasure = useCallback(
|
||||
(glyphClass: TerminalWideGlyphClass, measuredTextWidth: number) => {
|
||||
setWideGlyphWidths((current) =>
|
||||
current[glyphClass] === measuredTextWidth
|
||||
? current
|
||||
: { ...current, [glyphClass]: measuredTextWidth },
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const wideGlyphStyles = useMemo(() => {
|
||||
const entries = TERMINAL_WIDE_GLYPH_CLASSES.map((spec) => {
|
||||
const measuredTextWidth = wideGlyphWidths[spec.name];
|
||||
const spacingStyle: TextStyle = {
|
||||
letterSpacing:
|
||||
measuredTextWidth === undefined
|
||||
? 0
|
||||
: resolveTerminalTextLetterSpacing({
|
||||
measuredTextWidth,
|
||||
measureTextLength: MEASURE_TEXT.length,
|
||||
cellWidth: metrics.cellWidth,
|
||||
cellsPerGlyph: 2,
|
||||
}),
|
||||
};
|
||||
return [spec.name, spacingStyle] as const;
|
||||
});
|
||||
return Object.fromEntries(entries) as TerminalWideGlyphStyles;
|
||||
}, [metrics.cellWidth, wideGlyphWidths]);
|
||||
|
||||
const handleContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const { width, height } = event.nativeEvent.layout;
|
||||
setViewport((current) => {
|
||||
@@ -411,6 +531,15 @@ export function TerminalGridView({
|
||||
<Text onLayout={handleMeasure} pointerEvents="none" style={measureStyle}>
|
||||
{MEASURE_TEXT}
|
||||
</Text>
|
||||
{TERMINAL_WIDE_GLYPH_CLASSES.map((spec) => (
|
||||
<TerminalWideGlyphMeasure
|
||||
key={spec.name}
|
||||
glyphClass={spec.name}
|
||||
sample={spec.sample}
|
||||
style={measureStyle}
|
||||
onMeasure={handleWideGlyphMeasure}
|
||||
/>
|
||||
))}
|
||||
{rows.map((row) => (
|
||||
<MemoTerminalGridRow
|
||||
key={row.index}
|
||||
@@ -419,6 +548,8 @@ export function TerminalGridView({
|
||||
cellHeight={metrics.cellHeight}
|
||||
fontFamily={resolvedFontFamily}
|
||||
fontSize={fontSize}
|
||||
letterSpacing={letterSpacing}
|
||||
wideGlyphStyles={wideGlyphStyles}
|
||||
styleEpoch={resolver.themeKey}
|
||||
/>
|
||||
))}
|
||||
@@ -441,6 +572,17 @@ const styles = StyleSheet.create({
|
||||
run: {
|
||||
overflow: "hidden",
|
||||
},
|
||||
isolatedRun: {
|
||||
overflow: "visible",
|
||||
},
|
||||
// The run box owns cell dimensions and background. Text gets extra layout room without
|
||||
// changing those dimensions; only isolated fallback glyphs paint beyond the run box.
|
||||
runText: {
|
||||
backgroundColor: "transparent",
|
||||
left: 0,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
},
|
||||
customGlyphRun: {
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createNativeHeadlessTerminal } from "./headless-terminal-state";
|
||||
import { TerminalGridView } from "./terminal-grid-view.native";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("native terminal grid text layout", () => {
|
||||
it("gives short runs between accented glyphs layout room without widening their cells", async () => {
|
||||
const terminal = createNativeHeadlessTerminal({ rows: 1, cols: 30 });
|
||||
try {
|
||||
await terminal.write("café naïve|END");
|
||||
const view = render(<TerminalGridView state={terminal.getViewportState()} />);
|
||||
const text = view.getByText(" na", { normalizer: (value) => value });
|
||||
const run = text.parentElement!;
|
||||
const accentRun = view.getByText("é").parentElement!;
|
||||
const cellWidth = parseFloat(getComputedStyle(accentRun).width);
|
||||
expect(parseFloat(getComputedStyle(run).width)).toBe(3 * cellWidth);
|
||||
|
||||
// Native Text wraps or ellipsizes when rounding or trailing letter spacing puts this
|
||||
// leading-space run just past its layout width. Layout has room; the cell box clips paint.
|
||||
expect(parseFloat(getComputedStyle(text).width)).toBeGreaterThan(3 * cellWidth);
|
||||
expect(getComputedStyle(text).position).toBe("absolute");
|
||||
expect(getComputedStyle(run).overflowX).toBe("hidden");
|
||||
expect(view.getByTestId("terminal-row-0").getAttribute("aria-label")).toBe("café naïve|END");
|
||||
} finally {
|
||||
terminal.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,95 @@ describe("terminal row model", () => {
|
||||
});
|
||||
|
||||
expect(rows[0].runs.map((run) => ({ text: run.text, cellCount: run.cellCount }))).toEqual([
|
||||
{ text: "A界", cellCount: 3 },
|
||||
{ text: "A", cellCount: 2 },
|
||||
{ text: "界", cellCount: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps wide scripts in the run as segments the renderer spaces to two cells", () => {
|
||||
const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME);
|
||||
|
||||
const rows = buildRows({
|
||||
grid: [
|
||||
[
|
||||
cell("한", { width: 2 }),
|
||||
cell(" ", { width: 0 }),
|
||||
cell("글", { width: 2 }),
|
||||
cell(" ", { width: 0 }),
|
||||
cell(" ", { width: 1 }),
|
||||
cell("中", { width: 2 }),
|
||||
cell(" ", { width: 0 }),
|
||||
cell("あ", { width: 2 }),
|
||||
cell(" ", { width: 0 }),
|
||||
cell("o", { width: 1 }),
|
||||
cell("k", { width: 1 }),
|
||||
],
|
||||
],
|
||||
resolver,
|
||||
});
|
||||
|
||||
expect(
|
||||
rows[0].runs.map((run) => ({
|
||||
text: run.text,
|
||||
cellCount: run.cellCount,
|
||||
segments: run.renderKind === "text" ? run.segments : null,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
text: "한글 中あok",
|
||||
cellCount: 11,
|
||||
segments: [
|
||||
{ text: "한글", wideGlyphClass: "hangul" },
|
||||
{ text: " ", wideGlyphClass: null },
|
||||
{ text: "中", wideGlyphClass: "han" },
|
||||
{ text: "あ", wideGlyphClass: "kana" },
|
||||
{ text: "ok", wideGlyphClass: null },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("gives wide glyphs outside the spaced scripts their own box", () => {
|
||||
const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME);
|
||||
|
||||
const [row] = buildRows({
|
||||
grid: [
|
||||
[
|
||||
cell("ㄱ", { width: 2 }),
|
||||
cell(" ", { width: 0 }),
|
||||
cell("A", { width: 2 }),
|
||||
cell(" ", { width: 0 }),
|
||||
cell("가", { width: 2 }),
|
||||
cell(" ", { width: 0 }),
|
||||
],
|
||||
],
|
||||
resolver,
|
||||
});
|
||||
|
||||
expect(
|
||||
row.runs.map((run) => [run.text, run.cellCount, run.renderKind === "text" && run.isolated]),
|
||||
).toEqual([
|
||||
["ㄱ", 2, true],
|
||||
["A", 2, true],
|
||||
["가", 2, false],
|
||||
]);
|
||||
});
|
||||
|
||||
test("gives non-ASCII narrow glyphs their own cell", () => {
|
||||
const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME);
|
||||
|
||||
const [row] = buildRows({
|
||||
grid: [
|
||||
["⏺", " ", "e\u0301", " ", "d", "o", "n", "e"].map((char) => cell(char, { width: 1 })),
|
||||
],
|
||||
resolver,
|
||||
});
|
||||
|
||||
expect(row.runs.map((run) => [run.text, run.cellCount])).toEqual([
|
||||
["⏺", 1],
|
||||
[" ", 1],
|
||||
["e\u0301", 1],
|
||||
[" done", 5],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -27,8 +27,40 @@ interface TerminalRunBase {
|
||||
foregroundColor: string;
|
||||
}
|
||||
|
||||
export type TerminalWideGlyphClass = "hangul" | "han" | "kana";
|
||||
|
||||
interface TerminalWideGlyphClassSpec {
|
||||
name: TerminalWideGlyphClass;
|
||||
sample: string;
|
||||
ranges: ReadonlyArray<readonly [number, number]>;
|
||||
}
|
||||
|
||||
// Wide scripts whose glyphs share one advance within a font. The renderer measures each sample
|
||||
// once and letter-spaces the script so every glyph advances exactly two cells.
|
||||
export const TERMINAL_WIDE_GLYPH_CLASSES: ReadonlyArray<TerminalWideGlyphClassSpec> = [
|
||||
{ name: "hangul", sample: "가", ranges: [[0xac00, 0xd7a3]] },
|
||||
{
|
||||
name: "han",
|
||||
sample: "中",
|
||||
ranges: [
|
||||
[0x3400, 0x4dbf],
|
||||
[0x4e00, 0x9fff],
|
||||
],
|
||||
},
|
||||
{ name: "kana", sample: "あ", ranges: [[0x3041, 0x30ff]] },
|
||||
];
|
||||
|
||||
export interface TerminalTextSegment {
|
||||
text: string;
|
||||
/** Null for terminal-font text, which advances one cell per glyph. */
|
||||
wideGlyphClass: TerminalWideGlyphClass | null;
|
||||
}
|
||||
|
||||
export interface TerminalTextRun extends TerminalRunBase {
|
||||
renderKind: "text";
|
||||
/** Holds one glyph whose fallback-font advance is unknown, drawn in its own box. */
|
||||
isolated: boolean;
|
||||
segments: TerminalTextSegment[];
|
||||
}
|
||||
|
||||
export interface TerminalCustomGlyphRun extends TerminalRunBase {
|
||||
@@ -78,6 +110,43 @@ function terminalCharWidth(char: string): number {
|
||||
return isWide ? 2 : 1;
|
||||
}
|
||||
|
||||
// Text runs render with the font's natural advances. Printable ASCII comes from the terminal font
|
||||
// and the spaced wide scripts get a measured letter spacing, so both stay on the cell grid. Any
|
||||
// other glyph (symbols, emoji, jamo) falls back to a font with an unknown advance; inside a run it
|
||||
// would shift every glyph after it and pull the text away from the cursor.
|
||||
function resolveGlyphSpacing(
|
||||
char: string,
|
||||
cellCount: number,
|
||||
): TerminalWideGlyphClass | null | "isolated" {
|
||||
if (char.length !== 1) {
|
||||
return "isolated";
|
||||
}
|
||||
const code = char.charCodeAt(0);
|
||||
if (cellCount === 1 && code >= 0x20 && code <= 0x7e) {
|
||||
return null;
|
||||
}
|
||||
if (cellCount !== 2) {
|
||||
return "isolated";
|
||||
}
|
||||
const glyphClass = TERMINAL_WIDE_GLYPH_CLASSES.find((spec) =>
|
||||
spec.ranges.some(([start, end]) => code >= start && code <= end),
|
||||
);
|
||||
return glyphClass?.name ?? "isolated";
|
||||
}
|
||||
|
||||
function appendSegment(
|
||||
segments: TerminalTextSegment[],
|
||||
text: string,
|
||||
wideGlyphClass: TerminalWideGlyphClass | null,
|
||||
): void {
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && last.wideGlyphClass === wideGlyphClass) {
|
||||
last.text += text;
|
||||
return;
|
||||
}
|
||||
segments.push({ text, wideGlyphClass });
|
||||
}
|
||||
|
||||
function shouldSkipSpacerCell(cells: TerminalRenderableCell[], col: number, char: string): boolean {
|
||||
if (terminalCharWidth(char) < 2) {
|
||||
return false;
|
||||
@@ -107,15 +176,23 @@ function appendRun(input: {
|
||||
col: number;
|
||||
}): void {
|
||||
const renderKind = input.customGlyph ? "custom-glyph" : "text";
|
||||
const spacing = input.customGlyph ? null : resolveGlyphSpacing(input.text, input.cellCount);
|
||||
const isolated = spacing === "isolated";
|
||||
const wideGlyphClass = spacing === "isolated" ? null : spacing;
|
||||
const previousRun = input.runs[input.runs.length - 1];
|
||||
if (
|
||||
!isolated &&
|
||||
previousRun &&
|
||||
previousRun.styleKey === input.styleKey &&
|
||||
previousRun.renderKind === renderKind
|
||||
previousRun.renderKind === renderKind &&
|
||||
!(previousRun.renderKind === "text" && previousRun.isolated)
|
||||
) {
|
||||
const offset = previousRun.cellCount;
|
||||
previousRun.text += input.text;
|
||||
previousRun.cellCount += input.cellCount;
|
||||
if (previousRun.renderKind === "text") {
|
||||
appendSegment(previousRun.segments, input.text, wideGlyphClass);
|
||||
}
|
||||
if (previousRun.renderKind === "custom-glyph" && input.customGlyph) {
|
||||
previousRun.glyphs.push({
|
||||
key: `${input.col}:${input.text}`,
|
||||
@@ -142,7 +219,12 @@ function appendRun(input: {
|
||||
});
|
||||
return;
|
||||
}
|
||||
input.runs.push({ ...baseRun, renderKind: "text" });
|
||||
input.runs.push({
|
||||
...baseRun,
|
||||
renderKind: "text",
|
||||
isolated,
|
||||
segments: [{ text: input.text, wideGlyphClass }],
|
||||
});
|
||||
}
|
||||
|
||||
function cellIntersectsSelection(input: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import fs, { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import pino from "pino";
|
||||
@@ -7,6 +7,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import { claudeProjectDirSync } from "./project-dir.js";
|
||||
import { withClaudeReplayHistory } from "./transcript-history.js";
|
||||
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import type { ClaudeQueryFactory } from "./query.js";
|
||||
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
|
||||
|
||||
/**
|
||||
* A resumed Claude agent whose transcript is missing or unreadable opens with an empty timeline.
|
||||
@@ -29,19 +32,18 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
let configDir: string;
|
||||
let records: LogRecord[];
|
||||
|
||||
async function resume(): Promise<void> {
|
||||
async function resume(queryFactory?: ClaudeQueryFactory): Promise<AgentSession> {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: pino({ level: "info" }, { write: (line: string) => records.push(JSON.parse(line)) }),
|
||||
queryFactory: vi.fn(() => {
|
||||
throw new Error("history load must not start a query");
|
||||
}),
|
||||
queryFactory:
|
||||
queryFactory ??
|
||||
(() => {
|
||||
throw new Error("history load must not start a query");
|
||||
}),
|
||||
resolveBinary: async () => process.execPath,
|
||||
resolveVersion: async () => "2.1.220",
|
||||
});
|
||||
const session = await client.resumeSession(
|
||||
{ provider: "claude", sessionId: SESSION_ID },
|
||||
{ cwd },
|
||||
);
|
||||
await session.close();
|
||||
return client.resumeSession({ provider: "claude", sessionId: SESSION_ID }, { cwd });
|
||||
}
|
||||
|
||||
function transcriptPath(): string {
|
||||
@@ -58,12 +60,218 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.each(["file_path", "files"])(
|
||||
"replays %s without probing targets and preserves live existence checks",
|
||||
async (field) => {
|
||||
const existingPath = path.join(cwd, "existing.txt");
|
||||
const missingPath = path.join(cwd, "missing.txt");
|
||||
writeFileSync(existingPath, "existing");
|
||||
const targets = [existingPath, missingPath];
|
||||
const inputs = targets.map((target) => ({
|
||||
[field]: field === "files" ? [target] : target,
|
||||
}));
|
||||
const toolBlocks = inputs.map((input, index) => ({
|
||||
type: "tool_use",
|
||||
id: `change-${index}`,
|
||||
name: "FileChange",
|
||||
input,
|
||||
}));
|
||||
const resultBlocks = toolBlocks.map((block) => ({
|
||||
type: "tool_result",
|
||||
tool_use_id: block.id,
|
||||
content: "done",
|
||||
}));
|
||||
const transcript = [
|
||||
{ type: "user", message: { content: "Change files" } },
|
||||
{ type: "assistant", message: { content: toolBlocks } },
|
||||
{ type: "user", message: { content: resultBlocks } },
|
||||
{ type: "assistant", message: { content: "Finished" } },
|
||||
];
|
||||
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
|
||||
writeFileSync(transcriptPath(), transcript.map((entry) => JSON.stringify(entry)).join("\n"));
|
||||
|
||||
// Observe the real filesystem calls; neither existing nor missing targets are mocked.
|
||||
const exists = vi.spyOn(fs, "existsSync");
|
||||
const liveMessages = [
|
||||
{ type: "assistant", message: { content: toolBlocks } },
|
||||
{ type: "user", message: { content: resultBlocks } },
|
||||
{ type: "result", subtype: "success", total_cost_usd: 0 },
|
||||
];
|
||||
const queryFactory = vi.fn();
|
||||
queryFactory.mockReturnValue({
|
||||
async next() {
|
||||
const value = liveMessages.shift();
|
||||
return value ? { done: false, value } : { done: true, value: undefined };
|
||||
},
|
||||
close() {},
|
||||
async return() {
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
async supportedCommands() {
|
||||
return [];
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
});
|
||||
const session = await resume(queryFactory);
|
||||
try {
|
||||
const history: AgentStreamEvent[] = [];
|
||||
for await (const event of session.streamHistory()) history.push(event);
|
||||
expect(exists.mock.calls.filter(([target]) => targets.includes(String(target)))).toEqual(
|
||||
[],
|
||||
);
|
||||
const running = inputs.map((input, index) => ({
|
||||
type: "tool_call",
|
||||
callId: `change-${index}`,
|
||||
name: "FileChange",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "unknown", input, output: null },
|
||||
}));
|
||||
const completed = inputs.map((input, index) => ({
|
||||
type: "tool_call",
|
||||
callId: `change-${index}`,
|
||||
name: "FileChange",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input,
|
||||
output: {
|
||||
output: "done",
|
||||
files: [{ path: index === 0 ? "existing.txt" : "missing.txt", kind: "update" }],
|
||||
},
|
||||
},
|
||||
}));
|
||||
expect(history).toEqual(
|
||||
[
|
||||
{ type: "user_message", text: "Change files" },
|
||||
...running,
|
||||
...completed,
|
||||
{ type: "assistant_message", text: "Finished" },
|
||||
].map((item) => ({ type: "timeline", provider: "claude", timestamp: undefined, item })),
|
||||
);
|
||||
|
||||
exists.mockClear();
|
||||
const live = await collectSessionTurnEvents(session, "Change files again");
|
||||
expect(exists.mock.calls.filter(([target]) => targets.includes(String(target)))).toEqual([
|
||||
[existingPath],
|
||||
[missingPath],
|
||||
]);
|
||||
const liveCompleted = live.flatMap((event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.status === "completed"
|
||||
? [event.item]
|
||||
: [],
|
||||
);
|
||||
const missing = completed[1];
|
||||
expect(liveCompleted).toEqual([
|
||||
completed[0],
|
||||
{
|
||||
...missing,
|
||||
detail: {
|
||||
...missing.detail,
|
||||
output: { output: "done", files: [{ path: "missing.txt", kind: "add" }] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["parent", "subagent"])(
|
||||
"replays %s Write and Edit calls without probing their targets",
|
||||
async (scope) => {
|
||||
const filePath = path.join(cwd, "unavailable", "file.txt");
|
||||
const content = ["Write", "Edit"].map((name) => ({
|
||||
type: "tool_use",
|
||||
id: name,
|
||||
name,
|
||||
input: { file_path: filePath },
|
||||
}));
|
||||
const entry = { type: "assistant", message: { content } };
|
||||
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
|
||||
if (scope === "subagent") {
|
||||
const directory = path.join(path.dirname(transcriptPath()), SESSION_ID, "subagents");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(directory, "agent-child.meta.json"),
|
||||
JSON.stringify({ toolUseId: "task" }),
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(directory, "agent-child.jsonl"),
|
||||
JSON.stringify({
|
||||
...entry,
|
||||
isSidechain: true,
|
||||
agentId: "child",
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
transcriptPath(),
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "task",
|
||||
name: "Task",
|
||||
input: { description: "Change files" },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
writeFileSync(transcriptPath(), JSON.stringify(entry));
|
||||
}
|
||||
|
||||
const exists = vi.spyOn(fs, "existsSync");
|
||||
const session = await resume();
|
||||
try {
|
||||
const items = [];
|
||||
for await (const event of session.streamHistory()) {
|
||||
if (scope === "parent" && event.type === "timeline") items.push(event.item);
|
||||
if (event.type === "provider_subagent" && event.event.type === "timeline") {
|
||||
items.push(event.event.item);
|
||||
}
|
||||
}
|
||||
expect(exists.mock.calls.filter(([target]) => target === filePath)).toEqual([]);
|
||||
expect(items).toEqual([
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "Write",
|
||||
name: "Write",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "write", filePath },
|
||||
},
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "Edit",
|
||||
name: "Edit",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "edit", filePath },
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("logs the resolved transcript path when the transcript does not exist", async () => {
|
||||
await resume();
|
||||
await (await resume()).close();
|
||||
|
||||
expect(records).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -79,7 +287,7 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
// A directory at the transcript path exists but cannot be read as a file.
|
||||
mkdirSync(transcriptPath(), { recursive: true });
|
||||
|
||||
await resume();
|
||||
await (await resume()).close();
|
||||
|
||||
expect(records).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -148,7 +356,7 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
chmodSync(path.join(configDir, "projects"), 0o000);
|
||||
|
||||
try {
|
||||
await resume();
|
||||
await (await resume()).close();
|
||||
} finally {
|
||||
chmodSync(path.join(configDir, "projects"), 0o755);
|
||||
}
|
||||
|
||||
@@ -2326,6 +2326,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
{ type: "provider_subagent" }
|
||||
>[] = [];
|
||||
private historyPending = false;
|
||||
private replayingHistory = false;
|
||||
private turnState: TurnState = "idle";
|
||||
private nextTurnOrdinal = 1;
|
||||
private cancelCurrentTurn: (() => void) | null = null;
|
||||
@@ -5253,6 +5254,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
private loadPersistedHistory(sessionId: string): void {
|
||||
let historyPath: string | null = null;
|
||||
this.replayingHistory = true;
|
||||
try {
|
||||
this.taskState.reset();
|
||||
historyPath = this.resolveHistoryPath(sessionId);
|
||||
@@ -5272,6 +5274,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
{ err: error, sessionId, historyPath },
|
||||
"Failed to load Claude history from transcript",
|
||||
);
|
||||
} finally {
|
||||
this.replayingHistory = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6039,6 +6043,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private detectFileKind(filePath: string): string {
|
||||
// A historical target may be on an unreachable mount; its current state says nothing
|
||||
// about whether the past operation created or updated it.
|
||||
if (this.replayingHistory) {
|
||||
return "update";
|
||||
}
|
||||
try {
|
||||
return fs.existsSync(filePath) ? "update" : "add";
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { SessionInfo } from "@opencode/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
|
||||
import { V2Harness } from "../test-utils/v2-harness.js";
|
||||
import { SessionChildren } from "./children.js";
|
||||
|
||||
function setup() {
|
||||
const harness = new V2Harness();
|
||||
const child: SessionInfo = {
|
||||
...harness.info,
|
||||
id: "child",
|
||||
parentID: "session",
|
||||
title: "Check the worker model",
|
||||
agent: "worker",
|
||||
model: { providerID: "test", id: "worker-model", variant: "xhigh" },
|
||||
tokens: { input: 10000, output: 3000, reasoning: 2000, cache: { read: 3000, write: 800 } },
|
||||
};
|
||||
harness.api.session.list = async (input) => ({
|
||||
data: input?.parentID === "session" ? [child] : [],
|
||||
cursor: {},
|
||||
});
|
||||
harness.api.session.get = async () => child;
|
||||
const events: AgentStreamEvent[] = [];
|
||||
const children = new SessionChildren({
|
||||
client: () => harness.api,
|
||||
id: "session",
|
||||
emit: (event) => events.push(event),
|
||||
reconcilePermissions: async () => {},
|
||||
});
|
||||
const upserts = () =>
|
||||
events.flatMap((event) =>
|
||||
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
|
||||
);
|
||||
return { children, child, upserts };
|
||||
}
|
||||
|
||||
describe("OpenCode v2 child presentation", () => {
|
||||
test("restores agent, model, variant and all token categories in the child subtitle", async () => {
|
||||
const { children, upserts } = setup();
|
||||
await children.reconcile("session");
|
||||
expect(upserts()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "child",
|
||||
title: "Check the worker model",
|
||||
parentSubagentId: null,
|
||||
status: "completed",
|
||||
subtitle: "worker · worker-model · Extra High · 18.8k tokens",
|
||||
}),
|
||||
]);
|
||||
await children.reconcile("session");
|
||||
expect(upserts()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("publishes changed child facts on live events even when its timestamp is unchanged", async () => {
|
||||
const { children, child, upserts } = setup();
|
||||
await children.observe({ id: "created", created: 1, type: "session.created", data: child });
|
||||
child.model = { providerID: "test", id: "next-model", variant: "low" };
|
||||
child.tokens.output += 1200;
|
||||
await children.observe({
|
||||
id: "finished",
|
||||
created: 2,
|
||||
type: "session.execution.succeeded",
|
||||
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||
data: { sessionID: "child" },
|
||||
});
|
||||
expect(upserts().map((event) => event.subtitle)).toEqual([
|
||||
"worker · worker-model · Extra High · 18.8k tokens",
|
||||
"worker · next-model · Low · 20k tokens",
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps available agent information before a child has a model or token usage", async () => {
|
||||
const { children, child, upserts } = setup();
|
||||
delete child.model;
|
||||
child.tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } };
|
||||
await children.reconcile("session");
|
||||
expect(upserts()[0]).toMatchObject({ title: "Check the worker model", subtitle: "worker" });
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
|
||||
import type { ProviderSubagentInputEvent } from "../../../provider-subagents/store.js";
|
||||
|
||||
import { messages } from "./history.js";
|
||||
import { buildOpenCodeSubagentSubtitle } from "../subagent-presentation.js";
|
||||
|
||||
import type { V2Api } from "./api.js";
|
||||
interface ChildrenOptions {
|
||||
@@ -65,11 +66,23 @@ export class SessionChildren {
|
||||
if (active[info.id]) status = "running";
|
||||
else if (info.outcome === "failed") status = "failed";
|
||||
else if (info.outcome === "interrupted") status = "canceled";
|
||||
const subtitle = buildOpenCodeSubagentSubtitle({
|
||||
agentName: info.agent,
|
||||
modelId: info.model?.id,
|
||||
variant: info.model?.variant,
|
||||
totalTokens:
|
||||
info.tokens.input +
|
||||
info.tokens.output +
|
||||
info.tokens.reasoning +
|
||||
info.tokens.cache.read +
|
||||
info.tokens.cache.write,
|
||||
});
|
||||
const presentation: ProviderSubagentInputEvent = {
|
||||
type: "upsert",
|
||||
id: info.id,
|
||||
parentSubagentId: info.parentID === this.options.id ? null : info.parentID,
|
||||
title: info.title ?? null,
|
||||
...(subtitle ? { subtitle } : {}),
|
||||
status,
|
||||
cwd: info.location.directory,
|
||||
timestamp: new Date(info.time.updated).toISOString(),
|
||||
|
||||
Reference in New Issue
Block a user