mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 02:59:35 +00:00
1660 lines
65 KiB
TypeScript
1660 lines
65 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, useRef, useEffect, useMemo, useReducer } from "react";
|
|
import type {
|
|
AgentMessage,
|
|
ExtensionStatusItem,
|
|
ExtensionUiRequest,
|
|
ExtensionWidgetItem,
|
|
SessionInfo,
|
|
SessionTreeNode,
|
|
} from "@/lib/types";
|
|
import { normalizeToolCalls } from "@/lib/normalize";
|
|
import { sendAgentCommand } from "@/lib/agent-client";
|
|
import { getToolNamesForPreset, type ToolEntry } from "@/lib/tool-presets";
|
|
import type { SessionStatsInfo } from "@/lib/pi-types";
|
|
|
|
export interface SessionData {
|
|
sessionId: string;
|
|
filePath: string;
|
|
tree: SessionTreeNode[];
|
|
leafId: string | null;
|
|
context: {
|
|
messages: AgentMessage[];
|
|
entryIds: string[];
|
|
thinkingLevel: string;
|
|
model: { provider: string; modelId: string } | null;
|
|
};
|
|
}
|
|
|
|
interface StreamingState {
|
|
isStreaming: boolean;
|
|
streamingMessage: Partial<AgentMessage> | null;
|
|
}
|
|
|
|
type StreamAction =
|
|
| { type: "start" }
|
|
| { type: "update"; message: Partial<AgentMessage> }
|
|
| { type: "end" }
|
|
| { type: "reset" };
|
|
|
|
function streamReducer(state: StreamingState, action: StreamAction): StreamingState {
|
|
switch (action.type) {
|
|
case "start":
|
|
return { isStreaming: true, streamingMessage: null };
|
|
case "update":
|
|
return { isStreaming: true, streamingMessage: action.message };
|
|
case "end":
|
|
case "reset":
|
|
return { isStreaming: false, streamingMessage: null };
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
interface AgentEvent {
|
|
type: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface CompactCommandResult {
|
|
tokensBefore?: number;
|
|
estimatedTokensAfter?: number;
|
|
}
|
|
|
|
interface LastAssistantTextResponse {
|
|
text?: string;
|
|
}
|
|
|
|
type AgentStateResponse = {
|
|
contextUsage?: { percent: number | null; contextWindow: number; tokens: number | null } | null;
|
|
systemPrompt?: string;
|
|
thinkingLevel?: string;
|
|
isStreaming?: boolean;
|
|
isPromptRunning?: boolean;
|
|
isBashRunning?: boolean;
|
|
isCompacting?: boolean;
|
|
extensionStatuses?: ExtensionStatusItem[];
|
|
extensionWidgets?: ExtensionWidgetItem[];
|
|
queuedMessages?: { steering?: string[]; followUp?: string[] } | null;
|
|
};
|
|
|
|
export interface QueuedMessages {
|
|
steering: string[];
|
|
followUp: string[];
|
|
}
|
|
|
|
function normalizeQueuedMessages(q?: { steering?: string[]; followUp?: string[] } | null): QueuedMessages {
|
|
return { steering: q?.steering ?? [], followUp: q?.followUp ?? [] };
|
|
}
|
|
|
|
type ExtensionUiDialogRequest = Extract<ExtensionUiRequest, { method: "select" | "confirm" | "input" | "editor" }>;
|
|
type ExtensionUiCustomRequest = Extract<ExtensionUiRequest, { method: "custom" }>;
|
|
export type NoticeType = "info" | "success" | "warning" | "error";
|
|
|
|
export type NoticeItem = {
|
|
id: string;
|
|
message: string;
|
|
type: NoticeType;
|
|
exiting?: boolean;
|
|
};
|
|
|
|
type NoticeState = {
|
|
visible: NoticeItem[];
|
|
pending: NoticeItem[];
|
|
};
|
|
|
|
type NoticeAction =
|
|
| { type: "add"; notice: NoticeItem }
|
|
| { type: "mark_oldest_exiting" }
|
|
| { type: "remove"; id: string };
|
|
|
|
export type AgentPhase =
|
|
| { kind: "waiting_model" }
|
|
| { kind: "running_command" }
|
|
| { kind: "running_tools"; tools: { id: string; name: string }[] }
|
|
| null;
|
|
|
|
export interface CompactResultInfo {
|
|
reason: "manual" | "threshold" | "overflow" | "auto" | string;
|
|
tokensBefore: number;
|
|
estimatedTokensAfter: number;
|
|
}
|
|
|
|
export interface SlashCommandInfo {
|
|
name: string;
|
|
description?: string;
|
|
source: "extension" | "prompt" | "skill";
|
|
sourceInfo?: {
|
|
path: string;
|
|
source: string;
|
|
scope: "user" | "project" | "temporary";
|
|
origin: "package" | "top-level";
|
|
baseDir?: string;
|
|
};
|
|
}
|
|
|
|
export type BuiltinSlashCommandResult =
|
|
| { handled: false }
|
|
| { handled: true; message?: string; error?: string; action?: "openSessionStats" };
|
|
|
|
export interface UseAgentSessionOptions {
|
|
session: SessionInfo | null;
|
|
newSessionCwd: string | null;
|
|
onAgentEnd?: () => void;
|
|
onSessionCreated?: (session: SessionInfo) => void;
|
|
onSessionForked?: (newSessionId: string) => void;
|
|
modelsRefreshKey?: number;
|
|
chatInputRef?: React.RefObject<ChatInputHandle | null>;
|
|
onBranchDataChange?: (tree: SessionTreeNode[], activeLeafId: string | null, onLeafChange: (leafId: string | null) => void) => void;
|
|
onSystemPromptChange?: (prompt: string | null) => void;
|
|
onSessionStatsPanelOpen?: () => void;
|
|
setToolPreset?: (preset: "none" | "default" | "full") => void;
|
|
}
|
|
|
|
export type ThinkingLevelOption = "auto" | "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
|
|
const PROGRAMMATIC_SCROLL_IGNORE_MS = 700;
|
|
const USER_SCROLL_INTENT_MS = 1200;
|
|
const PROMPT_SETTLE_INITIAL_DELAY_MS = 800;
|
|
const PROMPT_SETTLE_POLL_MS = 600;
|
|
const PROMPT_SETTLE_MAX_MS = 20_000;
|
|
const AGENT_STATE_RECONCILE_MS = 15_000;
|
|
const BASH_STATE_RECONCILE_MS = 1_000;
|
|
const EVENT_STREAM_CONNECT_TIMEOUT_MS = 5_000;
|
|
const MAX_NOTICES = 5;
|
|
const NOTICE_VISIBLE_MS = 5000;
|
|
const NOTICE_EXIT_ANIMATION_MS = 180;
|
|
const SCROLL_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Space", "Spacebar"]);
|
|
|
|
type EventStreamConnectionStatus = "connected" | "timeout" | "closed";
|
|
|
|
type EventStreamConnectionResult = {
|
|
status: EventStreamConnectionStatus;
|
|
source: EventSource;
|
|
};
|
|
|
|
class EventStreamConnectionError extends Error {
|
|
constructor(public readonly status: Exclude<EventStreamConnectionStatus, "connected">) {
|
|
super(status === "timeout"
|
|
? "Timed out connecting to the agent event stream. Please try again."
|
|
: "Failed to connect to the agent event stream. Please try again.");
|
|
this.name = "EventStreamConnectionError";
|
|
}
|
|
}
|
|
|
|
function createNoticeId(): string {
|
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
return crypto.randomUUID();
|
|
}
|
|
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
}
|
|
|
|
function delay(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function markOldestNoticeExiting(notices: NoticeItem[]): NoticeItem[] {
|
|
const index = notices.findIndex((notice) => !notice.exiting);
|
|
if (index === -1) return notices;
|
|
return notices.map((notice, i) => (
|
|
i === index ? { ...notice, exiting: true } : notice
|
|
));
|
|
}
|
|
|
|
function fillPendingNotices(visible: NoticeItem[], pending: NoticeItem[]): NoticeState {
|
|
let nextVisible = visible;
|
|
let nextPending = pending;
|
|
while (nextPending.length > 0 && nextVisible.length < MAX_NOTICES) {
|
|
const [next, ...rest] = nextPending;
|
|
nextVisible = [...nextVisible, next];
|
|
nextPending = rest;
|
|
}
|
|
if (nextPending.length > 0 && !nextVisible.some((notice) => notice.exiting)) {
|
|
nextVisible = markOldestNoticeExiting(nextVisible);
|
|
}
|
|
return { visible: nextVisible, pending: nextPending };
|
|
}
|
|
|
|
function noticeReducer(state: NoticeState, action: NoticeAction): NoticeState {
|
|
switch (action.type) {
|
|
case "add": {
|
|
if (state.visible.some((notice) => notice.exiting) || state.visible.length >= MAX_NOTICES) {
|
|
return {
|
|
visible: state.visible.some((notice) => notice.exiting)
|
|
? state.visible
|
|
: markOldestNoticeExiting(state.visible),
|
|
pending: [...state.pending, action.notice],
|
|
};
|
|
}
|
|
return { ...state, visible: [...state.visible, action.notice] };
|
|
}
|
|
case "mark_oldest_exiting":
|
|
return { ...state, visible: markOldestNoticeExiting(state.visible) };
|
|
case "remove": {
|
|
const visible = state.visible.filter((notice) => notice.id !== action.id);
|
|
return fillPendingNotices(visible, state.pending);
|
|
}
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
function extractMessageText(message: Partial<AgentMessage>): string {
|
|
const content = (message as { content?: unknown }).content;
|
|
if (typeof content === "string") return content;
|
|
if (!Array.isArray(content)) return "";
|
|
return content
|
|
.map((block) =>
|
|
block && typeof block === "object"
|
|
&& (block as { type?: string }).type === "text"
|
|
&& typeof (block as { text?: unknown }).text === "string"
|
|
? (block as { text: string }).text
|
|
: "")
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
}
|
|
|
|
function imageSignature(block: unknown): string {
|
|
if (!block || typeof block !== "object" || (block as { type?: unknown }).type !== "image") return "";
|
|
const source = (block as { source?: unknown }).source;
|
|
if (source && typeof source === "object") {
|
|
const src = source as { type?: unknown; media_type?: unknown; data?: unknown; url?: unknown };
|
|
return [
|
|
src.type === "url" ? "url" : "base64",
|
|
typeof src.media_type === "string" ? src.media_type : "",
|
|
typeof src.data === "string" ? src.data : "",
|
|
typeof src.url === "string" ? src.url : "",
|
|
].join(":");
|
|
}
|
|
const flat = block as { data?: unknown; mimeType?: unknown };
|
|
return [
|
|
"base64",
|
|
typeof flat.mimeType === "string" ? flat.mimeType : "",
|
|
typeof flat.data === "string" ? flat.data : "",
|
|
"",
|
|
].join(":");
|
|
}
|
|
|
|
function userMessageKey(message: Partial<AgentMessage>): string {
|
|
const content = (message as { content?: unknown }).content;
|
|
if (typeof content === "string") return JSON.stringify({ text: content, images: [] });
|
|
if (!Array.isArray(content)) return JSON.stringify({ text: "", images: [] });
|
|
return JSON.stringify({
|
|
text: extractMessageText(message),
|
|
images: content.map(imageSignature).filter(Boolean),
|
|
});
|
|
}
|
|
|
|
function readCompactResult(result: unknown, reason: string): CompactResultInfo | null {
|
|
if (!result || typeof result !== "object") return null;
|
|
const r = result as CompactCommandResult;
|
|
if (typeof r.tokensBefore !== "number" || typeof r.estimatedTokensAfter !== "number") return null;
|
|
return { reason, tokensBefore: r.tokensBefore, estimatedTokensAfter: r.estimatedTokensAfter };
|
|
}
|
|
|
|
export interface ChatInputHandle {
|
|
insertText: (text: string) => void;
|
|
insertIfEmpty: (content: string) => void;
|
|
prependText: (text: string) => void;
|
|
addImages: (files: File[]) => void;
|
|
}
|
|
|
|
export interface AttachedImage {
|
|
data: string;
|
|
mimeType: string;
|
|
previewUrl: string;
|
|
}
|
|
|
|
type SelectedModel = { provider: string; modelId: string };
|
|
type ModelEntry = { id: string; name: string; provider: string };
|
|
type ModelsResponse = {
|
|
models: Record<string, string>;
|
|
modelList?: ModelEntry[];
|
|
defaultModel?: SelectedModel | null;
|
|
thinkingLevels?: Record<string, string[]>;
|
|
thinkingLevelMaps?: Record<string, Record<string, string | null>>;
|
|
modelError?: string;
|
|
};
|
|
|
|
type SlashCommandsResponse = {
|
|
commands?: SlashCommandInfo[];
|
|
};
|
|
|
|
export function useAgentSession(opts: UseAgentSessionOptions) {
|
|
const {
|
|
session, newSessionCwd, onAgentEnd, onSessionCreated, onSessionForked,
|
|
modelsRefreshKey, onBranchDataChange, onSystemPromptChange, onSessionStatsPanelOpen,
|
|
} = opts;
|
|
|
|
const isNew = session === null && newSessionCwd !== null;
|
|
|
|
const [data, setData] = useState<SessionData | null>(null);
|
|
const [loading, setLoading] = useState(!isNew);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [activeLeafId, setActiveLeafId] = useState<string | null>(null);
|
|
const [messages, setMessages] = useState<AgentMessage[]>([]);
|
|
const [entryIds, setEntryIds] = useState<string[]>([]);
|
|
const [streamState, dispatch] = useReducer(streamReducer, { isStreaming: false, streamingMessage: null });
|
|
const [agentRunning, setAgentRunning] = useState(false);
|
|
const [bashRunning, setBashRunning] = useState(false);
|
|
const [pendingBash, setPendingBash] = useState<{ command: string; excludeFromContext: boolean } | null>(null);
|
|
const [modelNames, setModelNames] = useState<Record<string, string>>({});
|
|
const [modelList, setModelList] = useState<ModelEntry[]>([]);
|
|
const [modelError, setModelError] = useState<string | null>(null);
|
|
const [modelThinkingLevels, setModelThinkingLevels] = useState<Record<string, string[]>>({});
|
|
const [modelThinkingLevelMaps, setModelThinkingLevelMaps] = useState<Record<string, Record<string, string | null>>>({});
|
|
const [newSessionModel, setNewSessionModel] = useState<SelectedModel | null>(null);
|
|
const [newSessionDefaultModel, setNewSessionDefaultModel] = useState<SelectedModel | null>(null);
|
|
const [toolPreset, setToolPreset] = useState<"none" | "default" | "full">("default");
|
|
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevelOption>("auto");
|
|
const [retryInfo, setRetryInfo] = useState<{ attempt: number; maxAttempts: number; errorMessage?: string } | null>(null);
|
|
const [contextUsage, setContextUsage] = useState<{ percent: number | null; contextWindow: number; tokens: number | null } | null>(null);
|
|
const [systemPrompt, setSystemPrompt] = useState<string | null>(null);
|
|
const [forkingEntryId, setForkingEntryId] = useState<string | null>(null);
|
|
const [currentModelOverride, setCurrentModelOverride] = useState<{ provider: string; modelId: string } | null>(null);
|
|
const [pendingModel, setPendingModel] = useState<{ provider: string; modelId: string } | null>(null);
|
|
const [isCompacting, setIsCompacting] = useState(false);
|
|
const [compactError, setCompactError] = useState<string | null>(null);
|
|
const [compactResult, setCompactResult] = useState<CompactResultInfo | null>(null);
|
|
const [agentPhase, setAgentPhase] = useState<AgentPhase>(null);
|
|
const [slashCommands, setSlashCommands] = useState<SlashCommandInfo[]>([]);
|
|
const [slashCommandsLoading, setSlashCommandsLoading] = useState(false);
|
|
const [noticeState, dispatchNotice] = useReducer(noticeReducer, { visible: [], pending: [] });
|
|
const [sessionStatsOverride, setSessionStatsOverride] = useState<SessionStatsInfo | null>(null);
|
|
const [extensionDialog, setExtensionDialog] = useState<ExtensionUiDialogRequest | null>(null);
|
|
const [extensionCustomUi, setExtensionCustomUi] = useState<ExtensionUiCustomRequest | null>(null);
|
|
const [extensionStatuses, setExtensionStatuses] = useState<ExtensionStatusItem[]>([]);
|
|
const [extensionWidgets, setExtensionWidgets] = useState<ExtensionWidgetItem[]>([]);
|
|
const [queuedMessages, setQueuedMessages] = useState<QueuedMessages>({ steering: [], followUp: [] });
|
|
|
|
const eventSourceRef = useRef<EventSource | null>(null);
|
|
const sessionIdRef = useRef<string | null>(session?.id ?? null);
|
|
const agentRunningRef = useRef(false);
|
|
const bashRunningRef = useRef(false);
|
|
const bashRecoveryIdRef = useRef(0);
|
|
const handleAgentEventRef = useRef<((event: AgentEvent) => void) | null>(null);
|
|
const initialScrollDoneRef = useRef(false);
|
|
const lastUserMsgRef = useRef<HTMLDivElement | null>(null);
|
|
const pendingScrollToUserRef = useRef(false);
|
|
const completionScrollAllowedRef = useRef(true);
|
|
const executeBashRef = useRef<(command: string, excludeFromContext: boolean) => Promise<void> | undefined>(undefined);
|
|
const userScrollIntentUntilRef = useRef(0);
|
|
const ignoreProgrammaticScrollUntilRef = useRef(0);
|
|
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
|
const ensuringNewSessionRef = useRef<Promise<string | null> | null>(null);
|
|
const newSessionPromotedRef = useRef(false);
|
|
const promptRunIdRef = useRef(0);
|
|
const optimisticUserMessageKeyRef = useRef<string | null>(null);
|
|
|
|
const setToolPresetState = opts.setToolPreset ?? setToolPreset;
|
|
|
|
const currentModel = currentModelOverride ?? data?.context.model ?? pendingModel ?? null;
|
|
const displayModel = isNew ? (newSessionModel ?? newSessionDefaultModel) : currentModel;
|
|
|
|
const sessionStats = useMemo(() => {
|
|
if (sessionStatsOverride) return sessionStatsOverride;
|
|
const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
|
|
let cost = 0;
|
|
let userMessages = 0;
|
|
let assistantMessages = 0;
|
|
let toolResults = 0;
|
|
let toolCalls = 0;
|
|
for (const msg of messages) {
|
|
if (msg.role === "user") userMessages += 1;
|
|
if (msg.role === "toolResult") toolResults += 1;
|
|
if (msg.role !== "assistant") continue;
|
|
assistantMessages += 1;
|
|
const u = (msg as import("@/lib/types").AssistantMessage).usage;
|
|
toolCalls += (msg as import("@/lib/types").AssistantMessage).content.filter((c) => c.type === "toolCall").length;
|
|
if (!u) continue;
|
|
tokens.input += u.input ?? 0;
|
|
tokens.output += u.output ?? 0;
|
|
tokens.cacheRead += u.cacheRead ?? 0;
|
|
tokens.cacheWrite += u.cacheWrite ?? 0;
|
|
cost += u.cost?.total ?? 0;
|
|
}
|
|
tokens.total = tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite;
|
|
if (tokens.total === 0 && messages.length === 0) return null;
|
|
return {
|
|
sessionFile: data?.filePath || undefined,
|
|
sessionId: sessionIdRef.current ?? session?.id ?? "",
|
|
sessionName: session?.name,
|
|
userMessages,
|
|
assistantMessages,
|
|
toolCalls,
|
|
toolResults,
|
|
totalMessages: messages.length,
|
|
tokens,
|
|
cost,
|
|
...(contextUsage ? { contextUsage } : {}),
|
|
} satisfies SessionStatsInfo;
|
|
}, [messages, sessionStatsOverride, contextUsage, data?.filePath, session?.id, session?.name]);
|
|
|
|
const loadSession = useCallback(async (sid: string, showLoading = false, includeState = false) => {
|
|
let messagesLoaded = false;
|
|
try {
|
|
if (showLoading) setLoading(true);
|
|
const params = new URLSearchParams({ deferThinking: "1", deferMedia: "1" });
|
|
const res = await fetch(`/api/sessions/${encodeURIComponent(sid)}?${params}`);
|
|
if (res.status === 404) {
|
|
if (showLoading) {
|
|
setData(null);
|
|
setActiveLeafId(null);
|
|
setMessages([]);
|
|
setError(null);
|
|
}
|
|
return null;
|
|
}
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const d = await res.json() as SessionData;
|
|
if (sessionIdRef.current !== sid) return null;
|
|
setData(d);
|
|
setActiveLeafId(d.leafId);
|
|
setMessages(d.context.messages);
|
|
setEntryIds(d.context.entryIds ?? []);
|
|
setCurrentModelOverride(null);
|
|
setError(null);
|
|
if (d.context.thinkingLevel && d.context.thinkingLevel !== "off") {
|
|
setThinkingLevel(d.context.thinkingLevel as ThinkingLevelOption);
|
|
}
|
|
|
|
messagesLoaded = true;
|
|
if (showLoading) setLoading(false);
|
|
if (!includeState) return null;
|
|
|
|
try {
|
|
const stateRes = await fetch(`/api/sessions/${encodeURIComponent(sid)}/state`);
|
|
if (stateRes.status === 404) {
|
|
if (sessionIdRef.current === sid) {
|
|
setQueuedMessages({ steering: [], followUp: [] });
|
|
}
|
|
return { running: false };
|
|
}
|
|
if (!stateRes.ok) throw new Error(`HTTP ${stateRes.status}`);
|
|
const agentState = await stateRes.json() as { running: boolean; state?: AgentStateResponse };
|
|
if (sessionIdRef.current !== sid) return null;
|
|
|
|
const liveState = agentState.state;
|
|
if (liveState) {
|
|
if (liveState.contextUsage !== undefined) setContextUsage(liveState.contextUsage ?? null);
|
|
if (liveState.systemPrompt !== undefined) setSystemPrompt(liveState.systemPrompt ?? null);
|
|
if (liveState.thinkingLevel !== undefined) setThinkingLevel((liveState.thinkingLevel as ThinkingLevelOption) ?? "auto");
|
|
if (liveState.extensionStatuses !== undefined) setExtensionStatuses(liveState.extensionStatuses ?? []);
|
|
if (liveState.extensionWidgets !== undefined) setExtensionWidgets(liveState.extensionWidgets ?? []);
|
|
if (liveState.queuedMessages !== undefined) setQueuedMessages(normalizeQueuedMessages(liveState.queuedMessages));
|
|
} else if (!agentState.running) {
|
|
setQueuedMessages({ steering: [], followUp: [] });
|
|
}
|
|
return agentState;
|
|
} catch (e) {
|
|
console.error("Failed to load agent state:", e);
|
|
return null;
|
|
}
|
|
} catch (e) {
|
|
setError(String(e));
|
|
return null;
|
|
} finally {
|
|
if (showLoading && !messagesLoaded) setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const loadContext = useCallback(async (sid: string, leafId: string | null) => {
|
|
try {
|
|
const params = new URLSearchParams({ deferThinking: "1", deferMedia: "1" });
|
|
if (leafId) params.set("leafId", leafId);
|
|
const url = `/api/sessions/${encodeURIComponent(sid)}/context?${params}`;
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const d = await res.json() as { context: { messages: AgentMessage[]; entryIds: string[] } };
|
|
setMessages(d.context.messages);
|
|
setEntryIds(d.context.entryIds ?? []);
|
|
} catch (e) {
|
|
console.error("Failed to load context:", e);
|
|
}
|
|
}, []);
|
|
|
|
const loadTools = useCallback(async (sid: string) => {
|
|
try {
|
|
const tools = await sendAgentCommand<ToolEntry[]>(sid, { type: "get_tools" });
|
|
if (tools) {
|
|
const { getPresetFromTools } = await import("@/lib/tool-presets");
|
|
setToolPresetState(getPresetFromTools(tools));
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to load tools:", e);
|
|
}
|
|
}, [setToolPresetState]);
|
|
|
|
const promoteNewSession = useCallback((messageCount = 0, firstMessage = "(no messages)") => {
|
|
const sid = sessionIdRef.current;
|
|
if (!isNew || !newSessionCwd || !sid || newSessionPromotedRef.current) return;
|
|
newSessionPromotedRef.current = true;
|
|
onSessionCreated?.({
|
|
id: sid,
|
|
path: "",
|
|
cwd: newSessionCwd,
|
|
name: undefined,
|
|
created: new Date().toISOString(),
|
|
modified: new Date().toISOString(),
|
|
messageCount,
|
|
firstMessage,
|
|
});
|
|
}, [isNew, newSessionCwd, onSessionCreated]);
|
|
|
|
const ensureNewSession = useCallback(async () => {
|
|
if (sessionIdRef.current) return sessionIdRef.current;
|
|
if (!isNew || !newSessionCwd) return sessionIdRef.current;
|
|
if (ensuringNewSessionRef.current) return ensuringNewSessionRef.current;
|
|
|
|
const promise = (async () => {
|
|
const selectedModel = newSessionModel ?? newSessionDefaultModel;
|
|
if (selectedModel) setPendingModel(selectedModel);
|
|
const toolNames = getToolNamesForPreset(toolPreset);
|
|
const res = await fetch("/api/agent/new", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
cwd: newSessionCwd,
|
|
type: "ensure_session",
|
|
toolNames,
|
|
...(selectedModel ? { provider: selectedModel.provider, modelId: selectedModel.modelId } : {}),
|
|
...(thinkingLevel !== "auto" ? { thinkingLevel } : {}),
|
|
}),
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const result = await res.json() as { sessionId: string };
|
|
const realId = result.sessionId;
|
|
sessionIdRef.current = realId;
|
|
return realId;
|
|
})();
|
|
|
|
ensuringNewSessionRef.current = promise;
|
|
try {
|
|
return await promise;
|
|
} finally {
|
|
ensuringNewSessionRef.current = null;
|
|
}
|
|
}, [isNew, newSessionCwd, newSessionModel, newSessionDefaultModel, toolPreset, thinkingLevel]);
|
|
|
|
const loadSlashCommands = useCallback(async () => {
|
|
const sid = sessionIdRef.current ?? await ensureNewSession();
|
|
if (!sid) {
|
|
setSlashCommands([]);
|
|
return [] as SlashCommandInfo[];
|
|
}
|
|
setSlashCommandsLoading(true);
|
|
try {
|
|
const data = await sendAgentCommand<SlashCommandsResponse>(sid, { type: "get_commands" });
|
|
const commands = data?.commands ?? [];
|
|
setSlashCommands(commands);
|
|
return commands;
|
|
} catch (e) {
|
|
console.error("Failed to load slash commands:", e);
|
|
setSlashCommands([]);
|
|
return [] as SlashCommandInfo[];
|
|
} finally {
|
|
setSlashCommandsLoading(false);
|
|
}
|
|
}, [ensureNewSession]);
|
|
|
|
const connectEvents = useCallback((sid: string): Promise<EventStreamConnectionResult> => {
|
|
if (eventSourceRef.current) {
|
|
eventSourceRef.current.close();
|
|
eventSourceRef.current = null;
|
|
}
|
|
const es = new EventSource(`/api/agent/${encodeURIComponent(sid)}/events`);
|
|
eventSourceRef.current = es;
|
|
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const settle = (status: EventStreamConnectionStatus) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timeout);
|
|
resolve({ status, source: es });
|
|
};
|
|
const timeout = setTimeout(() => settle("timeout"), EVENT_STREAM_CONNECT_TIMEOUT_MS);
|
|
|
|
es.onmessage = (e) => {
|
|
try {
|
|
const event = JSON.parse(e.data) as AgentEvent;
|
|
if (event.type === "connected") settle("connected");
|
|
handleAgentEventRef.current?.(event);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
es.onerror = () => {
|
|
if (es.readyState === EventSource.CLOSED) {
|
|
// Fatal error (404/500/content-type mismatch): browser won't
|
|
// auto-reconnect. Settle the Promise and manually reconnect for
|
|
// already-running sessions.
|
|
settle("closed");
|
|
if (eventSourceRef.current === es && agentRunningRef.current) {
|
|
eventSourceRef.current = null;
|
|
setTimeout(() => {
|
|
if (agentRunningRef.current) void connectEvents(sid);
|
|
}, 1000);
|
|
}
|
|
}
|
|
// Recoverable errors (CONNECTING): let EventSource auto-reconnect.
|
|
// The timeout above resolves only to let callers decide whether this
|
|
// connection must be ready before they continue.
|
|
};
|
|
});
|
|
}, []);
|
|
|
|
const ensureEventsConnected = useCallback(async (sid: string) => {
|
|
const result = await connectEvents(sid);
|
|
if (result.status === "connected" || result.source.readyState === EventSource.OPEN) return;
|
|
if (eventSourceRef.current === result.source) eventSourceRef.current = null;
|
|
result.source.close();
|
|
throw new EventStreamConnectionError(result.status);
|
|
}, [connectEvents]);
|
|
|
|
const respondToExtensionUi = useCallback(async (
|
|
request: ExtensionUiDialogRequest,
|
|
response: { value: string } | { confirmed: boolean } | { cancelled: true },
|
|
) => {
|
|
const sid = sessionIdRef.current;
|
|
setExtensionDialog((current) => current?.id === request.id ? null : current);
|
|
if (!sid) return;
|
|
try {
|
|
await sendAgentCommand(sid, {
|
|
type: "extension_ui_response",
|
|
id: request.id,
|
|
...response,
|
|
});
|
|
} catch (e) {
|
|
console.error("Failed to send extension UI response:", e);
|
|
}
|
|
}, []);
|
|
|
|
const sendExtensionCustomInput = useCallback(async (request: ExtensionUiCustomRequest, data: string) => {
|
|
const sid = sessionIdRef.current;
|
|
if (!sid) return;
|
|
try {
|
|
await sendAgentCommand(sid, {
|
|
type: "extension_ui_input",
|
|
id: request.id,
|
|
data,
|
|
});
|
|
} catch (e) {
|
|
console.error("Failed to send extension custom UI input:", e);
|
|
}
|
|
}, []);
|
|
|
|
const addNotice = useCallback((notice: { id?: string; message: string; type?: NoticeType }) => {
|
|
const message = notice.message.trim();
|
|
if (!message) return;
|
|
dispatchNotice({
|
|
type: "add",
|
|
notice: {
|
|
id: notice.id ?? createNoticeId(),
|
|
message,
|
|
type: notice.type ?? "info",
|
|
},
|
|
});
|
|
}, []);
|
|
|
|
const handleExtensionUiRequest = useCallback((request: ExtensionUiRequest) => {
|
|
switch (request.method) {
|
|
case "select":
|
|
case "confirm":
|
|
case "input":
|
|
case "editor":
|
|
setExtensionDialog(request);
|
|
break;
|
|
case "notify": {
|
|
addNotice({
|
|
id: request.id,
|
|
message: request.message,
|
|
type: request.notifyType ?? "info",
|
|
});
|
|
break;
|
|
}
|
|
case "setStatus":
|
|
setExtensionStatuses((prev) => {
|
|
const rest = prev.filter((item) => item.key !== request.statusKey);
|
|
return request.statusText !== undefined
|
|
? [...rest, { key: request.statusKey, text: request.statusText }]
|
|
: rest;
|
|
});
|
|
break;
|
|
case "setWidget":
|
|
setExtensionWidgets((prev) => {
|
|
const rest = prev.filter((item) => item.key !== request.widgetKey);
|
|
return request.widgetLines
|
|
? [...rest, {
|
|
key: request.widgetKey,
|
|
lines: request.widgetLines,
|
|
placement: request.widgetPlacement ?? "aboveEditor",
|
|
}]
|
|
: rest;
|
|
});
|
|
break;
|
|
case "setTitle":
|
|
if (request.title) document.title = request.title;
|
|
break;
|
|
case "set_editor_text":
|
|
opts.chatInputRef?.current?.insertText(request.text);
|
|
break;
|
|
case "custom":
|
|
setExtensionCustomUi((current) => {
|
|
if (request.closed) return current?.id === request.id ? null : current;
|
|
return request;
|
|
});
|
|
break;
|
|
}
|
|
}, [addNotice, opts.chatInputRef]);
|
|
|
|
const finishPromptWithoutStream = useCallback(async (sid: string | null = sessionIdRef.current, runId?: number) => {
|
|
// Bail out before loadSession too: a stale finish for a previous run
|
|
// must not overwrite the messages of the run currently streaming.
|
|
if (runId !== undefined && promptRunIdRef.current !== runId) return;
|
|
try {
|
|
if (sid) await loadSession(sid);
|
|
} finally {
|
|
if (runId !== undefined && promptRunIdRef.current !== runId) return;
|
|
optimisticUserMessageKeyRef.current = null;
|
|
if (!agentRunningRef.current) return;
|
|
agentRunningRef.current = false;
|
|
setAgentRunning(false);
|
|
setAgentPhase(null);
|
|
setRetryInfo(null);
|
|
dispatch({ type: "end" });
|
|
onAgentEnd?.();
|
|
}
|
|
}, [loadSession, onAgentEnd]);
|
|
|
|
const waitForPromptSettlement = useCallback(async (sid: string, runId?: number) => {
|
|
await delay(PROMPT_SETTLE_INITIAL_DELAY_MS);
|
|
const startedAt = Date.now();
|
|
|
|
while (agentRunningRef.current && Date.now() - startedAt < PROMPT_SETTLE_MAX_MS) {
|
|
if (runId !== undefined && promptRunIdRef.current !== runId) return;
|
|
try {
|
|
const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`);
|
|
if (res.ok) {
|
|
const data = await res.json() as { running?: boolean; state?: AgentStateResponse };
|
|
const state = data.state;
|
|
if (!data.running || !state || (!state.isStreaming && !state.isPromptRunning)) {
|
|
await finishPromptWithoutStream(sid, runId);
|
|
return;
|
|
}
|
|
}
|
|
} catch {
|
|
// SSE remains the primary completion path.
|
|
}
|
|
await delay(PROMPT_SETTLE_POLL_MS);
|
|
}
|
|
}, [finishPromptWithoutStream]);
|
|
|
|
const waitForBashSettlement = useCallback(async (sid: string) => {
|
|
const recoveryId = bashRecoveryIdRef.current + 1;
|
|
bashRecoveryIdRef.current = recoveryId;
|
|
|
|
while (
|
|
bashRunningRef.current
|
|
&& bashRecoveryIdRef.current === recoveryId
|
|
&& sessionIdRef.current === sid
|
|
) {
|
|
await delay(BASH_STATE_RECONCILE_MS);
|
|
try {
|
|
const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`);
|
|
if (!res.ok) continue;
|
|
const data = await res.json() as { state?: AgentStateResponse };
|
|
if (data.state?.isBashRunning) continue;
|
|
|
|
await loadSession(sid);
|
|
if (bashRecoveryIdRef.current !== recoveryId || sessionIdRef.current !== sid) return;
|
|
bashRunningRef.current = false;
|
|
setBashRunning(false);
|
|
setPendingBash(null);
|
|
return;
|
|
} catch {
|
|
// Keep polling while the page is mounted; network recovery is transparent.
|
|
}
|
|
}
|
|
}, [loadSession]);
|
|
|
|
// Reconcile client streaming state with the server. When SSE events are
|
|
// missed (network drop, mobile tab backgrounded, half-open connection),
|
|
// agent_end never arrives and the UI stays in streaming state forever.
|
|
// If the server reports idle while we still think it's running, finish
|
|
// through the same settlement path used by non-streaming prompts.
|
|
const reconcileAgentState = useCallback(async (sid: string) => {
|
|
if (!agentRunningRef.current) return;
|
|
const runId = promptRunIdRef.current;
|
|
try {
|
|
const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`);
|
|
if (!res.ok) return;
|
|
const data = await res.json() as { running?: boolean; state?: AgentStateResponse };
|
|
// A slow response can straddle a run boundary (previous run finished
|
|
// and the user already started the next one while this request was in
|
|
// flight) — everything in it is stale, drop it.
|
|
if (promptRunIdRef.current !== runId) return;
|
|
const state = data.state;
|
|
// Mirror compaction state unconditionally: a missed compaction_end
|
|
// would otherwise leave the "Stop compaction" UI stuck. No state
|
|
// (wrapper destroyed) means nothing is compacting.
|
|
setIsCompacting(state?.isCompacting ?? false);
|
|
setQueuedMessages(normalizeQueuedMessages(state?.queuedMessages));
|
|
const busy = data.running && state
|
|
&& (state.isStreaming || state.isPromptRunning || state.isCompacting);
|
|
if (busy || !agentRunningRef.current) return;
|
|
if (state) {
|
|
if (state.contextUsage !== undefined) setContextUsage(state.contextUsage ?? null);
|
|
if (state.systemPrompt !== undefined) setSystemPrompt(state.systemPrompt ?? null);
|
|
if (state.extensionStatuses !== undefined) setExtensionStatuses(state.extensionStatuses ?? []);
|
|
if (state.extensionWidgets !== undefined) setExtensionWidgets(state.extensionWidgets ?? []);
|
|
}
|
|
await finishPromptWithoutStream(sid, runId);
|
|
} catch {
|
|
// Network still down — the next poll / visibility / online tick retries.
|
|
}
|
|
}, [finishPromptWithoutStream]);
|
|
|
|
// Recovery net for missed SSE events: while the agent is running, verify
|
|
// against the server periodically and whenever the tab returns to the
|
|
// foreground or the network comes back.
|
|
useEffect(() => {
|
|
if (!agentRunning) return;
|
|
const reconcile = () => {
|
|
// Read the ref on every tick: for brand-new sessions the id is
|
|
// assigned only after ensure_session returns.
|
|
const sid = sessionIdRef.current;
|
|
if (sid) void reconcileAgentState(sid);
|
|
};
|
|
const onVisible = () => {
|
|
if (document.visibilityState === "visible") reconcile();
|
|
};
|
|
const interval = setInterval(reconcile, AGENT_STATE_RECONCILE_MS);
|
|
document.addEventListener("visibilitychange", onVisible);
|
|
window.addEventListener("online", reconcile);
|
|
return () => {
|
|
clearInterval(interval);
|
|
document.removeEventListener("visibilitychange", onVisible);
|
|
window.removeEventListener("online", reconcile);
|
|
};
|
|
}, [agentRunning, reconcileAgentState]);
|
|
|
|
useEffect(() => {
|
|
agentRunningRef.current = agentRunning;
|
|
}, [agentRunning]);
|
|
|
|
const handleAgentEvent = useCallback((event: AgentEvent) => {
|
|
switch (event.type) {
|
|
case "agent_start":
|
|
agentRunningRef.current = true;
|
|
setAgentRunning(true);
|
|
setAgentPhase({ kind: "waiting_model" });
|
|
dispatch({ type: "start" });
|
|
break;
|
|
case "agent_end":
|
|
// A late agent_end can arrive over SSE after reconcileAgentState
|
|
// already finished this run — don't re-trigger completion.
|
|
if (!agentRunningRef.current) break;
|
|
agentRunningRef.current = false;
|
|
setAgentRunning(false);
|
|
setAgentPhase(null);
|
|
setRetryInfo(null);
|
|
dispatch({ type: "end" });
|
|
if (sessionIdRef.current) {
|
|
loadSession(sessionIdRef.current);
|
|
fetch(`/api/agent/${encodeURIComponent(sessionIdRef.current)}`)
|
|
.then((r) => r.json())
|
|
.then((d: { state?: AgentStateResponse }) => {
|
|
if (d.state?.contextUsage !== undefined) setContextUsage(d.state.contextUsage ?? null);
|
|
if (d.state?.systemPrompt !== undefined) setSystemPrompt(d.state.systemPrompt ?? null);
|
|
if (d.state?.extensionStatuses !== undefined) setExtensionStatuses(d.state.extensionStatuses ?? []);
|
|
if (d.state?.extensionWidgets !== undefined) setExtensionWidgets(d.state.extensionWidgets ?? []);
|
|
// Aborted turns can leave messages queued in pi (delivered with the
|
|
// next turn); dead wrapper (no state) means the queue is gone.
|
|
setQueuedMessages(normalizeQueuedMessages(d.state?.queuedMessages));
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
onAgentEnd?.();
|
|
break;
|
|
case "prompt_done":
|
|
if (!agentRunningRef.current) break;
|
|
// Extension commands can call pi.sendUserMessage(), which starts its
|
|
// agent run asynchronously. In that case prompt_done for the command
|
|
// arrives before agent_start for the injected message. Give that run
|
|
// time to start and settle against server state instead of ending the
|
|
// UI immediately and dropping its subsequent streaming events.
|
|
if (sessionIdRef.current) {
|
|
void waitForPromptSettlement(sessionIdRef.current, promptRunIdRef.current);
|
|
}
|
|
break;
|
|
case "prompt_error":
|
|
addNotice({ type: "error", message: (event.errorMessage as string | undefined) ?? "Command failed" });
|
|
break;
|
|
case "extension_error":
|
|
addNotice({
|
|
type: "error",
|
|
message: (event.error as string | undefined) ?? "Extension command failed",
|
|
});
|
|
break;
|
|
case "message_start":
|
|
case "message_update": {
|
|
// Ignore streaming events arriving after this run already finished
|
|
// (e.g. SSE data buffered while the tab was frozen, flushed after
|
|
// reconcile) — they would resurrect a ghost streaming bubble.
|
|
if (!agentRunningRef.current) break;
|
|
const msg = event.message as Partial<AgentMessage> | undefined;
|
|
if (msg?.role === "user") {
|
|
break;
|
|
}
|
|
if (msg) {
|
|
dispatch({ type: "update", message: normalizeToolCalls(msg as AgentMessage) });
|
|
}
|
|
setAgentPhase(null);
|
|
break;
|
|
}
|
|
case "message_end": {
|
|
// Same late-event guard: after reconcile finished this run,
|
|
// loadSession already loaded this message from the session file —
|
|
// appending it again would duplicate it.
|
|
if (!agentRunningRef.current) break;
|
|
const completed = event.message as AgentMessage | undefined;
|
|
if (completed && completed.role === "user") {
|
|
// Delivered steering/follow-up messages surface here as user
|
|
// messages. The run's initial prompt also emits one, but handleSend
|
|
// already appended it optimistically. Consume only the still-adjacent
|
|
// optimistic bubble; later same-text queue deliveries must render.
|
|
const delivered = normalizeToolCalls(completed);
|
|
const deliveredKey = userMessageKey(delivered);
|
|
const optimisticKey = optimisticUserMessageKeyRef.current;
|
|
optimisticUserMessageKeyRef.current = null;
|
|
setMessages((prev) => {
|
|
const last = prev[prev.length - 1];
|
|
if (optimisticKey && last?.role === "user" && userMessageKey(last) === optimisticKey) {
|
|
return optimisticKey === deliveredKey
|
|
? prev
|
|
: [...prev.slice(0, -1), delivered];
|
|
}
|
|
return [...prev, delivered];
|
|
});
|
|
} else if (completed) {
|
|
setMessages((prev) => [...prev, normalizeToolCalls(completed)]);
|
|
}
|
|
dispatch({ type: "reset" });
|
|
setAgentPhase({ kind: "waiting_model" });
|
|
break;
|
|
}
|
|
case "tool_execution_start": {
|
|
const id = event.toolCallId as string;
|
|
const name = event.toolName as string;
|
|
setAgentPhase((prev) => {
|
|
const tools = prev?.kind === "running_tools" ? [...prev.tools] : [];
|
|
if (!tools.some((t) => t.id === id)) tools.push({ id, name });
|
|
return { kind: "running_tools", tools };
|
|
});
|
|
break;
|
|
}
|
|
case "tool_execution_end": {
|
|
const id = event.toolCallId as string;
|
|
setAgentPhase((prev) => {
|
|
if (prev?.kind !== "running_tools") return prev;
|
|
const tools = prev.tools.filter((t) => t.id !== id);
|
|
if (tools.length === 0) return { kind: "waiting_model" };
|
|
return { kind: "running_tools", tools };
|
|
});
|
|
break;
|
|
}
|
|
case "queue_update":
|
|
setQueuedMessages({
|
|
steering: [...((event.steering as string[] | undefined) ?? [])],
|
|
followUp: [...((event.followUp as string[] | undefined) ?? [])],
|
|
});
|
|
break;
|
|
case "auto_retry_start":
|
|
setRetryInfo({ attempt: event.attempt as number, maxAttempts: event.maxAttempts as number, errorMessage: event.errorMessage as string | undefined });
|
|
break;
|
|
case "auto_retry_end":
|
|
setRetryInfo(null);
|
|
break;
|
|
case "auto_compaction_start":
|
|
case "compaction_start":
|
|
setIsCompacting(true);
|
|
setCompactError(null);
|
|
setCompactResult(null);
|
|
break;
|
|
case "auto_compaction_end":
|
|
case "compaction_end":
|
|
setIsCompacting(false);
|
|
if (event.errorMessage) {
|
|
setCompactError(event.errorMessage as string);
|
|
setCompactResult(null);
|
|
} else if (!event.aborted) {
|
|
setCompactResult(readCompactResult(event.result, (event.reason as string | undefined) ?? "auto"));
|
|
if (sessionIdRef.current) loadSession(sessionIdRef.current);
|
|
}
|
|
break;
|
|
case "extension_ui_request":
|
|
handleExtensionUiRequest(event as ExtensionUiRequest);
|
|
break;
|
|
}
|
|
}, [addNotice, handleExtensionUiRequest, loadSession, onAgentEnd, waitForPromptSettlement]);
|
|
handleAgentEventRef.current = handleAgentEvent;
|
|
|
|
const handleSend = useCallback(async (message: string, images?: AttachedImage[]) => {
|
|
const trimmedMessage = message.trim();
|
|
if (!trimmedMessage && !images?.length) return;
|
|
if (agentRunningRef.current || bashRunningRef.current) return;
|
|
const isSlashCommandPrompt = !images?.length && trimmedMessage.startsWith("/");
|
|
|
|
const isBashCommand = !images?.length && trimmedMessage.startsWith("!");
|
|
if (isBashCommand) {
|
|
const isExcluded = trimmedMessage.startsWith("!!");
|
|
const bashCmd = (isExcluded ? trimmedMessage.slice(2) : trimmedMessage.slice(1)).trim();
|
|
if (!bashCmd) return;
|
|
await executeBashRef.current?.(bashCmd, isExcluded);
|
|
return;
|
|
}
|
|
|
|
const promptRunId = promptRunIdRef.current + 1;
|
|
|
|
const imageBlocks = images?.map((img) => ({ type: "image" as const, source: { type: "base64" as const, media_type: img.mimeType, data: img.data } }));
|
|
const userMsg: AgentMessage = {
|
|
role: "user",
|
|
content: imageBlocks?.length
|
|
? [...(message.trim() ? [{ type: "text" as const, text: message }] : []), ...imageBlocks]
|
|
: message,
|
|
timestamp: Date.now(),
|
|
};
|
|
setMessages((prev) => [...prev, userMsg]);
|
|
optimisticUserMessageKeyRef.current = userMessageKey(userMsg);
|
|
promptRunIdRef.current = promptRunId;
|
|
agentRunningRef.current = true;
|
|
setAgentRunning(true);
|
|
setAgentPhase(isSlashCommandPrompt ? { kind: "running_command" } : { kind: "waiting_model" });
|
|
dispatch({ type: "start" });
|
|
pendingScrollToUserRef.current = true;
|
|
completionScrollAllowedRef.current = true;
|
|
|
|
const piImages = images?.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType }));
|
|
|
|
try {
|
|
let sentSessionId: string | null = null;
|
|
if (isNew && newSessionCwd) {
|
|
const selectedModel = newSessionModel;
|
|
const existingSid = sessionIdRef.current ?? await ensuringNewSessionRef.current;
|
|
const sid = existingSid ?? await ensureNewSession();
|
|
|
|
if (sid) {
|
|
sentSessionId = sid;
|
|
if (selectedModel) {
|
|
setPendingModel(selectedModel);
|
|
if (existingSid) {
|
|
await sendAgentCommand(sid, { type: "set_model", provider: selectedModel.provider, modelId: selectedModel.modelId });
|
|
}
|
|
}
|
|
await ensureEventsConnected(sid);
|
|
await sendAgentCommand(sid, {
|
|
type: "prompt",
|
|
message,
|
|
...(piImages?.length ? { images: piImages } : {}),
|
|
});
|
|
promoteNewSession(1, message);
|
|
}
|
|
} else if (session) {
|
|
sentSessionId = session.id;
|
|
await ensureEventsConnected(session.id);
|
|
await sendAgentCommand(session.id, {
|
|
type: "prompt",
|
|
message,
|
|
...(piImages?.length ? { images: piImages } : {}),
|
|
});
|
|
}
|
|
if (isSlashCommandPrompt && sentSessionId) {
|
|
void waitForPromptSettlement(sentSessionId, promptRunId);
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to send message:", e);
|
|
if (e instanceof EventStreamConnectionError) {
|
|
const optimisticKey = optimisticUserMessageKeyRef.current;
|
|
if (optimisticKey) {
|
|
setMessages((prev) => {
|
|
const last = prev[prev.length - 1];
|
|
return last?.role === "user" && userMessageKey(last) === optimisticKey
|
|
? prev.slice(0, -1)
|
|
: prev;
|
|
});
|
|
}
|
|
addNotice({ type: "error", message: e.message });
|
|
// The prompt never reached the agent, so restore the user's text into
|
|
// the input instead of losing it. Mirrors the shell-command recovery in
|
|
// executeBash; insertIfEmpty avoids clobbering anything typed since.
|
|
if (message) opts.chatInputRef?.current?.insertIfEmpty(message);
|
|
}
|
|
optimisticUserMessageKeyRef.current = null;
|
|
agentRunningRef.current = false;
|
|
setAgentRunning(false);
|
|
setAgentPhase(null);
|
|
dispatch({ type: "end" });
|
|
}
|
|
}, [isNew, newSessionCwd, newSessionModel, session, ensureNewSession, ensureEventsConnected, promoteNewSession, waitForPromptSettlement, addNotice, opts.chatInputRef]);
|
|
|
|
const executeBash = useCallback(async (command: string, excludeFromContext: boolean) => {
|
|
if (agentRunningRef.current || bashRunningRef.current) return;
|
|
const inputText = `${excludeFromContext ? "!!" : "!"}${command}`;
|
|
bashRunningRef.current = true;
|
|
setPendingBash({ command, excludeFromContext });
|
|
setBashRunning(true);
|
|
try {
|
|
const sid = sessionIdRef.current ?? session?.id ?? await ensureNewSession();
|
|
if (!sid) throw new Error("Unable to create a session for the shell command");
|
|
await sendAgentCommand(sid, {
|
|
type: "bash",
|
|
command,
|
|
excludeFromContext,
|
|
});
|
|
await loadSession(sid);
|
|
promoteNewSession(1, inputText);
|
|
} catch (e) {
|
|
console.error("Failed to execute shell command:", e);
|
|
addNotice({ type: "error", message: e instanceof Error ? e.message : String(e) });
|
|
opts.chatInputRef?.current?.insertIfEmpty(inputText);
|
|
} finally {
|
|
bashRunningRef.current = false;
|
|
setPendingBash(null);
|
|
setBashRunning(false);
|
|
}
|
|
}, [addNotice, ensureNewSession, loadSession, opts.chatInputRef, promoteNewSession, session]);
|
|
executeBashRef.current = executeBash;
|
|
|
|
const handleAbort = useCallback(async () => {
|
|
const sid = sessionIdRef.current;
|
|
if (!sid) return;
|
|
if (bashRunningRef.current) {
|
|
try {
|
|
await sendAgentCommand(sid, { type: "abort_bash" });
|
|
} catch (e) {
|
|
console.error("Failed to abort bash:", e);
|
|
}
|
|
return;
|
|
}
|
|
try {
|
|
await sendAgentCommand(sid, { type: "abort" });
|
|
} catch (e) {
|
|
console.error("Failed to abort:", e);
|
|
}
|
|
}, []);
|
|
|
|
const handleFork = useCallback(async (entryId: string) => {
|
|
if (bashRunningRef.current) return;
|
|
const sid = sessionIdRef.current;
|
|
if (!sid) return;
|
|
setForkingEntryId(entryId);
|
|
try {
|
|
const result = await sendAgentCommand<{ cancelled?: boolean; newSessionId?: string }>(sid, {
|
|
type: "fork",
|
|
entryId,
|
|
});
|
|
const { cancelled, newSessionId } = result ?? {};
|
|
if (!cancelled && newSessionId) {
|
|
onSessionForked?.(newSessionId);
|
|
}
|
|
} catch (e) {
|
|
console.error("Fork failed:", e);
|
|
} finally {
|
|
setForkingEntryId(null);
|
|
}
|
|
}, [onSessionForked]);
|
|
|
|
const handleNavigate = useCallback(async (entryId: string) => {
|
|
if (bashRunningRef.current) return;
|
|
const sid = sessionIdRef.current;
|
|
if (!sid) return;
|
|
sendAgentCommand(sid, { type: "navigate_tree", targetId: entryId }).catch(() => {});
|
|
setActiveLeafId(entryId);
|
|
await loadContext(sid, entryId);
|
|
}, [loadContext]);
|
|
|
|
const handleLeafChange = useCallback(async (leafId: string | null) => {
|
|
if (bashRunningRef.current) return;
|
|
setActiveLeafId(leafId);
|
|
const sid = sessionIdRef.current;
|
|
if (!sid) return;
|
|
await loadContext(sid, leafId);
|
|
if (leafId) {
|
|
sendAgentCommand(sid, { type: "navigate_tree", targetId: leafId }).catch(() => {});
|
|
}
|
|
}, [loadContext]);
|
|
|
|
const handleModelChange = useCallback(async (provider: string, modelId: string) => {
|
|
if (isNew) {
|
|
setNewSessionModel({ provider, modelId });
|
|
setPendingModel({ provider, modelId });
|
|
const sid = sessionIdRef.current ?? await ensuringNewSessionRef.current;
|
|
if (!sid) return;
|
|
try {
|
|
await sendAgentCommand(sid, { type: "set_model", provider, modelId });
|
|
} catch (e) {
|
|
console.error("Failed to set model:", e);
|
|
}
|
|
return;
|
|
}
|
|
const sid = sessionIdRef.current;
|
|
if (!sid) return;
|
|
try {
|
|
await sendAgentCommand(sid, { type: "set_model", provider, modelId });
|
|
setCurrentModelOverride({ provider, modelId });
|
|
} catch (e) {
|
|
console.error("Failed to set model:", e);
|
|
}
|
|
}, [isNew, setNewSessionModel]);
|
|
|
|
const handleCompact = useCallback(async () => {
|
|
const sid = sessionIdRef.current;
|
|
if (!sid || isCompacting) return;
|
|
setIsCompacting(true);
|
|
setCompactError(null);
|
|
setCompactResult(null);
|
|
try {
|
|
const result = await sendAgentCommand<CompactCommandResult>(sid, { type: "compact" });
|
|
setCompactResult(readCompactResult(result, "manual"));
|
|
await loadSession(sid, true);
|
|
} catch (e) {
|
|
setCompactError(e instanceof Error ? e.message : String(e));
|
|
setCompactResult(null);
|
|
} finally {
|
|
setIsCompacting(false);
|
|
}
|
|
}, [isCompacting, loadSession]);
|
|
|
|
const loadModels = useCallback(async (signal?: AbortSignal) => {
|
|
const modelCwd = newSessionCwd ?? session?.cwd ?? "";
|
|
const modelsUrl = modelCwd ? `/api/models?cwd=${encodeURIComponent(modelCwd)}` : "/api/models";
|
|
const res = await fetch(modelsUrl, signal ? { signal } : undefined);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const d = await res.json() as ModelsResponse;
|
|
setModelNames(d.models);
|
|
setModelError(d.modelError ?? null);
|
|
setModelThinkingLevels(d.thinkingLevels ?? {});
|
|
setModelThinkingLevelMaps(d.thinkingLevelMaps ?? {});
|
|
const nextModelList = d.modelList ?? [];
|
|
setModelList(nextModelList);
|
|
if (isNew) {
|
|
const match = d.defaultModel
|
|
? nextModelList.find((m) => m.id === d.defaultModel?.modelId && m.provider === d.defaultModel?.provider)
|
|
: undefined;
|
|
const displayModel = match ?? nextModelList[0];
|
|
setNewSessionDefaultModel(displayModel ? { provider: displayModel.provider, modelId: displayModel.id } : null);
|
|
}
|
|
}, [isNew, newSessionCwd, session?.cwd]);
|
|
|
|
const handleBuiltinSlashCommand = useCallback(async (text: string): Promise<BuiltinSlashCommandResult> => {
|
|
if (!text.startsWith("/")) return { handled: false };
|
|
const match = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/);
|
|
if (!match) return { handled: false };
|
|
|
|
const [, commandName, rawArgs = ""] = match;
|
|
const args = rawArgs.trim();
|
|
const sid = sessionIdRef.current ?? await ensureNewSession();
|
|
const complete = (result: BuiltinSlashCommandResult): BuiltinSlashCommandResult => {
|
|
if (!result.handled) return result;
|
|
if (result.error) {
|
|
addNotice({ type: "error", message: result.error });
|
|
} else if (result.action !== "openSessionStats") {
|
|
addNotice({ type: "success", message: result.message ?? "Command completed" });
|
|
}
|
|
return result;
|
|
};
|
|
|
|
try {
|
|
switch (commandName) {
|
|
case "compact": {
|
|
if (!sid || isCompacting) return complete({ handled: true, error: "No active session to compact" });
|
|
setIsCompacting(true);
|
|
setCompactError(null);
|
|
setCompactResult(null);
|
|
const result = await sendAgentCommand<CompactCommandResult>(sid, {
|
|
type: "compact",
|
|
...(args ? { customInstructions: args } : {}),
|
|
});
|
|
setCompactResult(readCompactResult(result, "manual"));
|
|
if (await loadSession(sid, true)) promoteNewSession();
|
|
return complete({ handled: true, message: "Compacted context" });
|
|
}
|
|
|
|
case "reload": {
|
|
if (!sid) return complete({ handled: true, error: "No active session to reload" });
|
|
await sendAgentCommand(sid, { type: "reload" });
|
|
await Promise.all([
|
|
loadSession(sid, false, true),
|
|
loadTools(sid),
|
|
loadSlashCommands(),
|
|
loadModels(),
|
|
]);
|
|
return complete({ handled: true, message: "Reloaded session resources" });
|
|
}
|
|
|
|
case "name": {
|
|
if (!sid) return complete({ handled: true, error: "No active session to name" });
|
|
if (!args) return complete({ handled: true, error: "Usage: /name <name>" });
|
|
await sendAgentCommand(sid, { type: "set_session_name", name: args });
|
|
if (await loadSession(sid)) promoteNewSession();
|
|
return complete({ handled: true, message: `Session renamed to ${args}` });
|
|
}
|
|
|
|
case "session": {
|
|
if (!sid) return complete({ handled: true, error: "No active session" });
|
|
const stats = await sendAgentCommand<SessionStatsInfo>(sid, { type: "get_session_stats" });
|
|
if (stats) {
|
|
setSessionStatsOverride(stats);
|
|
}
|
|
onSessionStatsPanelOpen?.();
|
|
return complete({ handled: true, action: "openSessionStats" });
|
|
}
|
|
|
|
case "copy": {
|
|
if (!sid) return complete({ handled: true, error: "No active session" });
|
|
const data = await sendAgentCommand<LastAssistantTextResponse>(sid, { type: "get_last_assistant_text" });
|
|
const textToCopy = data?.text ?? "";
|
|
if (!textToCopy) return complete({ handled: true, error: "No assistant message to copy" });
|
|
await navigator.clipboard.writeText(textToCopy);
|
|
return complete({ handled: true, message: "Copied last assistant message" });
|
|
}
|
|
|
|
default:
|
|
return { handled: false };
|
|
}
|
|
} catch (e) {
|
|
return complete({ handled: true, error: e instanceof Error ? e.message : String(e) });
|
|
} finally {
|
|
if (commandName === "compact") setIsCompacting(false);
|
|
}
|
|
}, [addNotice, ensureNewSession, isCompacting, loadModels, loadSession, loadSlashCommands, loadTools, promoteNewSession, onSessionStatsPanelOpen]);
|
|
|
|
// Queued (undelivered) messages live in the queue panel only; the chat gets
|
|
// the real user message when pi delivers it (user message_end event). An
|
|
// optimistic chat bubble here would duplicate the queue panel and turn into
|
|
// a ghost message if the queue is recalled.
|
|
const handleSteer = useCallback(async (message: string, images?: AttachedImage[]) => {
|
|
const sid = sessionIdRef.current;
|
|
if (!sid) return;
|
|
const piImages = images?.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType }));
|
|
try {
|
|
await sendAgentCommand(sid, {
|
|
type: "steer",
|
|
message,
|
|
...(piImages?.length ? { images: piImages } : {}),
|
|
});
|
|
} catch (e) {
|
|
console.error("Failed to steer:", e);
|
|
}
|
|
}, []);
|
|
|
|
const handlePromptWithStreamingBehavior = useCallback(async (
|
|
message: string,
|
|
behavior: "steer" | "followUp",
|
|
images?: AttachedImage[],
|
|
) => {
|
|
const sid = sessionIdRef.current;
|
|
if (!sid) return;
|
|
const piImages = images?.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType }));
|
|
try {
|
|
await sendAgentCommand(sid, {
|
|
type: "prompt",
|
|
message,
|
|
streamingBehavior: behavior,
|
|
...(piImages?.length ? { images: piImages } : {}),
|
|
});
|
|
} catch (e) {
|
|
console.error("Failed to queue prompt:", e);
|
|
}
|
|
}, []);
|
|
|
|
const handleFollowUp = useCallback(async (message: string, images?: AttachedImage[]) => {
|
|
const sid = sessionIdRef.current;
|
|
if (!sid) return;
|
|
const piImages = images?.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType }));
|
|
try {
|
|
await sendAgentCommand(sid, {
|
|
type: "follow_up",
|
|
message,
|
|
...(piImages?.length ? { images: piImages } : {}),
|
|
});
|
|
} catch (e) {
|
|
console.error("Failed to follow up:", e);
|
|
}
|
|
}, []);
|
|
|
|
const handleAbortCompaction = useCallback(async () => {
|
|
const sid = sessionIdRef.current;
|
|
if (!sid) return;
|
|
try {
|
|
await sendAgentCommand(sid, { type: "abort_compaction" });
|
|
} catch (e) {
|
|
console.error("Failed to abort compaction:", e);
|
|
}
|
|
}, []);
|
|
|
|
const handleRecallQueue = useCallback(async () => {
|
|
const sid = sessionIdRef.current;
|
|
if (!sid) return;
|
|
try {
|
|
const result = await sendAgentCommand<{ steering?: string[]; followUp?: string[] }>(sid, { type: "clear_queue" });
|
|
// clearQueue also emits an empty queue_update, but that only reaches us
|
|
// while SSE is connected — clear locally so idle recalls update the UI.
|
|
setQueuedMessages({ steering: [], followUp: [] });
|
|
const texts = [...(result?.steering ?? []), ...(result?.followUp ?? [])];
|
|
if (texts.length > 0) {
|
|
opts.chatInputRef?.current?.prependText(texts.join("\n\n"));
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to recall queued messages:", e);
|
|
addNotice({ type: "error", message: "Failed to recall queued messages" });
|
|
}
|
|
}, [opts.chatInputRef, addNotice]);
|
|
|
|
const handleThinkingLevelChange = useCallback(async (level: ThinkingLevelOption) => {
|
|
setThinkingLevel(level);
|
|
if (level === "auto") return; // "auto" leaves pi's current setting untouched
|
|
const sid = sessionIdRef.current ?? await ensuringNewSessionRef.current;
|
|
if (!sid) return;
|
|
try {
|
|
await sendAgentCommand(sid, { type: "set_thinking_level", level });
|
|
} catch (e) {
|
|
console.error("Failed to set thinking level:", e);
|
|
}
|
|
}, []);
|
|
|
|
const handleToolPresetChange = useCallback(async (preset: "none" | "default" | "full") => {
|
|
const toolNames = getToolNamesForPreset(preset);
|
|
setToolPresetState(preset);
|
|
const sid = sessionIdRef.current ?? await ensuringNewSessionRef.current;
|
|
if (!sid) return;
|
|
try {
|
|
await sendAgentCommand(sid, { type: "set_tools", toolNames });
|
|
} catch (e) {
|
|
console.error("Failed to set tools:", e);
|
|
}
|
|
}, [setToolPresetState]);
|
|
|
|
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
|
ignoreProgrammaticScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_IGNORE_MS;
|
|
messagesEndRef.current?.scrollIntoView({ behavior });
|
|
}, []);
|
|
|
|
const scrollUserMsgToTop = useCallback(() => {
|
|
const container = scrollContainerRef.current;
|
|
const el = lastUserMsgRef.current;
|
|
if (!container || !el) return;
|
|
const elAbsTop = el.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop;
|
|
ignoreProgrammaticScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_IGNORE_MS;
|
|
container.scrollTo({ top: elAbsTop - 16, behavior: "smooth" });
|
|
}, []);
|
|
|
|
const markUserScrollIntent = useCallback((event: Event) => {
|
|
if (event instanceof KeyboardEvent) {
|
|
if (!SCROLL_KEYS.has(event.key)) return;
|
|
if (event.target instanceof Element && event.target.closest("input, textarea, [contenteditable='true']")) return;
|
|
}
|
|
userScrollIntentUntilRef.current = Date.now() + USER_SCROLL_INTENT_MS;
|
|
}, []);
|
|
|
|
const handleScrollPositionChange = useCallback(() => {
|
|
if (!agentRunningRef.current) return;
|
|
if (Date.now() < ignoreProgrammaticScrollUntilRef.current) return;
|
|
if (Date.now() > userScrollIntentUntilRef.current) return;
|
|
completionScrollAllowedRef.current = false;
|
|
}, []);
|
|
|
|
// Load session on mount
|
|
useEffect(() => {
|
|
if (session) {
|
|
sessionIdRef.current = session.id;
|
|
loadSession(session.id, true, true).then((agentState) => {
|
|
if (agentState?.running) {
|
|
loadTools(session.id);
|
|
if (agentState.state?.isStreaming || agentState.state?.isPromptRunning) {
|
|
agentRunningRef.current = true;
|
|
setAgentRunning(true);
|
|
setAgentPhase(agentState.state.isStreaming ? { kind: "waiting_model" } : { kind: "running_command" });
|
|
dispatch({ type: "start" });
|
|
void connectEvents(session.id);
|
|
if (!agentState.state.isStreaming && agentState.state.isPromptRunning) {
|
|
void waitForPromptSettlement(session.id);
|
|
}
|
|
}
|
|
if (agentState.state?.isBashRunning) {
|
|
bashRunningRef.current = true;
|
|
setBashRunning(true);
|
|
void waitForBashSettlement(session.id);
|
|
}
|
|
}
|
|
if (agentState?.state) {
|
|
if (agentState.state.isCompacting !== undefined) setIsCompacting(agentState.state.isCompacting);
|
|
if (agentState.state.contextUsage !== undefined) setContextUsage(agentState.state.contextUsage ?? null);
|
|
if (agentState.state.systemPrompt !== undefined) setSystemPrompt(agentState.state.systemPrompt ?? null);
|
|
if (agentState.state.thinkingLevel !== undefined) setThinkingLevel((agentState.state.thinkingLevel as ThinkingLevelOption) ?? "auto");
|
|
if (agentState.state.extensionStatuses !== undefined) setExtensionStatuses(agentState.state.extensionStatuses ?? []);
|
|
if (agentState.state.extensionWidgets !== undefined) setExtensionWidgets(agentState.state.extensionWidgets ?? []);
|
|
if (agentState.state.queuedMessages !== undefined) setQueuedMessages(normalizeQueuedMessages(agentState.state.queuedMessages));
|
|
}
|
|
});
|
|
}
|
|
return () => {
|
|
bashRecoveryIdRef.current += 1;
|
|
eventSourceRef.current?.close();
|
|
eventSourceRef.current = null;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
onSystemPromptChange?.(systemPrompt);
|
|
}, [systemPrompt, onSystemPromptChange]);
|
|
|
|
useEffect(() => {
|
|
if (!onBranchDataChange) return;
|
|
onBranchDataChange(data?.tree ?? [], activeLeafId, handleLeafChange);
|
|
}, [data?.tree, activeLeafId, handleLeafChange, onBranchDataChange]);
|
|
|
|
useEffect(() => {
|
|
window.addEventListener("keydown", markUserScrollIntent);
|
|
window.addEventListener("pointerdown", markUserScrollIntent, { passive: true });
|
|
return () => {
|
|
window.removeEventListener("keydown", markUserScrollIntent);
|
|
window.removeEventListener("pointerdown", markUserScrollIntent);
|
|
};
|
|
}, [markUserScrollIntent]);
|
|
|
|
useEffect(() => {
|
|
const container = scrollContainerRef.current;
|
|
if (!container) return;
|
|
container.addEventListener("wheel", markUserScrollIntent, { passive: true });
|
|
container.addEventListener("touchstart", markUserScrollIntent, { passive: true });
|
|
container.addEventListener("scroll", handleScrollPositionChange, { passive: true });
|
|
return () => {
|
|
container.removeEventListener("wheel", markUserScrollIntent);
|
|
container.removeEventListener("touchstart", markUserScrollIntent);
|
|
container.removeEventListener("scroll", handleScrollPositionChange);
|
|
};
|
|
}, [messages.length, loading, handleScrollPositionChange, markUserScrollIntent]);
|
|
|
|
useEffect(() => {
|
|
if (messages.length > 0) {
|
|
if (pendingScrollToUserRef.current) {
|
|
pendingScrollToUserRef.current = false;
|
|
initialScrollDoneRef.current = true;
|
|
scrollUserMsgToTop();
|
|
} else if (!initialScrollDoneRef.current) {
|
|
initialScrollDoneRef.current = true;
|
|
scrollToBottom("instant");
|
|
} else if (!agentRunningRef.current && completionScrollAllowedRef.current) {
|
|
scrollToBottom("smooth");
|
|
}
|
|
}
|
|
}, [messages.length, agentRunning, scrollToBottom, scrollUserMsgToTop]);
|
|
|
|
// Load model list
|
|
useEffect(() => {
|
|
const controller = new AbortController();
|
|
loadModels(controller.signal).catch((e) => {
|
|
if (e instanceof DOMException && e.name === "AbortError") return;
|
|
});
|
|
return () => controller.abort();
|
|
}, [loadModels, modelsRefreshKey]);
|
|
|
|
useEffect(() => {
|
|
if (!compactResult) return;
|
|
const t = setTimeout(() => setCompactResult(null), 6000);
|
|
return () => clearTimeout(t);
|
|
}, [compactResult]);
|
|
|
|
useEffect(() => {
|
|
if (noticeState.visible.length === 0) return;
|
|
const exiting = noticeState.visible.find((notice) => notice.exiting);
|
|
if (exiting) {
|
|
const t = setTimeout(() => {
|
|
dispatchNotice({ type: "remove", id: exiting.id });
|
|
}, NOTICE_EXIT_ANIMATION_MS);
|
|
return () => clearTimeout(t);
|
|
}
|
|
const oldest = noticeState.visible[0];
|
|
if (!oldest) return;
|
|
const t = setTimeout(() => {
|
|
dispatchNotice({ type: "mark_oldest_exiting" });
|
|
}, NOTICE_VISIBLE_MS);
|
|
return () => clearTimeout(t);
|
|
}, [noticeState.visible]);
|
|
|
|
useEffect(() => {
|
|
setSessionStatsOverride(null);
|
|
}, [messages.length, contextUsage?.tokens, contextUsage?.percent, contextUsage?.contextWindow]);
|
|
|
|
return {
|
|
// State
|
|
data, loading, error, activeLeafId, messages, entryIds, streamState,
|
|
agentRunning, modelNames, modelList, modelError, modelThinkingLevels, modelThinkingLevelMaps, newSessionModel, toolPreset, thinkingLevel,
|
|
retryInfo, contextUsage, systemPrompt, forkingEntryId,
|
|
isCompacting, compactError, compactResult, currentModel, displayModel, sessionStats,
|
|
slashCommands, slashCommandsLoading, queuedMessages,
|
|
notices: noticeState.visible, extensionDialog, extensionCustomUi, extensionStatuses, extensionWidgets, respondToExtensionUi, sendExtensionCustomInput,
|
|
isAutoModelSelection: isNew && newSessionModel === null,
|
|
agentPhase,
|
|
isNew,
|
|
// Refs
|
|
sessionIdRef, eventSourceRef, messagesEndRef, scrollContainerRef,
|
|
lastUserMsgRef, pendingScrollToUserRef, initialScrollDoneRef,
|
|
// Actions
|
|
handleSend, handleAbort, handleFork, handleNavigate, handleModelChange,
|
|
handleCompact, handleSteer, handleFollowUp, handlePromptWithStreamingBehavior, handleAbortCompaction,
|
|
handleRecallQueue,
|
|
handleBuiltinSlashCommand,
|
|
handleToolPresetChange, handleThinkingLevelChange, loadTools, loadSlashCommands, setActiveLeafId, setData, setMessages,
|
|
dispatch, setAgentRunning, setForkingEntryId,
|
|
bashRunning, pendingBash,
|
|
// Subscriptions
|
|
handleAgentEventRef,
|
|
};
|
|
}
|