Compare commits
10
Commits
f5ee3a0f2d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4755f6d1e3 | ||
|
|
6ef27e5cd6 | ||
|
|
b62e4e717a | ||
|
|
310127cf7a | ||
|
|
09fc72556d | ||
|
|
0f245c9ee2 | ||
|
|
c8cde3ff89 | ||
|
|
7ff9f77f40 | ||
|
|
5962b50c8d | ||
|
|
3b799c69fb |
+3
-1
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+25
-4
@@ -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,9 +301,12 @@ func (s *Spawner) runJob(repo, branch, model string, empty bool, sessionID strin
|
|||||||
lock.Lock()
|
lock.Lock()
|
||||||
defer lock.Unlock()
|
defer lock.Unlock()
|
||||||
|
|
||||||
if err := s.cloneOrUpdate(s.ctx, repo, branch, slug); err != nil {
|
// blank-container spawn: nothing to clone or update
|
||||||
s.setJob(sessionID, repo, stateError, "", err.Error())
|
if repo != "" {
|
||||||
return
|
if err := s.cloneOrUpdate(s.ctx, repo, branch, slug); err != nil {
|
||||||
|
s.setJob(sessionID, repo, stateError, "", err.Error())
|
||||||
|
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 {
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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..."
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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" }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+322
-26
@@ -5,8 +5,10 @@ import type { ChatMessage, ToolState } from "./derive";
|
|||||||
import ChatStream, {
|
import ChatStream, {
|
||||||
Bubble,
|
Bubble,
|
||||||
TypingIndicator,
|
TypingIndicator,
|
||||||
|
lineDiff,
|
||||||
renderMarkdown,
|
renderMarkdown,
|
||||||
toolArgsEntries,
|
toolArgsEntries,
|
||||||
|
toolSummaryText,
|
||||||
} from "./ChatStream";
|
} from "./ChatStream";
|
||||||
|
|
||||||
function msg(partial: Partial<ChatMessage>): ChatMessage {
|
function msg(partial: Partial<ChatMessage>): ChatMessage {
|
||||||
@@ -36,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;
|
||||||
@@ -48,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)}
|
||||||
@@ -61,10 +67,7 @@ function stream(p: {
|
|||||||
describe("Bubble", () => {
|
describe("Bubble", () => {
|
||||||
it("renders plain text per role class", () => {
|
it("renders plain text per role class", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<Bubble
|
<Bubble msg={msg({ role: "user", text: "hi there" })} tools={new Map()} />,
|
||||||
msg={msg({ role: "user", text: "hi there" })}
|
|
||||||
tools={new Map()}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
expect(container.querySelector(".bubble-row.user")).not.toBeNull();
|
expect(container.querySelector(".bubble-row.user")).not.toBeNull();
|
||||||
expect(container.textContent).toContain("hi there");
|
expect(container.textContent).toContain("hi there");
|
||||||
@@ -92,10 +95,7 @@ describe("Bubble", () => {
|
|||||||
it("toolResult collapses to a one-line preview, expands to full text", async () => {
|
it("toolResult collapses to a one-line preview, expands to full text", async () => {
|
||||||
const long = `${"x".repeat(200)}`;
|
const long = `${"x".repeat(200)}`;
|
||||||
render(
|
render(
|
||||||
<Bubble
|
<Bubble msg={msg({ role: "toolResult", text: long })} tools={new Map()} />,
|
||||||
msg={msg({ role: "toolResult", text: long })}
|
|
||||||
tools={new Map()}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
const details = screen
|
const details = screen
|
||||||
.getByText("result")
|
.getByText("result")
|
||||||
@@ -155,11 +155,11 @@ 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
|
const summary = screen
|
||||||
.getByText("🛠 bash")
|
.getByText("$ ls -la")
|
||||||
.closest("summary") as HTMLElement;
|
.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);
|
||||||
@@ -188,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", () => {
|
||||||
@@ -254,9 +254,7 @@ describe("ChatStream", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(container.querySelector(".typing")).toBeNull();
|
expect(container.querySelector(".typing")).toBeNull();
|
||||||
rerender(
|
rerender(stream({ messages: [msg({ key: "a", text: "one" })], busy: false }));
|
||||||
stream({ messages: [msg({ key: "a", text: "one" })], busy: false }),
|
|
||||||
);
|
|
||||||
expect(container.querySelector(".typing")).toBeNull();
|
expect(container.querySelector(".typing")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -765,15 +763,34 @@ describe("copy button", () => {
|
|||||||
|
|
||||||
describe("pretty tool args", () => {
|
describe("pretty tool args", () => {
|
||||||
const tool = (args: string): ToolState => ({
|
const tool = (args: string): ToolState => ({
|
||||||
id: "t1", name: "bash", args, running: false, isError: false, preview: "out",
|
id: "t1",
|
||||||
|
name: "bash",
|
||||||
|
args,
|
||||||
|
running: false,
|
||||||
|
isError: false,
|
||||||
|
preview: "out",
|
||||||
});
|
});
|
||||||
|
|
||||||
it("bash command renders as the bare command, no JSON braces", () => {
|
it("bash command renders as the bare command, no JSON braces", () => {
|
||||||
render(<Bubble msg={{ key: "k", role: "assistant", text: "", thinking: null,
|
render(
|
||||||
toolCalls: [{ id: "t1", name: "bash", argsJson: "{}" }], toolCallId: null, streaming: false, ts: 0 }}
|
<Bubble
|
||||||
tools={new Map([["t1", tool('{"command":"ls -la","foo":1}')]])} />);
|
msg={{
|
||||||
expect(screen.getByText("ls -la")).toBeInTheDocument();
|
key: "k",
|
||||||
expect(document.querySelector(".tool-args")?.textContent).not.toContain('"command"');
|
role: "assistant",
|
||||||
|
text: "",
|
||||||
|
thinking: null,
|
||||||
|
toolCalls: [{ id: "t1", name: "bash", argsJson: "{}" }],
|
||||||
|
toolCallId: null,
|
||||||
|
streaming: false,
|
||||||
|
ts: 0,
|
||||||
|
}}
|
||||||
|
tools={new Map([["t1", tool('{"command":"ls -la","foo":1}')]])}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(document.querySelector(".tool-label")?.textContent).toBe("$ ls -la");
|
||||||
|
expect(document.querySelector(".tool-args")?.textContent).not.toContain(
|
||||||
|
'"command"',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("priority ordering puts command first even when not first in JSON", () => {
|
it("priority ordering puts command first even when not first in JSON", () => {
|
||||||
@@ -783,10 +800,24 @@ describe("pretty tool args", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("single string arg renders label-less; multi-field keeps labels", () => {
|
it("single string arg renders label-less; multi-field keeps labels", () => {
|
||||||
render(<Bubble msg={{ key: "k", role: "assistant", text: "", thinking: null,
|
render(
|
||||||
toolCalls: [{ id: "t1", name: "read", argsJson: "{}" }], toolCallId: null, streaming: false, ts: 0 }}
|
<Bubble
|
||||||
tools={new Map([["t1", tool('{"path":"src/main.ts"}')]])} />);
|
msg={{
|
||||||
expect(screen.getByText("src/main.ts")).toBeInTheDocument();
|
key: "k",
|
||||||
|
role: "assistant",
|
||||||
|
text: "",
|
||||||
|
thinking: null,
|
||||||
|
toolCalls: [{ id: "t1", name: "read", argsJson: "{}" }],
|
||||||
|
toolCallId: null,
|
||||||
|
streaming: false,
|
||||||
|
ts: 0,
|
||||||
|
}}
|
||||||
|
tools={new Map([["t1", tool('{"path":"src/main.ts"}')]])}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(document.querySelector(".tool-label")?.textContent).toBe(
|
||||||
|
"$ src/main.ts",
|
||||||
|
);
|
||||||
expect(document.querySelector(".tool-args .arg-k")).toBeNull();
|
expect(document.querySelector(".tool-args .arg-k")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -805,3 +836,268 @@ describe("pretty tool args", () => {
|
|||||||
expect(entries[0]?.v).toBe("1");
|
expect(entries[0]?.v).toBe("1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("tool summary + diff", () => {
|
||||||
|
const tool = (args: string, name = "bash"): ToolState => ({
|
||||||
|
id: "t1",
|
||||||
|
name,
|
||||||
|
args,
|
||||||
|
running: false,
|
||||||
|
isError: false,
|
||||||
|
preview: "",
|
||||||
|
});
|
||||||
|
it("bash summary shows the command", () => {
|
||||||
|
render(
|
||||||
|
<Bubble
|
||||||
|
msg={msg({
|
||||||
|
toolCalls: [{ id: "t1", name: "bash", argsJson: "{}" }],
|
||||||
|
})}
|
||||||
|
tools={new Map([["t1", tool('{"command":"ls -la","timeout":30}')]])}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("$ ls -la")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("read summary shows the path", () => {
|
||||||
|
render(
|
||||||
|
<Bubble
|
||||||
|
msg={msg({
|
||||||
|
toolCalls: [{ id: "t1", name: "read", argsJson: "{}" }],
|
||||||
|
})}
|
||||||
|
tools={new Map([["t1", tool('{"path":"src/main.ts"}', "read")]])}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(document.querySelector(".tool-label")?.textContent).toBe(
|
||||||
|
"📄 src/main.ts",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("long summary is truncated with ellipsis", () => {
|
||||||
|
const long: string = "x".repeat(300);
|
||||||
|
expect(toolSummaryText(`{"command":"${long}"}`)).toBe(long);
|
||||||
|
render(
|
||||||
|
<Bubble
|
||||||
|
msg={msg({
|
||||||
|
toolCalls: [{ id: "t1", name: "bash", argsJson: "{}" }],
|
||||||
|
})}
|
||||||
|
tools={new Map([["t1", tool(`{"command":"${long}"}`)]])}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const summary = document.querySelector(".tool-label");
|
||||||
|
expect(summary?.textContent?.endsWith("…")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edit tool renders a line diff of old/new text", () => {
|
||||||
|
render(
|
||||||
|
<Bubble
|
||||||
|
msg={msg({
|
||||||
|
toolCalls: [{ id: "t1", name: "edit", argsJson: "{}" }],
|
||||||
|
})}
|
||||||
|
tools={
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
"t1",
|
||||||
|
tool(
|
||||||
|
'{"path":"a.ts","edits":[{"oldText":"const a = 1;\\nconst b = 2;","newText":"const a = 3;\\nconst b = 2;"}]}',
|
||||||
|
"edit",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(document.querySelector(".diff-del")?.textContent).toContain(
|
||||||
|
"-const a = 1;",
|
||||||
|
);
|
||||||
|
expect(document.querySelector(".diff-add")?.textContent).toContain(
|
||||||
|
"+const a = 3;",
|
||||||
|
);
|
||||||
|
expect(document.querySelector(".diff-ctx")?.textContent).toContain(
|
||||||
|
" const b = 2;",
|
||||||
|
);
|
||||||
|
expect(document.querySelector(".tool-args")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("write tool renders content as added lines", () => {
|
||||||
|
render(
|
||||||
|
<Bubble
|
||||||
|
msg={msg({
|
||||||
|
toolCalls: [{ id: "t1", name: "write", argsJson: "{}" }],
|
||||||
|
})}
|
||||||
|
tools={
|
||||||
|
new Map([
|
||||||
|
["t1", tool('{"path":"new.ts","content":"hello\\nworld"}', "write")],
|
||||||
|
])
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const adds = document.querySelectorAll(".diff-add");
|
||||||
|
expect(adds).toHaveLength(2);
|
||||||
|
expect(adds[0]?.textContent).toContain("+hello");
|
||||||
|
expect(adds[1]?.textContent).toContain("+world");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("lineDiff", () => {
|
||||||
|
it("identical text is all context", () => {
|
||||||
|
const d = lineDiff("a\nb", "a\nb");
|
||||||
|
expect(d).toEqual([
|
||||||
|
{ kind: "ctx", text: "a" },
|
||||||
|
{ kind: "ctx", text: "b" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("single line change is del + add", () => {
|
||||||
|
const d = lineDiff("a\nb\nc", "a\nx\nc");
|
||||||
|
expect(d).toEqual([
|
||||||
|
{ kind: "ctx", text: "a" },
|
||||||
|
{ kind: "del", text: "b" },
|
||||||
|
{ kind: "add", text: "x" },
|
||||||
|
{ kind: "ctx", text: "c" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pure addition and deletion", () => {
|
||||||
|
expect(lineDiff("a", "a\nb")).toEqual([
|
||||||
|
{ kind: "ctx", text: "a" },
|
||||||
|
{ kind: "add", text: "b" },
|
||||||
|
]);
|
||||||
|
expect(lineDiff("a\nb", "a")).toEqual([
|
||||||
|
{ kind: "ctx", text: "a" },
|
||||||
|
{ kind: "del", text: "b" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("oversized blocks fall back to del-all + add-all", () => {
|
||||||
|
const a = Array.from({ length: 401 }, (_, i) => `l${i}`).join("\n");
|
||||||
|
const d = lineDiff(a, "x");
|
||||||
|
expect(d.filter((l) => l.kind === "del")).toHaveLength(401);
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+336
-37
@@ -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,20 +137,86 @@ export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
|
|||||||
n += 1;
|
n += 1;
|
||||||
code = null;
|
code = null;
|
||||||
}
|
}
|
||||||
} else if (code !== null) {
|
i += 1;
|
||||||
code.push(line);
|
continue;
|
||||||
} else {
|
|
||||||
plain.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();
|
||||||
|
} else {
|
||||||
out.push(
|
out.push(
|
||||||
<pre className="md-code" key={`c-${n}`}>
|
<pre className="md-code" key={`c-${n}`}>
|
||||||
{code.join("\n")}
|
{code.join("\n")}
|
||||||
</pre>,
|
</pre>,
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
flushPlain();
|
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -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 (
|
||||||
@@ -162,16 +265,27 @@ function CopyButton({ text }: { text: string }): React.ReactNode {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ---------- pretty tool args ----------
|
// ---------- pretty tool args ----------
|
||||||
|
|
||||||
// Keys shown first when present (most readable "what is this tool doing"
|
// Keys shown first when present (most readable "what is this tool doing"
|
||||||
// signal); the rest follow in their original order.
|
// signal); the rest follow in their original order.
|
||||||
const ARG_PRIORITY: string[] = [
|
const ARG_PRIORITY: string[] = [
|
||||||
"command", "path", "file_path", "pattern", "url", "query", "content",
|
"command",
|
||||||
"prompt", "task", "description",
|
"path",
|
||||||
|
"file_path",
|
||||||
|
"pattern",
|
||||||
|
"url",
|
||||||
|
"query",
|
||||||
|
"content",
|
||||||
|
"prompt",
|
||||||
|
"task",
|
||||||
|
"description",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Above this many lines per side, LCS is skipped and the whole old block is
|
||||||
|
// rendered removed + the whole new block added.
|
||||||
|
const DIFF_MAX_LINES: number = 400;
|
||||||
|
|
||||||
export interface ArgEntry {
|
export interface ArgEntry {
|
||||||
k: string | null;
|
k: string | null;
|
||||||
v: string;
|
v: string;
|
||||||
@@ -186,8 +300,7 @@ export function toolArgsEntries(argsText: string): ArgEntry[] {
|
|||||||
} catch {
|
} catch {
|
||||||
return [{ k: null, v: argsText }];
|
return [{ k: null, v: argsText }];
|
||||||
}
|
}
|
||||||
if (typeof parsed === "string")
|
if (typeof parsed === "string") return [{ k: null, v: parsed }];
|
||||||
return [{ k: null, v: parsed }];
|
|
||||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
||||||
return [{ k: null, v: argsText }];
|
return [{ k: null, v: argsText }];
|
||||||
const obj = parsed as Record<string, unknown>;
|
const obj = parsed as Record<string, unknown>;
|
||||||
@@ -202,7 +315,7 @@ export function toolArgsEntries(argsText: string): ArgEntry[] {
|
|||||||
const v =
|
const v =
|
||||||
typeof raw === "string"
|
typeof raw === "string"
|
||||||
? raw
|
? raw
|
||||||
: JSON.stringify(raw, null, 2) ?? String(raw);
|
: (JSON.stringify(raw, null, 2) ?? String(raw));
|
||||||
entries.push({ k, v });
|
entries.push({ k, v });
|
||||||
}
|
}
|
||||||
if (entries.length === 1 && entries[0] !== undefined)
|
if (entries.length === 1 && entries[0] !== undefined)
|
||||||
@@ -210,32 +323,194 @@ export function toolArgsEntries(argsText: string): ArgEntry[] {
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One-line "what is this tool doing" for the collapsed summary line:
|
||||||
|
* the highest-priority arg's value (bash → command, read/write/edit → path). */
|
||||||
|
export function toolSummaryText(argsText: string): string {
|
||||||
|
const first = toolArgsEntries(argsText)[0];
|
||||||
|
return first === undefined ? "" : first.v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- edit/write diff view ----------
|
||||||
|
|
||||||
|
export interface DiffLine {
|
||||||
|
kind: "ctx" | "add" | "del";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Line-level LCS diff between two text blocks. */
|
||||||
|
export function lineDiff(a: string, b: string): DiffLine[] {
|
||||||
|
const left: string[] = a.split("\n");
|
||||||
|
const right: string[] = b.split("\n");
|
||||||
|
if (left.length > DIFF_MAX_LINES || right.length > DIFF_MAX_LINES)
|
||||||
|
return [
|
||||||
|
...left.map((text): DiffLine => ({ kind: "del", text })),
|
||||||
|
...right.map((text): DiffLine => ({ kind: "add", text })),
|
||||||
|
];
|
||||||
|
const n: number = left.length;
|
||||||
|
const m: number = right.length;
|
||||||
|
const dp: number[][] = Array.from({ length: n + 1 }, () =>
|
||||||
|
Array.from({ length: m + 1 }, (): number => 0),
|
||||||
|
);
|
||||||
|
for (let i = n - 1; i >= 0; i--) {
|
||||||
|
const row: number[] | undefined = dp[i];
|
||||||
|
if (row === undefined) continue;
|
||||||
|
for (let j = m - 1; j >= 0; j--) {
|
||||||
|
const a: string | undefined = left[i];
|
||||||
|
const b: string | undefined = right[j];
|
||||||
|
row[j] =
|
||||||
|
a !== undefined && a === b
|
||||||
|
? (dp[i + 1]?.[j + 1] ?? 0) + 1
|
||||||
|
: Math.max(dp[i + 1]?.[j] ?? 0, dp[i]?.[j + 1] ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out: DiffLine[] = [];
|
||||||
|
let i: number = 0;
|
||||||
|
let j: number = 0;
|
||||||
|
while (i < n && j < m) {
|
||||||
|
const a: string | undefined = left[i];
|
||||||
|
const b: string | undefined = right[j];
|
||||||
|
if (a !== undefined && a === b) {
|
||||||
|
out.push({ kind: "ctx", text: a });
|
||||||
|
i += 1;
|
||||||
|
j += 1;
|
||||||
|
} else if ((dp[i + 1]?.[j] ?? 0) >= (dp[i]?.[j + 1] ?? 0)) {
|
||||||
|
if (a !== undefined) out.push({ kind: "del", text: a });
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
if (b !== undefined) out.push({ kind: "add", text: b });
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (; i < n; i++) {
|
||||||
|
const a: string | undefined = left[i];
|
||||||
|
if (a !== undefined) out.push({ kind: "del", text: a });
|
||||||
|
}
|
||||||
|
for (; j < m; j++) {
|
||||||
|
const b: string | undefined = right[j];
|
||||||
|
if (b !== undefined) out.push({ kind: "add", text: b });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextEdit {
|
||||||
|
oldText: string;
|
||||||
|
newText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgsObj(argsText: string): Record<string, unknown> | null {
|
||||||
|
try {
|
||||||
|
const p: unknown = JSON.parse(argsText);
|
||||||
|
return typeof p === "object" && p !== null && !Array.isArray(p)
|
||||||
|
? (p as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Diff blocks for edit/write tool args; null when the tool is neither or
|
||||||
|
* the args do not carry the expected fields. */
|
||||||
|
export function toolDiffBlocks(
|
||||||
|
name: string,
|
||||||
|
argsText: string,
|
||||||
|
): DiffLine[][] | null {
|
||||||
|
const obj: Record<string, unknown> | null = parseArgsObj(argsText);
|
||||||
|
if (obj === null) return null;
|
||||||
|
if (name === "write" && typeof obj.content === "string")
|
||||||
|
return [
|
||||||
|
obj.content
|
||||||
|
.split("\n")
|
||||||
|
.slice(0, DIFF_MAX_LINES)
|
||||||
|
.map((text): DiffLine => ({ kind: "add", text })),
|
||||||
|
];
|
||||||
|
if (name !== "edit") return null;
|
||||||
|
const edits: TextEdit[] = [];
|
||||||
|
if (Array.isArray(obj.edits)) {
|
||||||
|
for (const e of obj.edits) {
|
||||||
|
if (
|
||||||
|
typeof e === "object" &&
|
||||||
|
e !== null &&
|
||||||
|
typeof (e as Record<string, unknown>).oldText === "string" &&
|
||||||
|
typeof (e as Record<string, unknown>).newText === "string"
|
||||||
|
)
|
||||||
|
edits.push(e as TextEdit);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
typeof obj.oldText === "string" &&
|
||||||
|
typeof obj.newText === "string"
|
||||||
|
) {
|
||||||
|
edits.push({ oldText: obj.oldText, newText: obj.newText });
|
||||||
|
}
|
||||||
|
return edits.length === 0
|
||||||
|
? null
|
||||||
|
: edits.map((e): DiffLine[] => lineDiff(e.oldText, e.newText));
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIFF_MARK: Record<DiffLine["kind"], string> = {
|
||||||
|
add: "+",
|
||||||
|
del: "-",
|
||||||
|
ctx: " ",
|
||||||
|
};
|
||||||
|
|
||||||
|
function DiffBlock({ lines }: { lines: DiffLine[] }): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<pre className="diff">
|
||||||
|
{lines.map((l, i) => (
|
||||||
|
<span key={i} className={`diff-line diff-${l.kind}`}>
|
||||||
|
{DIFF_MARK[l.kind] + l.text}
|
||||||
|
{"\n"}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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-status-ok";
|
: tool.running
|
||||||
|
? "tool-status-run"
|
||||||
|
: "tool-status-ok";
|
||||||
|
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);
|
||||||
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>
|
||||||
<span className={tool.running ? "" : statusClass}>
|
{rest.length > 0 && <span className="tool-summary">{rest}</span>}
|
||||||
{tool.running ? "working…" : status}
|
<span className={`tool-status ${statusClass}`}>{status}</span>
|
||||||
</span>
|
|
||||||
</summary>
|
</summary>
|
||||||
<div className="tool-body">
|
<div className="tool-body">
|
||||||
<div className="tool-args">
|
{diffBlocks === null ? (
|
||||||
{toolArgsEntries(tool.args).map((e, i) => (
|
<div className="tool-args">
|
||||||
<div key={e.k ?? i} className="tool-arg">
|
{toolArgsEntries(tool.args).map((e, i) => (
|
||||||
{e.k !== null && <span className="arg-k">{e.k}</span>}
|
<div key={e.k ?? i} className="tool-arg">
|
||||||
<pre className="arg-v">{e.v}</pre>
|
{e.k !== null && <span className="arg-k">{e.k}</span>}
|
||||||
</div>
|
<pre className="arg-v">{e.v}</pre>
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="tool-diffs">
|
||||||
|
{diffBlocks.map((b, i) => (
|
||||||
|
<DiffBlock key={i} lines={b} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<span className="arg-k">output</span>
|
<span className="arg-k">output</span>
|
||||||
<pre style={{ margin: "4px 0 0", whiteSpace: "pre-wrap" }}>
|
<pre style={{ margin: "4px 0 0", whiteSpace: "pre-wrap" }}>
|
||||||
@@ -288,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() : "";
|
||||||
@@ -343,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;
|
||||||
@@ -358,6 +640,8 @@ export default function ChatStream({
|
|||||||
messages,
|
messages,
|
||||||
tools,
|
tools,
|
||||||
busy,
|
busy,
|
||||||
|
queued,
|
||||||
|
unqueue,
|
||||||
hasOlder,
|
hasOlder,
|
||||||
loadingOlder,
|
loadingOlder,
|
||||||
onLoadOlder,
|
onLoadOlder,
|
||||||
@@ -411,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(() => {
|
||||||
@@ -476,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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+91
-60
@@ -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
|
? `${label} · writing…`
|
||||||
: streaming
|
: toolNow === undefined
|
||||||
? `${label} · writing…`
|
? `${label} · working…`
|
||||||
: toolNow !== undefined
|
: `${label} · ${toolNow}`
|
||||||
? `${label} · ${toolNow}`
|
: label;
|
||||||
: `${label} · working…`;
|
|
||||||
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,17 +590,15 @@ 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.outputTokens)} ·{" "}
|
||||||
↑{formatTokens(stats.inputTokens)} ↓
|
{stats.turns} turns · ${stats.totalCost.toFixed(2)}
|
||||||
{formatTokens(stats.outputTokens)} · {stats.turns} turns · $
|
</span>
|
||||||
{stats.totalCost.toFixed(2)}
|
)}
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span
|
<span
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"conn-dot",
|
"conn-dot",
|
||||||
@@ -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
|
|
||||||
type="button"
|
|
||||||
className="send-btn"
|
|
||||||
aria-label="Send message"
|
|
||||||
disabled={draft.trim().length === 0 || sending}
|
|
||||||
onClick={() => void send()}
|
|
||||||
>
|
|
||||||
↑
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="send-btn"
|
||||||
|
aria-label={busy ? "Queue message" : "Send message"}
|
||||||
|
disabled={draft.trim().length === 0 || sending}
|
||||||
|
onClick={() => void send()}
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+57
-23
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -265,7 +270,7 @@ describe("SpawnView spawn+poll", () => {
|
|||||||
repo: "g/proj",
|
repo: "g/proj",
|
||||||
startedAt: 1,
|
startedAt: 1,
|
||||||
online: true,
|
online: true,
|
||||||
busy: false,
|
busy: false,
|
||||||
lastEventAt: 1,
|
lastEventAt: 1,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
+165
-14
@@ -25,8 +25,7 @@
|
|||||||
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.35);
|
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||||
--shadow-2: 0 8px 28px rgba(0, 0, 0, 0.45);
|
--shadow-2: 0 8px 28px rgba(0, 0, 0, 0.45);
|
||||||
font-family:
|
font-family:
|
||||||
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Inter,
|
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Inter, sans-serif;
|
||||||
sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -687,6 +686,27 @@ a:hover {
|
|||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
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 {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 40px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.bubble .tool-card summary .tool-status {
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.bubble .tool-card .tool-body {
|
.bubble .tool-card .tool-body {
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
@@ -708,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;
|
||||||
@@ -754,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;
|
||||||
@@ -1367,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;
|
||||||
@@ -1433,24 +1518,37 @@ mark.hit {
|
|||||||
/* ---------- busy activity pip ---------- */
|
/* ---------- busy activity pip ---------- */
|
||||||
|
|
||||||
.busy-pip {
|
.busy-pip {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
margin-left: 7px;
|
margin-left: 7px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
animation: busy-pulse 1.1s ease-in-out infinite;
|
animation: busy-pulse 1.1s ease-in-out infinite;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
@keyframes busy-pulse {
|
@keyframes busy-pulse {
|
||||||
0%, 100% { transform: scale(0.55); opacity: 0.45; box-shadow: 0 0 0 0 rgba(108, 140, 255, 0.5); }
|
0%,
|
||||||
50% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 5px rgba(108, 140, 255, 0); }
|
100% {
|
||||||
|
transform: scale(0.55);
|
||||||
|
opacity: 0.45;
|
||||||
|
box-shadow: 0 0 0 0 rgba(108, 140, 255, 0.5);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow: 0 0 0 5px rgba(108, 140, 255, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- pretty tool args ---------- */
|
/* ---------- pretty tool args ---------- */
|
||||||
|
|
||||||
.tool-args { margin-bottom: 10px; }
|
.tool-args {
|
||||||
.tool-arg { margin-bottom: 6px; }
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.tool-arg {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
.arg-k {
|
.arg-k {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 10.5px;
|
font-size: 10.5px;
|
||||||
@@ -1468,3 +1566,56 @@ mark.hit {
|
|||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- edit/write diff ---------- */
|
||||||
|
|
||||||
|
.tool-diffs {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.diff {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
background: var(--bg-veil);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
.diff-line {
|
||||||
|
display: block;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
.diff-del {
|
||||||
|
color: var(--danger);
|
||||||
|
background: var(--danger-soft);
|
||||||
|
}
|
||||||
|
.diff-add {
|
||||||
|
color: var(--ok);
|
||||||
|
background: rgba(52, 211, 153, 0.08);
|
||||||
|
}
|
||||||
|
.diff-ctx {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user