4 Commits
Author SHA1 Message Date
Omar SobhandClaude Opus 5 022ef98e44 feat(auth): opt-in local auto-login for single-user deployments
deploy / test (push) Successful in 4m51s
deploy / build (push) Successful in 6m33s
Skips the login form and lands on the dashboard. It performs a REAL backend
login — the API still issues and can revoke the session — so this does not
weaken auth; it only removes a form for a deployment with exactly one operator.

Gated on BOTH LOCAL_AUTOLOGIN_EMAIL and LOCAL_AUTOLOGIN_PASSWORD, and refuses
outright in clerk mode. Prod sets neither, so the route 404s there. Two
conditions rather than one flag: a single misread value should not be able to
hand a session to an anonymous visitor.

The route emits a RELATIVE Location — inside the container request.url is the
0.0.0.0:3000 bind, so NextResponse.redirect would send the browser to a host
that only exists in Docker — and the cookie's secure flag keys on
x-forwarded-proto rather than NODE_ENV.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-13 10:47:14 -07:00
Omar SobhandClaude Opus 5 7cf77a9248 feat(ui): level up moves to the agents sidebar; repos open collapsed
Level up now sits at the bottom of the Agents sidebar, labelled with the
selected agent's name, and renders only once an agent is selected — it is hidden
during select mode so the reap bar stays the single footer action there. It is
gone from the ClawCommandCenter header.

Repos open with every org folded. Rather than seeding a "collapsed" set with all
keys on load, the state tracks EXPANDED: a smaller change that also stays correct
for orgs that arrive later from a sync, which a seeded set would render open.
With 182 repos across 8 orgs, an all-expanded default buried the org names the
list is meant to be navigated by.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-13 10:47:14 -07:00
Omar SobhandClaude Opus 5 5db695460f fix(missions-ui): the results area could not scroll at all
The canvas host is a position:relative BLOCK, so flex:1 on MissionCanvas's root
was inert and its height collapsed to its content. That starved the scroller
beneath it — scrollHeight === clientHeight — so it never scrolled, and the
overflow spilled past the page and was clipped by the host's overflow:hidden.
Long results were rendered and then thrown away. Every sibling canvas already
used position:absolute; inset:0; missions was the only one that did not.

Measured after, on a brief 5x the viewport: one scroller, clientH 736 vs
scrollH 3244, scrolling 0 -> 2508 (exactly scrollH - clientH, i.e. the true
bottom), zero page overflow, tab strip pinned throughout.

Also removed five nested scrollers (70vh on live events; maxHeight caps on run
streams, phase summaries, artifact bodies and error traces). Those existed only
to work around the missing height and would have become portholes onto the very
content the operator is trying to read. The xterm pane keeps its bounded box —
FitAddon needs one, and a terminal owning its scrollback is correct.

Deleting the header's description peek reclaims 104px for results (header
256 -> 152px); the same text renders in full in Setup -> Overview, as the code's
own comment noted.

Streaming now follows only when already at the bottom, via a shared
useStickToBottom hook replacing two byte-identical copies, plus a "jump to
latest" pill neither had. Defaults collapse by mission state, and a remount key
fixes scrollTop leaking between tabs — a bug that only appears once scrolling
works.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-13 10:47:00 -07:00
Omar SobhandClaude Opus 5 4dec77ae6d fix(missions): give repo-less container missions the workspace they are promised
Every agent on a research_only mission refused to work, each reporting it was
"in Claude Code", had no /mission/repo, and only had Read/Edit/Bash. All three
statements were true. The run still recorded completed — 5 turns, 7.4k tokens,
0 artifacts, no error.

The machinery is correct when a repo IS bound (verified on a live prod
per-mission container: /mission/repo present, all 5 agents pinned). Only the
repo-less path was broken, in three layers that disagreed by construction:

- sync_in no-oped without a host checkout and copy mode does not bind /mission,
  so NOTHING created /mission/repo. The microVM tier already creates it, for the
  stated reason that "the guest needs the workspace to exist before the agent
  writes into it". Creating it host-side also un-breaks sync_out, equally a
  no-op before, so work survives across phases instead of being wiped.
- pin_agent_workspaces returned Ok after pinning ZERO agents, so the
  deliberately-fatal guard in mission_orchestrator could never fire. Its error
  text already described the exact outcome we got.
- The prompt advertised ZeroClaw tool names and explicitly denied `bash`, while
  every executor ends in `claude -p`: microVM passes Read/Edit/Write/Bash/Agent,
  session passes Read/Edit/Write/Bash, and claude_cli agents get Claude Code's
  native toolset — ZeroClaw's gating never reaches the subprocess. It was
  telling agents to use missing tools and avoid present ones.

And it went green because mission_outputs logged the failed collect and
continued — with the fail-empty rule and the NO-OUTPUT marker both BELOW that
continue, so the phase was retried forever and never failed. The retry is now
bounded by a grace window off completed_at.

Verified end to end: mission completed, agent wrote
/mission/repo/research/firecracker_vs_docker.md, collected and registered as a
document artifact (6.6 kB of real content).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-13 10:46:45 -07:00
18 changed files with 516 additions and 199 deletions
+18 -3
View File
@@ -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
+37 -8
View File
@@ -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,21 +106,41 @@ 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} (will retry): {e}"
);
continue;
}
eprintln!(
"mission_outputs: could NOT collect outputs for phase {phase_id} \
of mission {mission_id}: {e}"
"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()
);
continue;
Vec::new()
}
};
+13 -1
View File
@@ -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
+66 -32
View File
@@ -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."
}
+68
View File
@@ -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
+12 -5
View File
@@ -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}
+82
View File
@@ -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 };
}
+17
View File
@@ -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;
}