refactor: standardize status badges and fix composer whitespace handling
This commit is contained in:
+3
-3
@@ -225,13 +225,13 @@ There is exactly one token per status signal — `statusSuccess`, `statusDanger`
|
||||
|
||||
Status **dots** are the one exception, and they are a family of their own — `statusDotSuccess`, `statusDotDanger`, `statusDotWarning`, `statusDotRunning`, read only by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`). Same hues and the same generation rule, but their own band: 90% of gamut chroma against the status family's 55–60%. A dot is a few points of solid color with no shape to read and no label attached, and the running one pulses, so at the status band's chroma the dots read dimmer than the metadata beside them — backwards, since the dot is the row's state. Lightness is set by hue separation rather than by distance from the surface: at 6pt four dark hues on a light surface all read as one dark blob no matter how much contrast they have. So the light band runs as bright as the contrast floor allows at L=0.62, the last step where all four clear 3:1 against the sidebar's `surface2`; the dark band sits at L=0.72, where danger turns pink above. All four move together; regenerate the set, never one hue.
|
||||
|
||||
Status pills are the status token as foreground on a 10%-alpha tint of the same token (`${token}1a`), with a 20% border (`${token}33`). The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical; a pill never reaches into `palette`.
|
||||
Status pills use the status token for text on the shared `surface3` and `border` shell. The neutral shell keeps the signal legible without manufacturing translucent colors outside the theme. The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical; a pill never reaches into `palette`.
|
||||
|
||||
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status token. Which token a given agent state maps to is owned by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`); a row, a group header, and a project icon all call it rather than restating the mapping. They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
|
||||
|
||||
Identity badges — the project icon, the sidebar host badge, and the PR-panel participant avatar — do not use the theme palette. They draw from the fixed ten-color identity table in `packages/app/src/styles/identity-colors.ts`, whose hexes are held to one contrast band so a color identifies rather than ranks. Project icons and PR avatars use it as a fill with a white letter — that is `identityColor`, one theme-independent hex per identity. Host badges use it as a _foreground_ on both the glyph and the label, which is a different contrast problem that the fill table cannot solve: no single hex clears 4.5:1 against both a near-white and a dark sidebar. Foregrounds therefore come from `identityForeground(name, colorScheme)`, one set per scheme, hue unchanged. That set is generated on the **status family's** lightness and chroma fraction, because a meta row puts a host badge beside a CI check and a diff stat, and two families at different lightness make the brighter one shout. Change the status band and this one changes with it. A host with no color assigned falls back to `foregroundMuted`. The table is theme-independent by design; do not fork it per theme, and do not add hexes to it without recomputing the band.
|
||||
|
||||
The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `packages/app/src/components/agent-list.tsx:607-632`, and `packages/app/src/components/sidebar-workspace-list.tsx:2889-2894` are drift to be removed. New code uses `<StatusBadge>`.
|
||||
New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link badges remain separate because color does not encode status there.
|
||||
|
||||
---
|
||||
|
||||
@@ -242,7 +242,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
|
||||
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
|
||||
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
|
||||
- The word "checkout" in UI strings or identifiers. The term is "workspace".
|
||||
- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds and the identity color table are the documented exceptions (§13), not a license.
|
||||
- New color tokens or hardcoded hex outside the palette. The identity color table is the documented exception (§13), not a license.
|
||||
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
|
||||
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
|
||||
- Raw DOM APIs without an `isWeb` guard.
|
||||
|
||||
@@ -260,6 +260,11 @@ Playwright dispatch lateness. Set `PASEO_PROFILE_TYPING_KEYS`, `PASEO_PROFILE_TY
|
||||
`PASEO_PROFILE_CPU_PATH` and `PASEO_PROFILE_TRACE_PATH` captures run separately after the latency
|
||||
measurements so profiling overhead does not contaminate them.
|
||||
|
||||
Set `PASEO_PROFILE_TYPING_SCENARIO=height-growth` to alternate `Shift+Enter` and printable input.
|
||||
That report includes input and composer height changes plus React work grouped into composer,
|
||||
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
|
||||
boundaries are nested. A printable key after an empty newline should not change either height.
|
||||
|
||||
### Desktop macOS compositor watchdog
|
||||
|
||||
macOS display sleep can leave Chromium's GPU-process display link — the vsync
|
||||
|
||||
@@ -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,7 +70,8 @@ export function useComposerHeightMirror({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const measure = useCallback(
|
||||
(text: string) => {
|
||||
const mirror = mirrorRef.current;
|
||||
const source = textareaRef.current;
|
||||
if (!mirror || !source || typeof window === "undefined") return;
|
||||
@@ -84,20 +85,21 @@ export function useComposerHeightMirror({
|
||||
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;
|
||||
mirror.value = text.endsWith("\n") ? `${text} ` : text;
|
||||
|
||||
const next = Math.max(currentMinHeight, Math.min(currentMaxHeight, mirror.scrollHeight));
|
||||
currentOnHeight(next);
|
||||
}, [textareaRef]);
|
||||
},
|
||||
[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,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
diff --git a/node_modules/@mattermost/react-native-paste-input/android/src/codegen-patch/react/renderer/components/PasteTextInputSpecs/ShadowNodes.cpp b/node_modules/@mattermost/react-native-paste-input/android/src/codegen-patch/react/renderer/components/PasteTextInputSpecs/ShadowNodes.cpp
|
||||
index 73466dd..8440ac9 100644
|
||||
--- a/node_modules/@mattermost/react-native-paste-input/android/src/codegen-patch/react/renderer/components/PasteTextInputSpecs/ShadowNodes.cpp
|
||||
+++ b/node_modules/@mattermost/react-native-paste-input/android/src/codegen-patch/react/renderer/components/PasteTextInputSpecs/ShadowNodes.cpp
|
||||
@@ -33,7 +33,11 @@ Size PasteTextInputShadowNode::measureContent(
|
||||
.surfaceId = getSurfaceId(),
|
||||
};
|
||||
|
||||
- if (getStateData().cachedAttributedStringId != 0) {
|
||||
+ AttributedString attributedString = getMostRecentAttributedString(layoutContext);
|
||||
+ const auto text = attributedString.getString();
|
||||
+ const auto hasTrailingNewline = !text.empty() && text.back() == '\n';
|
||||
+
|
||||
+ if (getStateData().cachedAttributedStringId != 0 && !hasTrailingNewline) {
|
||||
auto textSize = textLayoutManager_
|
||||
->measureCachedSpannableById(
|
||||
getStateData().cachedAttributedStringId,
|
||||
@@ -44,9 +48,10 @@ Size PasteTextInputShadowNode::measureContent(
|
||||
return layoutConstraints.clamp(textSize);
|
||||
}
|
||||
|
||||
- AttributedString attributedString = getMostRecentAttributedString(layoutContext);
|
||||
if (attributedString.isEmpty()) {
|
||||
attributedString = getPlaceholderAttributedString(layoutContext);
|
||||
+ } else if (hasTrailingNewline) {
|
||||
+ attributedString.getFragments().back().string += " ";
|
||||
}
|
||||
|
||||
auto textSize = textLayoutManager_
|
||||
Reference in New Issue
Block a user