mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 02:59:35 +00:00
- ChatWindow: follow the streaming bubble on every chunk (scrollIntoView block:end), driven by followStreamRef (true by default, only genuine scroll gestures flip it) - Remove the agentRunning spacer that pushed content above the viewport at the bottom - Move the scroll-to-bottom button into the input row (ChatInput scrollToBottomButton slot), always visible, dimmed at the bottom; click jumps to the live stream or the end - useAgentSession: drop window pointerdown as scroll-intent marker (send-click was misread as a scroll-away, disabling follow before the stream started); expose atBottom/followStreamRef/scrollToBottom - i18n: add chat.scrollToBottom (en/zh-CN)
2327 lines
67 KiB
TypeScript
2327 lines
67 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;
|
|
// Distance from the container bottom (px) under which the view is
|
|
// considered "at the bottom". Auto-follow stays engaged within this range;
|
|
// scrolling further up disengages it.
|
|
const FOLLOW_STREAM_THRESHOLD = 100;
|
|
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;
|
|
serviceTier?: 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 [atBottom, setAtBottom] = useState(true);
|
|
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);
|
|
// Whether the view should auto-follow new content (streaming output,
|
|
// completion). True by default; a user scroll away from the bottom flips
|
|
// it off, scrolling back to the bottom or clicking the scroll-to-bottom
|
|
// button flips it on again. A ref (not state) because only the streaming
|
|
// chunk render needs to react to it.
|
|
const followStreamRef = useRef(true);
|
|
const atBottomRef = 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;
|
|
followStreamRef.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") => {
|
|
followStreamRef.current = true;
|
|
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(() => {
|
|
const container = scrollContainerRef.current;
|
|
if (!container) return;
|
|
const distFromBottom =
|
|
container.scrollHeight - container.scrollTop - container.clientHeight;
|
|
const nearBottom = distFromBottom <= FOLLOW_STREAM_THRESHOLD;
|
|
|
|
// Keep the "at bottom" indicator (scroll-to-bottom button visibility)
|
|
// in sync on every scroll, regardless of agent state.
|
|
if (atBottomRef.current !== nearBottom) {
|
|
atBottomRef.current = nearBottom;
|
|
setAtBottom(nearBottom);
|
|
}
|
|
|
|
// Auto-follow only reacts to genuine scroll gestures (wheel / touch /
|
|
// keyboard scroll keys inside the intent window). Programmatic scrolls
|
|
// must never flip it — right after sending, scrollUserMsgToTop parks
|
|
// the view at the user message (away from the bottom because of the
|
|
// running spacer); treating that as a user scroll-away would disable
|
|
// follow before the stream even starts. A plain click (send button,
|
|
// minimap) is also not a scroll gesture.
|
|
if (Date.now() > userScrollIntentUntilRef.current) return;
|
|
|
|
// Scrolled away from the bottom → stop following the stream; scrolled
|
|
// back to the bottom → resume following.
|
|
if (followStreamRef.current !== nearBottom) {
|
|
followStreamRef.current = nearBottom;
|
|
if (!nearBottom && agentRunningRef.current) {
|
|
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(() => {
|
|
// Only genuine scroll gestures (wheel / touch / keyboard scroll keys)
|
|
// count as user scroll intent. A plain pointerdown (clicking send, a
|
|
// button, the minimap) is NOT — otherwise the programmatic
|
|
// scrollUserMsgToTop that runs right after sending would be treated as
|
|
// user-initiated, the handler would see the view away from the bottom
|
|
// (the user message sits above the spacer), and auto-follow would be
|
|
// disabled before the stream even starts.
|
|
window.addEventListener("keydown", markUserScrollIntent);
|
|
return () => {
|
|
window.removeEventListener("keydown", 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,
|
|
followStreamRef,
|
|
atBottom,
|
|
scrollToBottom,
|
|
// 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,
|
|
};
|
|
}
|