93c11e5557
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1280 lines
59 KiB
TypeScript
1280 lines
59 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|