Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ FROM rust:1.96-bookworm@sha256:5e2214abe154fe26e39f64488952e5c991eeed1d6d6da7cc8
COPY --from=node_toolchain /usr/local/bin/node /usr/local/bin/node
COPY --from=node_toolchain /usr/local/lib/node_modules/ /usr/local/lib/node_modules/

WORKDIR /app
COPY .cargo/config.toml .cargo/config.toml

RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
&& ln -sf ../lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack \
Expand All @@ -28,8 +31,6 @@ RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& corepack enable pnpm \
&& cargo install --locked cargo-chef@0.1.77

WORKDIR /app

FROM chef AS planner

COPY Cargo.toml Cargo.lock ./
Expand Down
46 changes: 34 additions & 12 deletions crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2719,28 +2719,50 @@ async fn static_i18n_module_guards_locale_race_and_clears_failed_pack_cache() {
}

#[tokio::test]
async fn static_typing_dot_animation_respects_reduced_motion() {
async fn static_near_process_animation_respects_reduced_motion() {
// Content-shape regression guard for the typing-indicator animation
// contract (PR #4493 review): `.v2-typing-dot` is the single
// intentional animation exception, so it must animate by default and
// be suppressed under `prefers-reduced-motion: reduce`. A behavioral
// check that the dot computes to `animation: none` via the emulated
// media query needs a browser (`getComputedStyle`), which this
// workspace's Rust/oneshot harness cannot drive; that belongs in the
// deferred e2e scaffold.
// contract: the NEAR glyph and its comet animate while the assistant is
// working, and both animations are suppressed under
// `prefers-reduced-motion: reduce`.
let body = served_app_stylesheet().await;

assert!(
body.contains("animation:1.4s ease-in-out infinite v2-typing-bounce"),
"typing dots must animate by default",
body.contains("animation:2s ease-in-out infinite near-pulse"),
"the working NEAR glyph must pulse by default",
);
assert!(
body.contains(".near-process.is-busy .near-base{opacity:.24}"),
"the working NEAR glyph must be dimmed beneath the comet",
);
assert!(
body.contains(".near-process-label{color:var(--v2-text-muted);font-weight:400}"),
"the completed-state label must use muted presentation",
);
assert!(
body.contains(
".near-process.is-busy .near-process-label{color:var(--v2-text-strong);\
font-weight:560}"
),
"the working-state label must use strong presentation",
);
assert!(
body.contains("animation:1.1s linear infinite near-chase"),
"the working NEAR comet must chase by default",
);
assert!(
body.contains("@media (prefers-reduced-motion:reduce)"),
"stylesheet must carry a reduced-motion opt-out block",
);
assert!(
body.contains(".v2-typing-dot,.v2-spin{animation:none"),
"the typing dot must be suppressed under prefers-reduced-motion: reduce",
body.contains(
".near-process.is-busy .near-process-icon,.near-process.is-busy \
.near-process-icon .near-comet{animation:none"
),
"the NEAR glyph and comet must stop under prefers-reduced-motion: reduce",
);
assert!(
body.contains(".near-process .near-comet{display:none"),
"the comet must be hidden under prefers-reduced-motion: reduce",
);
}

Expand Down
18 changes: 1 addition & 17 deletions crates/ironclaw_webui/frontend/src/pages/chat/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import { matchCommand } from "./lib/chat-commands";
import { channelConnectionDisplayName } from "../../lib/channel-connection-events";
import { channelConnectionFromGate } from "./lib/gates";
import { NEW_DRAFT_KEY } from "./lib/draft-store";
import { messageBelongsToActiveRun } from "./lib/message-types";
import { buildRuntimeContext } from "./lib/runtime-context";
import { buildScopedLogsPath } from "../logs/lib/logs-data";
import { useInterfacePreferences } from "../../lib/interface-preferences";
Expand All @@ -49,16 +48,6 @@ function pendingOnboardingLabel(onboarding) {
return channelConnectionDisplayName(onboarding?.extensionName);
}

function hasVisibleStreamingAssistantText(messages, activeRunId) {
return (messages || []).some((message) =>
message?.role === "assistant" &&
message.isFinalReply === false &&
typeof message.content === "string" &&
message.content.length > 0 &&
messageBelongsToActiveRun(message, activeRunId)
);
}

function cancellationFailureDiagnostic(error) {
const status =
error &&
Expand Down Expand Up @@ -155,14 +144,9 @@ export function Chat({
Boolean(activeThreadId) && Boolean(pendingOnboarding);
const activeThreadIsProcessing = Boolean(activeThreadId) && isProcessing;
const activeRunId = activeRun?.runId || null;
const streamingAssistantTextVisible = hasVisibleStreamingAssistantText(
messages,
activeRunId
);
const showTypingIndicator =
activeThreadIsProcessing &&
!activeThreadHasGate &&
!streamingAssistantTextVisible;
!activeThreadHasGate;
const hasMessages =
messages.length > 0 ||
activeThreadIsProcessing ||
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// @ts-nocheck
import assert from "node:assert/strict";
import { test } from "vitest";
import vm from "node:vm";
import { componentSourceForTest } from "../../../lib/vm-component-harness";

function chatInputSourceForTest() {
return componentSourceForTest(
new URL("./chat-input.tsx", import.meta.url),
"ChatInput",
);
}

function renderChatInput({ sendDisabled = true, statusText = "" } = {}) {
const context = {
globalThis: {},
Icon() {},
Button() {},
React: {
useCallback: (callback) => callback,
useEffect: () => {},
useRef: (initial) => ({ current: initial }),
useState: (initial) => [
typeof initial === "function" ? initial() : initial,
() => {},
],
},
authScope: () => "test-scope",
commandMenuMatches: () => [],
commandMenuToken: () => null,
getDraft: () => "",
getStagedAttachments: () => [],
INITIAL_COMMAND_MENU_SELECTION: { index: 0, dismissed: false },
NEW_DRAFT_KEY: "new",
useAttachmentConfig: () => ({ accept: [] }),
useT: () => (key) => key,
};

vm.runInNewContext(chatInputSourceForTest(), context);
return context.globalThis.__testExports.ChatInput({
onSend: () => {},
disabled: false,
sendDisabled,
draftKey: "draft-key",
statusText,
});
}

function containsScalar(value, expected, seen = new Set()) {
if (value === expected) return true;
if (!value || typeof value !== "object" || seen.has(value)) return false;
seen.add(value);
return Object.values(value).some((child) =>
Array.isArray(child)
? child.some((item) => containsScalar(item, expected, seen))
: containsScalar(child, expected, seen)
);
}

test("ChatInput only shows explicit composer status text while sending is disabled", () => {
const busyComposer = renderChatInput();
assert.equal(
containsScalar(busyComposer, "chat.statusWorking"),
false,
"run progress belongs beside the thread indicator, not in the composer",
);

const gatedComposer = renderChatInput({ statusText: "Approval required" });
assert.equal(
containsScalar(gatedComposer, "Approval required"),
true,
"approval and cooldown notices should remain visible in the composer",
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -830,11 +830,11 @@ export function ChatInput({
/>

<div className="mt-2 flex items-center gap-2">
{isSubmitDisabled &&
{isSubmitDisabled && statusText &&
(
<span className="inline-flex items-center gap-2 text-xs text-[var(--v2-text-muted)]">
<span className="h-2 w-2 rounded-full bg-[var(--v2-accent)]" />
{statusText || t("chat.statusWorking")}
{statusText}
</span>
)}
<div className="ml-auto flex items-center gap-1.5">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Link } from "react-router";
import { useT } from "../../../lib/i18n";
import { ActivityRun } from "./activity-run";
import { MessageBubble } from "./message-bubble";
import { TypingIndicator } from "./typing-indicator";
import { Icon } from "../../../design-system/icons";
import { groupMessages } from "../lib/message-groups";

Expand Down Expand Up @@ -396,17 +397,25 @@ export function MessageList({
activeRunId={activeRunId}
/>
)
: (<MessageBubble
key={item.id}
message={item.message}
onRetry={onRetryMessage}
threadId={threadId}
activeRunId={activeRunId}
regressionArtifactExportEnabled={
regressionArtifactExportEnabled
}
commands={commands}
/>)
: item.type === "run-completion"
? (
<TypingIndicator
key={item.id}
state="done"
durationSeconds={item.durationSeconds}
/>
)
: (<MessageBubble
key={item.id}
message={item.message}
onRetry={onRetryMessage}
threadId={threadId}
activeRunId={activeRunId}
regressionArtifactExportEnabled={
regressionArtifactExportEnabled
}
commands={commands}
/>)
)}
{children}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// @ts-nocheck
import assert from "node:assert/strict";
import { test } from "vitest";
import vm from "node:vm";
import { componentSourceForTest } from "../../../lib/vm-component-harness";
import "../../../test/vm-tsx-setup";

const CLIP_ID = "near-clip-test";

// Load the presentational component the same way connection-status.test.ts does:
// strip imports, expose the function, and run it in a VM with a mocked React so
// `React.useId()` returns a deterministic clipPath id. The vm-tsx-setup shim
// transpiles the JSX into inspectable `{ type, props, children }` nodes.
function loadNearProcessIndicator() {
const context = {
React: { useId: () => CLIP_ID },
globalThis: {},
};
vm.runInNewContext(
componentSourceForTest(
new URL("./near-process-indicator.tsx", import.meta.url),
"NearProcessIndicator",
),
context,
);
return context.globalThis.__testExports.NearProcessIndicator;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

function findNode(value, predicate, seen = new Set()) {
if (!value || typeof value !== "object" || seen.has(value)) return null;
seen.add(value);
if (Array.isArray(value)) {
for (const candidate of value) {
const match = findNode(candidate, predicate, seen);
if (match) return match;
}
return null;
}
if (predicate(value)) return value;
for (const key of ["children", "values"]) {
const candidates = Array.isArray(value[key]) ? value[key] : [];
for (const candidate of candidates) {
const match = findNode(candidate, predicate, seen);
if (match) return match;
}
}
return null;
}

const byClass = (name) => (node) => node.props?.className === name;

test("NearProcessIndicator working state chases the NEAR spine with elapsed time", () => {
const NearProcessIndicator = loadNearProcessIndicator();
const rendered = NearProcessIndicator({
state: "working",
label: "Working…",
elapsed: "0:03",
});

assert.match(rendered.props.className, /\bnear-process\b/);
assert.match(rendered.props.className, /\bis-busy\b/);

// The canonical NEAR glyph is rendered while working; the busy-state class
// scopes its dimmed presentation in app.css.
const base = findNode(rendered, byClass("near-base"));
assert.notEqual(base, null, "base glyph should render");
assert.ok(base.props.d.startsWith("M21.443"), "base uses the NEAR mark path");

// The comet only exists while working, clipped to the glyph via the useId ref.
const comet = findNode(rendered, byClass("near-comet"));
assert.notEqual(comet, null, "comet should render while working");
assert.ok(comet.props.d.startsWith("M2.6 22.2"), "comet rides the N spine");
const clipGroup = findNode(rendered, (node) => node.props?.clipPath);
assert.equal(clipGroup.props.clipPath, `url(#${CLIP_ID})`);

// State-scoped CSS makes the working label strong; elapsed is shown beside it.
const label = findNode(rendered, byClass("near-process-label"));
assert.notEqual(label, null, "working label uses the shared label class");
assert.equal(label.children[0], "Working…");
const elapsed = findNode(rendered, byClass("near-process-elapsed"));
assert.notEqual(elapsed, null, "elapsed should render while working");
assert.equal(elapsed.children[0], "0:03");
});

test("NearProcessIndicator done state is a solid, static glyph with a muted label", () => {
const NearProcessIndicator = loadNearProcessIndicator();
const rendered = NearProcessIndicator({ state: "done", label: "Done" });

assert.match(rendered.props.className, /\bis-done\b/);

const base = findNode(rendered, byClass("near-base"));
assert.ok(base.props.d.startsWith("M21.443"), "base uses the NEAR mark path");

assert.equal(
findNode(rendered, byClass("near-comet")),
null,
"comet is hidden when done",
);

const label = findNode(rendered, byClass("near-process-label"));
assert.notEqual(label, null, "done label uses the shared label class");
assert.equal(label.children[0], "Done");
});
Loading
Loading