Files
penguin-harness/packages/web/src/features/chat/chat-page.tsx
T
2026-08-03 21:02:33 +08:00

1280 lines
59 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Chat page (refactored version):
* a thin top toolbar (Session title / status / iconized stats / Agents + Files toggles /
* details popup) + the message stream and input area (input box vertically centered when there
* are no messages).
* Files is no longer a mutually exclusive tab — it's a persistent, closable, resizable docked
* panel on the right (use-files-panel.ts), and each message's trailing file summary card jumps to
* and locates the file in the tree via onOpenFile. The subagents panel docks the same way
* (use-subagents-panel.ts): subagent chips in the stream open it focused via onOpenSubagent,
* and the two docked panels are mutually exclusive (coordinated here, not in the hooks).
* Approval mode and Model/context usage live in the input area's toolbar; context is compacted
* via the /compact slash command.
* Draft state (/chat/new) is carried by DraftView: Agent / Workspace / approval mode / Model are
* chosen before sending, and everything except approval mode is locked once the Session is
* created. The Session list and the new-chat entry point live in the global sidebar.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { useNavigate, useParams } from "react-router";
import type {
AgentSummary,
ApprovalMode,
ModelRefDto,
ModelsResponse,
SkillMetadataItem,
TaskCreateRequest,
TaskInputPart,
} from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { useDocumentTitle } from "../../lib/use-document-title";
import {
formatDateTime,
formatMoney,
humanizeDuration,
humanizeDurationLive,
humanizeTokens,
} from "../../lib/format";
import { latestConversation } from "../../lib/session-grouping";
import { approvalKey, isModelAuthDead } from "../../lib/omni/stream-model";
import type { StreamModel } from "../../lib/omni/stream-model";
import { bucketCostUsd, liveSessionElapsedMs } from "../../lib/omni/task-stats";
import type { BucketPricing, TaskStatsTracker } from "../../lib/omni/task-stats";
import { useTheme } from "../../state/theme";
import { useProject } from "../../state/project";
import { useSessions } from "../../state/sessions";
import { Modal } from "../../components/ui/modal";
import { Button } from "../../components/ui/button";
import { Skeleton } from "../../components/ui/skeleton";
import { Truncated } from "../../components/ui/truncated";
import { Dropdown } from "../../components/ui/dropdown";
import { EmptyState } from "../../components/ui/empty-state";
import { toastError } from "../../components/ui/toast";
import { MessageStream } from "./message-stream";
import type { StreamRenderContext } from "./message-stream";
import { latestTaskHasSubagent, taskStartCount } from "./agent-topology";
import { ChatInput } from "./chat-input";
import { ConversationOutline, OutlineMenuButton, useOutlineRailFit } from "./conversation-outline";
import { DraftView } from "./draft-view";
import { buildInputHistory } from "./input-history";
import { buildOutline } from "./outline-model";
import { GoalStatusBanner } from "./goal-banner";
import { handoffMessage, modelSwitchMessage } from "./agent-handoff";
import { sameModelRef } from "../models/model-grouping";
import { providerInfo } from "@prismshadow/penguin-core/model-catalog";
import { FilesPanel } from "./files-panel";
import { useFilesPanel } from "./use-files-panel";
import type { FilesPanelState } from "./use-files-panel";
import { SubagentsPanel } from "./subagents-panel";
import {
advancePanelTaskScope,
createPanelTaskScope,
useSubagentsPanel,
} from "./use-subagents-panel";
import type { SubagentsPanelState } from "./use-subagents-panel";
import { useSessionDraft } from "./use-session-draft";
import { useSessionStream } from "./use-session-stream";
const STAT_ICONS = {
// Tokens (database / stacked cylinders)
tokens:
"M4 6c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3zm0 0v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3",
// Cost (circled dollar sign)
cost: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zm0-15v12m2.6-9.3c-.5-.8-1.5-1.2-2.6-1.2-1.5 0-2.7.8-2.7 2 0 2.7 5.4 1.3 5.4 4 0 1.2-1.2 2-2.7 2-1.2 0-2.2-.5-2.7-1.4",
// Elapsed time (clock)
elapsed: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zm0-14v5l3 2",
// Files (folder)
folder: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z",
} as const;
/** Iconized stat item: a symbol + a value, with the title giving the full meaning. */
function StatChip({ icon, value, label }: { icon: string; value: ReactNode; label: string }) {
return (
<span
title={label}
className="flex shrink-0 items-center gap-1 font-mono text-xs text-gray-500 dark:text-gray-400"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d={icon} />
</svg>
{value}
</span>
);
}
/**
* Elapsed value for the header statistics: while a Task runs it ticks once per second over the
* live cumulative (settled cross-Task total + the running Task's wall clock so far, see
* liveSessionElapsedMs); when idle no timer runs and it renders exactly the settled total.
* Whole seconds while ticking, decimals only on the settled value — same convention as
* LiveDuration on running tool/thinking cards.
*/
function SessionElapsed({
stats,
taskOpen,
taskStartLocalMs,
}: {
stats: TaskStatsTracker;
taskOpen: boolean;
taskStartLocalMs: number;
}) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!taskOpen) return;
// Load-bearing, not redundant: `now` still holds whatever the state last saw (mount time,
// or the final tick of a previous Task), and the first interval callback is a full second
// away. The first live render after a Task starts must not compute from that stale clock,
// so re-anchor immediately on entering the running state.
setNow(Date.now());
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, [taskOpen]);
if (!taskOpen) return <>{humanizeDuration(stats.sessionElapsedMs)}</>;
return <>{humanizeDurationLive(liveSessionElapsedMs(stats, taskOpen, taskStartLocalMs, now))}</>;
}
/** The header's three statistics — the chip row and the info dropdown render these verbatim. */
interface HeaderStats {
tokensText: string;
/** Formatted session cost; null = nothing to show (no recorded figure and no live estimate). */
costText: string | null;
/** Server-reported "some usage had no pricing" flag from the last idle refetch (the chip's `*`). */
costUncosted: boolean;
elapsedNode: ReactNode;
}
/**
* Computes the header statistics, live while a Task runs:
* - Tokens: session cumulative (main + subagents), already advancing per completed request;
* - Cost: the last idle-refetched session cost, plus — while a Task is open and the session
* Model has pricing — a live estimate converted from this Task's usage buckets. Subagents
* may run on different models, but the estimate applies the main Model's pricing to all
* live buckets: the estimate may be slightly off mid-task, and the idle getUsage refetch
* reconciles to the server-recorded value (kept deliberately simple). Without pricing the
* live addition is skipped (bucketCostUsd returns null, mirroring taskCost's uncosted
* signal) and the value stays exactly as when idle. costText stays null until there is an
* actual figure — no recorded cost plus a still-zero estimate renders nothing, not $0.00;
* - Elapsed: ticking cumulative while running, settled cumulative when idle (SessionElapsed).
*/
function headerStats(
model: StreamModel,
sessionCost: number | null,
costUncosted: boolean,
pricing: BucketPricing | undefined,
currency: "USD" | "CNY",
): HeaderStats {
const stats = model.stats;
const liveCost = model.taskOpen
? bucketCostUsd(
{
cacheRead: stats.taskCacheRead,
cacheWrite: stats.taskCacheWrite,
output: stats.taskOutput,
},
pricing,
)
: null;
// Only take the live path once it has something to say — a positive estimate, or a recorded
// session cost to keep showing from the Task's first instant. On a brand-new session,
// sessionCost is still null and liveCost is 0 until the first token_usage lands; blindly
// summing would flash a formatted $0.00 the moment the Task starts — exactly the "cost is
// zero or something's broken" reading the chip's render conditional exists to avoid.
const costUsd =
liveCost != null && (liveCost > 0 || sessionCost != null)
? (sessionCost ?? 0) + liveCost
: sessionCost;
return {
tokensText: humanizeTokens(stats.sessionTotal + stats.subagentTotal),
costText: costUsd != null ? formatMoney(costUsd, currency) : null,
costUncosted,
elapsedNode: (
<SessionElapsed
stats={stats}
taskOpen={model.taskOpen}
taskStartLocalMs={model.taskStartLocalMs}
/>
),
};
}
/**
* Route id for a draft chat (`/chat/new`): the Session hasn't been persisted yet — the user may
* still want to change the model or configure a key first. The actual Session is only created
* once **the first message is sent** (once created, the model is locked into its meta).
* Real session ids always start with `session-`, so there's no collision with this constant.
*/
export const DRAFT_SESSION_ID = "new";
/**
* Server-enforced ceiling on paths per files/stat call (STAT_MAX_PATHS in the sessions routes,
* which 400s above it). A Task-level summary aggregates candidates across the whole Task and can
* exceed it, so cache misses are checked in chunks of this size.
*/
const STAT_PATHS_PER_REQUEST = 100;
export function ChatPage() {
const navigate = useNavigate();
const params = useParams<{ sessionId?: string }>();
const { currency } = useTheme();
const { currentProject, currentAgent, setCurrentAgentId, reloadAgents, agents } = useProject();
const projectId = currentProject?.projectId ?? null;
const agentId = currentAgent?.agentId ?? null;
const {
sessions,
loading: sessionsLoading,
reload: reloadSessions,
add: addSession,
replace,
setStatus,
setTitle,
} = useSessions();
const [sessionCost, setSessionCost] = useState<number | null>(null);
const [costUncosted, setCostUncosted] = useState(false);
const [credentialGuide, setCredentialGuide] = useState(false);
const [infoOpen, setInfoOpen] = useState(false);
const [modeSaving, setModeSaving] = useState(false);
const [models, setModels] = useState<ModelsResponse | null>(null);
// Per-turn thinking level, local per-session UI state: "" = untouched — the picker then
// displays the Agent config's level and postTask omits thinkingLevel (auto-follow: the
// server/core fallback applies, so mid-session Agent-config edits keep taking effect).
// Once the user picks a level it sticks for the session and rides on every subsequent
// postTask. Never written through to the Agent config (that behavior stays draft-only).
const [turnThinkingLevel, setTurnThinkingLevel] = useState("");
const routeSessionId = params.sessionId ?? null;
const filesPanelRaw = useFilesPanel(routeSessionId);
const subagentsPanelRaw = useSubagentsPanel(routeSessionId);
// The two docked panels are MUTUALLY EXCLUSIVE — side by side they'd crush the chat column at
// the 1024px breakpoint (and two stacked Sheets on mobile would be worse). Exclusivity is
// enforced here, on the panel objects every consumer receives, rather than at each call site:
// ANY path that opens one panel (toolbar toggles, message file cards via onOpenFile, subagent
// chips via onOpenSubagent, or a future caller) closes the other as a side effect of
// setOpen(true). Closing never cascades. The hooks stay uncoordinated on purpose — they don't
// know about each other; only this page, which owns both, does.
const filesPanel: FilesPanelState = {
...filesPanelRaw,
setOpen: (next: boolean) => {
if (next) subagentsPanelRaw.setOpen(false);
filesPanelRaw.setOpen(next);
},
};
const subagentsPanel: SubagentsPanelState = {
...subagentsPanelRaw,
setOpen: (next: boolean) => {
if (next) filesPanelRaw.setOpen(false);
subagentsPanelRaw.setOpen(next);
},
};
const draft = routeSessionId === DRAFT_SESSION_ID;
const selected = draft ? null : (sessions.find((s) => s.sessionId === routeSessionId) ?? null);
// Currently effective model (session state, the model reference comes from the Session DTO): model selection in draft state is handled internally by DraftView.
const activeModelRef = selected
? { provider: selected.provider, modelId: selected.modelId }
: null;
// Tab title follows the current Session (refreshes in sync once the auto-generated title arrives).
useDocumentTitle(selected ? (selected.title ?? S.chat.defaultSessionTitle) : S.nav.chat);
const stream = useSessionStream(
selected?.sessionId ?? null,
selected?.status ?? "idle",
setTitle,
// Sub-session registration notice (session_created is pushed over the parent session's channel): reload the list so it appears immediately.
() => void reloadSessions(),
);
// Chat input area draft: caches text, both staged switch chips (`/agent` target, `/model`
// target) and the selected skills keyed by sessionId; restored after navigating away and back
// or a refresh, discarded on successful send.
const {
initial: sessionDraft,
onTextChange: onDraftTextChange,
onHandoffTargetChange: onDraftHandoffChange,
onPendingModelChange: onDraftPendingModelChange,
onSkillsChange: onDraftSkillsChange,
discard: discardSessionDraft,
} = useSessionDraft(selected?.sessionId ?? null);
// Derivations over the stream items (the model mutates in place, so `version` — its own
// repaint signal — keys the memos; the session id covers a switch racing a same-valued
// version): the composer's ↑/↓ recall list and the left outline's entries.
const inputHistory = useMemo(
() => buildInputHistory(stream.model.items),
// eslint-disable-next-line react-hooks/exhaustive-deps
[stream.version, routeSessionId],
);
const outline = useMemo(
() => buildOutline(stream.model.items),
// eslint-disable-next-line react-hooks/exhaustive-deps
[stream.version, routeSessionId],
);
// The message stream's scroll container, exposed by MessageStream for the outline's
// jump/scrollspy (anchors are queried inside it, never document-wide).
const streamScrollRef = useRef<HTMLDivElement | null>(null);
// Which outline shape fits: the gutter tick rail, or (exactly when it can't show —
// phones without hover, and any window whose gutter a docked panel ate) the toolbar's
// dropdown index button.
const railFit = useOutlineRailFit(streamScrollRef, stream.version);
// Current Agent follows the Session in the route (keeps the sidebar and stats aligned on deep
// links / refresh). Only aligns when **the selected Session changes** — never put agentId in
// the dependency array: otherwise, when switching from a "running session" to a new chat with
// a different Agent, navigate and setCurrentAgentId aren't in the same batch — a transitional
// render of "new agentId + old route (old session still selected)" would appear first, and
// this effect would then flip the Agent back to the old session's Agent based on that,
// causing the new chat to end up created on the old Agent.
const selectedSessionId = selected?.sessionId ?? null;
const selectedAgentId = selected?.agentId ?? null;
useEffect(() => {
if (selectedSessionId && selectedAgentId) setCurrentAgentId(selectedAgentId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSessionId, selectedAgentId, setCurrentAgentId]);
// A NEW chat starts with both panels closed: a panel opened for an earlier conversation must
// not carry into a freshly created one. The draft is the reset point — it renders no panels
// itself, so the Session created from it (first send navigates to /chat/:id) begins closed,
// while a plain conversation switch keeps whatever the user had open. This effect owns the
// ONLY automatic close of either panel.
useEffect(() => {
if (!draft) return;
filesPanelRaw.setOpen(false);
subagentsPanelRaw.setOpen(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draft]);
// Subagents panel AUTO-OPEN (the one visibility rule this panel has beyond the Files panel's):
// the pure tracker (advancePanelTaskScope, unit-tested) opens it on the CURRENT task's first
// live spawn, re-armed at every task boundary so a manual close is respected until the next
// one. Boundaries themselves no longer close anything — an open panel now survives Session
// switches and new Tasks alike, matching the Files panel.
// The auto-open applies only when docked (a mobile Sheet sliding over the conversation
// uninvited would be worse than staying discoverable via the row), never over an open Files
// panel (an automatic open must not steal an explicit one — the row and the toolbar's amber
// dot still signal), and never re-triggers an already-open panel (that would yank a pinned
// historical graph back to the latest Task); the tracker consumes the attempt regardless.
const panelTaskScopeRef = useRef(createPanelTaskScope());
const taskCount = taskStartCount(stream.model.items);
const liveSpawn = stream.taskState !== "idle" && latestTaskHasSubagent(stream.model);
useEffect(() => {
const action = advancePanelTaskScope(panelTaskScopeRef.current, {
sessionId: selectedSessionId,
taskCount,
liveSpawn,
});
if (
action === "autoOpen" &&
subagentsPanelRaw.isDocked &&
!subagentsPanelRaw.open &&
!filesPanelRaw.open
) {
subagentsPanelRaw.setOpen(true);
}
// The panel objects are rebuilt every render; the tracker only acts on real transitions of
// these three observed values.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSessionId, taskCount, liveSpawn]);
// Skills installed on the session's Agent (candidates for the input area's skill dropdown):
// fetched keyed on the session's Agent; on switch, cleared first (which also clears the input
// area's selection) before refetching; a failed fetch is silently treated as no skills.
// Clearing preserves reference identity (an already-empty array isn't replaced), matching
// draft-view's convention.
const [agentSkills, setAgentSkills] = useState<SkillMetadataItem[]>([]);
useEffect(() => {
setAgentSkills((prev) => (prev.length > 0 ? [] : prev));
if (!projectId || !selectedAgentId) return;
let cancelled = false;
api
.getAgentSkills(projectId, selectedAgentId)
.then((res) => {
if (!cancelled) setAgentSkills(res.skills);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [projectId, selectedAgentId]);
// The session Agent's configured thinking level ("" = unset/loading), via the same
// agent-config endpoint the draft picker uses: the in-session picker DISPLAYS this while
// the user hasn't picked a level (auto-follow — sending still omits the level until
// touched, see turnThinkingLevel). Refetched when the session's Agent changes; a failed
// fetch leaves it unset (the picker then shows an em dash until picked).
const [agentThinkingLevel, setAgentThinkingLevel] = useState("");
useEffect(() => {
setAgentThinkingLevel("");
if (!projectId || !selectedAgentId) return;
let cancelled = false;
api
.getAgentConfig(projectId, selectedAgentId)
.then((res) => {
if (!cancelled) setAgentThinkingLevel(res.config.model?.thinkingLevel ?? "");
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [projectId, selectedAgentId]);
// The Session list is paged: a deep-linked Session (old bookmark, cross-page jump) may sit
// beyond the loaded pages. Look it up directly and insert it before the auto-select effect
// below concludes it doesn't exist; only a failed probe releases that redirect.
const [probeFailedId, setProbeFailedId] = useState<string | null>(null);
useEffect(() => {
if (draft || !routeSessionId || sessionsLoading) return;
if (sessions.some((s) => s.sessionId === routeSessionId)) return;
let cancelled = false;
api.getSession(routeSessionId).then(
(res) => {
if (!cancelled) addSession(res.session);
},
() => {
if (!cancelled) setProbeFailedId(routeSessionId);
},
);
return () => {
cancelled = true;
};
}, [draft, routeSessionId, sessionsLoading, sessions, addSession]);
// Auto-select the most recent conversation when the route doesn't select one (newest loaded
// active/schedule Session — archived rows are hidden by choice and subagent Sessions belong
// to their parent, so neither is auto-opened); if there is none, fall back to draft state
// (instead of auto-creating one).
useEffect(() => {
if (sessionsLoading || draft) return;
if (routeSessionId && sessions.some((s) => s.sessionId === routeSessionId)) return;
// A routed id missing from the paged list isn't gone until the direct lookup fails.
if (routeSessionId && probeFailedId !== routeSessionId) return;
const last = latestConversation(sessions);
navigate(last ? `/chat/${last.sessionId}` : `/chat/${DRAFT_SESSION_ID}`, { replace: true });
}, [sessionsLoading, draft, routeSessionId, probeFailedId, sessions, navigate]);
// Sync task_state to the sidebar list badge.
useEffect(() => {
if (selected) setStatus(selected.sessionId, stream.taskState);
}, [stream.taskState, selected, setStatus]);
// Task returns from running/compacting to idle: this turn may have spawned a sub-session or
// auto-created a new Agent — reload the session and Agent lists so they appear in the sidebar
// immediately (no manual refresh needed).
const prevTaskRef = useRef(stream.taskState);
useEffect(() => {
const prev = prevTaskRef.current;
prevTaskRef.current = stream.taskState;
if (prev !== "idle" && stream.taskState === "idle") {
void reloadSessions();
void reloadAgents();
}
}, [stream.taskState, reloadSessions, reloadAgents]);
// Positive-only existence cache for file summary cards (session-level): normalized relative
// path -> true, or the shared in-flight lookup. Missing files aren't retained — a later Task may
// create the same path, so its summary must re-check instead of inheriting stale false state.
const statCacheRef = useRef(new Map<string, true | Promise<boolean>>());
// Session switch: resets the cost, the file-card existence cache, and the per-turn thinking
// level (it's per-session UI state), avoiding stale data from the previous Session (Files
// panel state resets itself keyed on sessionId inside use-files-panel).
useEffect(() => {
setSessionCost(null);
setCostUncosted(false);
setTurnThinkingLevel("");
statCacheRef.current = new Map();
}, [routeSessionId]);
// Batched existence check for file summaries: cache stable positive results and share in-flight
// requests, but never retain a negative result. Each pending lookup mutates the cache only while
// it is still the current entry, so an old request can't delete or overwrite a newer one.
const statFiles = useCallback(
async (paths: string[]): Promise<ReadonlySet<string>> => {
const sessionId = selected?.sessionId ?? null;
const cache = statCacheRef.current;
const misses = sessionId === null ? [] : paths.filter((p) => !cache.has(p));
if (sessionId !== null && misses.length > 0) {
for (let i = 0; i < misses.length; i += STAT_PATHS_PER_REQUEST) {
const chunk = misses.slice(i, i + STAT_PATHS_PER_REQUEST);
const batch = api
.statSessionFiles(sessionId, chunk)
.then((res) => new Set(res.existing))
.catch(() => null);
for (const p of chunk) {
const pending = batch.then((existing) => existing?.has(p) ?? false);
cache.set(p, pending);
void pending.then((exists) => {
if (cache.get(p) !== pending) return;
if (exists) cache.set(p, true);
else cache.delete(p);
});
}
}
}
const result = new Set<string>();
await Promise.all(
paths.map(async (p) => {
const hit = cache.get(p);
if (hit === true || (hit instanceof Promise && (await hit))) result.add(p);
}),
);
return result;
},
[selected?.sessionId],
);
// Session's cumulative cost: refreshed on entry and every time it returns to idle (cost is computed by the server in real time based on current pricing).
useEffect(() => {
if (!projectId || !selected || stream.taskState !== "idle") return;
let cancelled = false;
api
.getUsage(projectId, { groupBy: "session", agentId: selected.agentId })
.then((res) => {
if (cancelled) return;
const row = res.groups.find((g) => g.key === selected.sessionId);
setSessionCost(row?.cost ?? null);
setCostUncosted(row?.hasUncosted ?? false);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [projectId, selected, stream.taskState]);
// Model config (context window + credential guide): fetched once per Project.
//
// The credential guide **only ever nags once per lifetime** (first entry after registration):
// gated by the server prefs' credentialGuideSeen — previously it checked "default model has no
// key" and popped up a dialog on every visit to the chat page, which was repeated nagging for
// users who simply don't intend to configure a key / use environment variables instead.
useEffect(() => {
if (!projectId) return;
let cancelled = false;
setModels(null);
void (async () => {
try {
const res = await api.getModels(projectId);
if (cancelled) return;
setModels(res);
const { prefs } = await api.getPrefs();
if (cancelled || prefs.credentialGuideSeen) return;
const def = res.models.find((m) => sameModelRef(m, res.defaultModel));
const missing = !res.defaultModel || !def?.credential?.apiKeyMasked;
if (missing) setCredentialGuide(true);
// Mark as "seen" regardless of whether the dialog actually popped up: only ever once.
void api.putPrefs({ credentialGuideSeen: true }).catch(() => undefined);
} catch {
// A failed fetch doesn't affect the rest of the page.
}
})();
return () => {
cancelled = true;
};
}, [projectId]);
// Self-heal: the server returned a new session_id, update the route and list (shared by tasks and compact).
const syncHealedSessionId = useCallback(
async (currentId: string, respondedId: string) => {
if (respondedId === currentId) return;
await reloadSessions();
navigate(`/chat/${respondedId}`, { replace: true });
},
[reloadSessions, navigate],
);
const onSend = useCallback(
async (input: TaskInputPart[], goal: { budget: number } | null): Promise<boolean> => {
if (!selected) return false;
try {
// An explicitly picked per-turn thinking level rides on each task; "" (untouched)
// sends nothing — the server/core falls back to the Agent config, so config edits
// keep taking effect mid-session until the user pins a level.
const res = await api.postTask(selected.sessionId, {
input,
...(goal ? { goal } : {}),
...(turnThinkingLevel
? { thinkingLevel: turnThinkingLevel as TaskCreateRequest["thinkingLevel"] }
: {}),
});
discardSessionDraft();
await syncHealedSessionId(selected.sessionId, res.sessionId);
return true;
} catch (e) {
// Returning false -> the input area keeps the draft, letting the user fix it and resend (the error copy includes the session model's upstream id).
toastError(apiErrorText(e, { modelId: selected.modelId }));
return false;
}
},
[selected, turnThinkingLevel, discardSessionDraft, syncHealedSessionId],
);
// /model switch (handoff-style, mirroring onHandoff exactly): opens a NEW session for the
// SAME agent on the picked model via the normal createSession API — deliberately with the
// SOURCE session's Workspace, so files the conversation refers to stay reachable — then
// posts a first task whose input starts with a [model_switch_from] source block (source
// session id / its latest trace path / workspace / previous model pair) followed by the
// user's remainder text and images. The earlier history is NOT injected into the new
// context (some models require thinking payloads and provider fidelity byte-for-byte on
// history replay, which cannot cross models); the model reads the source trace file itself
// when it needs the context. Returns false on failure, keeping the draft so it can be
// resent (the empty session that never got its first message is deleted, like handoff).
const onSwitchModel = useCallback(
async (ref: ModelRefDto, input: TaskInputPart[]): Promise<boolean> => {
if (!projectId || !selected) return false;
// The source's latest trace file path comes from the single-session GET (list rows
// don't carry it); best-effort — a brand-new source has no trace, the block then
// simply omits the line.
const tracePath = await api
.getSession(selected.sessionId)
.then((res) => res.session.tracePath)
.catch(() => undefined);
const origin: TaskInputPart = {
type: "text",
text: modelSwitchMessage({
sessionId: selected.sessionId,
...(selected.title !== undefined ? { sessionTitle: selected.title } : {}),
...(tracePath !== undefined ? { tracePath } : {}),
workspace: selected.workspace,
prevProvider: selected.provider,
prevModelId: selected.modelId,
}),
};
let createdId: string | null = null;
try {
const created = await api.createSession(projectId, selected.agentId, {
provider: ref.provider,
modelId: ref.modelId,
workspace: selected.workspace,
approvalMode: selected.approvalMode,
});
createdId = created.session.sessionId;
const res = await api.postTask(createdId, { input: [origin, ...input] });
addSession(created.session);
// The remainder text has been carried into the new chat: discard the source session's input draft along with it.
discardSessionDraft();
navigate(`/chat/${res.sessionId}`);
return true;
} catch (e) {
if (createdId) void api.deleteSession(createdId).catch(() => undefined);
toastError(apiErrorText(e, { modelId: ref.modelId }));
return false;
}
},
[projectId, selected, addSession, discardSessionDraft, navigate],
);
// /agent handoff: doesn't use the current Session — creates a new chat for the picked agent
// (approval mode carries over from the input area's current value; model/Workspace use the
// creation defaults). The first input = a [handoff_from] source block (current agent / Session
// / Workspace info) + the user's input and images; jumps to the new
// chat once sent.
// Returns false on failure, keeping the draft so it can be resent (deletes the empty Session that never got its first message sent).
const onHandoff = useCallback(
async (target: AgentSummary, input: TaskInputPart[]): Promise<boolean> => {
if (!projectId || !currentAgent || !selected) return false;
const origin: TaskInputPart = {
type: "text",
text: handoffMessage({
agentId: currentAgent.agentId,
...(currentAgent.name !== undefined ? { agentName: currentAgent.name } : {}),
sessionId: selected.sessionId,
workspace: selected.workspace,
...(selected.title !== undefined ? { sessionTitle: selected.title } : {}),
}),
};
let createdId: string | null = null;
try {
const created = await api.createSession(projectId, target.agentId, {
approvalMode: selected.approvalMode,
});
createdId = created.session.sessionId;
const res = await api.postTask(createdId, { input: [origin, ...input] });
addSession(created.session);
// The text body has been handed off into the new chat: discard the current session's input draft along with it.
discardSessionDraft();
navigate(`/chat/${res.sessionId}`);
return true;
} catch (e) {
if (createdId) void api.deleteSession(createdId).catch(() => undefined);
// The new chat uses the project's default model (createSession doesn't specify a model reference), so the error copy's model context follows suit.
toastError(
apiErrorText(e, models?.defaultModel ? { modelId: models.defaultModel.modelId } : {}),
);
return false;
}
},
[projectId, currentAgent, selected, addSession, discardSessionDraft, navigate, models],
);
const onStop = useCallback(async () => {
if (!selected) return;
await api.postAbort(selected.sessionId).catch(() => undefined);
}, [selected]);
// Follow-up queue: post the full input with queueIfBusy — a busy session holds it
// server-side and auto-sends it as an ordinary next task once this run finishes (the
// "N queued" count arrives via task_state). Succeeds either way (queued or started
// directly in the completion race), so the input area clears the draft on true.
const onQueueFollowUp = useCallback(
async (input: TaskInputPart[]): Promise<boolean> => {
if (!selected) return false;
try {
const res = await api.postTask(selected.sessionId, { input, queueIfBusy: true });
discardSessionDraft();
await syncHealedSessionId(selected.sessionId, res.sessionId);
return true;
} catch (e) {
toastError(apiErrorText(e, { modelId: selected.modelId }));
return false;
}
},
[selected, discardSessionDraft, syncHealedSessionId],
);
// Mid-run steering: the message is queued on the server and delivered between turns as a
// standalone `[user_steering]` user message followed by its images (visible once they
// arrive over SSE / from the Trace); file attachments land in the Session scratchpad and
// ride the steering text as `[attached file: <path>]` lines, exactly as a task's do. On
// "queued" the localStorage draft is discarded, like a successful send — without this a
// reload resurrects the already-sent text as a draft, and re-sending it duplicates the
// steering message (#136). "not_running" (409) means no Task is in progress anymore (race
// with completion): the input area then falls back to its **full** normal send path —
// skills and the whole draft included — rather than a text+images task.
const onSteer = useCallback(
async (
text: string,
images: string[] = [],
files: { fileName: string; dataUrl: string }[] = [],
): Promise<"queued" | "not_running" | "failed"> => {
if (!selected) return "failed";
try {
await api.postSteer(selected.sessionId, {
text,
...(images.length > 0 ? { images } : {}),
...(files.length > 0 ? { files } : {}),
});
discardSessionDraft();
return "queued";
} catch (e) {
if (e instanceof ApiError && e.status === 409) return "not_running";
toastError(apiErrorText(e, { modelId: selected.modelId }));
return "failed";
}
},
[selected, discardSessionDraft],
);
const onApprove = useCallback(
async (toolCallId: string, decision: "allow" | "deny", origin: string[]) => {
if (!selected) return;
// A decision clicked locally is marked "manual"; removed from the pending table keyed by the origin composite key.
stream.markLocalDecision(toolCallId);
const key = approvalKey(origin, toolCallId);
try {
await api.postApproval(selected.sessionId, toolCallId, { decision });
stream.resolveApproval(key);
} catch (e) {
if (e instanceof ApiError && e.status === 404) stream.resolveApproval(key);
}
},
[selected, stream],
);
const onChangeApprovalMode = useCallback(
(mode: ApprovalMode) => {
if (!selected || modeSaving) return;
setModeSaving(true);
void api
.patchSession(selected.sessionId, { approvalMode: mode })
.then((res) => replace(res.session))
.catch((e: unknown) => {
toastError(apiErrorText(e));
})
.finally(() => setModeSaving(false));
},
[selected, modeSaving, replace],
);
const onCompact = useCallback(async () => {
if (!selected) return;
try {
// compact shares get-or-resume-or-heal with tasks: it can likewise self-heal to a new session_id.
const res = await api.postCompact(selected.sessionId);
await syncHealedSessionId(selected.sessionId, res.sessionId);
} catch (e) {
toastError(apiErrorText(e, { modelId: selected.modelId }));
}
}, [selected, syncHealedSessionId]);
// "New Chat" = enter draft state: no Session is created until the first message is sent.
const newChat = useCallback(() => {
navigate(`/chat/${DRAFT_SESSION_ID}`);
}, [navigate]);
// Auth-dead notice primary CTA: the Models page is where the credential is actually fixed.
const openModels = useCallback(() => {
navigate("/models");
}, [navigate]);
// Real-time cost for this turn: converts the Task's bucketed usage using the session Model's
// (paired reference) current pricing; null if no pricing is configured.
const modelPricing = models?.models.find((m) => sameModelRef(m, activeModelRef))?.pricing;
const ctx: StreamRenderContext = {
pendingApprovals: stream.pendingApprovals,
onApprove,
origin: [],
// Any non-idle state (running / compacting) counts as "not yet stopped": compaction can
// happen mid-turn, and if only running were checked, the trailing group would flash
// "finished running" during compaction before flipping back to "running".
taskRunning: stream.taskState !== "idle",
taskCost: (stats) => bucketCostUsd(stats.tokensByBucket, modelPricing),
// Reconnect countdown controls (live waiting state only): retry-now skips the
// remaining backoff server-side (benign no-op on timing races), give-up is the
// ordinary session abort — the engine's abort-during-backoff path ends the turn.
onRetryNow: () => {
if (selected) void api.postRetryNow(selected.sessionId).catch(() => undefined);
},
onGiveUp: () => {
void onStop();
},
onOpenFile: (path) => {
// The file card has already normalized the text path to a Workspace-relative path
// (toWorkspaceRelative, including stripping absolute-path prefixes and converting Windows
// separators), so this just opens the panel and navigates to it directly (the wrapped
// setOpen already closes the subagents panel — see the exclusivity block above).
filesPanel.setOpen(true);
filesPanel.browsePath(path);
},
onOpenSubagent: (sessionId, origin) => {
// Chip click: open the panel focused on that child (the focus chain ends with the child's
// own id; the wrapped setOpen closes the Files panel). focusSubagent after setOpen: the
// open resets the Task scope to "latest", and the focus then pins it to this chip's Task.
subagentsPanel.setOpen(true);
subagentsPanel.focusSubagent(sessionId, [...origin, sessionId]);
},
workspace: selected?.workspace ?? null,
statFiles,
};
// Any pending approval sitting inside a subagent (approvalKey = "originChain toolCallId";
// main-session keys start with a space): surfaces an amber dot on the toolbar button so a
// nested approval stays discoverable while the panel is closed.
const anySubagentPending = [...stream.pendingApprovals.keys()].some((k) => !k.startsWith(" "));
if (!projectId || !agentId) {
return (
<div className="p-6">
<Skeleton className="h-6 w-64" />
</div>
);
}
// Header statistics (chip row + info dropdown), live while a Task runs; recomputed every
// stream version bump, so the in-place-mutated model stats always read fresh.
const hs = headerStats(stream.model, sessionCost, costUncosted, modelPricing, currency);
const modelInfo = models?.models.find((m) => sameModelRef(m, activeModelRef));
const contextWindow = modelInfo?.contextWindow;
// Assumed supported by default: only models explicitly marked vision=false show a blocking hint when adding images.
const vision = modelInfo?.vision !== false;
const emptyChat =
selected !== null && !stream.loading && !stream.error && stream.model.items.length === 0;
// Auth-dead gate (recoverable): an auth failure is on record AND the Project's credentials
// have not been updated since — only the model reference is fixed at creation, credentials
// come from the current Project config, so a key update (Models page) unlocks the session
// (live via the credentials_updated event; across reloads via this time comparison against
// the models response's updatedAt).
const credsUpdatedMs = models?.updatedAt !== undefined ? Date.parse(models.updatedAt) : NaN;
const modelAuthDead = isModelAuthDead(
stream.lastAuthFailureMs,
Number.isFinite(credsUpdatedMs) ? credsUpdatedMs : null,
);
// Input area in session state: Agent / Workspace / Model are already locked by the Session
// (the model selector isn't rendered; models feeds the locked model's read-only display and
// the /model switch picker) — approval mode and the per-turn thinking level stay editable;
// /model forks the conversation onto another model.
const input = selected && (
<ChatInput
status={stream.taskState}
modelAuthDead={modelAuthDead}
onOpenModels={openModels}
onRetryModelAuth={stream.dismissModelAuthDead}
onNewSession={newChat}
onSend={onSend}
onSteer={onSteer}
// Count of steering messages already visible in the stream: the input area keeps its
// "queued" indicator up until this count increases (i.e. the steering message arrived).
steeringDeliveredCount={stream.model.items.filter((i) => i.kind === "user_steering").length}
pendingSteering={stream.pendingSteering}
onQueueFollowUp={onQueueFollowUp}
queuedFollowUps={stream.queuedFollowUps}
onStop={onStop}
onCompact={onCompact}
modelRef={activeModelRef}
{...(models !== null ? { models: models.models } : {})}
{...(models?.defaultModel !== undefined ? { defaultModel: models.defaultModel } : {})}
onSwitchModel={onSwitchModel}
// Display value: the user's pick for this session, else the Agent config's level
// (auto-follow while untouched; the send path uses the raw pick — see onSend).
turnThinkingLevel={turnThinkingLevel || agentThinkingLevel}
onChangeTurnThinkingLevel={setTurnThinkingLevel}
{...(contextWindow !== undefined ? { contextWindow } : {})}
contextNow={stream.model.stats.contextNow}
contextStale={stream.model.stats.contextStale}
vision={vision}
approvalMode={selected.approvalMode}
onChangeApprovalMode={onChangeApprovalMode}
modeSaving={modeSaving}
autoFocus
agents={agents}
currentAgentId={selected.agentId}
skills={agentSkills}
{...(sessionDraft.skills && sessionDraft.skills.length > 0
? { initialSkills: sessionDraft.skills }
: {})}
onSkillsChange={onDraftSkillsChange}
onHandoff={onHandoff}
initialText={sessionDraft.text ?? ""}
onTextChange={onDraftTextChange}
history={inputHistory}
{...(sessionDraft.handoffAgentId
? { initialHandoffTargetId: sessionDraft.handoffAgentId }
: {})}
onHandoffTargetChange={onDraftHandoffChange}
{...(sessionDraft.switchModelRef
? { initialPendingModelRef: sessionDraft.switchModelRef }
: {})}
onPendingModelChange={onDraftPendingModelChange}
/>
);
return (
<div className="flex h-full flex-col bg-white dark:bg-gray-950">
{/* Thin top toolbar */}
{selected && (
<div className="flex shrink-0 items-center gap-2.5 border-b border-gray-200 px-3 py-2 md:px-4 dark:border-gray-800">
<div className="flex min-w-0 flex-1 items-center gap-3">
<h1 className="flex min-w-0 text-[15px] font-semibold">
<Truncated text={selected.title ?? S.chat.defaultSessionTitle} />
</h1>
{/* Running indicator (placed to the right of the title); the compacting state is shown separately by the compaction banner within the message stream, not repeated here.
Below sm only the pulsing dot remains (title carries the wording) — the text would eat the title's room on phones. */}
{stream.taskState === "running" && (
<span
title={S.chat.statusRunning}
className="flex shrink-0 items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400"
>
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" />
<span className="hidden sm:inline">{S.chat.statusRunning}</span>
</span>
)}
</div>
{/* Stats: Token / cost / elapsed time (icon + title for the full meaning) */}
<div className="hidden items-center gap-3 sm:flex">
<StatChip
icon={STAT_ICONS.tokens}
value={hs.tokensText}
label={`${S.chat.statTokens}(Token)`}
/>
{/* When there's no cost (the Model has no pricing configured), don't render this stat
at all, rather than showing a "—" — that would take up space while saying
nothing, only making people think the cost is zero or something's broken. */}
{hs.costText != null && (
<StatChip
icon={STAT_ICONS.cost}
value={`${hs.costText}${hs.costUncosted ? " *" : ""}`}
label={`${S.common.cost}(${currency})${hs.costUncosted ? ` · ${S.usage.uncostedNote}` : ""}`}
/>
)}
<StatChip icon={STAT_ICONS.elapsed} value={hs.elapsedNode} label={S.chat.statElapsed} />
</div>
{/* Subagents panel toggle: latest-Task call graph + child conversations dock on the right (use-subagents-panel.ts); opening closes the Files panel (wrapped setOpen). */}
<button
type="button"
aria-expanded={subagentsPanel.open}
onClick={() => subagentsPanel.setOpen(!subagentsPanel.open)}
title={S.chat.openAgents}
aria-label={S.chat.openAgents}
className={`flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium transition-colors duration-150 ${
subagentsPanel.open
? "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
: "text-gray-500 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
}`}
>
{/* Nodes/network glyph (spawn tree). */}
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
aria-hidden
>
<circle cx="5" cy="12" r="2.5" />
<circle cx="19" cy="5.5" r="2.5" />
<circle cx="19" cy="18.5" r="2.5" />
<path d="M7.4 11 16.7 6.6M7.4 13l9.3 4.4" />
</svg>
{/* Below sm the button is icon-only (title/aria keep the name), same rule as the
workspace button next to it: the label ate the title's room on phones. */}
<span className="hidden sm:inline">{S.chat.openAgents}</span>
{/* A pending approval inside a subagent: amber dot (the chip in the stream carries the accessible announcement). */}
{anySubagentPending && (
<span aria-hidden className="h-1.5 w-1.5 rounded-full bg-amber-500" />
)}
</button>
{/* Files panel toggle: docks on the right of the chat instead of replacing it full-screen (use-files-panel.ts); opening closes the subagents panel (wrapped setOpen). */}
<button
type="button"
aria-expanded={filesPanel.open}
onClick={() => filesPanel.setOpen(!filesPanel.open)}
title={S.chat.openWorkspace}
aria-label={S.chat.openWorkspace}
className={`flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium transition-colors duration-150 ${
filesPanel.open
? "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
: "text-gray-500 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
}`}
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
aria-hidden
>
<path d={STAT_ICONS.folder} />
</svg>
{/* Below sm the button is icon-only (title/aria keep the name): the label plus the
running indicator squeezed the session title to nothing on phones. */}
<span className="hidden sm:inline">{S.chat.openWorkspace}</span>
</button>
{/* Conversation index fallback: exactly when the gutter tick rail can't show
(phones without a hover pointer; a desktop window whose gutter a docked panel
ate) the index moves up here as a dropdown — navigation stays reachable. */}
{!railFit.shown && (
<OutlineMenuButton
entries={outline}
scrollRef={streamScrollRef}
running={stream.taskState !== "idle"}
/>
)}
{/* Details popup: Model / Workspace / created time / stats */}
<Dropdown
open={infoOpen}
setOpen={setInfoOpen}
menuClass="right-0 top-full mt-1 w-80 max-w-[calc(100vw-1.5rem)] origin-top-right"
button={
<button
type="button"
title={S.chat.infoPanel}
onClick={() => setInfoOpen(!infoOpen)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5m0-8h.01" />
</svg>
</button>
}
>
<div className="space-y-3 px-3.5 py-2.5 text-sm">
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
{S.chat.model}
</p>
{/* Paired display: upstream model_id + provider name (two separate fields on the Session DTO). */}
<p className="truncate text-xs">
<span className="font-mono">{selected.modelId}</span>
<span className="ml-1.5 text-gray-400 dark:text-gray-500">
{providerInfo(selected.provider)?.label ?? selected.provider}
</span>
</p>
</div>
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
{S.chat.workspace}
</p>
<p className="break-all font-mono text-xs leading-5">{selected.workspace}</p>
</div>
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
{S.common.created}
</p>
<p className="font-mono text-xs">{formatDateTime(selected.createdAt)}</p>
</div>
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
{S.chat.sessionStats}
</p>
{/* Same as above: if there's no cost, the whole item is omitted, not left as "Cost —". */}
<p className="font-mono text-xs">
{S.chat.statTokens} {hs.tokensText}
{hs.costText != null && ` · ${S.common.cost} ${hs.costText}`} ·{" "}
{S.chat.statElapsed} {hs.elapsedNode}
</p>
</div>
</div>
</Dropdown>
</div>
)}
{/* Body: chat column + the docked panels on the right (message file cards jump to and locate a file in the tree via onOpenFile). */}
<div className="flex min-h-0 flex-1">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{draft ? (
// Draft state: DraftView's vertically centered input card + Agent / Workspace
// selection panel; the Session is only created once the first message is sent. Keyed
// by Project: switching Project remounts and switches to that Project's draft cache
// (Agent selection happens inside the draft itself, so it's no longer part of the key).
<DraftView key={`draft:${projectId}`} projectId={projectId} models={models} />
) : (
// Keyed by Session: the whole block does a light fade-in when switching sessions.
<div
key={selected?.sessionId ?? "empty"}
className="anim-fade flex min-h-0 flex-1 flex-col"
>
{selected ? (
stream.error ? (
// History failed to load: show a clear error and a retry entry point, instead of staying on a misleading empty state.
<div className="flex h-full flex-col items-center justify-center gap-3 p-6">
<p className="text-sm text-red-600 dark:text-red-400">
{S.chat.historyLoadFailed}:{stream.error}
</p>
<Button onClick={stream.retry}>{S.common.retry}</Button>
</div>
) : stream.loading ? (
<div className="space-y-3 p-6">
<Skeleton className="h-5 w-1/3" />
<Skeleton className="h-5 w-2/3" />
<Skeleton className="h-5 w-1/2" />
</div>
) : (
// The empty state shares the same structure as the message stream (message
// area + bottom input area): only the message area's content differs, and
// ChatInput always mounts in the same JSX slot, so it isn't unmounted and
// recreated when the first message arrives (preserving draft/focus).
<>
<div className="min-h-0 flex-1">
{emptyChat ? (
<div className="flex h-full items-center justify-center px-4">
<p className="text-lg font-medium text-gray-400 dark:text-gray-500">
{S.chat.emptyGreeting}
</p>
</div>
) : (
<MessageStream
items={stream.model.items}
version={stream.version}
ctx={ctx}
scrollElRef={streamScrollRef}
// Tick-rail minimap over the stream's left gutter (zero layout
// width; hides itself when the gutter is too narrow or the
// pointer can't hover).
outline={
<ConversationOutline
entries={outline}
version={stream.version}
scrollRef={streamScrollRef}
running={stream.taskState !== "idle"}
fit={railFit}
/>
}
/>
)}
</div>
<div className="shrink-0 border-t border-gray-200 bg-white px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-3 md:pb-3 dark:border-gray-800 dark:bg-gray-950">
<div className="mx-auto max-w-3xl">
{/* Goal banner docked above the composer: an in-flight goal's progress
(restored on load while still active), or the terminal state reached
during this page's lifetime. The stop button is the composer's
regular stop (one abort ends the whole goal loop). */}
{stream.goal && <GoalStatusBanner goal={stream.goal} />}
{input}
</div>
</div>
</>
)
) : sessionsLoading ? (
<div className="space-y-3 p-6">
<Skeleton className="h-5 w-1/2" />
</div>
) : (
<EmptyState
title={S.chat.noSessions}
action={<Button onClick={newChat}>{S.nav.newChat}</Button>}
/>
)}
</div>
)}
</div>
{selected && (
<SubagentsPanel
session={selected}
panel={subagentsPanel}
model={stream.model}
version={stream.version}
taskRunning={stream.taskState !== "idle"}
ctx={ctx}
/>
)}
{selected && <FilesPanel session={selected} panel={filesPanel} />}
</div>
<Modal
open={credentialGuide}
title={S.project.noCredentialTitle}
onClose={() => setCredentialGuide(false)}
footer={
<>
<Button onClick={() => setCredentialGuide(false)}>{S.project.later}</Button>
<Button
variant="primary"
onClick={() => {
setCredentialGuide(false);
navigate("/models");
}}
>
{S.project.goToModels}
</Button>
</>
}
>
<p className="text-sm text-gray-600 dark:text-gray-300">{S.project.noCredentialBody}</p>
</Modal>
</div>
);
}