diff --git a/docs/design.md b/docs/design.md index 4eb6b8577..5eb619a66 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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 `` 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 `` 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 ``. +New status pills use ``. 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 `` for a section header inside settings. `` 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. diff --git a/docs/development.md b/docs/development.md index d7849bb3b..e36c827ac 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 diff --git a/packages/app/e2e/browser/composer-whitespace.spec.ts b/packages/app/e2e/browser/composer-whitespace.spec.ts new file mode 100644 index 000000000..f199f03ac --- /dev/null +++ b/packages/app/e2e/browser/composer-whitespace.spec.ts @@ -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 { + return composerLocator(page).evaluate((element) => element.getBoundingClientRect().height); +} + +async function expectComposerHeight(page: Page, expected: number): Promise { + await expect.poll(() => composerHeight(page)).toBe(expected); +} + +async function pressComposerKeyAndMeasureNextPaint(page: Page, key: string): Promise { + const composer = composerLocator(page); + await installNextComposerPaintProbe(page); + await composer.press(key); + return readNextComposerPaintHeight(page); +} + +async function typeComposerTextAndMeasureNextPaint(page: Page, text: string): Promise { + const composer = composerLocator(page); + await installNextComposerPaintProbe(page); + await composer.pressSequentially(text); + return readNextComposerPaintHeight(page); +} + +async function installNextComposerPaintProbe(page: Page): Promise { + await composerLocator(page).evaluate((element) => { + Reflect.set( + globalThis, + "__composerNextPaintHeight", + new Promise((resolve) => { + element.addEventListener( + "input", + () => { + requestAnimationFrame(() => resolve(element.getBoundingClientRect().height)); + }, + { once: true }, + ); + }), + ); + }); +} + +async function readNextComposerPaintHeight(page: Page): Promise { + 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(); + } +}); diff --git a/packages/app/scripts/profile-composer-typing.mjs b/packages/app/scripts/profile-composer-typing.mjs index 977005c65..e243cc395 100644 --- a/packages/app/scripts/profile-composer-typing.mjs +++ b/packages/app/scripts/profile-composer-typing.mjs @@ -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, diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 386a8c2f9..00f9f2d9b 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -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 ( - - {icon} - {label} - - ); + let variant: StatusBadgeVariant = "muted"; + if (tone === "warning") variant = "warning"; + else if (tone === "danger") variant = "error"; + return ; } 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", diff --git a/packages/app/src/components/ui/status-badge.browser.test.tsx b/packages/app/src/components/ui/status-badge.browser.test.tsx new file mode 100644 index 000000000..a47b5036e --- /dev/null +++ b/packages/app/src/components/ui/status-badge.browser.test.tsx @@ -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()); + 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); + }); +}); diff --git a/packages/app/src/components/ui/status-badge.tsx b/packages/app/src/components/ui/status-badge.tsx index c4ed0e386..d792a1dc6 100644 --- a/packages/app/src/components/ui/status-badge.tsx +++ b/packages/app/src/components/ui/status-badge.tsx @@ -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 ( - + + {leading} {label} ); @@ -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, }, diff --git a/packages/app/src/composer/draft/input-draft-core.ts b/packages/app/src/composer/draft/input-draft-core.ts index 9e727433d..162869855 100644 --- a/packages/app/src/composer/draft/input-draft-core.ts +++ b/packages/app/src/composer/draft/input-draft-core.ts @@ -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: { diff --git a/packages/app/src/composer/draft/input-draft.test.ts b/packages/app/src/composer/draft/input-draft.test.ts index 7ca166e3b..d9bbb6838 100644 --- a/packages/app/src/composer/draft/input-draft.test.ts +++ b/packages/app/src/composer/draft/input-draft.test.ts @@ -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( diff --git a/packages/app/src/composer/input/height-mirror.native.ts b/packages/app/src/composer/input/height-mirror.native.ts index 3946c9c79..f8c12873d 100644 --- a/packages/app/src/composer/input/height-mirror.native.ts +++ b/packages/app/src/composer/input/height-mirror.native.ts @@ -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; } diff --git a/packages/app/src/composer/input/height-mirror.web.ts b/packages/app/src/composer/input/height-mirror.web.ts index c24b49b0d..2be9d9642 100644 --- a/packages/app/src/composer/input/height-mirror.web.ts +++ b/packages/app/src/composer/input/height-mirror.web.ts @@ -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; } diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index d09777535..ed48866d8 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1586,7 +1586,7 @@ export const MessageInput = forwardRef( setBoundedInputHeight(inputHeightRef.current); }, [setBoundedInputHeight]); - useComposerHeightMirror({ + const measureComposerHeight = useComposerHeightMirror({ value, textareaRef: webTextareaRef, minHeight: MIN_INPUT_HEIGHT, @@ -1689,10 +1689,11 @@ export const MessageInput = forwardRef( const handleInputChange = useCallback( (nextValue: string) => { valueRef.current = nextValue; + measureComposerHeight(nextValue); updateLiveTextPresence(nextValue); onChangeText(nextValue); }, - [onChangeText, updateLiveTextPresence], + [measureComposerHeight, onChangeText, updateLiveTextPresence], ); const handleInputFocus = useCallback(() => { diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 85c0ac25d..f0b65fbca 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -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(() => , [statusDotStyle]); return ( - - - {statusLabel} - + {connectionBadge ? ( {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", diff --git a/packages/app/src/styles/markdown-styles.test.ts b/packages/app/src/styles/markdown-styles.test.ts index 3018f097e..59e5721ce 100644 --- a/packages/app/src/styles/markdown-styles.test.ts +++ b/packages/app/src/styles/markdown-styles.test.ts @@ -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"); }); }); diff --git a/packages/app/src/styles/markdown-styles.ts b/packages/app/src/styles/markdown-styles.ts index ce7ddafe9..0cd5cec6a 100644 --- a/packages/app/src/styles/markdown-styles.ts +++ b/packages/app/src/styles/markdown-styles.ts @@ -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, diff --git a/patches/@mattermost+react-native-paste-input+2.0.1.patch b/patches/@mattermost+react-native-paste-input+2.0.1.patch new file mode 100644 index 000000000..b4c1c2b69 --- /dev/null +++ b/patches/@mattermost+react-native-paste-input+2.0.1.patch @@ -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_