refactor: standardize status badges and fix composer whitespace handling
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
async function composerHeight(page: Page): Promise<number> {
|
||||
return composerLocator(page).evaluate((element) => element.getBoundingClientRect().height);
|
||||
}
|
||||
|
||||
async function expectComposerHeight(page: Page, expected: number): Promise<void> {
|
||||
await expect.poll(() => composerHeight(page)).toBe(expected);
|
||||
}
|
||||
|
||||
async function pressComposerKeyAndMeasureNextPaint(page: Page, key: string): Promise<number> {
|
||||
const composer = composerLocator(page);
|
||||
await installNextComposerPaintProbe(page);
|
||||
await composer.press(key);
|
||||
return readNextComposerPaintHeight(page);
|
||||
}
|
||||
|
||||
async function typeComposerTextAndMeasureNextPaint(page: Page, text: string): Promise<number> {
|
||||
const composer = composerLocator(page);
|
||||
await installNextComposerPaintProbe(page);
|
||||
await composer.pressSequentially(text);
|
||||
return readNextComposerPaintHeight(page);
|
||||
}
|
||||
|
||||
async function installNextComposerPaintProbe(page: Page): Promise<void> {
|
||||
await composerLocator(page).evaluate((element) => {
|
||||
Reflect.set(
|
||||
globalThis,
|
||||
"__composerNextPaintHeight",
|
||||
new Promise<number>((resolve) => {
|
||||
element.addEventListener(
|
||||
"input",
|
||||
() => {
|
||||
requestAnimationFrame(() => resolve(element.getBoundingClientRect().height));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function readNextComposerPaintHeight(page: Page): Promise<number> {
|
||||
return page.evaluate(() => Reflect.get(globalThis, "__composerNextPaintHeight"));
|
||||
}
|
||||
|
||||
test("blank composer lines remain present and keep their measured height", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-whitespace-",
|
||||
title: "Composer whitespace",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
const composer = composerLocator(page);
|
||||
const blankLines = "\n\n\n\n\n";
|
||||
const collapsedHeight = await composerHeight(page);
|
||||
|
||||
await test.step("grow the composer with blank lines followed by text", async () => {
|
||||
await composer.fill(`${blankLines}x`);
|
||||
await expect(composer).toHaveValue(`${blankLines}x`);
|
||||
await expect.poll(() => composerHeight(page)).toBeGreaterThan(collapsedHeight);
|
||||
});
|
||||
|
||||
await test.step("delete only the text without losing the blank lines or height", async () => {
|
||||
const expandedHeight = await composerHeight(page);
|
||||
await composer.press("Backspace");
|
||||
await expect(composer).toHaveValue(blankLines);
|
||||
await expectComposerHeight(page, expandedHeight);
|
||||
});
|
||||
|
||||
await test.step("grow on the first paint of a newline and stay stable when it receives a glyph", async () => {
|
||||
await composer.fill("alpha\nbeta");
|
||||
const filledLineHeight = await composerHeight(page);
|
||||
const trailingLineHeight = await pressComposerKeyAndMeasureNextPaint(page, "Shift+Enter");
|
||||
expect(trailingLineHeight).toBeGreaterThan(filledLineHeight);
|
||||
|
||||
const filledTrailingLineHeight = await typeComposerTextAndMeasureNextPaint(page, "x");
|
||||
await expect(composer).toHaveValue("alpha\nbeta\nx");
|
||||
expect(filledTrailingLineHeight).toBe(trailingLineHeight);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -4,17 +4,28 @@ import { chromium } from "playwright";
|
||||
|
||||
const baseUrl = process.env.PASEO_PROFILE_APP_URL ?? "http://localhost:19010";
|
||||
const cadenceMs = Number(process.env.PASEO_PROFILE_TYPING_CADENCE_MS ?? 16);
|
||||
const sampleCount = Number(process.env.PASEO_PROFILE_TYPING_KEYS ?? 300);
|
||||
const scenario = process.env.PASEO_PROFILE_TYPING_SCENARIO ?? "continuous-text";
|
||||
if (!["continuous-text", "height-growth"].includes(scenario)) {
|
||||
throw new Error(
|
||||
`Unknown PASEO_PROFILE_TYPING_SCENARIO: ${scenario}. Use continuous-text or height-growth.`,
|
||||
);
|
||||
}
|
||||
const sampleCount = Number(
|
||||
process.env.PASEO_PROFILE_TYPING_KEYS ?? (scenario === "height-growth" ? 24 : 300),
|
||||
);
|
||||
const repeatCount = Number(process.env.PASEO_PROFILE_TYPING_REPEATS ?? 3);
|
||||
const workspaceDigit = process.env.PASEO_PROFILE_WORKSPACE_DIGIT ?? "1";
|
||||
const headless = process.env.PASEO_PROFILE_HEADLESS !== "0";
|
||||
const cpuProfilePath = process.env.PASEO_PROFILE_CPU_PATH;
|
||||
const tracePath = process.env.PASEO_PROFILE_TRACE_PATH;
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyz";
|
||||
const measuredText = Array.from(
|
||||
{ length: sampleCount },
|
||||
(_, index) => alphabet[index % alphabet.length],
|
||||
).join("");
|
||||
const measuredInput = Array.from({ length: sampleCount }, (_, index) => {
|
||||
if (scenario === "height-growth" && index % 2 === 0) {
|
||||
return { key: "Enter", text: "\n", shiftKey: true };
|
||||
}
|
||||
return { key: alphabet[index % alphabet.length], text: alphabet[index % alphabet.length] };
|
||||
});
|
||||
const measuredText = measuredInput.map((input) => input.text).join("");
|
||||
|
||||
function round(value) {
|
||||
return Math.round(value * 100) / 100;
|
||||
@@ -62,6 +73,29 @@ function keyCode(character) {
|
||||
};
|
||||
}
|
||||
|
||||
function reactScope(id) {
|
||||
if (
|
||||
id.startsWith("AgentComposerSection:") ||
|
||||
["ComposerContent", "MessageInput", "ComposerTextSurface"].includes(id)
|
||||
) {
|
||||
return "composer";
|
||||
}
|
||||
if (id.startsWith("AgentStreamSection:") || id.startsWith("AgentStreamView:")) {
|
||||
return "stream";
|
||||
}
|
||||
return "ancestor/root (includes descendants)";
|
||||
}
|
||||
|
||||
function measuredInputKind(sequence) {
|
||||
if (measuredInput[sequence].key === "Enter") {
|
||||
return "newline";
|
||||
}
|
||||
if (measuredInput[sequence - 1]?.key === "Enter") {
|
||||
return "printable after newline";
|
||||
}
|
||||
return "printable";
|
||||
}
|
||||
|
||||
async function installAppBootstrap(page) {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
@@ -139,6 +173,13 @@ async function installTypingProbe(page, target) {
|
||||
if (!(element instanceof HTMLTextAreaElement)) {
|
||||
throw new Error(`Typing target ${probeTarget} was not found`);
|
||||
}
|
||||
const composerRoot = element.closest('[data-testid="message-input-root"]');
|
||||
const readLayout = () => ({
|
||||
inputHeight: element.getBoundingClientRect().height,
|
||||
composerHeight: composerRoot?.getBoundingClientRect().height ?? null,
|
||||
valueLength: element.value.length,
|
||||
lineCount: element.value.split("\n").length,
|
||||
});
|
||||
|
||||
let sequence = 0;
|
||||
let frameScheduled = false;
|
||||
@@ -147,6 +188,7 @@ async function installTypingProbe(page, target) {
|
||||
const keydownAt = [];
|
||||
const inputAt = [];
|
||||
const paintedAt = [];
|
||||
const paintedLayout = [];
|
||||
const longTasks = [];
|
||||
const eventTimings = [];
|
||||
const longTaskObserver = new PerformanceObserver((list) => {
|
||||
@@ -171,7 +213,7 @@ async function installTypingProbe(page, target) {
|
||||
const onKeydown = (event) => {
|
||||
if (
|
||||
event.target !== element ||
|
||||
event.key.length !== 1 ||
|
||||
(event.key.length !== 1 && !(event.key === "Enter" && event.shiftKey)) ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.altKey
|
||||
@@ -195,6 +237,7 @@ async function installTypingProbe(page, target) {
|
||||
const frameAt = performance.now();
|
||||
for (const completedSequence of pending.splice(0)) {
|
||||
paintedAt[completedSequence] = frameAt;
|
||||
paintedLayout[completedSequence] = readLayout();
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -210,6 +253,8 @@ async function installTypingProbe(page, target) {
|
||||
keydownAt,
|
||||
inputAt,
|
||||
paintedAt,
|
||||
initialLayout: readLayout(),
|
||||
paintedLayout,
|
||||
longTasks,
|
||||
eventTimings,
|
||||
timeOrigin: performance.timeOrigin,
|
||||
@@ -254,6 +299,116 @@ function summarizeReactComponents(samples) {
|
||||
return [...totals.values()].sort((left, right) => right.durationMs - left.durationMs);
|
||||
}
|
||||
|
||||
function summarizeReactScopes(samples) {
|
||||
const totals = new Map();
|
||||
for (const sample of samples) {
|
||||
const scope = reactScope(sample.id);
|
||||
const current = totals.get(scope) ?? {
|
||||
scope,
|
||||
profilerCallbacks: 0,
|
||||
renderingCallbacks: 0,
|
||||
durationMs: 0,
|
||||
components: new Set(),
|
||||
};
|
||||
current.profilerCallbacks += 1;
|
||||
if (sample.actualDuration > 0.01) {
|
||||
current.renderingCallbacks += 1;
|
||||
}
|
||||
current.durationMs += sample.actualDuration;
|
||||
current.components.add(sample.id);
|
||||
totals.set(scope, current);
|
||||
}
|
||||
return [...totals.values()]
|
||||
.map((total) => ({
|
||||
scope: total.scope,
|
||||
profilerCallbacks: total.profilerCallbacks,
|
||||
renderingCallbacks: total.renderingCallbacks,
|
||||
durationMs: round(total.durationMs),
|
||||
components: [...total.components].sort(),
|
||||
}))
|
||||
.sort((left, right) => right.durationMs - left.durationMs);
|
||||
}
|
||||
|
||||
function summarizeReactByInputKind(samples, keydownAt, paintedAt) {
|
||||
const totals = new Map();
|
||||
for (const sample of samples) {
|
||||
const sequence = keydownAt.findIndex(
|
||||
(startedAt, index) =>
|
||||
sample.commitTime >= startedAt &&
|
||||
sample.commitTime < (keydownAt[index + 1] ?? paintedAt[index] + cadenceMs),
|
||||
);
|
||||
if (sequence === -1) continue;
|
||||
const inputKind = measuredInputKind(sequence);
|
||||
const scope = reactScope(sample.id);
|
||||
const key = `${inputKind}:${scope}`;
|
||||
const current = totals.get(key) ?? {
|
||||
inputKind,
|
||||
scope,
|
||||
commits: new Set(),
|
||||
renderingCommits: new Set(),
|
||||
durationMs: 0,
|
||||
};
|
||||
current.commits.add(sample.commitTime);
|
||||
if (sample.actualDuration > 0.01) {
|
||||
current.renderingCommits.add(sample.commitTime);
|
||||
}
|
||||
current.durationMs += sample.actualDuration;
|
||||
totals.set(key, current);
|
||||
}
|
||||
return [...totals.values()]
|
||||
.map((total) => ({
|
||||
inputKind: total.inputKind,
|
||||
scope: total.scope,
|
||||
commits: total.commits.size,
|
||||
renderingCommits: total.renderingCommits.size,
|
||||
durationMs: round(total.durationMs),
|
||||
}))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.inputKind.localeCompare(right.inputKind) || left.scope.localeCompare(right.scope),
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeHeightGrowth(initialLayout, layout) {
|
||||
const changes = [];
|
||||
const textAfterNewlineShifts = [];
|
||||
for (let sequence = 0; sequence < layout.length; sequence += 1) {
|
||||
const previous = sequence === 0 ? initialLayout : layout[sequence - 1];
|
||||
const current = layout[sequence];
|
||||
if (!previous || !current) continue;
|
||||
const inputDelta = round(current.inputHeight - previous.inputHeight);
|
||||
const composerDelta =
|
||||
current.composerHeight === null || previous.composerHeight === null
|
||||
? null
|
||||
: round(current.composerHeight - previous.composerHeight);
|
||||
if (inputDelta !== 0 || (composerDelta !== null && composerDelta !== 0)) {
|
||||
const change = {
|
||||
sequence,
|
||||
key: measuredInput[sequence].key,
|
||||
inputDelta,
|
||||
composerDelta,
|
||||
inputHeight: current.inputHeight,
|
||||
composerHeight: current.composerHeight,
|
||||
lineCount: current.lineCount,
|
||||
};
|
||||
changes.push(change);
|
||||
if (
|
||||
sequence > 0 &&
|
||||
measuredInput[sequence - 1].key === "Enter" &&
|
||||
measuredInput[sequence].key !== "Enter"
|
||||
) {
|
||||
textAfterNewlineShifts.push(change);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
initial: initialLayout,
|
||||
final: layout.at(-1) ?? null,
|
||||
changes,
|
||||
textAfterNewlineShifts,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeReactCommitShapes(commits) {
|
||||
const shapes = new Map();
|
||||
for (const commit of commits) {
|
||||
@@ -275,16 +430,16 @@ function summarizeReactCommitShapes(commits) {
|
||||
return [...shapes.values()].sort((left, right) => right.rootDurationMs - left.rootDurationMs);
|
||||
}
|
||||
|
||||
async function dispatchMeasuredText(page, text) {
|
||||
async function dispatchMeasuredInput(page, inputs) {
|
||||
const session = await page.context().newCDPSession(page);
|
||||
const dispatchedAt = Array.from({ length: text.length });
|
||||
const scheduledAt = Array.from({ length: text.length });
|
||||
const dispatchedAt = Array.from({ length: inputs.length });
|
||||
const scheduledAt = Array.from({ length: inputs.length });
|
||||
const now = () => performance.timeOrigin + performance.now();
|
||||
const startedAt = now() + 100;
|
||||
const dispatches = [];
|
||||
|
||||
await Promise.all(
|
||||
Array.from(text, async (character, sequence) => {
|
||||
inputs.map(async (input, sequence) => {
|
||||
const dueAt = startedAt + sequence * cadenceMs;
|
||||
scheduledAt[sequence] = dueAt;
|
||||
const waitMs = dueAt - now();
|
||||
@@ -292,18 +447,22 @@ async function dispatchMeasuredText(page, text) {
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
}
|
||||
dispatchedAt[sequence] = now();
|
||||
const key = keyCode(character);
|
||||
const key =
|
||||
input.key === "Enter" ? { code: "Enter", windowsVirtualKeyCode: 13 } : keyCode(input.key);
|
||||
const modifiers = input.shiftKey ? 8 : 0;
|
||||
dispatches.push(
|
||||
session.send("Input.dispatchKeyEvent", {
|
||||
type: "keyDown",
|
||||
key: character,
|
||||
text: character,
|
||||
unmodifiedText: character,
|
||||
key: input.key,
|
||||
text: input.key === "Enter" ? "\r" : input.text,
|
||||
unmodifiedText: input.key === "Enter" ? "\r" : input.text,
|
||||
modifiers,
|
||||
...key,
|
||||
}),
|
||||
session.send("Input.dispatchKeyEvent", {
|
||||
type: "keyUp",
|
||||
key: character,
|
||||
key: input.key,
|
||||
modifiers,
|
||||
...key,
|
||||
}),
|
||||
);
|
||||
@@ -334,14 +493,18 @@ async function measureTarget(page, input, target, originalText) {
|
||||
await input.evaluate((element) => {
|
||||
element.setSelectionRange(element.value.length, element.value.length);
|
||||
});
|
||||
await page.evaluate(
|
||||
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
|
||||
);
|
||||
await installTypingProbe(page, target);
|
||||
await page.evaluate(() => globalThis.__PASEO_RESET_RENDER_PROFILE__?.());
|
||||
const { dispatchedAt, scheduledAt } = await dispatchMeasuredText(page, measuredText);
|
||||
const { dispatchedAt, scheduledAt } = await dispatchMeasuredInput(page, measuredInput);
|
||||
await waitForMeasurement(page, measuredText.length);
|
||||
|
||||
const diagnostics = await page.evaluate(() => ({
|
||||
state: globalThis.__PASEO_TYPING_BENCHMARK__,
|
||||
commits: globalThis.__PASEO_RENDER_PROFILE__ ?? [],
|
||||
renderReasons: globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {},
|
||||
value: document.activeElement?.value ?? null,
|
||||
}));
|
||||
if (diagnostics.value !== `${originalText}${measuredText}`) {
|
||||
@@ -398,7 +561,15 @@ async function measureTarget(page, input, target, originalText) {
|
||||
reactCommitCount: rootCommits.length,
|
||||
rootReactDurationMs: round(rootCommits.reduce((sum, commit) => sum + commit.rootDurationMs, 0)),
|
||||
reactComponents: summarizeReactComponents(diagnostics.commits),
|
||||
reactScopes: summarizeReactScopes(diagnostics.commits),
|
||||
reactByInputKind: summarizeReactByInputKind(
|
||||
diagnostics.commits,
|
||||
state.keydownAt,
|
||||
state.paintedAt,
|
||||
),
|
||||
reactRenderReasons: diagnostics.renderReasons,
|
||||
reactCommitShapes: summarizeReactCommitShapes(commits),
|
||||
heightGrowth: summarizeHeightGrowth(state.initialLayout, state.paintedLayout),
|
||||
longTasks: state.longTasks,
|
||||
slowSamples,
|
||||
};
|
||||
@@ -426,7 +597,7 @@ async function captureCpuProfile(page, input, originalText) {
|
||||
await session.send("Profiler.setSamplingInterval", { interval: 100 });
|
||||
await session.send("Profiler.start");
|
||||
try {
|
||||
await dispatchMeasuredText(page, measuredText);
|
||||
await dispatchMeasuredInput(page, measuredInput);
|
||||
await waitForMeasurement(page, measuredText.length);
|
||||
} finally {
|
||||
const { profile } = await session.send("Profiler.stop");
|
||||
@@ -473,7 +644,7 @@ async function captureTrace(page, input, originalText) {
|
||||
transferMode: "ReturnAsStream",
|
||||
});
|
||||
try {
|
||||
await dispatchMeasuredText(page, measuredText);
|
||||
await dispatchMeasuredInput(page, measuredInput);
|
||||
await waitForMeasurement(page, measuredText.length);
|
||||
} finally {
|
||||
await session.send("Tracing.end");
|
||||
@@ -520,9 +691,12 @@ async function main() {
|
||||
JSON.stringify(
|
||||
{
|
||||
appUrl: baseUrl,
|
||||
scenario,
|
||||
cadenceMs,
|
||||
sampleCount,
|
||||
repeatCount,
|
||||
reactScopeNote:
|
||||
"Profiler boundaries are nested. Ancestor/root durations include descendant work and must not be added to composer or stream durations.",
|
||||
cpuProfilePath: cpuProfilePath ?? null,
|
||||
tracePath: tracePath ?? null,
|
||||
runs,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { HighlightedText } from "@/components/ui/highlighted-text";
|
||||
import { StatusBadge, type StatusBadgeVariant } from "@/components/ui/status-badge";
|
||||
import type { AgentSearchMatch } from "@getpaseo/protocol/messages";
|
||||
import type { MatchRange } from "@getpaseo/protocol/search/text-match";
|
||||
|
||||
@@ -115,28 +116,10 @@ function SessionBadge({
|
||||
icon?: ReactElement;
|
||||
tone?: "neutral" | "warning" | "danger";
|
||||
}) {
|
||||
const badgeStyle = useMemo(
|
||||
() => [
|
||||
styles.badge,
|
||||
tone === "warning" && styles.badgeWarning,
|
||||
tone === "danger" && styles.badgeDanger,
|
||||
],
|
||||
[tone],
|
||||
);
|
||||
const badgeTextStyle = useMemo(
|
||||
() => [
|
||||
styles.badgeText,
|
||||
tone === "warning" && styles.badgeTextWarning,
|
||||
tone === "danger" && styles.badgeTextDanger,
|
||||
],
|
||||
[tone],
|
||||
);
|
||||
return (
|
||||
<View style={badgeStyle}>
|
||||
{icon}
|
||||
<Text style={badgeTextStyle}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
let variant: StatusBadgeVariant = "muted";
|
||||
if (tone === "warning") variant = "warning";
|
||||
else if (tone === "danger") variant = "error";
|
||||
return <StatusBadge label={label} variant={variant} leading={icon} />;
|
||||
}
|
||||
|
||||
function WorkspaceTitlePrefix({
|
||||
@@ -742,33 +725,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginLeft: theme.spacing[4],
|
||||
textAlign: "right" as const,
|
||||
},
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexShrink: 0,
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
badgeWarning: {
|
||||
backgroundColor: "rgba(245, 158, 11, 0.12)",
|
||||
},
|
||||
badgeDanger: {
|
||||
backgroundColor: "rgba(239, 68, 68, 0.14)",
|
||||
},
|
||||
badgeText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
badgeTextWarning: {
|
||||
color: theme.colors.palette.amber[500],
|
||||
},
|
||||
badgeTextDanger: {
|
||||
color: theme.colors.palette.red[300],
|
||||
},
|
||||
sheetOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-end",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { StatusBadge } from "./status-badge";
|
||||
|
||||
interface MountedBadge {
|
||||
root: Root;
|
||||
container: HTMLDivElement;
|
||||
}
|
||||
|
||||
const mountedBadges: MountedBadge[] = [];
|
||||
|
||||
function mountBadge(variant: "success" | "warning" | "error" | "muted"): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => root.render(<StatusBadge label="Status" variant={variant} />));
|
||||
mountedBadges.push({ root, container });
|
||||
|
||||
const badge = container.firstElementChild;
|
||||
if (!(badge instanceof HTMLElement)) {
|
||||
throw new Error("StatusBadge did not render a badge element");
|
||||
}
|
||||
return badge;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const mounted of mountedBadges.splice(0)) {
|
||||
act(() => mounted.root.unmount());
|
||||
mounted.container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe("StatusBadge", () => {
|
||||
it.each(["success", "warning", "error", "muted"] as const)(
|
||||
"uses the semantic badge shell for the %s variant",
|
||||
(variant) => {
|
||||
const badge = mountBadge(variant);
|
||||
const style = getComputedStyle(badge);
|
||||
|
||||
expect(style.backgroundColor).toBe("rgb(228, 228, 231)");
|
||||
expect(style.borderColor).toBe("rgb(228, 228, 231)");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["success", "rgb(21, 128, 61)"],
|
||||
["warning", "rgb(217, 119, 6)"],
|
||||
["error", "rgb(185, 28, 28)"],
|
||||
["muted", "rgb(102, 102, 102)"],
|
||||
] as const)("uses the semantic %s signal for its text", (variant, expectedColor) => {
|
||||
const badge = mountBadge(variant);
|
||||
const text = badge.lastElementChild;
|
||||
if (!(text instanceof HTMLElement)) {
|
||||
throw new Error("StatusBadge did not render its label");
|
||||
}
|
||||
|
||||
expect(getComputedStyle(text).color).toBe(expectedColor);
|
||||
});
|
||||
});
|
||||
@@ -1,34 +1,29 @@
|
||||
import { useMemo } from "react";
|
||||
import React, { useMemo, type ReactNode } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
type StatusBadgeVariant = "success" | "error" | "muted";
|
||||
export type StatusBadgeVariant = "success" | "warning" | "error" | "muted";
|
||||
|
||||
interface StatusBadgeProps {
|
||||
label: string;
|
||||
variant?: StatusBadgeVariant;
|
||||
leading?: ReactNode;
|
||||
}
|
||||
|
||||
export function StatusBadge({ label, variant = "muted" }: StatusBadgeProps) {
|
||||
const pillStyle = useMemo(
|
||||
() => [
|
||||
styles.pill,
|
||||
variant === "success" && styles.pillSuccess,
|
||||
variant === "error" && styles.pillError,
|
||||
],
|
||||
[variant],
|
||||
);
|
||||
export function StatusBadge({ label, variant = "muted", leading }: StatusBadgeProps) {
|
||||
const textStyle = useMemo(
|
||||
() => [
|
||||
styles.pillText,
|
||||
variant === "success" && styles.pillTextSuccess,
|
||||
variant === "warning" && styles.pillTextWarning,
|
||||
variant === "error" && styles.pillTextError,
|
||||
],
|
||||
[variant],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={pillStyle}>
|
||||
<View style={styles.pill}>
|
||||
{leading}
|
||||
<Text style={textStyle}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -38,6 +33,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
pill: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
@@ -45,16 +41,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 3,
|
||||
},
|
||||
// Tinted from the one status token rather than a palette step, so the pill tracks the
|
||||
// theme. `1a`/`33` are the 10%/20% alpha suffixes the identity table uses.
|
||||
pillSuccess: {
|
||||
backgroundColor: `${theme.colors.statusSuccess}1a`,
|
||||
borderColor: `${theme.colors.statusSuccess}33`,
|
||||
},
|
||||
pillError: {
|
||||
backgroundColor: `${theme.colors.statusDanger}1a`,
|
||||
borderColor: `${theme.colors.statusDanger}33`,
|
||||
},
|
||||
pillText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
@@ -63,6 +49,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
pillTextSuccess: {
|
||||
color: theme.colors.statusSuccess,
|
||||
},
|
||||
pillTextWarning: {
|
||||
color: theme.colors.statusWarning,
|
||||
},
|
||||
pillTextError: {
|
||||
color: theme.colors.statusDanger,
|
||||
},
|
||||
|
||||
@@ -57,7 +57,7 @@ export function hasDraftContent(input: {
|
||||
text: string;
|
||||
attachments: UserComposerAttachment[];
|
||||
}): boolean {
|
||||
return input.text.trim().length > 0 || input.attachments.length > 0;
|
||||
return input.text.length > 0 || input.attachments.length > 0;
|
||||
}
|
||||
|
||||
export function areAttachmentsEqual(input: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveDraftKey } from "./input-draft-core";
|
||||
import { hasDraftContent, resolveDraftKey } from "./input-draft-core";
|
||||
import {
|
||||
buildDraftCommandConfig,
|
||||
resolveEffectiveComposerModelId,
|
||||
@@ -26,6 +26,12 @@ describe("resolveDraftKey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasDraftContent", () => {
|
||||
it("preserves whitespace-only text while the user is editing", () => {
|
||||
expect(hasDraftContent({ text: "\n\n\n\n\n", attachments: [] })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEffectiveComposerModelId", () => {
|
||||
it("returns the selected model trimmed", () => {
|
||||
expect(
|
||||
|
||||
@@ -8,6 +8,7 @@ interface Args {
|
||||
onHeight: (height: number) => void;
|
||||
}
|
||||
|
||||
export function useComposerHeightMirror(_args: Args): void {
|
||||
export function useComposerHeightMirror(_args: Args): (_value: string) => void {
|
||||
// No-op on native: onContentSizeChange drives height natively.
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export function useComposerHeightMirror({
|
||||
minHeight,
|
||||
maxHeight,
|
||||
onHeight,
|
||||
}: Args): void {
|
||||
}: Args): (value: string) => void {
|
||||
const paramsRef = useRef({ value, minHeight, maxHeight, onHeight });
|
||||
paramsRef.current = { value, minHeight, maxHeight, onHeight };
|
||||
|
||||
@@ -70,34 +70,36 @@ export function useComposerHeightMirror({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const mirror = mirrorRef.current;
|
||||
const source = textareaRef.current;
|
||||
if (!mirror || !source || typeof window === "undefined") return;
|
||||
if (!(source instanceof HTMLElement)) return;
|
||||
const measure = useCallback(
|
||||
(text: string) => {
|
||||
const mirror = mirrorRef.current;
|
||||
const source = textareaRef.current;
|
||||
if (!mirror || !source || typeof window === "undefined") return;
|
||||
if (!(source instanceof HTMLElement)) return;
|
||||
|
||||
const cs = window.getComputedStyle(source);
|
||||
const ms = mirror.style;
|
||||
for (const prop of COPIED_STYLES) {
|
||||
ms[prop] = cs[prop];
|
||||
}
|
||||
ms.width = `${source.clientWidth}px`;
|
||||
const cs = window.getComputedStyle(source);
|
||||
const ms = mirror.style;
|
||||
for (const prop of COPIED_STYLES) {
|
||||
ms[prop] = cs[prop];
|
||||
}
|
||||
ms.width = `${source.clientWidth}px`;
|
||||
|
||||
const {
|
||||
value: currentValue,
|
||||
minHeight: currentMinHeight,
|
||||
maxHeight: currentMaxHeight,
|
||||
onHeight: currentOnHeight,
|
||||
} = paramsRef.current;
|
||||
// Trailing newline is collapsed by textarea measurement — pad with a space.
|
||||
mirror.value = currentValue.endsWith("\n") ? `${currentValue} ` : currentValue;
|
||||
const {
|
||||
minHeight: currentMinHeight,
|
||||
maxHeight: currentMaxHeight,
|
||||
onHeight: currentOnHeight,
|
||||
} = paramsRef.current;
|
||||
// Trailing newline is collapsed by textarea measurement — pad with a space.
|
||||
mirror.value = text.endsWith("\n") ? `${text} ` : text;
|
||||
|
||||
const next = Math.max(currentMinHeight, Math.min(currentMaxHeight, mirror.scrollHeight));
|
||||
currentOnHeight(next);
|
||||
}, [textareaRef]);
|
||||
const next = Math.max(currentMinHeight, Math.min(currentMaxHeight, mirror.scrollHeight));
|
||||
currentOnHeight(next);
|
||||
},
|
||||
[textareaRef],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measure();
|
||||
measure(value);
|
||||
}, [maxHeight, minHeight, value, measure]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -109,9 +111,11 @@ export function useComposerHeightMirror({
|
||||
const nextWidth = source.clientWidth;
|
||||
if (Math.abs(nextWidth - previousWidth) < 1) return;
|
||||
previousWidth = nextWidth;
|
||||
measure();
|
||||
measure(paramsRef.current.value);
|
||||
});
|
||||
observer.observe(source);
|
||||
return () => observer.disconnect();
|
||||
}, [textareaRef, measure]);
|
||||
|
||||
return measure;
|
||||
}
|
||||
|
||||
@@ -1586,7 +1586,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
setBoundedInputHeight(inputHeightRef.current);
|
||||
}, [setBoundedInputHeight]);
|
||||
|
||||
useComposerHeightMirror({
|
||||
const measureComposerHeight = useComposerHeightMirror({
|
||||
value,
|
||||
textareaRef: webTextareaRef,
|
||||
minHeight: MIN_INPUT_HEIGHT,
|
||||
@@ -1689,10 +1689,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const handleInputChange = useCallback(
|
||||
(nextValue: string) => {
|
||||
valueRef.current = nextValue;
|
||||
measureComposerHeight(nextValue);
|
||||
updateLiveTextPresence(nextValue);
|
||||
onChangeText(nextValue);
|
||||
},
|
||||
[onChangeText, updateLiveTextPresence],
|
||||
[measureComposerHeight, onChangeText, updateLiveTextPresence],
|
||||
);
|
||||
|
||||
const handleInputFocus = useCallback(() => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda
|
||||
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
||||
import { Alert as InlineAlert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StatusBadge, type StatusBadgeVariant } from "@/components/ui/status-badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
ProfileDraft,
|
||||
@@ -170,45 +171,30 @@ function HostStatusBadges({ serverId }: { serverId: string }) {
|
||||
const activeConnection = snapshot?.activeConnection ?? null;
|
||||
const statusLabel = formatConnectionStatus(connectionStatus);
|
||||
const statusTone = getConnectionStatusTone(connectionStatus);
|
||||
let statusColor: string;
|
||||
let statusVariant: StatusBadgeVariant = "muted";
|
||||
let statusDotColor = theme.colors.foregroundMuted;
|
||||
if (statusTone === "success") {
|
||||
statusColor = theme.colors.palette.green[400];
|
||||
statusVariant = "success";
|
||||
statusDotColor = theme.colors.statusDotSuccess;
|
||||
} else if (statusTone === "warning") {
|
||||
statusColor = theme.colors.palette.amber[500];
|
||||
statusVariant = "warning";
|
||||
statusDotColor = theme.colors.statusDotWarning;
|
||||
} else if (statusTone === "error") {
|
||||
statusColor = theme.colors.destructive;
|
||||
} else {
|
||||
statusColor = theme.colors.foregroundMuted;
|
||||
}
|
||||
let statusPillBg: string;
|
||||
if (statusTone === "success") {
|
||||
statusPillBg = "rgba(74, 222, 128, 0.1)";
|
||||
} else if (statusTone === "warning") {
|
||||
statusPillBg = "rgba(245, 158, 11, 0.1)";
|
||||
} else if (statusTone === "error") {
|
||||
statusPillBg = "rgba(248, 113, 113, 0.1)";
|
||||
} else {
|
||||
statusPillBg = "rgba(161, 161, 170, 0.1)";
|
||||
statusVariant = "error";
|
||||
statusDotColor = theme.colors.statusDotDanger;
|
||||
}
|
||||
const connectionBadge = formatActiveConnectionBadge(activeConnection, theme, t);
|
||||
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
|
||||
|
||||
const statusPillStyle = useMemo(
|
||||
() => [styles.statusPill, { backgroundColor: statusPillBg }],
|
||||
[statusPillBg],
|
||||
);
|
||||
const statusDotStyle = useMemo(
|
||||
() => [styles.statusDot, { backgroundColor: statusColor }],
|
||||
[statusColor],
|
||||
() => [styles.statusDot, { backgroundColor: statusDotColor }],
|
||||
[statusDotColor],
|
||||
);
|
||||
const statusTextStyle = useMemo(() => [styles.statusText, { color: statusColor }], [statusColor]);
|
||||
const statusLeading = useMemo(() => <View style={statusDotStyle} />, [statusDotStyle]);
|
||||
|
||||
return (
|
||||
<View style={styles.identityBadges} testID="host-page-identity">
|
||||
<View style={statusPillStyle}>
|
||||
<View style={statusDotStyle} />
|
||||
<Text style={statusTextStyle}>{statusLabel}</Text>
|
||||
</View>
|
||||
<StatusBadge label={statusLabel} variant={statusVariant} leading={statusLeading} />
|
||||
{connectionBadge ? (
|
||||
<View style={styles.badgePill}>
|
||||
{connectionBadge.icon}
|
||||
@@ -1836,23 +1822,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexWrap: "wrap",
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
statusPill: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 4,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
statusDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
badgePill: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -117,9 +117,14 @@ describe("createMarkdownStyles", () => {
|
||||
|
||||
expect(styles.blockquote).toMatchObject({
|
||||
backgroundColor: darkTheme.colors.surface1,
|
||||
color: `${darkTheme.colors.foreground}cc`,
|
||||
borderLeftColor: darkTheme.colors.surface2,
|
||||
paddingTop: darkTheme.spacing[3],
|
||||
paddingBottom: 0,
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
});
|
||||
expect(styles.paragraph.marginBottom).toBe(darkTheme.spacing[3]);
|
||||
expect(styles.text).not.toHaveProperty("color");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,6 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
|
||||
text: {
|
||||
...webSelectableTextStyle,
|
||||
color: theme.colors.foreground,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
overflowWrap: "anywhere" as const,
|
||||
@@ -303,10 +302,12 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
|
||||
blockquote: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
color: `${theme.colors.foreground}cc`,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: theme.colors.surface2,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingTop: theme.spacing[3],
|
||||
paddingBottom: 0,
|
||||
marginVertical: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderTopLeftRadius: 0,
|
||||
|
||||
Reference in New Issue
Block a user