feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* @file RunConsole.test.tsx
|
||||
* @description Pins the props-only boundary of `RunConsole` after its move out
|
||||
* of `pages/Run.tsx`: the envelope stream renders from the `envelopes` prop
|
||||
* (no stream subscription of its own), the token meter rolls up usage from
|
||||
* those same envelopes, the prompt editor's `/` autocomplete filters and fills
|
||||
* the prompt through `onFollowUpChange`, and `onSend` / `onStop` fire from the
|
||||
* send and stop controls.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { RunConsole, type SlashCommand } from "../RunConsole";
|
||||
import type { Envelope } from "../../../hooks/useRunStream";
|
||||
import type { RunHandle } from "../../../lib/api";
|
||||
|
||||
const HANDLE: RunHandle = {
|
||||
id: "run-1",
|
||||
pid: 4242,
|
||||
mode: "conversation",
|
||||
cwd: "/tmp/project",
|
||||
model: "claude-opus-5",
|
||||
permissionMode: "acceptEdits",
|
||||
effort: "",
|
||||
prompt: "hi",
|
||||
argv: [],
|
||||
resumeSessionId: null,
|
||||
status: "running",
|
||||
startedAt: 1,
|
||||
endedAt: null,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
error: null,
|
||||
sessionId: null,
|
||||
envelopeCount: 0,
|
||||
stdoutTail: "",
|
||||
stderrTail: "",
|
||||
};
|
||||
|
||||
const COMMANDS: SlashCommand[] = [
|
||||
{ name: "code-review", description: "Review the working diff", source: "project" },
|
||||
{ name: "compact", description: "Compact the conversation context", source: "builtin" },
|
||||
{ name: "logout", description: "Sign out", source: "builtin" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Mount the console with the parent-owned follow-up state it expects, so the
|
||||
* autocomplete assertions exercise the real controlled-input round trip.
|
||||
*/
|
||||
function renderConsole(
|
||||
props: Partial<React.ComponentProps<typeof RunConsole>> = {},
|
||||
onFollowUp?: (s: string) => void
|
||||
) {
|
||||
const seen = { followUp: "" };
|
||||
function Harness() {
|
||||
const [followUp, setFollowUp] = useState("");
|
||||
seen.followUp = followUp;
|
||||
return (
|
||||
<RunConsole
|
||||
handle={HANDLE}
|
||||
envelopes={[]}
|
||||
mode="conversation"
|
||||
isLive
|
||||
hasFinished={false}
|
||||
followUp={followUp}
|
||||
onFollowUpChange={(s) => {
|
||||
setFollowUp(s);
|
||||
onFollowUp?.(s);
|
||||
}}
|
||||
busy={null}
|
||||
onSend={() => {}}
|
||||
onStop={() => {}}
|
||||
onNewRun={() => {}}
|
||||
slashCommands={COMMANDS}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Harness />
|
||||
</MemoryRouter>
|
||||
);
|
||||
return seen;
|
||||
}
|
||||
|
||||
describe("RunConsole", () => {
|
||||
it("renders assistant text from the envelopes prop", () => {
|
||||
const envelopes: Envelope[] = [
|
||||
{ type: "user", message: { content: "explain this repo" } },
|
||||
{ type: "assistant", message: { content: [{ type: "text", text: "Here is the answer." }] } },
|
||||
] as Envelope[];
|
||||
renderConsole({ envelopes });
|
||||
|
||||
expect(screen.getByText("explain this repo")).toBeInTheDocument();
|
||||
expect(screen.getByText("Here is the answer.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the empty-stream placeholder when there are no envelopes", () => {
|
||||
renderConsole({ isLive: false });
|
||||
expect(screen.getByText("Nothing yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the token totals computed from the envelopes", () => {
|
||||
// Transcript-shaped assistant envelope (no `message.id`), which is the
|
||||
// branch computeTokens folds into the running totals.
|
||||
const envelopes: Envelope[] = [
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "text", text: "done" }],
|
||||
usage: { input_tokens: 12_000, output_tokens: 2_500, cache_read_input_tokens: 8_000 },
|
||||
},
|
||||
},
|
||||
] as Envelope[];
|
||||
renderConsole({ envelopes });
|
||||
|
||||
// Context gauge: (input + cache read) / default 200k window.
|
||||
// The CLI-style meter is one status line: context usage as a single label,
|
||||
// then output and cache-hit figures with terminal glyphs. Input is implied
|
||||
// by the context total rather than listed separately.
|
||||
expect(screen.getByText("20.0k / 200k (10%)")).toBeInTheDocument();
|
||||
expect(screen.getByText("↑2.5k")).toBeInTheDocument(); // Output
|
||||
expect(screen.getByText("⚡8.0k")).toBeInTheDocument(); // Cache hit
|
||||
});
|
||||
|
||||
it("filters slash commands as the user types and fills the prompt on pick", () => {
|
||||
const seen = renderConsole();
|
||||
const textarea = screen.getByRole("textbox");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "/co" } });
|
||||
|
||||
expect(screen.getByText("/code-review")).toBeInTheDocument();
|
||||
expect(screen.getByText("/compact")).toBeInTheDocument();
|
||||
expect(screen.queryByText("/logout")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("/code-review"));
|
||||
|
||||
expect(seen.followUp).toBe("/code-review");
|
||||
expect(screen.queryByText("/compact")).not.toBeInTheDocument(); // dropdown closed
|
||||
});
|
||||
|
||||
it("fires onSend from the send button with the prompt the parent holds", () => {
|
||||
const onSend = vi.fn();
|
||||
const seen = renderConsole({ onSend });
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "follow up please" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
expect(seen.followUp).toBe("follow up please");
|
||||
});
|
||||
|
||||
it("fires onStop from the stop control while live, and hides it when not", () => {
|
||||
const onStop = vi.fn();
|
||||
renderConsole({ onStop });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /stop/i }));
|
||||
expect(onStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("hides the stop control and the follow-up editor once the run is not live", () => {
|
||||
renderConsole({ isLive: false, hasFinished: true });
|
||||
|
||||
expect(screen.queryByRole("button", { name: /stop/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user