Compare commits

...
8 Commits
Author SHA1 Message Date
buenosair 4755f6d1e3 feat(spawn): share host spawn-pi mesh + models.json with spawned containers
- bind $LVMH_HOST_PI_RUNTIME_DIR/spawn-pi -> /root/.pi/spawn-pi (rw) so
  container pi's join the host pi's node mesh (spawn_pi/list_pi_nodes/
  send_pi_message now reach across the host/container boundary)
- bind host models.json (when present) over the stale image-baked
  fallback so newly added catalog models resolve in spawns
2026-09-07 13:21:49 +00:00
buenosair 6ef27e5cd6 feat(models): add glm-5.3-flash to worker model fallback catalog 2026-09-07 12:09:56 +00:00
buenosair b62e4e717a fix: glm not found in dotfiles-less workers — bridge promotes models.json.fallback at startup (the fallback was never read by anything) 2026-09-04 07:59:54 +00:00
buenosair 310127cf7a fix(model selection): deploys from dotfiles-less machines no longer wipe the remote baked pi config (deploy.sh excludes docker/pi-agent); resync endpoint fixed for in-container runs (bash/rsync/python3/npm in daemon image, cross-device copy instead of rename) 2026-09-02 11:51:47 +00:00
buenosair 09fc72556d feat: markdown block parsing in LLM output — headings, lists (ul/ol), blockquotes, hr on top of fences + inline; 300 tests green 2026-09-02 09:49:56 +00:00
buenosair 0f245c9ee2 feat: start on a blank container — spawn with empty:true and no repo (skip clone, default worker image); Spawn UI enables blank spawn without repo selection 2026-09-02 09:24:54 +00:00
buenosair c8cde3ff89 style: reformat ChatStream/ChatView (line-width normalization) from review pass 2026-09-01 15:21:42 +00:00
buenosair 7ff9f77f40 feat: unified tool cards (glyph + arg, call+result in one card, ✓/✗ status), client-side message queue (queue while busy, flush on settle, removable pending bubbles), mobile chat-header wrap 2026-09-01 14:14:52 +00:00
18 changed files with 777 additions and 149 deletions
+3 -1
View File
@@ -10,7 +10,9 @@ COPY daemon/ ./
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/lvmh-daemon . RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/lvmh-daemon .
FROM alpine:3.21 FROM alpine:3.21
RUN apk add --no-cache ca-certificates git # bash + rsync: POST /api/pi-config/resync runs deploy/rsync-pi-agent.sh
# inside this container (alpine has neither by default)
RUN apk add --no-cache ca-certificates git bash rsync python3 nodejs npm
WORKDIR /app WORKDIR /app
COPY --from=build /out/lvmh-daemon /app/lvmh-daemon COPY --from=build /out/lvmh-daemon /app/lvmh-daemon
# Placeholder UI; replace via build (cp web/dist daemon/webdist) or mount at # Placeholder UI; replace via build (cp web/dist daemon/webdist) or mount at
+7 -1
View File
@@ -373,7 +373,13 @@ func (s *Server) handleSpawn(w http.ResponseWriter, r *http.Request) {
if !decodeBody(w, r, &body) { if !decodeBody(w, r, &body) {
return return
} }
if !validRepoPath(body.Repo) { // Blank-container spawns carry no repo; everything else must look like
// group/project.
if body.Repo == "" && !body.Empty {
writeError(w, http.StatusBadRequest, "repo required (or set empty for a blank container)")
return
}
if body.Repo != "" && !validRepoPath(body.Repo) {
writeError(w, http.StatusBadRequest, "repo must look like group/project") writeError(w, http.StatusBadRequest, "repo must look like group/project")
return return
} }
+17
View File
@@ -682,3 +682,20 @@ func TestRenameAndSetModelBodyValidation(t *testing.T) {
t.Fatalf("bad model body = %d, want 400", code) t.Fatalf("bad model body = %d, want 400", code)
} }
} }
func TestAPISpawnBlankContainerNoRepo(t *testing.T) {
ts, _ := newSpawnAPIServer(t)
// no repo and not empty → 400
if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/spawn", testToken, `{"repo":""}`); code != http.StatusBadRequest {
t.Fatalf("no-repo non-empty spawn = %d %s, want 400", code, body)
}
// blank container: no repo required
code, body := apiReq(t, http.MethodPost, ts.URL+"/api/spawn", testToken, `{"empty":true}`)
if code != http.StatusCreated {
t.Fatalf("blank spawn = %d %s, want 201", code, body)
}
if !strings.Contains(body, `"sessionId"`) {
t.Fatalf("blank spawn body = %s, want sessionId", body)
}
}
+22 -1
View File
@@ -47,11 +47,14 @@ const (
volumePiCache string = "lvmh-pi-cache" // pi package cache (git:/npm:), shared across spawns volumePiCache string = "lvmh-pi-cache" // pi package cache (git:/npm:), shared across spawns
cacheMount string = "/root/.pi/agent/cache" cacheMount string = "/root/.pi/agent/cache"
authMountTarget string = "/root/.pi/agent/auth.json" authMountTarget string = "/root/.pi/agent/auth.json"
modelsMountTarget string = "/root/.pi/agent/models.json"
envHostPiAgentDir string = "LVMH_HOST_PI_AGENT_DIR" envHostPiAgentDir string = "LVMH_HOST_PI_AGENT_DIR"
envHostPiRuntimeDir string = "LVMH_HOST_PI_RUNTIME_DIR"
envSecretsDir string = "LVMH_SECRETS_DIR" envSecretsDir string = "LVMH_SECRETS_DIR"
envCloakCacheDir string = "LVMH_CLOAK_CACHE_DIR" envCloakCacheDir string = "LVMH_CLOAK_CACHE_DIR"
envPlaywrightCacheDir string = "LVMH_PLAYWRIGHT_CACHE_DIR" envPlaywrightCacheDir string = "LVMH_PLAYWRIGHT_CACHE_DIR"
sshMountTarget string = "/root/.ssh" sshMountTarget string = "/root/.ssh"
meshMountTarget string = "/root/.pi/spawn-pi"
gitconfigMountTarget string = "/root/.gitconfig" gitconfigMountTarget string = "/root/.gitconfig"
workspaceMount string = "/workspace" workspaceMount string = "/workspace"
sessionsMount string = "/pi-sessions" sessionsMount string = "/pi-sessions"
@@ -298,10 +301,13 @@ func (s *Spawner) runJob(repo, branch, model string, empty bool, sessionID strin
lock.Lock() lock.Lock()
defer lock.Unlock() defer lock.Unlock()
// blank-container spawn: nothing to clone or update
if repo != "" {
if err := s.cloneOrUpdate(s.ctx, repo, branch, slug); err != nil { if err := s.cloneOrUpdate(s.ctx, repo, branch, slug); err != nil {
s.setJob(sessionID, repo, stateError, "", err.Error()) s.setJob(sessionID, repo, stateError, "", err.Error())
return return
} }
}
s.setJob(sessionID, repo, stateBuilding, "", "") s.setJob(sessionID, repo, stateBuilding, "", "")
if err := s.ensureImage(s.ctx); err != nil { if err := s.ensureImage(s.ctx); err != nil {
s.setJob(sessionID, repo, stateError, "", err.Error()) s.setJob(sessionID, repo, stateError, "", err.Error())
@@ -570,9 +576,24 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, model string,
volumePiCache+":"+cacheMount, volumePiCache+":"+cacheMount,
) )
// Host pi credentials (OAuth tokens for anthropic etc.), read-only, so // Host pi credentials (OAuth tokens for anthropic etc.), read-only, so
// spawned agents can use every model the catalog offers. // spawned agents can use every model the catalog offers. The host
// models.json rides along when present: image fallbacks are baked at
// build time and go stale the moment the dotfiles catalog changes.
if hostAgent := os.Getenv(envHostPiAgentDir); hostAgent != "" { if hostAgent := os.Getenv(envHostPiAgentDir); hostAgent != "" {
binds = append(binds, hostAgent+"/auth.json:"+authMountTarget+":ro") binds = append(binds, hostAgent+"/auth.json:"+authMountTarget+":ro")
if _, err := os.Stat(filepath.Join(hostAgent, "models.json")); err == nil {
binds = append(binds, hostAgent+"/models.json:"+modelsMountTarget+":ro")
}
}
// Shared spawn-pi mesh directory (node registry + AF_UNIX sockets),
// read-write: each pi creates its own socket and node file. Sharing it
// with the host puts container pi's on the same mesh as the host pi —
// without this each container is an isolated island.
if hostRuntime := os.Getenv(envHostPiRuntimeDir); hostRuntime != "" {
mesh := filepath.Join(hostRuntime, "spawn-pi")
if _, err := os.Stat(mesh); err == nil {
binds = append(binds, mesh+":"+meshMountTarget)
}
} }
// Shared playwright browser cache (host path, read-only); the env var // Shared playwright browser cache (host path, read-only); the env var
// below makes every playwright-based MCP use it instead of downloading. // below makes every playwright-based MCP use it instead of downloading.
+58
View File
@@ -519,6 +519,64 @@ func TestSpawnerSecretsBinds(t *testing.T) {
} }
} }
func TestWorkerBindsHostModelsJSON(t *testing.T) {
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
sp, _ := newTestSpawner(t, f)
agent := t.TempDir()
if err := os.WriteFile(filepath.Join(agent, "models.json"), []byte(`{}`), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv(envHostPiAgentDir, agent)
res, err := sp.Start(context.Background(), "group/project", "", "", false)
if err != nil {
t.Fatalf("Start: %v", err)
}
waitJobState(t, sp, res.SessionID, stateRunning)
creates := f.createsByName("lvmh-agent-")
binds := creates[0].HostConfig.Binds
want := agent + "/models.json:/root/.pi/agent/models.json:ro"
ok := false
for _, b := range binds {
if b == want {
ok = true
}
}
if !ok {
t.Fatalf("models.json bind missing from %v", binds)
}
}
func TestWorkerBindsSpawnPiMesh(t *testing.T) {
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
sp, _ := newTestSpawner(t, f)
runtimeDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(runtimeDir, "spawn-pi"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv(envHostPiRuntimeDir, runtimeDir)
res, err := sp.Start(context.Background(), "group/project", "", "", false)
if err != nil {
t.Fatalf("Start: %v", err)
}
waitJobState(t, sp, res.SessionID, stateRunning)
creates := f.createsByName("lvmh-agent-")
binds := creates[0].HostConfig.Binds
want := filepath.Join(runtimeDir, "spawn-pi") + ":/root/.pi/spawn-pi"
ok := false
for _, b := range binds {
if b == want {
ok = true
}
}
if !ok {
t.Fatalf("spawn-pi mesh bind missing from %v", binds)
}
}
func TestWorkerBindsCaches(t *testing.T) { func TestWorkerBindsCaches(t *testing.T) {
useFakeGit(t, fakeGitModeOK) useFakeGit(t, fakeGitModeOK)
f := newFakeDocker() f := newFakeDocker()
+7 -2
View File
@@ -100,10 +100,15 @@ func (s *Server) handlePiConfigResync(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "clear stale pi-agent: "+err.Error()) writeError(w, http.StatusInternalServerError, "clear stale pi-agent: "+err.Error())
return return
} }
if err := os.Rename(bakeDir, filepath.Join(bakeCtx, "docker", "pi-agent")); err != nil { // copy, not rename: bakeDir (data volume) and bakeCtx (tmp) are
writeError(w, http.StatusInternalServerError, "move bake dir: "+err.Error()) // different mounts — rename(2) fails with EXDEV
if err := copyTree(bakeDir, filepath.Join(bakeCtx, "docker", "pi-agent")); err != nil {
writeError(w, http.StatusInternalServerError, "copy bake dir: "+err.Error())
return return
} }
if err := os.RemoveAll(bakeDir); err != nil {
log.Printf("pi-config: remove bake dir after copy: %v", err)
}
if err := s.spawn.buildImage(ctx, bakeCtx, filepath.Join(bakeCtx, "docker", "worker.Dockerfile"), imageRefWorker); err != nil { if err := s.spawn.buildImage(ctx, bakeCtx, filepath.Join(bakeCtx, "docker", "worker.Dockerfile"), imageRefWorker); err != nil {
writeError(w, http.StatusInternalServerError, "worker rebuild: "+err.Error()) writeError(w, http.StatusInternalServerError, "worker rebuild: "+err.Error())
+21
View File
@@ -1348,3 +1348,24 @@ func TestSpawnerEmptyScratch(t *testing.T) {
t.Fatalf("sessions volume missing: %v", binds) t.Fatalf("sessions volume missing: %v", binds)
} }
} }
func TestSpawnerEmptyScratchNoRepo(t *testing.T) {
f := newFakeDocker()
sp, _ := newTestSpawner(t, f)
res, err := sp.Start(context.Background(), "", "", "", true)
if err != nil {
t.Fatalf("Start with no repo: %v", err)
}
waitJobState(t, sp, res.SessionID, stateRunning)
creates := f.createsByName("lvmh-agent-")
if len(creates) != 1 {
t.Fatalf("creates = %d, want 1", len(creates))
}
for _, b := range creates[0].HostConfig.Binds {
if b == "lvmh-sessions:/pi-sessions" || b == "lvmh-pi-cache:/root/.pi/agent/cache" {
continue
}
t.Fatalf("blank spawn must mount no repo volume: %v", creates[0].HostConfig.Binds)
}
}
+9
View File
@@ -22,10 +22,19 @@ fi
# Bake the user's pi config (dotfiles) into the worker image context. # Bake the user's pi config (dotfiles) into the worker image context.
bash "$(dirname "$0")/rsync-pi-agent.sh" bash "$(dirname "$0")/rsync-pi-agent.sh"
# A dotfiles-less checkout must not wipe the remote's baked pi config
# (daemon catalog + worker image depend on it).
PIAGENT_EXCLUDE=""
if [ ! -f docker/pi-agent/settings.json ]; then
echo "no local pi config — preserving remote docker/pi-agent"
PIAGENT_EXCLUDE="--exclude /docker/pi-agent"
fi
rsync -az --delete \ rsync -az --delete \
--exclude /.git --exclude /node_modules --exclude /web/node_modules \ --exclude /.git --exclude /node_modules --exclude /web/node_modules \
--exclude /.env --exclude /.scratch --exclude /.pi --exclude /coverage \ --exclude /.env --exclude /.scratch --exclude /.pi --exclude /coverage \
--exclude '*.db' --exclude /daemon/lvmh-daemon --exclude /.playwright-mcp \ --exclude '*.db' --exclude /daemon/lvmh-daemon --exclude /.playwright-mcp \
${PIAGENT_EXCLUDE} \
./ "$REMOTE:$REMOTE_DIR/" ./ "$REMOTE:$REMOTE_DIR/"
echo "building daemon image + worker image on $REMOTE..." echo "building daemon image + worker image on $REMOTE..."
+3
View File
@@ -19,6 +19,9 @@ services:
# docker.sock bind semantics: bind sources resolve on the HOST, so # docker.sock bind semantics: bind sources resolve on the HOST, so
# this must be the HOST path of the pi config (auth.json etc.). # this must be the HOST path of the pi config (auth.json etc.).
LVMH_HOST_PI_AGENT_DIR: ${LVMH_PI_AGENT_DIR:-/home/alarm/.dotfiles/pi/agent} LVMH_HOST_PI_AGENT_DIR: ${LVMH_PI_AGENT_DIR:-/home/alarm/.dotfiles/pi/agent}
# host pi runtime dir (spawn-pi mesh: nodes + sockets), shared rw with
# spawned containers so host pi and container pi's form one mesh
LVMH_HOST_PI_RUNTIME_DIR: ${LVMH_PI_RUNTIME_DIR:-/home/alarm/.pi}
LVMH_SECRETS_DIR: /zdata/root/lvmh-secrets LVMH_SECRETS_DIR: /zdata/root/lvmh-secrets
LVMH_CLOAK_CACHE_DIR: /home/alarm/.cloakbrowser LVMH_CLOAK_CACHE_DIR: /home/alarm/.cloakbrowser
LVMH_PLAYWRIGHT_CACHE_DIR: /home/alarm/.cache/ms-playwright LVMH_PLAYWRIGHT_CACHE_DIR: /home/alarm/.cache/ms-playwright
+8 -1
View File
@@ -3,7 +3,7 @@
// web prompts through pi.sendUserMessage. This process just hosts the session. // web prompts through pi.sendUserMessage. This process just hosts the session.
// Global npm layout: resolve pi via absolute path (NODE_PATH does not apply to ESM). // Global npm layout: resolve pi via absolute path (NODE_PATH does not apply to ESM).
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { existsSync } from "node:fs"; import { copyFileSync, existsSync } from "node:fs";
import { import {
createAgentSession, createAgentSession,
ModelRuntime, ModelRuntime,
@@ -61,6 +61,13 @@ process.on("unhandledRejection", (err) => {
try { try {
await runRepoSetup(); await runRepoSetup();
// Dotfiles-less bakes ship only models.json.fallback: promote it so the
// registry always resolves the zai provider (glm) instead of "not found".
const piAgent = "/root/.pi/agent";
if (!existsSync(`${piAgent}/models.json`) && existsSync(`${piAgent}/models.json.fallback`)) {
copyFileSync(`${piAgent}/models.json.fallback`, `${piAgent}/models.json`);
console.error("[lvmh-bridge] promoted models.json.fallback -> models.json");
}
// Optional initial model: LVMH_MODEL="provider/model-id" from the spawn // Optional initial model: LVMH_MODEL="provider/model-id" from the spawn
// request. Resolved against the session's model registry; a bad value is // request. Resolved against the session's model registry; a bad value is
// logged and falls back to the default model. // logged and falls back to the default model.
+7
View File
@@ -16,6 +16,13 @@
"reasoning": true, "reasoning": true,
"contextWindow": 1000000, "contextWindow": 1000000,
"thinkingLevelMap": { "xhigh": "xhigh", "max": "max" } "thinkingLevelMap": { "xhigh": "xhigh", "max": "max" }
},
{
"id": "glm-5.3-flash",
"name": "GLM-5.3 Flash",
"reasoning": true,
"contextWindow": 1000000,
"thinkingLevelMap": { "xhigh": "xhigh", "max": "max" }
} }
] ]
} }
+144 -13
View File
@@ -38,6 +38,8 @@ const tool = (p: Partial<ToolState>): ToolState => ({
function stream(p: { function stream(p: {
messages: ChatMessage[]; messages: ChatMessage[];
busy: boolean; busy: boolean;
queued?: string[];
unqueue?: (index: number) => void;
hasOlder?: boolean; hasOlder?: boolean;
loadingOlder?: boolean; loadingOlder?: boolean;
onOlder?: () => void; onOlder?: () => void;
@@ -50,6 +52,8 @@ function stream(p: {
messages={p.messages} messages={p.messages}
tools={new Map()} tools={new Map()}
busy={p.busy} busy={p.busy}
queued={p.queued ?? []}
unqueue={p.unqueue ?? (() => undefined)}
hasOlder={p.hasOlder ?? false} hasOlder={p.hasOlder ?? false}
loadingOlder={p.loadingOlder ?? false} loadingOlder={p.loadingOlder ?? false}
onLoadOlder={p.onOlder ?? (() => undefined)} onLoadOlder={p.onOlder ?? (() => undefined)}
@@ -151,10 +155,12 @@ describe("Bubble", () => {
tools={tools} tools={tools}
/>, />,
); );
expect(screen.getByText("🛠 bash")).toBeInTheDocument(); expect(screen.getByText("$ ls -la")).toBeInTheDocument();
expect(screen.getByText("finished")).toBeInTheDocument(); expect(screen.getByText("finished")).toBeInTheDocument();
const summary = screen.getByText("🛠 bash").closest("summary") as HTMLElement; const summary = screen
.getByText("$ ls -la")
.closest("summary") as HTMLElement;
const card = summary.closest("details") as HTMLDetailsElement; const card = summary.closest("details") as HTMLDetailsElement;
expect(card.open).toBe(false); expect(card.open).toBe(false);
await userEvent.click(summary); await userEvent.click(summary);
@@ -182,9 +188,9 @@ describe("Bubble", () => {
tools={tools} tools={tools}
/>, />,
); );
expect(screen.getByText("working…")).toBeInTheDocument(); expect(screen.getByText("")).toBeInTheDocument();
expect(screen.getByText("error")).toBeInTheDocument(); expect(screen.getByText("")).toBeInTheDocument();
expect(screen.getByText("done")).toBeInTheDocument(); expect(screen.getByText("")).toBeInTheDocument();
}); });
it("tool call with no matching state renders no card", () => { it("tool call with no matching state renders no card", () => {
@@ -781,7 +787,7 @@ describe("pretty tool args", () => {
tools={new Map([["t1", tool('{"command":"ls -la","foo":1}')]])} tools={new Map([["t1", tool('{"command":"ls -la","foo":1}')]])}
/>, />,
); );
expect(document.querySelector(".tool-summary")?.textContent).toBe("ls -la"); expect(document.querySelector(".tool-label")?.textContent).toBe("$ ls -la");
expect(document.querySelector(".tool-args")?.textContent).not.toContain( expect(document.querySelector(".tool-args")?.textContent).not.toContain(
'"command"', '"command"',
); );
@@ -809,8 +815,8 @@ describe("pretty tool args", () => {
tools={new Map([["t1", tool('{"path":"src/main.ts"}')]])} tools={new Map([["t1", tool('{"path":"src/main.ts"}')]])}
/>, />,
); );
expect(document.querySelector(".tool-summary")?.textContent).toBe( expect(document.querySelector(".tool-label")?.textContent).toBe(
"src/main.ts", "$ src/main.ts",
); );
expect(document.querySelector(".tool-args .arg-k")).toBeNull(); expect(document.querySelector(".tool-args .arg-k")).toBeNull();
}); });
@@ -849,8 +855,7 @@ describe("tool summary + diff", () => {
tools={new Map([["t1", tool('{"command":"ls -la","timeout":30}')]])} tools={new Map([["t1", tool('{"command":"ls -la","timeout":30}')]])}
/>, />,
); );
expect(screen.getByText("🛠 bash")).toBeInTheDocument(); expect(screen.getByText("$ ls -la")).toBeInTheDocument();
expect(document.querySelector(".tool-summary")?.textContent).toBe("ls -la");
}); });
it("read summary shows the path", () => { it("read summary shows the path", () => {
@@ -862,8 +867,8 @@ describe("tool summary + diff", () => {
tools={new Map([["t1", tool('{"path":"src/main.ts"}', "read")]])} tools={new Map([["t1", tool('{"path":"src/main.ts"}', "read")]])}
/>, />,
); );
expect(document.querySelector(".tool-summary")?.textContent).toBe( expect(document.querySelector(".tool-label")?.textContent).toBe(
"src/main.ts", "📄 src/main.ts",
); );
}); });
@@ -878,7 +883,7 @@ describe("tool summary + diff", () => {
tools={new Map([["t1", tool(`{"command":"${long}"}`)]])} tools={new Map([["t1", tool(`{"command":"${long}"}`)]])}
/>, />,
); );
const summary = document.querySelector(".tool-summary"); const summary = document.querySelector(".tool-label");
expect(summary?.textContent?.endsWith("…")).toBe(true); expect(summary?.textContent?.endsWith("…")).toBe(true);
}); });
@@ -970,3 +975,129 @@ describe("lineDiff", () => {
expect(d.filter((l) => l.kind === "add")).toHaveLength(1); expect(d.filter((l) => l.kind === "add")).toHaveLength(1);
}); });
}); });
describe("unified tool card", () => {
const tool = (args: string): ToolState => ({
id: "t1",
name: "bash",
args,
running: false,
isError: false,
preview: "",
});
it("toolResult bubble is skipped when its tool card exists", () => {
const { container } = render(
<>
<Bubble
msg={msg({
role: "assistant",
toolCalls: [{ id: "t1", name: "bash", argsJson: "{}" }],
})}
tools={new Map([["t1", tool('{"command":"ls"}')]])}
/>
<Bubble
msg={msg({ role: "toolResult", text: "file1\nfile2", toolCallId: "t1" })}
tools={new Map([["t1", tool('{"command":"ls"}')]])}
/>
</>,
);
expect(container.querySelectorAll(".tool-card")).toHaveLength(1);
expect(container.textContent).not.toContain("result");
});
it("toolResult without tool state still renders a result card", () => {
render(
<Bubble
msg={msg({ role: "toolResult", text: "orphan output", toolCallId: "gone" })}
tools={new Map()}
/>,
);
expect(screen.getAllByText("orphan output").length).toBeGreaterThan(0);
});
it("queued messages render as removable pending bubbles", async () => {
const unqueue = vi.fn();
const { rerender } = render(
stream({
messages: [msg({ role: "user", text: "hi" })],
busy: true,
queued: ["next msg"],
unqueue,
}),
);
expect(screen.getByText("next msg")).toBeInTheDocument();
await userEvent.click(screen.getByLabelText("Remove queued message"));
expect(unqueue).toHaveBeenCalledWith(0);
rerender(
stream({
messages: [msg({ role: "user", text: "hi" })],
busy: true,
queued: [],
unqueue,
}),
);
expect(screen.queryByText("next msg")).toBeNull();
});
});
describe("renderMarkdown blocks", () => {
it("headings render as h1-h6 with inline content", () => {
const out = renderMarkdown("## Hello **world**");
const h = out[0] as React.ReactElement<{ className: string }>;
expect(h.type).toBe("h2");
expect(h.props.className).toContain("md-h-2");
});
it("--- renders a horizontal rule", () => {
const out = renderMarkdown("above\n---\nbelow");
expect(out.some((n) => (n as React.ReactElement).type === "hr")).toBe(true);
});
it("> lines group into one blockquote", () => {
const out = renderMarkdown("> quoted a\n> quoted b\nplain");
const q = out.find(
(n) => (n as React.ReactElement).type === "blockquote",
) as React.ReactElement<{ children: React.ReactNode[] }>;
expect(q).toBeDefined();
expect(q.props.children.join("")).toContain("quoted a");
expect(q.props.children.join("")).toContain("quoted b");
});
it("- and * lines render as an unordered list", () => {
const out = renderMarkdown("- one\n- two\n* three");
const ul = out[0] as React.ReactElement<{ children: React.ReactNode[] }>;
expect(ul.type).toBe("ul");
expect(ul.props.children).toHaveLength(3);
});
it("1. lines render as an ordered list", () => {
const out = renderMarkdown("1. first\n2. second");
const ol = out[0] as React.ReactElement<{ children: React.ReactNode[] }>;
expect(ol.type).toBe("ol");
expect(ol.props.children).toHaveLength(2);
});
it("emphasis start is not a list marker", () => {
const out = renderMarkdown("*bold start* to line");
expect(out.some((n) => (n as React.ReactElement).type === "ul")).toBe(false);
});
it("list items keep inline markdown", () => {
const out = renderMarkdown("- has `code` and [l](https://x.io)");
const ul = out[0] as React.ReactElement<{ children: React.ReactNode[] }>;
const li = ul.props.children[0] as React.ReactElement<{
children: React.ReactNode[];
}>;
expect(JSON.stringify(li.props.children)).toContain("code");
expect(JSON.stringify(li.props.children)).toContain("https://x.io");
});
it("fences still win over block markers", () => {
const out = renderMarkdown("```\n- not a list\n# not a heading\n```");
const pre = out[0] as React.ReactElement<{ children: string }>;
expect(pre.type).toBe("pre");
expect(pre.props.children).toContain("- not a list");
});
});
+156 -20
View File
@@ -6,6 +6,11 @@ const COPY_FEEDBACK_MS: number = 1200;
const PIN_THRESHOLD_PX: number = 80; const PIN_THRESHOLD_PX: number = 80;
const FAB_THRESHOLD_PX: number = 400; const FAB_THRESHOLD_PX: number = 400;
const FENCE: string = "```"; const FENCE: string = "```";
const HEADING_RE: RegExp = /^(#{1,6})\s+(.*)$/;
const HR_RE: RegExp = /^(?:-{3,}|\*{3,}|_{3,})$/;
const QUOTE_RE: RegExp = /^>\s?(.*)$/;
const UL_RE: RegExp = /^[-*+]\s+(.*)$/;
const OL_RE: RegExp = /^\d{1,9}[.)]\s+(.*)$/;
const ENTER_KEY: string = "Enter"; const ENTER_KEY: string = "Enter";
const ESCAPE_KEY: string = "Escape"; const ESCAPE_KEY: string = "Escape";
const INLINE_RE: RegExp = const INLINE_RE: RegExp =
@@ -79,11 +84,13 @@ function inlineNodes(
return out; return out;
} }
/** ```fences``` → <pre class="md-code">, `code`, **bold**, *italic*, /** Block markdown: ```fences```, # headings, --- rules, > quotes, -/*
* [t](http…) links (http/https only). Plain segments keep the bubble's * lists, 1. lists; inline: `code`, **bold**, *italic*, [t](http…)
* pre-wrap. Unterminated fences (streaming) render the tail as code. */ * links (http/https only). Plain segments keep the bubble's pre-wrap.
* Unterminated fences (streaming) render the tail as code. */
export function renderMarkdown(text: string, query = ""): React.ReactNode[] { export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
const out: React.ReactNode[] = []; const out: React.ReactNode[] = [];
const lines: string[] = text.split("\n");
let plain: string[] = []; let plain: string[] = [];
let code: string[] | null = null; let code: string[] | null = null;
let n = 0; let n = 0;
@@ -94,7 +101,29 @@ export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
plain = []; plain = [];
} }
}; };
for (const line of text.split("\n")) { // consecutive lines sharing a marker accumulate into one block element
const collect = (
re: RegExp,
from: number,
): { items: string[]; next: number } => {
const items: string[] = [];
let k: number = from;
while (k < lines.length) {
const raw: string | undefined = lines[k];
if (raw === undefined) break;
const m: RegExpMatchArray | null = raw.match(re);
if (m === null) break;
const item: string | undefined = m[1];
if (item === undefined) break;
items.push(item);
k += 1;
}
return { items, next: k };
};
let i: number = 0;
while (i < lines.length) {
const line: string | undefined = lines[i];
if (line === undefined) break;
if (line.trimStart().startsWith(FENCE)) { if (line.trimStart().startsWith(FENCE)) {
if (code === null) { if (code === null) {
flushPlain(); flushPlain();
@@ -108,11 +137,77 @@ export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
n += 1; n += 1;
code = null; code = null;
} }
} else if (code === null) { i += 1;
plain.push(line); continue;
} else {
code.push(line);
} }
if (code !== null) {
code.push(line);
i += 1;
continue;
}
const heading: RegExpMatchArray | null = line.match(HEADING_RE);
if (heading !== null) {
flushPlain();
const level: number = Math.min(heading[1]?.length ?? 1, 6);
const Tag: keyof React.JSX.IntrinsicElements = `h${level}` as "h1";
out.push(
<Tag className={`md-h md-h-${level}`} key={`h-${n}`}>
{inlineNodes(heading[2] ?? "", query, `h-${n}`)}
</Tag>,
);
n += 1;
i += 1;
continue;
}
if (HR_RE.test(line.trim())) {
flushPlain();
out.push(<hr className="md-hr" key={`r-${n}`} />);
n += 1;
i += 1;
continue;
}
if (QUOTE_RE.test(line)) {
flushPlain();
const q: { items: string[]; next: number } = collect(QUOTE_RE, i);
out.push(
<blockquote className="md-quote" key={`q-${n}`}>
{inlineNodes(q.items.join("\n"), query, `q-${n}`)}
</blockquote>,
);
n += 1;
i = q.next;
continue;
}
if (UL_RE.test(line)) {
flushPlain();
const l: { items: string[]; next: number } = collect(UL_RE, i);
out.push(
<ul className="md-list" key={`u-${n}`}>
{l.items.map((item, k) => (
<li key={k}>{inlineNodes(item, query, `u-${n}-${k}`)}</li>
))}
</ul>,
);
n += 1;
i = l.next;
continue;
}
if (OL_RE.test(line)) {
flushPlain();
const l: { items: string[]; next: number } = collect(OL_RE, i);
out.push(
<ol className="md-list md-list-ol" key={`o-${n}`}>
{l.items.map((item, k) => (
<li key={k}>{inlineNodes(item, query, `o-${n}-${k}`)}</li>
))}
</ol>,
);
n += 1;
i = l.next;
continue;
}
plain.push(line);
i += 1;
} }
if (code === null) { if (code === null) {
flushPlain(); flushPlain();
@@ -143,6 +238,14 @@ function oneLine(text: string): string {
return flat.length > PREVIEW_LEN ? `${flat.slice(0, PREVIEW_LEN)}` : flat; return flat.length > PREVIEW_LEN ? `${flat.slice(0, PREVIEW_LEN)}` : flat;
} }
/** true when the standalone result bubble is skipped because its ToolCard
* (which carries the output preview) is already rendered */
function resultSkipped(m: ChatMessage, tools: Map<string, ToolState>): boolean {
return (
m.role === "toolResult" && m.toolCallId !== null && tools.has(m.toolCallId)
);
}
function CopyButton({ text }: { text: string }): React.ReactNode { function CopyButton({ text }: { text: string }): React.ReactNode {
const [copied, setCopied] = useState<boolean>(false); const [copied, setCopied] = useState<boolean>(false);
return ( return (
@@ -362,25 +465,34 @@ function DiffBlock({ lines }: { lines: DiffLine[] }): React.ReactNode {
); );
} }
// Well-known tools get a compact glyph + their primary arg instead of
// the raw tool name (bash → "$ ls -la", read → "📄 src/main.ts", …).
const TOOL_ICON: Record<string, string> = {
bash: "$",
read: "📄",
edit: "✎",
write: "📝",
};
function ToolCard({ tool }: { tool: ToolState }) { function ToolCard({ tool }: { tool: ToolState }) {
const status: string = tool.running const status: string = tool.running ? "⋯" : tool.isError ? "✗" : "✓";
? "running…"
: tool.isError
? "error"
: "done";
const statusClass: string = tool.isError const statusClass: string = tool.isError
? "tool-status-err" ? "tool-status-err"
: tool.running
? "tool-status-run"
: "tool-status-ok"; : "tool-status-ok";
const summary: string = oneLine(toolSummaryText(tool.args)); const summary: string = oneLine(toolSummaryText(tool.args));
const icon: string | undefined = TOOL_ICON[tool.name];
const label: string =
icon === undefined ? tool.name : `${icon} ${summary}`.trim();
const rest: string = icon === undefined ? summary : "";
const diffBlocks: DiffLine[][] | null = toolDiffBlocks(tool.name, tool.args); const diffBlocks: DiffLine[][] | null = toolDiffBlocks(tool.name, tool.args);
return ( return (
<details className="tool-card"> <details className="tool-card">
<summary> <summary>
<span className="tool-name">🛠 {tool.name}</span> <span className="tool-label">{label}</span>
{summary.length > 0 && <span className="tool-summary">{summary}</span>} {rest.length > 0 && <span className="tool-summary">{rest}</span>}
<span className={`tool-status ${tool.running ? "" : statusClass}`}> <span className={`tool-status ${statusClass}`}>{status}</span>
{tool.running ? "working…" : status}
</span>
</summary> </summary>
<div className="tool-body"> <div className="tool-body">
{diffBlocks === null ? ( {diffBlocks === null ? (
@@ -451,6 +563,9 @@ export function Bubble({
if (t !== undefined) msgTools.push(t); if (t !== undefined) msgTools.push(t);
} }
} }
// the ToolCard above already carries this result (output preview): skip
// the duplicate standalone "result" bubble when the tool state exists
if (resultSkipped(msg, tools)) return null;
const copyable: boolean = const copyable: boolean =
msg.role === "assistant" || (msg.role === "user" && msg.text.length > 0); msg.role === "assistant" || (msg.role === "user" && msg.text.length > 0);
const tsTitle: string = msg.ts > 0 ? new Date(msg.ts).toISOString() : ""; const tsTitle: string = msg.ts > 0 ? new Date(msg.ts).toISOString() : "";
@@ -506,6 +621,10 @@ interface Props {
messages: ChatMessage[]; messages: ChatMessage[];
tools: Map<string, ToolState>; tools: Map<string, ToolState>;
busy: boolean; busy: boolean;
/** messages queued client-side while the agent runs; sent on settle */
queued: string[];
/** drop a queued message before it is sent (the ✕ on a queued bubble) */
unqueue: (index: number) => void;
/** an older page exists beyond the loaded window (B1) */ /** an older page exists beyond the loaded window (B1) */
hasOlder: boolean; hasOlder: boolean;
loadingOlder: boolean; loadingOlder: boolean;
@@ -521,6 +640,8 @@ export default function ChatStream({
messages, messages,
tools, tools,
busy, busy,
queued,
unqueue,
hasOlder, hasOlder,
loadingOlder, loadingOlder,
onLoadOlder, onLoadOlder,
@@ -574,9 +695,9 @@ export default function ChatStream({
const q: string = query.trim().toLowerCase(); const q: string = query.trim().toLowerCase();
if (q.length === 0) return []; if (q.length === 0) return [];
return messages return messages
.filter((m) => searchHaystack(m).includes(q)) .filter((m) => !resultSkipped(m, tools) && searchHaystack(m).includes(q))
.map((m) => m.key); .map((m) => m.key);
}, [messages, query]); }, [messages, tools, query]);
// live events can shrink the match set under the cursor // live events can shrink the match set under the cursor
useEffect(() => { useEffect(() => {
@@ -639,6 +760,21 @@ export default function ChatStream({
<Bubble msg={m} tools={tools} query={query} showTs={showTs} /> <Bubble msg={m} tools={tools} query={query} showTs={showTs} />
</div> </div>
))} ))}
{queued.map((text, i) => (
<div className="bubble-row user" key={`q-${i}-${text}`}>
<div className="bubble queued-bubble">
<div className="queued-line">{text}</div>
<button
type="button"
className="icon-btn queued-remove"
aria-label="Remove queued message"
onClick={() => unqueue(i)}
>
</button>
</div>
</div>
))}
{showTyping && <TypingIndicator />} {showTyping && <TypingIndicator />}
</div> </div>
</div> </div>
+51
View File
@@ -1718,3 +1718,54 @@ describe("document title", () => {
await waitFor(() => expect(document.title).toContain("bash")); await waitFor(() => expect(document.title).toContain("bash"));
}); });
}); });
describe("ChatView message queue", () => {
it("Enter while busy queues instead of sending; flushed on settle", async () => {
const fetchMock = mockFetchJson((_url, init) =>
init?.method === "POST" ? { ok: true } : [],
);
renderChat(makeStore());
const ta = (await screen.findByLabelText("Message")) as HTMLTextAreaElement;
const promptCalls = (): number =>
fetchMock.mock.calls.filter(([u]) =>
String(u).endsWith("/api/sessions/s1/prompt"),
).length;
push([ev("agent_start")]); // busy
await userEvent.type(ta, "queued hello");
fireEvent.keyDown(ta, { key: "Enter" });
// not sent yet; visible as a pending bubble
expect(promptCalls()).toBe(0);
expect(await screen.findByText("queued hello")).toBeInTheDocument();
// settled → flush
push([ev("agent_settled")]);
await vi.waitFor(() => expect(promptCalls()).toBe(1));
});
it("queued bubble can be removed before it is sent", async () => {
const fetchMock = mockFetchJson((_url, init) =>
init?.method === "POST" ? { ok: true } : [],
);
renderChat(makeStore());
const ta = (await screen.findByLabelText("Message")) as HTMLTextAreaElement;
const promptCalls = (): number =>
fetchMock.mock.calls.filter(([u]) =>
String(u).endsWith("/api/sessions/s1/prompt"),
).length;
push([ev("agent_start")]);
await userEvent.type(ta, "do not send");
fireEvent.keyDown(ta, { key: "Enter" });
expect(await screen.findByText("do not send")).toBeInTheDocument();
await userEvent.click(screen.getByLabelText("Remove queued message"));
push([ev("agent_settled")]);
await vi.waitFor(() =>
expect(screen.queryByText("do not send")).toBeNull(),
);
expect(promptCalls()).toBe(0);
});
});
+77 -46
View File
@@ -86,6 +86,8 @@ export default function ChatView({ store, pushToast }: Props) {
const [loadError, setLoadError] = useState<string>(""); const [loadError, setLoadError] = useState<string>("");
const [draft, setDraft] = useState<string>(""); const [draft, setDraft] = useState<string>("");
const [sending, setSending] = useState<boolean>(false); const [sending, setSending] = useState<boolean>(false);
// messages waiting for the current run to settle (sent one per idle window)
const [queued, setQueued] = useState<string[]>([]);
const [tasksOpen, setTasksOpen] = useState<boolean>(false); const [tasksOpen, setTasksOpen] = useState<boolean>(false);
const [hasOlder, setHasOlder] = useState<boolean>(false); const [hasOlder, setHasOlder] = useState<boolean>(false);
const [loadingOlder, setLoadingOlder] = useState<boolean>(false); const [loadingOlder, setLoadingOlder] = useState<boolean>(false);
@@ -140,15 +142,11 @@ export default function ChatView({ store, pushToast }: Props) {
(incoming: EventFrame[]): void => { (incoming: EventFrame[]): void => {
setEvents((prev) => { setEvents((prev) => {
const merged = mergeEvents(prev, incoming); const merged = mergeEvents(prev, incoming);
lastSeqRef.current = Math.max( lastSeqRef.current = Math.max(lastSeqRef.current, lastPersistedSeq(merged));
lastSeqRef.current,
lastPersistedSeq(merged),
);
return merged; return merged;
}); });
// a finished run means usage changed server-side // a finished run means usage changed server-side
if (incoming.some((e) => e.type === EventType.AgentSettled)) if (incoming.some((e) => e.type === EventType.AgentSettled)) refreshStats();
refreshStats();
}, },
[refreshStats], [refreshStats],
); );
@@ -166,6 +164,7 @@ export default function ChatView({ store, pushToast }: Props) {
// leak into the new one // leak into the new one
setDraft(""); setDraft("");
setSending(false); setSending(false);
setQueued([]);
setSearchOpen(false); setSearchOpen(false);
setMenuOpen(false); setMenuOpen(false);
sentRef.current = []; sentRef.current = [];
@@ -226,14 +225,13 @@ export default function ChatView({ store, pushToast }: Props) {
const toolNow: string | undefined = [...chat.tools.values()] const toolNow: string | undefined = [...chat.tools.values()]
.filter((t) => t.running) .filter((t) => t.running)
.map((t) => t.name)[0]; .map((t) => t.name)[0];
const titleClaim: string | null = const titleClaim: string | null = busy
!busy ? streaming
? label
: streaming
? `${label} · writing…` ? `${label} · writing…`
: toolNow !== undefined : toolNow === undefined
? `${label} · ${toolNow}` ? `${label} · working…`
: `${label} · working…`; : `${label} · ${toolNow}`
: label;
useTitle(titleClaim); useTitle(titleClaim);
const minSeq: number = useMemo( const minSeq: number = useMemo(
@@ -276,9 +274,7 @@ export default function ChatView({ store, pushToast }: Props) {
const t = e.target; const t = e.target;
if ( if (
t instanceof HTMLElement && t instanceof HTMLElement &&
(t.tagName === "INPUT" || (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)
t.tagName === "TEXTAREA" ||
t.isContentEditable)
) )
return; return;
e.preventDefault(); e.preventDefault();
@@ -312,30 +308,74 @@ export default function ChatView({ store, pushToast }: Props) {
}; };
}, [menuOpen]); }, [menuOpen]);
const send = async (): Promise<void> => { const postMessage = async (text: string): Promise<boolean> => {
const text = draft.trim();
if (text.length === 0 || sending) return;
setDraft("");
setSending(true);
const hist = sentRef.current;
hist.push(text);
if (hist.length > SENT_HISTORY_MAX) hist.shift();
try { try {
await fetchJson(Route.SessionPrompt(sessionId), { await fetchJson(Route.SessionPrompt(sessionId), {
method: "POST", method: "POST",
body: JSON.stringify({ message: text }), body: JSON.stringify({ message: text }),
}); });
return true;
} catch (err) { } catch (err) {
// the message never reached the session: put it back (S6)
setDraft(text);
if (err instanceof ApiError && err.status === 409) if (err instanceof ApiError && err.status === 409)
pushToast("session offline"); pushToast("session offline");
else pushToast(errMessage(err)); else pushToast(errMessage(err));
return false;
}
};
const rememberSent = (text: string): void => {
const hist = sentRef.current;
hist.push(text);
if (hist.length > SENT_HISTORY_MAX) hist.shift();
};
const send = async (): Promise<void> => {
const text = draft.trim();
if (text.length === 0 || sending) return;
setDraft("");
// agent busy → queue; flushed when the run settles
if (busy) {
setQueued((q) => [...q, text]);
return;
}
setSending(true);
rememberSent(text);
try {
// the message never reached the session: put it back (S6)
if (!(await postMessage(text))) setDraft(text);
} finally { } finally {
setSending(false); setSending(false);
} }
}; };
const unqueue = (index: number): void => {
setQueued((q) => q.filter((_, n) => n !== index));
};
// drain the queue: one message per idle window — the gate only reopens
// when the busy window actually opens (the POST resolving before the
// agent_start event would otherwise fire the next message back-to-back)
const flushingRef = useRef<boolean>(false);
useEffect(() => {
if (busy) {
flushingRef.current = false;
return;
}
if (sending || queued.length === 0 || flushingRef.current) return;
const text: string | undefined = queued[0];
if (text === undefined) return;
flushingRef.current = true;
setQueued((q) => q.slice(1));
rememberSent(text);
void (async () => {
// the message never reached the session: put it back (S6)
if (!(await postMessage(text))) {
flushingRef.current = false;
setDraft(text);
}
})();
}, [busy, sending, queued, postMessage]);
const abort = async (): Promise<void> => { const abort = async (): Promise<void> => {
try { try {
await fetchJson(Route.SessionAbort(sessionId), { method: "POST" }); await fetchJson(Route.SessionAbort(sessionId), { method: "POST" });
@@ -357,10 +397,7 @@ export default function ChatView({ store, pushToast }: Props) {
} }
}; };
const pickModel = async ( const pickModel = async (provider: string, modelId: string): Promise<void> => {
provider: string,
modelId: string,
): Promise<void> => {
if (switching) return; if (switching) return;
setSwitching(true); setSwitching(true);
try { try {
@@ -447,8 +484,7 @@ export default function ChatView({ store, pushToast }: Props) {
} }
// empty composer + history: ArrowUp recalls the last sent message // empty composer + history: ArrowUp recalls the last sent message
if (e.key === ARROW_UP_KEY && draft.length === 0) { if (e.key === ARROW_UP_KEY && draft.length === 0) {
const last: string | undefined = const last: string | undefined = sentRef.current[sentRef.current.length - 1];
sentRef.current[sentRef.current.length - 1];
if (last !== undefined) { if (last !== undefined) {
e.preventDefault(); e.preventDefault();
setDraft(last); setDraft(last);
@@ -554,15 +590,13 @@ export default function ChatView({ store, pushToast }: Props) {
</div> </div>
)} )}
</div> </div>
{stats !== null && {stats !== null && (stats.inputTokens > 0 || stats.outputTokens > 0) && (
(stats.inputTokens > 0 || stats.outputTokens > 0) && (
<span <span
className="usage-chip" className="usage-chip"
title={`${stats.inputTokens.toLocaleString()}${stats.outputTokens.toLocaleString()} · ${stats.turns} turns · $${stats.totalCost.toFixed(2)}`} title={`${stats.inputTokens.toLocaleString()}${stats.outputTokens.toLocaleString()} · ${stats.turns} turns · $${stats.totalCost.toFixed(2)}`}
> >
{formatTokens(stats.inputTokens)} {formatTokens(stats.inputTokens)} {formatTokens(stats.outputTokens)} ·{" "}
{formatTokens(stats.outputTokens)} · {stats.turns} turns · $ {stats.turns} turns · ${stats.totalCost.toFixed(2)}
{stats.totalCost.toFixed(2)}
</span> </span>
)} )}
<span <span
@@ -594,11 +628,7 @@ export default function ChatView({ store, pushToast }: Props) {
</button> </button>
{menuOpen && ( {menuOpen && (
<div <div className="chat-menu" role="menu" aria-label="Session actions">
className="chat-menu"
role="menu"
aria-label="Session actions"
>
<button <button
type="button" type="button"
className="chat-menu-item" className="chat-menu-item"
@@ -663,6 +693,8 @@ export default function ChatView({ store, pushToast }: Props) {
messages={chat.messages} messages={chat.messages}
tools={chat.tools} tools={chat.tools}
busy={busy} busy={busy}
queued={queued}
unqueue={unqueue}
hasOlder={hasOlder} hasOlder={hasOlder}
loadingOlder={loadingOlder} loadingOlder={loadingOlder}
onLoadOlder={() => void loadOlder()} onLoadOlder={() => void loadOlder()}
@@ -682,7 +714,7 @@ export default function ChatView({ store, pushToast }: Props) {
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
/> />
{busy ? ( {busy && (
<button <button
type="button" type="button"
className="abort-btn" className="abort-btn"
@@ -691,17 +723,16 @@ export default function ChatView({ store, pushToast }: Props) {
> >
stop stop
</button> </button>
) : ( )}
<button <button
type="button" type="button"
className="send-btn" className="send-btn"
aria-label="Send message" aria-label={busy ? "Queue message" : "Send message"}
disabled={draft.trim().length === 0 || sending} disabled={draft.trim().length === 0 || sending}
onClick={() => void send()} onClick={() => void send()}
> >
</button> </button>
)}
</div> </div>
</div> </div>
</div> </div>
+56 -22
View File
@@ -1,4 +1,10 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom"; import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Repo, RepoImage, SessionListItem } from "./protocol"; import type { Repo, RepoImage, SessionListItem } from "./protocol";
@@ -106,8 +112,7 @@ describe("SpawnView connect flow", () => {
connected = true; connected = true;
return { username: "alice" }; return { username: "alice" };
} }
if (url.endsWith("/api/gitlab/repos")) if (url.endsWith("/api/gitlab/repos")) return [repo("g/one"), repo("g/two")];
return [repo("g/one"), repo("g/two")];
return []; return [];
}); });
render(tree(makeStore())); render(tree(makeStore()));
@@ -181,9 +186,7 @@ describe("SpawnView repo picker", () => {
expect(screen.queryByText("g/alpha")).toBeNull(); expect(screen.queryByText("g/alpha")).toBeNull();
expect(screen.getByText("g/beta")).toBeInTheDocument(); expect(screen.getByText("g/beta")).toBeInTheDocument();
const item = screen const item = screen.getByText("g/beta").closest(".repo-item") as HTMLElement;
.getByText("g/beta")
.closest(".repo-item") as HTMLElement;
fireEvent.keyDown(item, { key: "Enter" }); fireEvent.keyDown(item, { key: "Enter" });
expect(screen.getByLabelText("Branch")).toHaveValue("dev"); expect(screen.getByLabelText("Branch")).toHaveValue("dev");
@@ -195,9 +198,7 @@ describe("SpawnView repo picker", () => {
connectedMock(); connectedMock();
render(tree(makeStore())); render(tree(makeStore()));
await flush(); await flush();
const item = screen const item = screen.getByText("g/alpha").closest(".repo-item") as HTMLElement;
.getByText("g/alpha")
.closest(".repo-item") as HTMLElement;
const enter = fireEvent.keyDown(item, { key: "Enter" }); const enter = fireEvent.keyDown(item, { key: "Enter" });
expect(enter).toBe(false); // preventDefault consumed: no page scroll expect(enter).toBe(false); // preventDefault consumed: no page scroll
@@ -235,7 +236,11 @@ describe("SpawnView repo picker", () => {
render(tree(makeStore())); render(tree(makeStore()));
await flush(); await flush();
expect(screen.getByLabelText("Spawn container")).toBeDisabled(); expect(screen.getByLabelText("Spawn container")).toBeDisabled();
expect(screen.getByText("select a repo above")).toBeInTheDocument(); expect(
screen.getByText(
"select a repo above — or tick “Empty container” for a blank workspace",
),
).toBeInTheDocument();
}); });
}); });
@@ -353,8 +358,7 @@ describe("SpawnView spawn+poll", () => {
return []; return [];
}); });
const failingRefresh = makeStore({ const failingRefresh = makeStore({
refresh: (): Promise<SessionListItem[]> => refresh: (): Promise<SessionListItem[]> => Promise.reject(new Error("boom")),
Promise.reject(new Error("boom")),
}); });
render(tree(failingRefresh)); render(tree(failingRefresh));
await flush(); await flush();
@@ -532,10 +536,7 @@ describe("SpawnView repo images (registry)", () => {
const ok = screen.getByText("custom image"); const ok = screen.getByText("custom image");
expect(ok.className).toContain("ok"); expect(ok.className).toContain("ok");
expect(ok).toHaveAttribute( expect(ok).toHaveAttribute("title", "custom image lvmh-worker-alpha — built");
"title",
"custom image lvmh-worker-alpha — built",
);
const warn = screen.getByText("needs build"); const warn = screen.getByText("needs build");
expect(warn.className).toContain("warn"); expect(warn.className).toContain("warn");
@@ -561,8 +562,7 @@ describe("SpawnView repo images (registry)", () => {
const prepareCalls = fetchMock.mock.calls.filter( const prepareCalls = fetchMock.mock.calls.filter(
([u, i]) => ([u, i]) =>
String(u).endsWith("/api/repos/g/alpha/prepare") && String(u).endsWith("/api/repos/g/alpha/prepare") && i?.method === "POST",
i?.method === "POST",
); );
expect(prepareCalls).toHaveLength(1); expect(prepareCalls).toHaveLength(1);
expect(PUSH_TOAST).toHaveBeenCalledWith( expect(PUSH_TOAST).toHaveBeenCalledWith(
@@ -614,8 +614,7 @@ describe("SpawnView repo images (registry)", () => {
if (url.endsWith("/api/gitlab/status")) if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "a" }; return { connected: true, baseUrl: "https://gl", username: "a" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/alpha")]; if (url.endsWith("/api/gitlab/repos")) return [repo("g/alpha")];
if (url.endsWith("/api/repos")) if (url.endsWith("/api/repos")) return jsonResponse({ error: "boom" }, 500);
return jsonResponse({ error: "boom" }, 500);
return []; return [];
}); });
render(tree(makeStore())); render(tree(makeStore()));
@@ -687,7 +686,9 @@ describe("spawn model selection", () => {
await flush(); await flush();
const sel = screen.getByLabelText("Initial model"); const sel = screen.getByLabelText("Initial model");
await waitFor(() => expect(sel.querySelectorAll("optgroup").length).toBe(2)); await waitFor(() => expect(sel.querySelectorAll("optgroup").length).toBe(2));
expect(sel.querySelector('option[value="zai-renaud/glm-5.3"]')).not.toBeNull(); expect(
sel.querySelector('option[value="zai-renaud/glm-5.3"]'),
).not.toBeNull();
}); });
it("selected model is sent in the spawn body", async () => { it("selected model is sent in the spawn body", async () => {
@@ -708,7 +709,9 @@ describe("spawn model selection", () => {
await flush(); await flush();
fireEvent.click(screen.getByText("g/p")); fireEvent.click(screen.getByText("g/p"));
const sel = screen.getByLabelText("Initial model"); const sel = screen.getByLabelText("Initial model");
await waitFor(() => expect(sel.querySelectorAll("option").length).toBeGreaterThan(1)); await waitFor(() =>
expect(sel.querySelectorAll("option").length).toBeGreaterThan(1),
);
fireEvent.change(sel, { target: { value: "zai-renaud/glm-5.3" } }); fireEvent.change(sel, { target: { value: "zai-renaud/glm-5.3" } });
fireEvent.click(screen.getByLabelText("Spawn container")); fireEvent.click(screen.getByLabelText("Spawn container"));
await flush(); await flush();
@@ -739,4 +742,35 @@ describe("empty container spawn", () => {
await flush(); await flush();
expect(bodies[0]).toContain('"empty":true'); expect(bodies[0]).toContain('"empty":true');
}); });
it("blank container: spawn without selecting a repo", async () => {
const bodies: string[] = [];
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
bodies.push(String(init.body));
return { sessionId: "e2", imageUsed: "lvmh-worker:latest" };
}
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "a" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
return [];
});
render(tree(makeStore()));
await flush();
// no repo selected: Spawn disabled
const spawnBtn = screen.getByLabelText(
"Spawn container",
) as HTMLButtonElement;
expect(spawnBtn.disabled).toBe(true);
fireEvent.click(screen.getByRole("checkbox"));
expect(spawnBtn.disabled).toBe(false);
fireEvent.click(spawnBtn);
await flush();
expect(bodies[0]).toContain('"empty":true');
expect(bodies[0]).toContain('"repo":""');
expect(bodies[0]).not.toContain('"branch"');
});
}); });
+18 -23
View File
@@ -41,9 +41,7 @@ function SpawnSteps({ state }: { state: string }): React.ReactNode {
{SPAWN_STEPS.map((step, i) => ( {SPAWN_STEPS.map((step, i) => (
<span <span
key={step} key={step}
className={ className={idx > i ? "step done" : idx === i ? "step current" : "step"}
idx > i ? "step done" : idx === i ? "step current" : "step"
}
title={step} title={step}
/> />
))} ))}
@@ -167,15 +165,17 @@ export default function SpawnView({ store, pushToast }: Props) {
r.path.toLowerCase().includes(query.toLowerCase()), r.path.toLowerCase().includes(query.toLowerCase()),
); );
// spawn is only reachable from the Spawn button, which is disabled until a // spawn is reachable with a repo selected, or with the blank-container
// repo is selected. // checkbox and no repo (empty workspace).
const spawn = async (): Promise<void> => { const spawn = async (): Promise<void> => {
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
const body = { const body = {
repo: selected!.path, repo: selected?.path ?? "",
...(branch.trim().length > 0 ? { branch: branch.trim() } : {}), ...(selected !== null && branch.trim().length > 0
? { branch: branch.trim() }
: {}),
...(model.length > 0 ? { model } : {}), ...(model.length > 0 ? { model } : {}),
...(empty ? { empty: true } : {}), ...(empty ? { empty: true } : {}),
}; };
@@ -207,8 +207,7 @@ export default function SpawnView({ store, pushToast }: Props) {
const list = await store.refresh(); const list = await store.refresh();
const s = list.find((x) => x.id === sessionId); const s = list.find((x) => x.id === sessionId);
if (s !== undefined && s.online) { if (s !== undefined && s.online) {
if (timerRef.current !== null) if (timerRef.current !== null) window.clearInterval(timerRef.current);
window.clearInterval(timerRef.current);
timerRef.current = null; timerRef.current = null;
navigate(`/s/${sessionId}`); navigate(`/s/${sessionId}`);
} }
@@ -236,7 +235,8 @@ export default function SpawnView({ store, pushToast }: Props) {
job.message !== undefined && job.message.length > 0 job.message !== undefined && job.message.length > 0
? ` (${job.message})` ? ` (${job.message})`
: ""; : "";
return `${job.repo}: ${job.state}${detail}`; const label = job.repo.length > 0 ? job.repo : "blank container";
return `${label}: ${job.state}${detail}`;
} }
return "waiting for session to come online…"; return "waiting for session to come online…";
}; };
@@ -246,9 +246,7 @@ export default function SpawnView({ store, pushToast }: Props) {
<div className="page"> <div className="page">
<h1>Spawn</h1> <h1>Spawn</h1>
<p className="empty"> <p className="empty">
{error.length > 0 {error.length > 0 ? `gitlab status failed: ${error}` : "checking gitlab…"}
? `gitlab status failed: ${error}`
: "checking gitlab…"}
</p> </p>
</div> </div>
); );
@@ -338,8 +336,7 @@ export default function SpawnView({ store, pushToast }: Props) {
<div style={{ minWidth: 0, flex: 1 }}> <div style={{ minWidth: 0, flex: 1 }}>
<div className="repo-path">{r.path}</div> <div className="repo-path">{r.path}</div>
<div className="repo-meta"> <div className="repo-meta">
default {r.defaultBranch} ·{" "} default {r.defaultBranch} · {r.lastActivityAt.slice(0, 10)}
{r.lastActivityAt.slice(0, 10)}
</div> </div>
</div> </div>
{reg !== undefined && ( {reg !== undefined && (
@@ -364,8 +361,7 @@ export default function SpawnView({ store, pushToast }: Props) {
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
// let the native button handle Enter/Space // let the native button handle Enter/Space
if (e.key === "Enter" || e.key === " ") if (e.key === "Enter" || e.key === " ") e.stopPropagation();
e.stopPropagation();
}} }}
> >
Prepare Prepare
@@ -390,29 +386,28 @@ export default function SpawnView({ store, pushToast }: Props) {
<button <button
type="button" type="button"
className="btn-primary" className="btn-primary"
disabled={selected === null || busy} disabled={(selected === null && !empty) || busy}
aria-label="Spawn container" aria-label="Spawn container"
onClick={() => void spawn()} onClick={() => void spawn()}
> >
{busy ? "Spawning…" : "Spawn"} {busy ? "Spawning…" : "Spawn"}
</button> </button>
</div> </div>
{selected === null && ( {selected === null && !empty && (
<p className="repo-meta" style={{ marginTop: 8 }}> <p className="repo-meta" style={{ marginTop: 8 }}>
select a repo above select a repo above or tick Empty container for a blank workspace
</p> </p>
)} )}
{selected !== null && ( {selected !== null && (
<p className="repo-meta" style={{ marginTop: 8 }}> <p className="repo-meta" style={{ marginTop: 8 }}>
Will use:{" "} Will use: {registration(selected.path)?.image ?? "base worker image"}
{registration(selected.path)?.image ?? "base worker image"}
</p> </p>
)} )}
<div className="row" style={{ marginTop: 8 }}> <div className="row" style={{ marginTop: 8 }}>
<select <select
aria-label="Initial model" aria-label="Initial model"
value={model} value={model}
disabled={selected === null} disabled={selected === null && !empty}
onChange={(e) => setModel(e.target.value)} onChange={(e) => setModel(e.target.value)}
> >
<option value="">Default model (settings)</option> <option value="">Default model (settings)</option>
+94
View File
@@ -688,6 +688,15 @@ a:hover {
color: var(--text); color: var(--text);
flex-shrink: 0; flex-shrink: 0;
} }
.bubble .tool-card summary .tool-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-family: var(--mono);
font-size: 12px;
color: var(--text);
}
.bubble .tool-card summary .tool-summary { .bubble .tool-card summary .tool-summary {
flex: 1; flex: 1;
min-width: 40px; min-width: 40px;
@@ -719,6 +728,9 @@ a:hover {
color: var(--danger); color: var(--danger);
font-weight: 600; font-weight: 600;
} }
.tool-status-run {
color: var(--text-faint);
}
details.thinking { details.thinking {
margin-bottom: 10px; margin-bottom: 10px;
@@ -765,6 +777,48 @@ details.thinking .thinking-body {
overflow-x: auto; overflow-x: auto;
color: var(--text-dim); color: var(--text-dim);
} }
.bubble .md-h {
margin: 14px 0 6px;
line-height: 1.3;
color: var(--text);
}
.bubble .md-h:first-child {
margin-top: 4px;
}
.bubble .md-h-1 {
font-size: 1.35em;
}
.bubble .md-h-2 {
font-size: 1.22em;
}
.bubble .md-h-3 {
font-size: 1.1em;
}
.bubble .md-h-4,
.bubble .md-h-5,
.bubble .md-h-6 {
font-size: 1em;
}
.bubble .md-hr {
border: 0;
border-top: 1px solid var(--border);
margin: 12px 0;
}
.bubble .md-quote {
margin: 8px 0;
padding: 2px 0 2px 12px;
border-left: 3px solid var(--border-strong);
color: var(--text-dim);
white-space: pre-wrap;
}
.bubble .md-list {
margin: 8px 0;
padding-left: 22px;
white-space: pre-wrap;
}
.bubble .md-list li {
margin: 3px 0;
}
.bubble code { .bubble code {
font-family: var(--mono); font-family: var(--mono);
font-size: 0.92em; font-size: 0.92em;
@@ -1378,6 +1432,26 @@ mark.hit {
} }
@media (max-width: 700px) { @media (max-width: 700px) {
.chat-header {
flex-wrap: wrap;
gap: 6px 8px;
padding: 8px 12px;
min-height: 0;
}
.chat-header .title {
flex: 1 1 100%;
min-width: 0;
}
.chat-header .model-chip span:first-child {
display: inline-block;
max-width: 110px;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: bottom;
}
.chat-header .usage-chip {
display: none;
}
.sidebar { .sidebar {
position: fixed; position: fixed;
inset: 0 auto 0 0; inset: 0 auto 0 0;
@@ -1525,3 +1599,23 @@ mark.hit {
.diff-ctx { .diff-ctx {
color: var(--text-faint); color: var(--text-faint);
} }
/* ---------- queued (pending) messages ---------- */
.queued-bubble {
display: flex;
align-items: flex-start;
gap: 8px;
opacity: 0.7;
border-style: dashed;
}
.queued-line {
flex: 1;
min-width: 0;
white-space: pre-wrap;
word-break: break-word;
}
.queued-remove {
flex-shrink: 0;
padding: 2px 6px;
}