Compare commits
4
Commits
af89020dfd
...
022ef98e44
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
022ef98e44 | ||
|
|
7cf77a9248 | ||
|
|
5db695460f | ||
|
|
4dec77ae6d |
@@ -249,12 +249,27 @@ fn host_repo(mission_id: uuid::Uuid) -> std::path::PathBuf {
|
||||
|
||||
/// Push the host checkout into the container before a phase runs.
|
||||
///
|
||||
/// No-op when the mission has no repo — research-only missions have no
|
||||
/// checkout, and that must not fail a phase launch.
|
||||
/// A repo-less mission has no checkout to push, but it still needs
|
||||
/// `/mission/repo` to EXIST inside the container: the phase prompt tells the
|
||||
/// agent that is its working directory, `mission_orchestrator` pins every
|
||||
/// claw's `workspace.path` to it, and `mission_outputs` copies it back out to
|
||||
/// register artifacts. This used to return early instead, so none of those three
|
||||
/// were true — the pin resolved to nothing, ZeroClaw fell back to each agent's
|
||||
/// own sandbox, and the agents (correctly) reported they had no such directory
|
||||
/// and refused to work. Creating it empty is what the microVM tier already does,
|
||||
/// for the same reason: see `microvm_executor::inject` ("the guest needs the
|
||||
/// workspace to exist before the agent writes into it").
|
||||
///
|
||||
/// Creating it host-side rather than `mkdir`-ing in the container keeps the copy
|
||||
/// cycle symmetric — `sync_out` unpacks over this same path, so work written by
|
||||
/// one phase survives into the next instead of being wiped by the next
|
||||
/// `sync_in`.
|
||||
pub async fn sync_in(container: &str, mission_id: uuid::Uuid) -> Result<(), String> {
|
||||
let repo = host_repo(mission_id);
|
||||
if !repo.is_dir() {
|
||||
return Ok(());
|
||||
tokio::fs::create_dir_all(&repo)
|
||||
.await
|
||||
.map_err(|e| format!("create empty workspace {}: {e}", repo.display()))?;
|
||||
}
|
||||
let docker = crate::container_exec::connect()?;
|
||||
copy_in(&docker, container, &repo, "repo").await
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sqlx::{PgPool, Row};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Directories never worth capturing, whatever an agent leaves behind.
|
||||
@@ -56,6 +57,14 @@ const SKIP_DIRS: &[&str] = &[
|
||||
/// How many phases to capture per tick, matching `CAPTURE_BATCH`.
|
||||
const BATCH: i64 = 5;
|
||||
|
||||
/// How long a phase's outputs may stay uncollectable before the sweep stops
|
||||
/// retrying and calls it empty.
|
||||
///
|
||||
/// Generous on purpose: the container is torn down asynchronously after a
|
||||
/// phase, so an early tick can legitimately fail. What must NOT happen is
|
||||
/// retrying forever — that is the state this constant exists to end.
|
||||
const COLLECT_GRACE: Duration = Duration::minutes(10);
|
||||
|
||||
/// The artifact kind this path registers. Also the idempotency key: a phase with
|
||||
/// one of these has already been captured.
|
||||
pub const OUTPUT_KIND: &str = "document";
|
||||
@@ -66,7 +75,7 @@ const EMPTY_MARKER: &str = "NO-OUTPUT.md";
|
||||
/// Capture the outputs of finished phases on missions that have no repo.
|
||||
pub async fn capture_repo_less_phases(pool: &PgPool) -> Result<(), String> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT mp.id, mp.mission_id, mp.kind, mp.config, m.runtime_kind
|
||||
"SELECT mp.id, mp.mission_id, mp.kind, mp.config, mp.completed_at, m.runtime_kind
|
||||
FROM mission_phases mp
|
||||
JOIN missions m ON m.id = mp.mission_id
|
||||
WHERE mp.status IN ('completed', 'failed')
|
||||
@@ -97,22 +106,42 @@ pub async fn capture_repo_less_phases(pool: &PgPool) -> Result<(), String> {
|
||||
let mission_id: Uuid = row.get("mission_id");
|
||||
let kind: String = row.get("kind");
|
||||
let config: serde_json::Value = row.get("config");
|
||||
let completed_at: Option<OffsetDateTime> = row.get("completed_at");
|
||||
let runtime_kind: String = row.get("runtime_kind");
|
||||
|
||||
let dest = outputs_dir(mission_id, phase_id);
|
||||
let captured = match collect_into(mission_id, &dest, &runtime_kind).await {
|
||||
Ok(files) => files,
|
||||
Err(e) => {
|
||||
// Loud and retryable, never silently "captured nothing": the
|
||||
// whole defect this module exists for is work disappearing
|
||||
// without a word. The next tick tries again; if the container is
|
||||
// already gone the phase is failed below on the next pass.
|
||||
// Retryable, but BOUNDED. A bare `continue` here is how a phase
|
||||
// whose collect can never succeed stayed `completed` with zero
|
||||
// artifacts forever: the fail-empty rule and the NO-OUTPUT
|
||||
// marker both live below this point, so neither was ever
|
||||
// reached, and the phase was re-attempted on every tick for the
|
||||
// life of the deployment.
|
||||
//
|
||||
// The grace window exists because the container may legitimately
|
||||
// not be ready on the first tick after a phase finishes. Past
|
||||
// that, "cannot collect" and "collected nothing" are the same
|
||||
// fact for the operator, so we fall through and let the rules
|
||||
// below fail the phase and leave a marker explaining why.
|
||||
let settled = completed_at
|
||||
.map(|t| OffsetDateTime::now_utc() - t > COLLECT_GRACE)
|
||||
.unwrap_or(true);
|
||||
if !settled {
|
||||
eprintln!(
|
||||
"mission_outputs: could NOT collect outputs for phase {phase_id} \
|
||||
of mission {mission_id}: {e}"
|
||||
of mission {mission_id} (will retry): {e}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
eprintln!(
|
||||
"mission_outputs: giving up collecting phase {phase_id} of mission \
|
||||
{mission_id} after {}s: {e} — treating it as having produced nothing",
|
||||
COLLECT_GRACE.whole_seconds()
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
for file in &captured {
|
||||
|
||||
@@ -897,8 +897,20 @@ impl MissionRuntimeProvisioner {
|
||||
.map(|c| crate::runtime_provision::claw_alias(c.as_uuid()))
|
||||
.collect();
|
||||
let (edited, pinned) = stamp_workspace_paths(&raw, &aliases, workspace_path)?;
|
||||
// Pinning NOTHING is a failure, not a no-op. Returning Ok here meant the
|
||||
// caller's deliberately-fatal guard could not fire, so a mission whose
|
||||
// aliases were missing from the config launched anyway with every agent
|
||||
// writing into its own sandbox and delivering nothing — the outcome that
|
||||
// guard's error message already describes. Name the aliases: the only
|
||||
// way this happens is a config/alias mismatch, and the aliases are the
|
||||
// evidence needed to find it.
|
||||
if pinned == 0 {
|
||||
return Ok(());
|
||||
return Err(format!(
|
||||
"pinned 0 of {} agent workspace(s) to {workspace_path} — none of these \
|
||||
aliases exist in {CONFIG_PATH}: {}",
|
||||
aliases.len(),
|
||||
aliases.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
// Upload rather than exec. This used to base64 the whole config into a
|
||||
|
||||
@@ -250,9 +250,38 @@ mod repo_less_text_tests {
|
||||
fn both_variants_still_demand_files_on_disk() {
|
||||
for has_repo in [true, false] {
|
||||
let t = phase_task_text("research", "T", None, None, has_repo);
|
||||
assert!(t.contains("REAL files with file_edit"), "has_repo={has_repo}: {t}");
|
||||
assert!(t.contains("REAL files with Write/Edit"), "has_repo={has_repo}: {t}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The prompt must advertise the tools the agent actually has.
|
||||
///
|
||||
/// Every executor ends in `claude -p`, so the names are Claude Code's.
|
||||
/// Advertising ZeroClaw's names instead (`file_edit`, `content_search`) —
|
||||
/// and denying Bash — is what made five agents stop and ask what environment
|
||||
/// they were in rather than do the work.
|
||||
#[test]
|
||||
fn the_prompt_names_the_tools_the_agent_actually_has() {
|
||||
let t = phase_task_text("coding", "T", None, None, true);
|
||||
for real in ["Read", "Edit", "Write", "Bash", "Glob", "Grep"] {
|
||||
assert!(t.contains(real), "missing {real}: {t}");
|
||||
}
|
||||
for absent in ["file_edit", "content_search", "glob_search", "git_operations"] {
|
||||
assert!(
|
||||
!t.contains(absent),
|
||||
"{absent} does not exist under claude_cli — advertising it is the bug: {t}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A repo-less agent must be told the workspace EXISTS. It is created by
|
||||
/// `mission_fs::sync_in`; saying so is what stops the agent concluding the
|
||||
/// environment is broken and refusing.
|
||||
#[test]
|
||||
fn a_repo_less_workspace_is_promised_to_exist() {
|
||||
let t = phase_task_text("research", "T", None, None, false);
|
||||
assert!(t.contains("EXISTS and is writable"), "{t}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the baseline for benchmark phases that have finished and have none.
|
||||
@@ -1387,31 +1416,36 @@ fn phase_task_text(
|
||||
has_repo: bool,
|
||||
) -> String {
|
||||
let base = description.unwrap_or("").trim();
|
||||
// The prior template-derived system prompts trained agents to look
|
||||
// for `file_read`/`file_write` — tools that no longer exist under
|
||||
// ZeroClaw v0.8+. The current toolset uses `file_edit` (create /
|
||||
// overwrite / patch) plus `content_search`/`glob_search`. Injecting
|
||||
// the real tool inventory + concrete workspace path stops the agent
|
||||
// from hallucinating "I only have file_read" and dumping the entire
|
||||
// implementation into the context window instead of onto disk.
|
||||
// These are Claude Code's OWN tool names, because every executor that runs a
|
||||
// mission turn ends in `claude -p`: the microVM passes
|
||||
// `--allowedTools Read Edit Write Bash Agent`
|
||||
// (`microvm_executor::LEAD_TOOLS`), the direct path passes
|
||||
// `Read Edit Write Bash` (`session_executor::ALLOWED_TOOLS`), and the
|
||||
// container tier binds every claw to `claude_cli.default`
|
||||
// (`runtime_provision::provider_alias_for`), whose subprocess gets Claude
|
||||
// Code's native toolset — ZeroClaw's own tool gating "never reaches the
|
||||
// subprocess" (see the note above `direct_mode`).
|
||||
//
|
||||
// This block previously advertised ZeroClaw tool names (`file_edit`,
|
||||
// `content_search`, …) and explicitly told the agent that `bash` did NOT
|
||||
// exist. On every tier in service that was backwards: those tools were the
|
||||
// ones absent, and Bash was one of the ones present. Agents answered by
|
||||
// describing the mismatch and asking what to do — five of them, on one
|
||||
// mission, for 7.4k tokens and zero artifacts.
|
||||
let tool_preamble = format!("\
|
||||
TOOLS AVAILABLE (use these exact names — do NOT assume older tool names like file_read / file_write / bash exist):\n\
|
||||
- file_edit — create, overwrite, or patch files in your workspace\n\
|
||||
- content_search — grep across your workspace (regex on file contents)\n\
|
||||
- glob_search — find files by path glob\n\
|
||||
- git_operations — git status / add / commit / diff / log\n\
|
||||
- git_forge — Gitea PR / branch / issue operations\n\
|
||||
- web_search_tool / web_fetch — external references (research-tier profiles only)\n\
|
||||
- spawn_subagent — hand off a subtask to another claw\n\
|
||||
- delegate — call a peer role by name\n\
|
||||
- memory_store / memory_recall — durable per-agent notes\n\
|
||||
TOOLS AVAILABLE (Claude Code's standard tools — use these exact names):\n\
|
||||
- Read — read a file\n\
|
||||
- Edit — modify an existing file\n\
|
||||
- Write — create or overwrite a file\n\
|
||||
- Bash — run a shell command\n\
|
||||
- Glob — find files by path glob\n\
|
||||
- Grep — search file contents\n\
|
||||
\n\
|
||||
{workspace}\n\
|
||||
All file_edit / content_search / glob_search\n\
|
||||
operations resolve there. To read a file: file_edit with mode='read'\n\
|
||||
or content_search first, then file_edit to patch. Write your outputs\n\
|
||||
as REAL files with file_edit — do NOT paste code blocks in your reply\n\
|
||||
expecting the platform to save them; nothing else writes files for you.\n",
|
||||
All file operations resolve there — use absolute paths under it, or cd\n\
|
||||
there first. Write your outputs as REAL files with Write/Edit — do NOT\n\
|
||||
paste code blocks in your reply expecting the platform to save them;\n\
|
||||
nothing else writes files for you.\n",
|
||||
workspace = if has_repo {
|
||||
"WORKSPACE: Your working directory is /mission/repo. That path is the\n\
|
||||
mission's git checkout."
|
||||
@@ -1421,11 +1455,11 @@ fn phase_task_text(
|
||||
// found no repo, wrote the files anyway, and nothing collected them.
|
||||
// Now `mission_outputs` DOES collect them, and the agent is told so
|
||||
// — an instruction the platform can actually keep.
|
||||
"WORKSPACE: Your working directory is /mission/repo. This mission has\n\
|
||||
NO git repository — that path is a scratch workspace, so git_operations\n\
|
||||
and git_forge have nothing to act on. Every file you leave there is\n\
|
||||
collected when the phase ends and published as a mission artifact, so\n\
|
||||
write your output as files exactly as you would in a repo."
|
||||
"WORKSPACE: Your working directory is /mission/repo. It EXISTS and is writable.\n\
|
||||
This mission has NO git repository — that path is a scratch workspace, so\n\
|
||||
there is nothing to commit or push. Every file you leave there is collected\n\
|
||||
when the phase ends and published as a mission artifact, so write your\n\
|
||||
output as files exactly as you would in a repo."
|
||||
}
|
||||
);
|
||||
// The INT-XX markers are a machine contract, not a style preference:
|
||||
@@ -1456,14 +1490,14 @@ fn phase_task_text(
|
||||
Investigate the topic, gather sources, and produce a \
|
||||
sectioned Markdown brief the coding phase can implement \
|
||||
directly. Save findings under /mission/repo/research/ \
|
||||
using file_edit — one Markdown file per topic. Emit INT-XX \
|
||||
using Write — one Markdown file per topic. Emit INT-XX \
|
||||
task markers in the last file for concrete follow-ups."
|
||||
}
|
||||
"coding" => {
|
||||
"Your team is running the CODING phase of this mission. \
|
||||
Implement the mission's acceptance criteria against the \
|
||||
/mission/repo checkout using file_edit for every source \
|
||||
file, then git_operations to commit small focused changes \
|
||||
/mission/repo checkout using Write/Edit for every source \
|
||||
file, then git via Bash to commit small focused changes \
|
||||
with test coverage. Emit COMPLETED: <INT-id> markers as you \
|
||||
close research-produced tasks. Do NOT respond with source \
|
||||
code in text — write it as files."
|
||||
@@ -1471,7 +1505,7 @@ fn phase_task_text(
|
||||
"benchmark" => {
|
||||
"Your team is running the BENCHMARK phase of this mission. \
|
||||
Author or extend benchmarks under /mission/repo/benches or \
|
||||
the crate's bench harness using file_edit. Baseline the \
|
||||
the crate's bench harness using Write/Edit. Baseline the \
|
||||
pre-change performance, apply the change (or use the \
|
||||
mission's committed diff), then measure after."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// LOCAL-ONLY auto-login. Mints a real session for a fixed operator so a
|
||||
// single-user local stack lands on the dashboard instead of the login form.
|
||||
//
|
||||
// This is a genuine login against the Rust backend — the same call
|
||||
// /auth/session makes — not an auth bypass in the server. The backend still
|
||||
// issues (and can revoke) the session, so nothing here weakens API auth.
|
||||
//
|
||||
// OFF unless LOCAL_AUTOLOGIN_EMAIL *and* LOCAL_AUTOLOGIN_PASSWORD are both
|
||||
// set. Prod (Clerk) never sets them, and `authMode() === "clerk"` refuses
|
||||
// outright, so this route 404s everywhere but the local box. It is deliberately
|
||||
// two conditions: a single misread flag should not be able to hand a session to
|
||||
// an anonymous visitor.
|
||||
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
import { apiOrigin, TOKEN_COOKIE } from "@/lib/api/http";
|
||||
import { authMode } from "@/lib/auth/mode";
|
||||
|
||||
const SESSION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
|
||||
|
||||
// A RELATIVE Location, deliberately. NextResponse.redirect() needs an absolute
|
||||
// URL, and inside the container `request.url` is the 0.0.0.0:3000 bind — so
|
||||
// behind `tailscale serve` it would redirect the browser to a host that only
|
||||
// exists inside Docker. A relative Location (RFC 7231 §7.1.2) lets the browser
|
||||
// resolve against whatever origin it actually asked for.
|
||||
function redirectTo(location: string): NextResponse {
|
||||
return new NextResponse(null, { status: 307, headers: { location } });
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const email = process.env.LOCAL_AUTOLOGIN_EMAIL;
|
||||
const password = process.env.LOCAL_AUTOLOGIN_PASSWORD;
|
||||
if (authMode() === "clerk" || !email || !password) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
const upstream = await fetch(`${apiOrigin()}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!upstream.ok) {
|
||||
// Land on the real login form rather than a redirect loop back through the
|
||||
// middleware — a wrong password here must be visibly a login problem.
|
||||
return redirectTo("/login?autologin=failed");
|
||||
}
|
||||
const { token } = (await upstream.json()) as { token: string };
|
||||
|
||||
// `next` is same-origin-checked: an open redirect here would be a way to
|
||||
// bounce a freshly-minted session cookie off this host.
|
||||
const requested = request.nextUrl.searchParams.get("next") ?? "/";
|
||||
const target = requested.startsWith("/") && !requested.startsWith("//") ? requested : "/";
|
||||
|
||||
const response = redirectTo(target);
|
||||
response.cookies.set({
|
||||
name: TOKEN_COOKIE,
|
||||
value: token,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
// Mirrors /auth/session. Behind `tailscale serve` the browser hop is HTTPS,
|
||||
// so a Secure cookie is correct there; over plain http://localhost it would
|
||||
// never be sent back, hence keying on the forwarded scheme.
|
||||
secure: request.headers.get("x-forwarded-proto") === "https",
|
||||
path: "/",
|
||||
maxAge: SESSION_MAX_AGE_SECONDS,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -7,10 +7,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Activity, Brain, Camera, Sparkles } from "lucide-react";
|
||||
|
||||
import { proposeForAgent } from "@/lib/api/level-up";
|
||||
import { LevelUpDrawer } from "./LevelUpDrawer";
|
||||
import { Activity, Brain, Camera } from "lucide-react";
|
||||
|
||||
import type { DemoAgent } from "@/lib/dashboard-demo";
|
||||
import { useAgentTelemetry, useLiveEvent } from "@/lib/live/useClawmatesLive";
|
||||
@@ -208,21 +205,8 @@ export function ClawCommandCenter({
|
||||
const tele = useAgentTelemetry(agent.id);
|
||||
const doors = tele?.doorsPending ?? 0;
|
||||
|
||||
const [levelUpBusy, setLevelUpBusy] = useState(false);
|
||||
const [levelUpOpenId, setLevelUpOpenId] = useState<string | null>(null);
|
||||
const [levelUpError, setLevelUpError] = useState<string | null>(null);
|
||||
const proposeLevelUp = async () => {
|
||||
setLevelUpBusy(true);
|
||||
setLevelUpError(null);
|
||||
try {
|
||||
const { proposal_id } = await proposeForAgent(agent.id);
|
||||
setLevelUpOpenId(proposal_id);
|
||||
} catch (e) {
|
||||
setLevelUpError(e instanceof Error ? e.message : "level-up failed");
|
||||
} finally {
|
||||
setLevelUpBusy(false);
|
||||
}
|
||||
};
|
||||
// Level up now lives at the bottom of the Agents sidebar (Dashboard), where
|
||||
// it sits next to the agent you picked rather than in this header.
|
||||
|
||||
return (
|
||||
<div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", background: "#08080a" }}>
|
||||
@@ -239,41 +223,7 @@ export function ClawCommandCenter({
|
||||
<div style={{ fontFamily: mono, fontSize: 11.5, letterSpacing: ".06em", color: "#ff8a7a", marginTop: 2 }}>{agent.role}<span style={{ color: "#5a5a62" }}> · part of {teamName}</span></div>
|
||||
</div>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={proposeLevelUp}
|
||||
disabled={levelUpBusy}
|
||||
title="Ask the proposer to suggest improvements to this claw's identity, skills, and brain"
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(201,160,255,.45)",
|
||||
background: "rgba(201,160,255,.1)",
|
||||
color: "#c9a0ff",
|
||||
fontFamily: mono,
|
||||
fontSize: 11,
|
||||
letterSpacing: ".08em",
|
||||
textTransform: "uppercase",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
opacity: levelUpBusy ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
{levelUpBusy ? "Proposing…" : "Level up"}
|
||||
</button>
|
||||
</div>
|
||||
{levelUpError && (
|
||||
<div style={{ padding: "6px 22px", color: "#ff8a7a", fontSize: 11 }}>{levelUpError}</div>
|
||||
)}
|
||||
{levelUpOpenId && (
|
||||
<LevelUpDrawer
|
||||
proposalId={levelUpOpenId}
|
||||
onClose={() => setLevelUpOpenId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Metric grid — 2-column, wraps to 3 rows for the 5 tiles.
|
||||
Activity now lives here (top-of-fold quick-glance); the beefier
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties }
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQueryStates } from "nuqs";
|
||||
import { Bot, Brain, History, MessageSquare, Monitor, PanelLeftClose, PanelLeftOpen, PanelRight, Trash2, Users, Wrench, X } from "lucide-react";
|
||||
import { Bot, Brain, History, MessageSquare, Monitor, PanelLeftClose, PanelLeftOpen, PanelRight, Sparkles, Trash2, Users, Wrench, X } from "lucide-react";
|
||||
|
||||
import type { DemoAgent, DemoCompany, DemoOrg, DemoTeam } from "@/lib/dashboard-demo";
|
||||
import type { Agent } from "@/lib/api/schemas";
|
||||
@@ -27,6 +27,8 @@ import { StructureTree, orgNode, type TreeItem, clawNode } from "./StructureTree
|
||||
import { MissionsList } from "./MissionsList";
|
||||
import { MissionWizard } from "./MissionWizard";
|
||||
import { LevelUpInbox } from "./LevelUpInbox";
|
||||
import { LevelUpDrawer } from "./LevelUpDrawer";
|
||||
import { proposeForAgent } from "@/lib/api/level-up";
|
||||
import { MissionCanvas } from "./MissionCanvas";
|
||||
import { RepoList } from "./RepoList";
|
||||
import { RepoCanvas } from "./RepoCanvas";
|
||||
@@ -387,6 +389,26 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
||||
(agent ? agentById.get(agent.id) : undefined) ??
|
||||
(agent ? { id: agent.id, workspace_id: "", name: agent.name, job_title: agent.role, system_prompt: agent.systemPrompt, avatar: "", accent: "#ff6f61", wallpaper: "", managed_by: "", status: "online" } : undefined);
|
||||
|
||||
// Level up — moved here from the ClawCommandCenter header so it sits at the
|
||||
// bottom of the Agents sidebar, next to the agent you picked. Keyed off
|
||||
// `clawAgent`, so the control simply is not rendered until one is selected.
|
||||
const [levelUpBusy, setLevelUpBusy] = useState(false);
|
||||
const [levelUpOpenId, setLevelUpOpenId] = useState<string | null>(null);
|
||||
const [levelUpError, setLevelUpError] = useState<string | null>(null);
|
||||
const proposeLevelUp = async () => {
|
||||
if (!clawAgent) return;
|
||||
setLevelUpBusy(true);
|
||||
setLevelUpError(null);
|
||||
try {
|
||||
const { proposal_id } = await proposeForAgent(clawAgent.id);
|
||||
setLevelUpOpenId(proposal_id);
|
||||
} catch (e) {
|
||||
setLevelUpError(e instanceof Error ? e.message : "level-up failed");
|
||||
} finally {
|
||||
setLevelUpBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Computer panel state (size + open app), shared with DevicePanel via nuqs.
|
||||
const [{ app, device }, setParams] = useQueryStates(panelParsers, { shallow: true });
|
||||
// Custom computer width (px) from dragging the panel's left edge; null = use the
|
||||
@@ -1046,6 +1068,26 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
{/* Level up — pinned to the bottom of the Agents sidebar, and only
|
||||
once an agent is actually selected. Hidden during select mode so
|
||||
the reap bar below is the single footer action in that flow. */}
|
||||
{isClaw && clawAgent && !selectMode ? (
|
||||
<div style={{ flex: "none", borderTop: "1px solid rgba(255,255,255,.08)", padding: "10px 12px", display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={proposeLevelUp}
|
||||
disabled={levelUpBusy}
|
||||
title={`Ask the proposer to suggest improvements to ${clawAgent.name}'s identity, skills, and brain`}
|
||||
style={{ width: "100%", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 7, padding: "9px 0", borderRadius: 9, border: "1px solid rgba(201,160,255,.45)", background: "rgba(201,160,255,.1)", color: "#c9a0ff", fontFamily: mono, fontSize: 11, letterSpacing: ".08em", textTransform: "uppercase", cursor: levelUpBusy ? "default" : "pointer", opacity: levelUpBusy ? 0.5 : 1 }}
|
||||
>
|
||||
<Sparkles aria-hidden size={13} />
|
||||
{levelUpBusy ? "Proposing…" : `Level up ${clawAgent.name}`}
|
||||
</button>
|
||||
{levelUpError ? (
|
||||
<div style={{ color: "#ff8a7a", fontSize: 11 }}>{levelUpError}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{selectMode ? (
|
||||
<div style={{ flex: "none", borderTop: "1px solid rgba(255,255,255,.08)", padding: "10px 12px", display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={{ fontFamily: mono, fontSize: 11, color: selectedItems.length ? "#ff8a7a" : "#6a6a72" }}>{selectedItems.length} selected{selectedItems.length ? ` · ${reapKind}` : ""}</div>
|
||||
@@ -1352,6 +1394,9 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
||||
{historyOpen && clawAgent ? <BrainHistoryModal clawId={clawAgent.id} clawName={clawAgent.name} onClose={() => setHistoryOpen(false)} onRolledBack={() => setEnrichBump((b) => b + 1)} /> : null}
|
||||
{(() => { const sel = allNodes.find((n) => n.id === worldSel); return runsOpen && sel?.level === "team" ? <TeamRunsModal teamId={sel.id} teamName={sel.label} onClose={() => setRunsOpen(false)} /> : null; })()}
|
||||
{reapOpen ? <ReapProgressModal items={selectedItems} kind={reapKind} onClose={() => setReapOpen(false)} onDone={() => { setReapOpen(false); setSelectMode(false); setSelectedAgents(new Set()); router.refresh(); }} /> : null}
|
||||
{/* Level-up review drawer. Mounted here rather than inside the sidebar
|
||||
footer so it is not clipped by the sidebar's own bounds. */}
|
||||
{levelUpOpenId ? <LevelUpDrawer proposalId={levelUpOpenId} onClose={() => setLevelUpOpenId(null)} /> : null}
|
||||
|
||||
{/* Group existing claws into a new named team, then land on its team page. */}
|
||||
|
||||
|
||||
@@ -197,13 +197,13 @@ export function MissionArtifacts({
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div
|
||||
// No maxHeight — this body is only rendered because the operator
|
||||
// opened it to read it. The tab scroller handles the length.
|
||||
style={{
|
||||
border: "1px solid rgba(255,255,255,.06)",
|
||||
borderRadius: 8,
|
||||
background: "#0a0a0d",
|
||||
padding: 16,
|
||||
maxHeight: 640,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
@@ -41,6 +39,7 @@ import {
|
||||
} from "@/lib/api/missions";
|
||||
import { EditMissionModal } from "./EditMissionModal";
|
||||
import { MarkdownBlock } from "./MarkdownBlock";
|
||||
import { MissionTabScroller } from "./MissionTabScroller";
|
||||
import { MissionArtifacts } from "./MissionArtifacts";
|
||||
import { MissionLiveEvents } from "./MissionLiveEvents";
|
||||
import { MissionLivePane } from "./MissionLivePane";
|
||||
@@ -141,22 +140,6 @@ export function MissionCanvas({
|
||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||
const [runs, setRuns] = useState<MissionRunSummary[]>([]);
|
||||
const [lastLoadedAt, setLastLoadedAt] = useState<Date | null>(null);
|
||||
// Persist the collapsed state across mission switches so the operator
|
||||
// can keep the header hidden once they've read it.
|
||||
const [headerCollapsed, setHeaderCollapsed] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.localStorage.getItem("cm.mission.headerCollapsed") === "1";
|
||||
});
|
||||
const toggleHeader = useCallback(() => {
|
||||
setHeaderCollapsed((v) => {
|
||||
const next = !v;
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem("cm.mission.headerCollapsed", next ? "1" : "0");
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!selectedId) {
|
||||
setMission(null);
|
||||
@@ -320,7 +303,7 @@ export function MissionCanvas({
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
position: "absolute", inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -336,20 +319,22 @@ export function MissionCanvas({
|
||||
}
|
||||
if (loading && !mission) {
|
||||
return (
|
||||
<div style={{ padding: 20, fontFamily: mono, color: "#5ec8d8" }}>
|
||||
<div style={{ position: "absolute", inset: 0, padding: 20, fontFamily: mono, color: "#5ec8d8" }}>
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: 20, color: "#ff8a7a", fontSize: 12 }}>{error}</div>
|
||||
<div style={{ position: "absolute", inset: 0, padding: 20, color: "#ff8a7a", fontSize: 12 }}>{error}</div>
|
||||
);
|
||||
}
|
||||
if (!mission) return null;
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||
// absolute-inset, NOT flex:1 — the canvas host is a position:relative BLOCK,
|
||||
// so flex:1 is inert here and collapses this root (and its scroller) to zero.
|
||||
<div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
flex: "none",
|
||||
@@ -531,44 +516,15 @@ export function MissionCanvas({
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
{/* Title only. The description peek that used to live here was ~92px of
|
||||
permanently-pinned chrome showing a masked, unreadable fragment of
|
||||
text that renders in full a click away in Setup → Overview. Deleting
|
||||
it gives that space to the results and removes one collapsible. */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<h1 style={{ margin: 0, fontSize: 20, color: "#f3f3f5", flex: 1, minWidth: 0 }}>
|
||||
{mission.title}
|
||||
</h1>
|
||||
{mission.description && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleHeader}
|
||||
title={headerCollapsed ? "Expand description" : "Collapse description"}
|
||||
aria-label={headerCollapsed ? "Expand description" : "Collapse description"}
|
||||
style={{
|
||||
...iconBtn,
|
||||
flex: "none",
|
||||
}}
|
||||
>
|
||||
{headerCollapsed ? <ChevronDown size={13} /> : <ChevronUp size={13} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{mission.description && !headerCollapsed && (
|
||||
// Clipped, NOT scrollable — a scroll container here was a fourth
|
||||
// nested scrollbar above the content area. The full text lives in
|
||||
// Setup → Overview.
|
||||
<div
|
||||
style={{
|
||||
marginTop: 4,
|
||||
maxHeight: 92,
|
||||
overflow: "hidden",
|
||||
paddingRight: 8,
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, #000 60%, transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to bottom, #000 60%, transparent 100%)",
|
||||
}}
|
||||
>
|
||||
<MarkdownBlock source={mission.description} />
|
||||
</div>
|
||||
)}
|
||||
{refineDiff && (
|
||||
<RefineDiffModal
|
||||
original={refineDiff.original}
|
||||
@@ -721,7 +677,11 @@ export function MissionCanvas({
|
||||
visible
|
||||
/>
|
||||
) : (
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 22 }}>
|
||||
// `key` remounts the scroller per tab. Without it every tab shares one DOM
|
||||
// node, so scrollTop leaks: scroll to the bottom of a long run list, switch
|
||||
// to Setup, and you land mid-page in unrelated content.
|
||||
// `stick` only on the streaming views — see MissionTabScroller.
|
||||
<MissionTabScroller key={`${tab}:${tab === "run" ? runSub : tab === "output" ? outputSub : setupSub}`} stick={tab === "run" && (runSub === "live" || runSub === "phases")}>
|
||||
{tab === "setup" && setupSub === "overview" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{mission.description && (
|
||||
@@ -841,7 +801,19 @@ export function MissionCanvas({
|
||||
<PhaseGoalStrip missionId={mission.id} phase={p} />
|
||||
<PhaseRunsList runs={runsByPhase.get(p.id) ?? []} />
|
||||
{(p.status === "completed" || p.status === "failed") && (
|
||||
<PhaseSummaryCard missionId={mission.id} phaseId={p.id} />
|
||||
// Which summary is worth reading right now: while the
|
||||
// mission runs, the operator is watching the live phase, so
|
||||
// finished ones fold away. Once it is over, the LAST phase
|
||||
// holds the outcome. A failure always opens.
|
||||
<PhaseSummaryCard
|
||||
missionId={mission.id}
|
||||
phaseId={p.id}
|
||||
defaultOpen={
|
||||
p.status === "failed" ||
|
||||
(mission.status !== "running" &&
|
||||
p.id === mission.phases[mission.phases.length - 1]?.id)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{mission.status === "running" || mission.status === "completed" ? (
|
||||
<div
|
||||
@@ -1157,7 +1129,7 @@ export function MissionCanvas({
|
||||
visible={tab === "setup" && setupSub === "pane"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</MissionTabScroller>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -36,7 +36,6 @@ export function MissionLiveEvents({
|
||||
const [feed, setFeed] = useState<FeedItem[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const seq = useRef(0);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Poll the run list every 5s while there's any activity — cheap and
|
||||
// lets a newly-enqueued run auto-attach without a manual refresh.
|
||||
@@ -130,16 +129,15 @@ export function MissionLiveEvents({
|
||||
};
|
||||
}, [runIds, visible]);
|
||||
|
||||
// Auto-scroll to bottom on new events (unless the operator scrolled up).
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
if (nearBottom) el.scrollTop = el.scrollHeight;
|
||||
}, [feed.length]);
|
||||
// Following the tail is no longer this component's job: it has no scroller of
|
||||
// its own now, so the mission tab scroller does it (useStickToBottom).
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10, height: "70vh" }}>
|
||||
// No height here. `70vh` used to be a workaround for MissionCanvas's root
|
||||
// having no height at all; now that it does, a fixed height would cap this
|
||||
// pane below the space available AND make it a scroller inside the tab
|
||||
// scroller — the nesting MissionCanvas explicitly forbids.
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -165,11 +163,7 @@ export function MissionLiveEvents({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
padding: 12,
|
||||
background: "#0a0a0d",
|
||||
border: "1px solid rgba(255,255,255,.06)",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
// The ONE scroller for a mission tab's body.
|
||||
//
|
||||
// It exists as a component rather than a bare <div> for two reasons:
|
||||
// 1. It owns the scroll ref, so tail-following can act on the real scroller
|
||||
// without prop-drilling a ref down into MissionLiveEvents / PhaseRunStream
|
||||
// (which used to scroll their own little boxes — nested scrollers).
|
||||
// 2. It gives the "jump to latest" affordance somewhere to live.
|
||||
//
|
||||
// `stick` is opt-in per tab: only streaming views want the view yanked to the
|
||||
// newest content. Auto-jumping while someone reads Setup → Overview would be
|
||||
// obnoxious, so that tab passes stick={false}.
|
||||
|
||||
import { ArrowDown } from "lucide-react";
|
||||
|
||||
import { useStickToBottom } from "@/lib/live/useStickToBottom";
|
||||
|
||||
const mono =
|
||||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||
|
||||
export function MissionTabScroller({
|
||||
stick = false,
|
||||
children,
|
||||
}: {
|
||||
stick?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { scrollRef, contentRef, following, jumpToLatest } =
|
||||
useStickToBottom(stick);
|
||||
|
||||
return (
|
||||
// position:relative so the pill can anchor to the scroller's viewport
|
||||
// rather than to the (much taller) scrolled content.
|
||||
<div style={{ flex: 1, minHeight: 0, position: "relative", display: "flex" }}>
|
||||
<div ref={scrollRef} style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 22 }}>
|
||||
<div ref={contentRef}>{children}</div>
|
||||
</div>
|
||||
{stick && !following ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={jumpToLatest}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 16,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "7px 13px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid rgba(124,214,224,.45)",
|
||||
background: "rgba(10,10,13,.92)",
|
||||
color: "#7cd6e0",
|
||||
fontFamily: mono,
|
||||
fontSize: 10.5,
|
||||
letterSpacing: ".08em",
|
||||
textTransform: "uppercase",
|
||||
cursor: "pointer",
|
||||
boxShadow: "0 6px 20px rgba(0,0,0,.5)",
|
||||
}}
|
||||
>
|
||||
<ArrowDown aria-hidden size={12} />
|
||||
Jump to latest
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -194,7 +194,10 @@ export function PhaseGoalStrip({
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{evals.map((e) => (
|
||||
{/* Latest 10 only. A phase that looped many times would otherwise
|
||||
push the rest of the page down with mostly-identical passes;
|
||||
the recent ones are the ones that explain the outcome. */}
|
||||
{evals.slice(-10).map((e) => (
|
||||
<li
|
||||
key={e.iteration}
|
||||
style={{ fontSize: 11, color: "#8a8a92", lineHeight: 1.4 }}
|
||||
@@ -207,6 +210,12 @@ export function PhaseGoalStrip({
|
||||
— {e.reason}
|
||||
</li>
|
||||
))}
|
||||
{evals.length > 10 && (
|
||||
<li style={{ fontSize: 11, color: "#6a6a72", listStyle: "none", marginLeft: -16 }}>
|
||||
+{evals.length - 10} earlier pass
|
||||
{evals.length - 10 === 1 ? "" : "es"} not shown
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -25,7 +25,6 @@ export function PhaseRunStream({ runId }: { runId: string }) {
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [terminal, setTerminal] = useState<string | null>(null);
|
||||
const seq = useRef(0);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const es = new EventSource(`/api/topology-runs/${runId}/events`);
|
||||
@@ -79,16 +78,14 @@ export function PhaseRunStream({ runId }: { runId: string }) {
|
||||
};
|
||||
}, [runId]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
if (nearBottom) el.scrollTop = el.scrollHeight;
|
||||
}, [events.length]);
|
||||
// Tail-following moved to the mission tab scroller (useStickToBottom) — this
|
||||
// component no longer owns a scroller to follow.
|
||||
|
||||
return (
|
||||
// No maxHeight: a 260px porthole onto a live agent stream is the "can't read
|
||||
// the results" complaint in miniature. This is only mounted for a running,
|
||||
// explicitly-expanded run, so it lets the tab scroller do the scrolling.
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={{
|
||||
marginTop: 6,
|
||||
padding: 8,
|
||||
@@ -97,8 +94,6 @@ export function PhaseRunStream({ runId }: { runId: string }) {
|
||||
color: "#cfcfd5",
|
||||
fontSize: 10.5,
|
||||
fontFamily: mono,
|
||||
maxHeight: 260,
|
||||
overflow: "auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 3,
|
||||
|
||||
@@ -13,7 +13,16 @@ const mono =
|
||||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||
|
||||
export function PhaseRunsList({ runs }: { runs: MissionRunSummary[] }) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
// Open exactly ONE run by default, and only when it is worth looking at:
|
||||
// still running (there is live output) or failed (it needs attention).
|
||||
// Everything else stays closed — N stacked open runs was a large part of
|
||||
// "too many open sections".
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => {
|
||||
const newest = runs[runs.length - 1];
|
||||
return newest && (newest.status === "running" || newest.status === "failed")
|
||||
? new Set([newest.id])
|
||||
: new Set<string>();
|
||||
});
|
||||
if (runs.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
@@ -124,10 +133,11 @@ export function PhaseRunsList({ runs }: { runs: MissionRunSummary[] }) {
|
||||
background: "rgba(0,0,0,.35)",
|
||||
color: "#e0d0cf",
|
||||
fontSize: 10.5,
|
||||
// pre-wrap + break-word already handle long lines; a
|
||||
// maxHeight would truncate the stack trace the operator
|
||||
// opened this <details> specifically to read.
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
maxHeight: 260,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{r.error}
|
||||
|
||||
@@ -19,17 +19,29 @@ const mono =
|
||||
export function PhaseSummaryCard({
|
||||
missionId,
|
||||
phaseId,
|
||||
defaultOpen = true,
|
||||
}: {
|
||||
missionId: string;
|
||||
phaseId: string;
|
||||
/**
|
||||
* Whether this card starts expanded when the operator has no stored
|
||||
* preference. Callers pass false for phases that are not the one worth
|
||||
* reading right now, so a mission with many phases does not open as a wall
|
||||
* of stacked summaries. An explicit stored choice always wins over this.
|
||||
*/
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [summary, setSummary] = useState<PhaseSummary | null>(null);
|
||||
const [waiting, setWaiting] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const collapseKey = `${COLLAPSE_KEY_PREFIX}${phaseId}`;
|
||||
const [collapsed, setCollapsed] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.localStorage.getItem(collapseKey) === "1";
|
||||
if (typeof window === "undefined") return !defaultOpen;
|
||||
const stored = window.localStorage.getItem(collapseKey);
|
||||
// Only "1"/"0" count as a real choice; anything else means "never set".
|
||||
if (stored === "1") return true;
|
||||
if (stored === "0") return false;
|
||||
return !defaultOpen;
|
||||
});
|
||||
const toggleCollapsed = useCallback(() => {
|
||||
setCollapsed((v) => {
|
||||
@@ -327,13 +339,9 @@ function Section({
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
maxHeight: 280,
|
||||
overflow: "auto",
|
||||
}}
|
||||
// Several Sections render per card; a maxHeight here gave each one its
|
||||
// own scrollbar — the worst nesting in the tree. Size to content.
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
{items.map((it, i) => (
|
||||
<div
|
||||
|
||||
@@ -59,11 +59,18 @@ export function RepoList({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
// Collapsed-org state is per-<connection_id>::<owner> — the same owner
|
||||
// string appearing under two different providers folds independently.
|
||||
const [collapsedOrgs, setCollapsedOrgs] = useState<Set<string>>(new Set());
|
||||
// Org fold state is per-<connection_id>::<owner> — the same owner string
|
||||
// appearing under two different providers folds independently.
|
||||
//
|
||||
// Tracks EXPANDED, not collapsed, so the empty initial set means every org
|
||||
// starts folded: with 180+ repos across a dozen orgs, an all-expanded default
|
||||
// buries the org names the list is meant to be navigated by. Inverting the set
|
||||
// (rather than seeding it with every key on load) also keeps the default
|
||||
// correct for orgs that arrive later from a sync, which a seeded set would
|
||||
// render expanded.
|
||||
const [expandedOrgs, setExpandedOrgs] = useState<Set<string>>(new Set());
|
||||
const toggleOrg = (key: string) =>
|
||||
setCollapsedOrgs((prev) => {
|
||||
setExpandedOrgs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
@@ -342,7 +349,7 @@ export function RepoList({
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{groupByOwner(list).map(([owner, orgRepos]) => {
|
||||
const key = `${c.id}::${owner}`;
|
||||
const collapsed = collapsedOrgs.has(key);
|
||||
const collapsed = !expandedOrgs.has(key);
|
||||
return (
|
||||
<OrgGroup
|
||||
key={key}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Follow a growing feed, but only while the reader is already at the bottom.
|
||||
//
|
||||
// Extracted from two byte-identical copies (MissionLiveEvents, PhaseRunStream)
|
||||
// that each scrolled their OWN little box. Those boxes are gone — they were
|
||||
// nested scrollers inside the mission tab scroller — so following now has to act
|
||||
// on whatever scroller actually owns the content.
|
||||
//
|
||||
// Growth is detected with a ResizeObserver on the content wrapper rather than a
|
||||
// `[feed.length]` dependency: after un-nesting, the thing that grows is several
|
||||
// components below the scroller (live events, phase run streams, artifact
|
||||
// bodies), and there is no single length to depend on. The observer fires for
|
||||
// any growing descendant with no plumbing.
|
||||
//
|
||||
// The 80px threshold is inherited from the original implementations: it is the
|
||||
// slack that keeps "at the bottom" true when a partially-rendered row or a
|
||||
// sub-pixel layout shift leaves the scroller a few pixels short.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
const NEAR_BOTTOM_PX = 80;
|
||||
|
||||
export type StickToBottom = {
|
||||
/** Attach to the scrolling element. */
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
/** Attach to the element that WRAPS the growing content. */
|
||||
contentRef: React.RefObject<HTMLDivElement | null>;
|
||||
/** False once the reader has scrolled up — surface a "jump to latest" affordance. */
|
||||
following: boolean;
|
||||
/** Re-attach and jump to the newest content. */
|
||||
jumpToLatest: () => void;
|
||||
};
|
||||
|
||||
export function useStickToBottom(enabled = true): StickToBottom {
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const [following, setFollowing] = useState(true);
|
||||
// Mirrors `following` for use inside observer callbacks without making them a
|
||||
// dependency — re-subscribing the observer on every scroll would be wasteful
|
||||
// and would drop resize events in the gap.
|
||||
const followingRef = useRef(true);
|
||||
|
||||
const setFollow = useCallback((v: boolean) => {
|
||||
followingRef.current = v;
|
||||
setFollowing((prev) => (prev === v ? prev : v));
|
||||
}, []);
|
||||
|
||||
const jumpToLatest = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
setFollow(true);
|
||||
}, [setFollow]);
|
||||
|
||||
// Reader intent: scrolling away from the bottom detaches, scrolling back
|
||||
// re-attaches. Passive — this never calls preventDefault.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || !enabled) return;
|
||||
const onScroll = () => {
|
||||
const near =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX;
|
||||
setFollow(near);
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, [enabled, setFollow]);
|
||||
|
||||
// Content grew: pin to the new bottom, but only if we were already there.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!el || !content || !enabled) return;
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (followingRef.current) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
ro.observe(content);
|
||||
return () => ro.disconnect();
|
||||
}, [enabled]);
|
||||
|
||||
return { scrollRef, contentRef, following, jumpToLatest };
|
||||
}
|
||||
@@ -38,6 +38,23 @@ export default async function proxy(
|
||||
|
||||
// Local mode: key on our own session cookie.
|
||||
if (!request.cookies.has(SESSION_COOKIE)) {
|
||||
// LOCAL-ONLY: a single-user local stack goes straight to the dashboard.
|
||||
// The route itself re-checks both env vars and 404s without them, so this
|
||||
// redirect is inert in any deployment that has not opted in. Excluded
|
||||
// paths would otherwise loop: /auth/autologin sets the cookie, and /login
|
||||
// must stay reachable to show a failed-autologin message.
|
||||
const path = request.nextUrl.pathname;
|
||||
if (
|
||||
process.env.LOCAL_AUTOLOGIN_EMAIL &&
|
||||
process.env.LOCAL_AUTOLOGIN_PASSWORD &&
|
||||
path !== "/auth/autologin" &&
|
||||
path !== "/login"
|
||||
) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/auth/autologin";
|
||||
url.search = `?next=${encodeURIComponent(path + request.nextUrl.search)}`;
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
const rewrite = marketingRewrite(request);
|
||||
if (rewrite) return rewrite;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user