c7cc92382b
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
1455 lines
49 KiB
TypeScript
1455 lines
49 KiB
TypeScript
"use client";
|
|
|
|
/* eslint-disable max-lines -- this component composes the complete transcript surface. */
|
|
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
memo,
|
|
type MutableRefObject,
|
|
type RefObject,
|
|
} from "react";
|
|
import { PanelRoot, PanelBody } from "./panel-primitives";
|
|
import { ComposerFooterAllocation } from "./chat/composer-disclosure";
|
|
import { useSettingsData } from "@/hooks/domains/settings/use-settings-data";
|
|
import {
|
|
type ChatInputContainerHandle,
|
|
type ChatSubmitPayload,
|
|
type ChatSubmitResult,
|
|
} from "@/components/task/chat/chat-input-container";
|
|
import { MessageList } from "@/components/task/chat/message-list";
|
|
import {
|
|
type MessageListHandle,
|
|
type LastPromptEdge,
|
|
getLastUserMessageId,
|
|
getFirstUserMessageId,
|
|
resolveLastPromptControls,
|
|
} from "@/components/task/chat/message-list-shared";
|
|
import { AnchoredLastPromptBar } from "@/components/task/chat/anchored-last-prompt-bar";
|
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
|
import { useIsTaskArchived } from "./task-archived-context";
|
|
import { useChatPanelState } from "./chat/use-chat-panel-state";
|
|
import { ChatInputArea, useSubmitHandler, useChatPanelHandlers } from "./chat/chat-input-area";
|
|
import { useDockviewStore, type TranscriptScrollTarget } from "@/lib/state/dockview-store";
|
|
import { ClarificationPanelSection } from "./chat/clarification-panel-section";
|
|
import { useComposerAgentStartHint } from "./chat/use-composer-agent-start-hint";
|
|
import { PanelSearchBar } from "@/components/search/panel-search-bar";
|
|
import { SessionSearchHits } from "@/components/task/chat/session-search-hits";
|
|
import { usePanelSearch } from "@/hooks/use-panel-search";
|
|
import { useSessionSearch } from "@/hooks/domains/session/use-session-search";
|
|
import { useLazyLoadMessages } from "@/hooks/use-lazy-load-messages";
|
|
import { findUnreadDividerItemId, lastRenderedMessageId } from "@/lib/session-unread-divider";
|
|
import { useSessionReadTracking } from "./chat/use-session-read-tracking";
|
|
import { useDrainOlderMessages } from "@/components/task/chat/use-drain-older-messages";
|
|
import type { RenderItem } from "@/hooks/use-processed-messages";
|
|
|
|
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
|
import { getSessionWorkspacePath } from "@/lib/session-workspace-path";
|
|
import type { AppState } from "@/lib/state/store";
|
|
import type { StoreApi } from "zustand";
|
|
import { routePanelMouseDown } from "./chat/route-panel-mouse-down";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { loadMessageWindowAround } from "@/hooks/domains/session/load-message-window";
|
|
import { LaunchWarning } from "./launch-warning";
|
|
import { useTaskLaunchErrorContext } from "./task-launch-error-context";
|
|
import { useTaskStatusSummary } from "@/hooks/domains/task/use-task-status-summary";
|
|
import { TaskMarkdownFileLinkProvider } from "@/components/shared/task-markdown-file-link-provider";
|
|
import { statusSummaryTaskError } from "@/lib/task-status-summary";
|
|
import {
|
|
hasWorkflowParkingMarker,
|
|
LaunchQueueStatus,
|
|
ParkedSessionNote,
|
|
} from "./launch-queue-status";
|
|
import { WipQueueStatus } from "./wip-queue-status";
|
|
import { useLateClarificationMessage } from "@/hooks/use-late-clarification-message";
|
|
|
|
/** Returns a `clarificationKey` that increments each time a pending
|
|
* clarification is resolved, letting the composer reset its input state for
|
|
* the next clarification round. */
|
|
function useClarificationKey(agentMessageCount: number) {
|
|
const lastCountRef = useRef(agentMessageCount);
|
|
const [clarificationKey, setClarificationKey] = useState(0);
|
|
useEffect(() => {
|
|
lastCountRef.current = agentMessageCount;
|
|
}, [agentMessageCount]);
|
|
const handleClarificationResolved = useCallback(() => setClarificationKey((k) => k + 1), []);
|
|
return { clarificationKey, handleClarificationResolved };
|
|
}
|
|
|
|
/** Identity for a prompt-history target owned by a non-Dockview host. */
|
|
export type PendingMessageScrollTarget = {
|
|
sessionId: string;
|
|
messageId: string;
|
|
token: number;
|
|
hostPanelId: string;
|
|
};
|
|
/** Reports whether a target has a dedicated DOM row in the transcript. */
|
|
export function isMessageRowRendered(items: readonly RenderItem[], messageId: string): boolean {
|
|
return items.some((item) => item.type === "message" && item.message.id === messageId);
|
|
}
|
|
|
|
/** Scrolls a non-Dockview host target after the message row becomes rendered. */
|
|
type PendingMessageScrollOptions = {
|
|
messageListRef: RefObject<MessageListHandle | null>;
|
|
sessionId: string | null;
|
|
messageId: string | null | undefined;
|
|
target?: PendingMessageScrollTarget | null;
|
|
onConsumed: ((messageId: string) => void) | undefined;
|
|
readinessKey: string;
|
|
isInitialMessagesLoading: boolean;
|
|
isVisible?: boolean;
|
|
};
|
|
|
|
type MessageTargetLifecycle = {
|
|
sessionId: string | null;
|
|
messageId: string | null | undefined;
|
|
target?: PendingMessageScrollTarget | null;
|
|
isVisible: boolean;
|
|
};
|
|
|
|
type AroundWindowRequestOptions = {
|
|
sessionId: string;
|
|
messageId: string;
|
|
targetKey: string;
|
|
store: StoreApi<AppState>;
|
|
requestKeysRef: MutableRefObject<Set<string>>;
|
|
mountedRef: MutableRefObject<boolean>;
|
|
isCurrentTarget: () => boolean;
|
|
setLoading: (loading: boolean) => void;
|
|
onMerged: () => void;
|
|
onCancelled: () => void;
|
|
};
|
|
|
|
function requestMessageWindowAround({
|
|
sessionId,
|
|
messageId,
|
|
targetKey,
|
|
store,
|
|
requestKeysRef,
|
|
mountedRef,
|
|
isCurrentTarget,
|
|
setLoading,
|
|
onMerged,
|
|
onCancelled,
|
|
}: AroundWindowRequestOptions) {
|
|
requestKeysRef.current.add(targetKey);
|
|
setLoading(true);
|
|
void loadMessageWindowAround(sessionId, messageId, isCurrentTarget, store)
|
|
.then((result) => {
|
|
if (!isCurrentTarget()) return;
|
|
if (result.kind === "merged") onMerged();
|
|
else if (result.kind === "deleted-target") onCancelled();
|
|
})
|
|
.catch(() => {
|
|
if (isCurrentTarget()) onCancelled();
|
|
})
|
|
.finally(() => {
|
|
requestKeysRef.current.delete(targetKey);
|
|
if (mountedRef.current) setLoading(requestKeysRef.current.size > 0);
|
|
});
|
|
}
|
|
|
|
type TargetReassertionOptions = {
|
|
targetKey: string;
|
|
timerRef: MutableRefObject<number | null>;
|
|
attemptedRef: MutableRefObject<Set<string>>;
|
|
completedAroundRef: MutableRefObject<Set<string>>;
|
|
isCurrentTarget: () => boolean;
|
|
scroll: () => void;
|
|
consume: () => void;
|
|
};
|
|
|
|
function scheduleTargetReassertion({
|
|
targetKey,
|
|
timerRef,
|
|
attemptedRef,
|
|
completedAroundRef,
|
|
isCurrentTarget,
|
|
scroll,
|
|
consume,
|
|
}: TargetReassertionOptions) {
|
|
if (timerRef.current !== null || attemptedRef.current.has(targetKey) || !isCurrentTarget()) {
|
|
return;
|
|
}
|
|
timerRef.current = window.setTimeout(() => {
|
|
timerRef.current = null;
|
|
if (!isCurrentTarget()) return;
|
|
attemptedRef.current.add(targetKey);
|
|
scroll();
|
|
completedAroundRef.current.delete(targetKey);
|
|
consume();
|
|
}, 250);
|
|
}
|
|
|
|
function cancelTargetReassertion(timerRef: MutableRefObject<number | null>) {
|
|
if (timerRef.current === null) return;
|
|
window.clearTimeout(timerRef.current);
|
|
timerRef.current = null;
|
|
}
|
|
|
|
type PendingMessageScrollEffectOptions = {
|
|
messageListRef: RefObject<MessageListHandle | null>;
|
|
target: PendingMessageScrollTarget | null | undefined;
|
|
onConsumed: ((messageId: string) => void) | undefined;
|
|
readinessKey: string;
|
|
isInitialMessagesLoading: boolean;
|
|
isVisible: boolean;
|
|
effectiveSessionId: string | null;
|
|
effectiveMessageId: string | null | undefined;
|
|
effectiveTargetKey: string | null;
|
|
targetBelongsToHost: boolean;
|
|
store: StoreApi<AppState>;
|
|
refs: {
|
|
requestKeys: MutableRefObject<Set<string>>;
|
|
completedAround: MutableRefObject<Set<string>>;
|
|
targetIdentity: MutableRefObject<string | null>;
|
|
scrollSucceededTarget: MutableRefObject<string | null>;
|
|
reassertionTimer: MutableRefObject<number | null>;
|
|
reassertionAttempted: MutableRefObject<Set<string>>;
|
|
mounted: MutableRefObject<boolean>;
|
|
lifecycle: MutableRefObject<MessageTargetLifecycle>;
|
|
};
|
|
setIsLoading: (loading: boolean) => void;
|
|
};
|
|
type PendingMessageTargetAttemptOptions = {
|
|
messageListRef: RefObject<MessageListHandle | null>;
|
|
sessionId: string;
|
|
messageId: string;
|
|
targetKey: string;
|
|
isInitialMessagesLoading: boolean;
|
|
store: StoreApi<AppState>;
|
|
refs: PendingMessageScrollEffectOptions["refs"];
|
|
setIsLoading: (loading: boolean) => void;
|
|
isCurrentTarget: () => boolean;
|
|
consume: () => void;
|
|
};
|
|
|
|
function attemptPendingMessageScroll({
|
|
messageListRef,
|
|
sessionId,
|
|
messageId,
|
|
targetKey,
|
|
isInitialMessagesLoading,
|
|
store,
|
|
refs,
|
|
setIsLoading,
|
|
isCurrentTarget,
|
|
consume,
|
|
}: PendingMessageTargetAttemptOptions) {
|
|
if (!isCurrentTarget()) return;
|
|
if (
|
|
refs.completedAround.current.has(targetKey) &&
|
|
(refs.reassertionTimer.current !== null || refs.reassertionAttempted.current.has(targetKey))
|
|
) {
|
|
return;
|
|
}
|
|
const scheduleReassertion = () =>
|
|
scheduleTargetReassertion({
|
|
targetKey,
|
|
timerRef: refs.reassertionTimer,
|
|
attemptedRef: refs.reassertionAttempted,
|
|
completedAroundRef: refs.completedAround,
|
|
isCurrentTarget,
|
|
scroll: () => {
|
|
messageListRef.current?.scrollToMessage(messageId, {
|
|
align: "start",
|
|
behavior: "auto",
|
|
});
|
|
},
|
|
consume,
|
|
});
|
|
if (
|
|
messageListRef.current?.scrollToMessage(messageId, {
|
|
align: "start",
|
|
behavior: "auto",
|
|
})
|
|
) {
|
|
refs.scrollSucceededTarget.current = targetKey;
|
|
if (refs.completedAround.current.has(targetKey)) scheduleReassertion();
|
|
else if (!refs.requestKeys.current.has(targetKey)) consume();
|
|
return;
|
|
}
|
|
if (isInitialMessagesLoading) return;
|
|
const loaded = store
|
|
.getState()
|
|
.messages.bySession[sessionId]?.some((message) => message.id === messageId);
|
|
if (
|
|
loaded ||
|
|
refs.requestKeys.current.has(targetKey) ||
|
|
refs.completedAround.current.has(targetKey)
|
|
) {
|
|
return;
|
|
}
|
|
requestMessageWindowAround({
|
|
sessionId,
|
|
messageId,
|
|
targetKey,
|
|
store,
|
|
requestKeysRef: refs.requestKeys,
|
|
mountedRef: refs.mounted,
|
|
isCurrentTarget,
|
|
setLoading: setIsLoading,
|
|
onMerged: () => {
|
|
refs.completedAround.current.add(targetKey);
|
|
if (refs.scrollSucceededTarget.current === targetKey) scheduleReassertion();
|
|
},
|
|
onCancelled: consume,
|
|
});
|
|
}
|
|
|
|
function usePendingMessageScrollEffect(options: PendingMessageScrollEffectOptions) {
|
|
const {
|
|
messageListRef,
|
|
target,
|
|
onConsumed,
|
|
readinessKey,
|
|
isInitialMessagesLoading,
|
|
isVisible,
|
|
effectiveSessionId,
|
|
effectiveMessageId,
|
|
effectiveTargetKey,
|
|
targetBelongsToHost,
|
|
store,
|
|
refs,
|
|
setIsLoading,
|
|
} = options;
|
|
useEffect(() => {
|
|
const cancelReassertion = () => cancelTargetReassertion(refs.reassertionTimer);
|
|
if (!targetBelongsToHost) {
|
|
refs.targetIdentity.current = null;
|
|
refs.completedAround.current.clear();
|
|
cancelReassertion();
|
|
setIsLoading(false);
|
|
if (target) onConsumed?.(target.messageId);
|
|
return;
|
|
}
|
|
if (!effectiveSessionId || !effectiveMessageId || !effectiveTargetKey) {
|
|
refs.targetIdentity.current = null;
|
|
refs.completedAround.current.clear();
|
|
cancelReassertion();
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
if (refs.targetIdentity.current !== effectiveTargetKey) {
|
|
refs.targetIdentity.current = effectiveTargetKey;
|
|
refs.scrollSucceededTarget.current = null;
|
|
refs.completedAround.current.clear();
|
|
cancelReassertion();
|
|
}
|
|
if (!isVisible) {
|
|
cancelReassertion();
|
|
refs.completedAround.current.delete(effectiveTargetKey);
|
|
return;
|
|
}
|
|
const isCurrentTarget = () =>
|
|
refs.mounted.current &&
|
|
refs.lifecycle.current.isVisible &&
|
|
refs.targetIdentity.current === effectiveTargetKey &&
|
|
refs.lifecycle.current.sessionId === effectiveSessionId &&
|
|
refs.lifecycle.current.messageId === effectiveMessageId;
|
|
const consume = () => onConsumed?.(effectiveMessageId);
|
|
const frame = requestAnimationFrame(() => {
|
|
attemptPendingMessageScroll({
|
|
messageListRef,
|
|
sessionId: effectiveSessionId,
|
|
messageId: effectiveMessageId,
|
|
targetKey: effectiveTargetKey,
|
|
isInitialMessagesLoading,
|
|
store,
|
|
refs,
|
|
setIsLoading,
|
|
isCurrentTarget,
|
|
consume,
|
|
});
|
|
});
|
|
return () => cancelAnimationFrame(frame);
|
|
}, [
|
|
effectiveMessageId,
|
|
effectiveSessionId,
|
|
effectiveTargetKey,
|
|
isInitialMessagesLoading,
|
|
isVisible,
|
|
messageListRef,
|
|
onConsumed,
|
|
readinessKey,
|
|
refs,
|
|
setIsLoading,
|
|
store,
|
|
target,
|
|
targetBelongsToHost,
|
|
]);
|
|
}
|
|
|
|
export function usePendingMessageScroll({
|
|
messageListRef,
|
|
sessionId,
|
|
messageId,
|
|
target,
|
|
onConsumed,
|
|
readinessKey,
|
|
isInitialMessagesLoading,
|
|
isVisible = true,
|
|
}: PendingMessageScrollOptions) {
|
|
const store = useAppStoreApi();
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const refs = useMemo(
|
|
() => ({
|
|
requestKeys: { current: new Set<string>() },
|
|
completedAround: { current: new Set<string>() },
|
|
targetIdentity: { current: null as string | null },
|
|
scrollSucceededTarget: { current: null as string | null },
|
|
reassertionTimer: { current: null as number | null },
|
|
reassertionAttempted: { current: new Set<string>() },
|
|
mounted: { current: true },
|
|
lifecycle: { current: { sessionId, messageId, target, isVisible } },
|
|
}),
|
|
[],
|
|
);
|
|
const effectiveSessionId = target?.sessionId ?? sessionId;
|
|
const effectiveMessageId = target?.messageId ?? messageId;
|
|
const targetToken = target?.token ?? 0;
|
|
const targetHostPanelId = target?.hostPanelId ?? "pending";
|
|
const effectiveTargetKey =
|
|
effectiveSessionId && effectiveMessageId
|
|
? `${effectiveSessionId}\u0000${effectiveMessageId}\u0000${targetToken}\u0000${targetHostPanelId}`
|
|
: null;
|
|
refs.lifecycle.current = {
|
|
sessionId: effectiveSessionId,
|
|
messageId: effectiveMessageId,
|
|
target,
|
|
isVisible,
|
|
};
|
|
useEffect(() => {
|
|
refs.mounted.current = true;
|
|
return () => {
|
|
refs.mounted.current = false;
|
|
cancelTargetReassertion(refs.reassertionTimer);
|
|
};
|
|
}, [refs]);
|
|
usePendingMessageScrollEffect({
|
|
messageListRef,
|
|
target,
|
|
onConsumed,
|
|
readinessKey,
|
|
isInitialMessagesLoading,
|
|
isVisible,
|
|
effectiveSessionId,
|
|
effectiveMessageId,
|
|
effectiveTargetKey,
|
|
targetBelongsToHost: !target || target.sessionId === sessionId,
|
|
store,
|
|
refs,
|
|
setIsLoading,
|
|
});
|
|
return { isLoading };
|
|
}
|
|
|
|
const SCROLL_TO_START_RETRY_DELAY_MS = 50;
|
|
const MAX_SCROLL_TO_START_ATTEMPTS = 120;
|
|
|
|
type PendingScrollToStartOptions = {
|
|
messageListRef: RefObject<MessageListHandle | null>;
|
|
firstMessageId: string | null;
|
|
hasMore: boolean;
|
|
pending: boolean;
|
|
requestKey: number;
|
|
onComplete: (didScroll: boolean) => void;
|
|
};
|
|
|
|
/** Keeps a scroll-to-start request alive until the final page's row is mounted. */
|
|
export function usePendingScrollToStart({
|
|
messageListRef,
|
|
firstMessageId,
|
|
hasMore,
|
|
pending,
|
|
requestKey,
|
|
onComplete,
|
|
}: PendingScrollToStartOptions) {
|
|
useEffect(() => {
|
|
if (!pending || hasMore) return;
|
|
if (!firstMessageId) {
|
|
onComplete(false);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
let frameId: number | null = null;
|
|
let timeoutId: number | null = null;
|
|
let attempts = 0;
|
|
const attempt = () => {
|
|
if (cancelled) return;
|
|
attempts += 1;
|
|
const didScroll = Boolean(
|
|
messageListRef.current?.scrollToMessage(firstMessageId, { align: "start" }),
|
|
);
|
|
if (didScroll || attempts >= MAX_SCROLL_TO_START_ATTEMPTS) {
|
|
onComplete(didScroll);
|
|
return;
|
|
}
|
|
timeoutId = window.setTimeout(attempt, SCROLL_TO_START_RETRY_DELAY_MS);
|
|
};
|
|
|
|
frameId = requestAnimationFrame(attempt);
|
|
return () => {
|
|
cancelled = true;
|
|
if (frameId !== null) cancelAnimationFrame(frameId);
|
|
if (timeoutId !== null) window.clearTimeout(timeoutId);
|
|
};
|
|
}, [firstMessageId, hasMore, messageListRef, onComplete, pending, requestKey]);
|
|
}
|
|
|
|
/** Computes the render-item key the unread "New" divider should appear
|
|
* immediately before: tracks the latest rendered message id for session read
|
|
* tracking, then maps the resulting divider anchor onto the grouped items. */
|
|
function useUnreadDividerBeforeItemKey(
|
|
sessionId: string | null,
|
|
isVisible: boolean,
|
|
groupedItems: Parameters<typeof lastRenderedMessageId>[0],
|
|
isInitialMessagesLoading: boolean,
|
|
) {
|
|
const latestMessageId = useMemo(() => lastRenderedMessageId(groupedItems), [groupedItems]);
|
|
const dividerAnchor = useSessionReadTracking(
|
|
sessionId,
|
|
isVisible,
|
|
latestMessageId,
|
|
isInitialMessagesLoading,
|
|
);
|
|
return useMemo(
|
|
() => findUnreadDividerItemId(groupedItems, dividerAnchor),
|
|
[groupedItems, dividerAnchor],
|
|
);
|
|
}
|
|
|
|
/** Floating session-search overlay over the transcript: the search bar plus
|
|
* its hits list, with next/prev cycling through hits. Renders nothing while
|
|
* the search is closed. */
|
|
function SessionSearchOverlay({
|
|
search,
|
|
agentLabel,
|
|
agentName,
|
|
}: {
|
|
search: ReturnType<typeof useSessionSearch>;
|
|
agentLabel: string | null;
|
|
agentName: string | null;
|
|
}) {
|
|
const currentIdx = search.activeHitId
|
|
? search.hits.findIndex((h) => h.id === search.activeHitId)
|
|
: -1;
|
|
const total = search.hits.length;
|
|
const handleNext = useCallback(() => {
|
|
if (!total) return;
|
|
const next = search.hits[(Math.max(currentIdx, -1) + 1) % total];
|
|
if (next) search.setActiveHit(next.id);
|
|
}, [search, currentIdx, total]);
|
|
const handlePrev = useCallback(() => {
|
|
if (!total) return;
|
|
const prevIdx = (Math.max(currentIdx, 0) - 1 + total) % total;
|
|
const prev = search.hits[prevIdx];
|
|
if (prev) search.setActiveHit(prev.id);
|
|
}, [search, currentIdx, total]);
|
|
if (!search.isOpen) return null;
|
|
return (
|
|
<div className="absolute top-2 right-2 z-20 flex flex-col items-end gap-1">
|
|
<PanelSearchBar
|
|
className="static"
|
|
value={search.query}
|
|
onChange={search.setQuery}
|
|
onNext={handleNext}
|
|
onPrev={handlePrev}
|
|
onClose={search.close}
|
|
matchInfo={{ current: currentIdx >= 0 ? currentIdx + 1 : 0, total }}
|
|
isLoading={search.isSearching}
|
|
// Session search already debounces in useDebouncedSearch; skip the
|
|
// bar's debounce so we don't stack 150ms + 180ms per keystroke.
|
|
debounceMs={0}
|
|
/>
|
|
<SessionSearchHits
|
|
hits={search.hits}
|
|
query={search.query}
|
|
activeHitId={search.activeHitId}
|
|
onSelect={search.setActiveHit}
|
|
isSearching={search.isSearching}
|
|
agentLabel={agentLabel}
|
|
agentName={agentName}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Returns the AgentProfileOption for the session's profile, or null. Uses
|
|
* primitive profile id to avoid getSnapshot-cache errors from returning
|
|
* fresh objects on every selector call. */
|
|
function useSessionAgentProfile(sessionId: string | null | undefined) {
|
|
const profileId = useAppStore((state) =>
|
|
sessionId ? (state.taskSessions.items[sessionId]?.agent_profile_id ?? null) : null,
|
|
);
|
|
return useAppStore((state) =>
|
|
profileId
|
|
? (state.agentProfiles.items.find((p: { id: string }) => p.id === profileId) ?? null)
|
|
: null,
|
|
);
|
|
}
|
|
|
|
/** Resolves the agent profile name + registry slug for the given session.
|
|
* Label is "Profile Name" from the "Agent • Profile Name" store label; slug
|
|
* feeds <AgentLogo> which fetches the logo by agent type. */
|
|
function useSessionAgentIdentity(sessionId: string | null | undefined): {
|
|
label: string | null;
|
|
name: string | null;
|
|
} {
|
|
const profile = useSessionAgentProfile(sessionId);
|
|
// User-supplied session name wins over the derived profile label,
|
|
// matching the session tab title precedence (resolveSessionTabTitle).
|
|
const sessionName = useAppStore((state) =>
|
|
sessionId ? (state.taskSessions.items[sessionId]?.name ?? null) : null,
|
|
);
|
|
return useMemo(() => {
|
|
if (!profile) return { label: sessionName, name: null };
|
|
const parts = profile.label.split(" \u2022 ");
|
|
const label = sessionName || parts[1] || parts[0] || profile.label;
|
|
return { label, name: profile.agent_name ?? null };
|
|
}, [profile, sessionName]);
|
|
}
|
|
|
|
type TaskChatPanelProps = {
|
|
onSend?: (payload: ChatSubmitPayload) => ChatSubmitResult;
|
|
sessionId?: string | null;
|
|
taskId?: string | null;
|
|
/**
|
|
* Task this panel belongs to, independent of whether it has a session yet.
|
|
* Only the status row uses it, so a task with no session still shows its
|
|
* dependency and autopilot chips. `taskId` above stays session-gated because
|
|
* it also drives plan mode, the composer, and read tracking.
|
|
*/
|
|
statusTaskId?: string | null;
|
|
onOpenFile?: (path: string, repo?: string) => void;
|
|
showRequestChangesTooltip?: boolean;
|
|
onRequestChangesTooltipDismiss?: () => void;
|
|
/** Callback to open a file at a specific line (for comment clicks) */
|
|
onOpenFileAtLine?: (filePath: string, repositoryName?: string) => void;
|
|
/** Hide the sessions dropdown (session tabs in dockview replace it) */
|
|
hideSessionsDropdown?: boolean;
|
|
/** Mobile layout renders the task queue above its session picker. */
|
|
hideLaunchQueueStatus?: boolean;
|
|
/** Mobile layout renders the WIP queue above its session picker. */
|
|
hideWipQueueStatus?: boolean;
|
|
/**
|
|
* Embedded multi-panel hosts do not own the global workbench or shortcuts.
|
|
* They keep the conversation and composer, but suppress those side effects.
|
|
*/
|
|
embedded?: boolean;
|
|
/**
|
|
* Whether this panel is the one actually on screen — gates the
|
|
* Slack-style unread-divider read tracking (see
|
|
* chat/use-session-read-tracking.ts). Dockview-hosted callers must pass
|
|
* real tab-activation state (see hooks/use-panel-active.ts); other hosts
|
|
* (mobile, quick chat, kanban preview) default to true since mounting
|
|
* already implies visibility for them.
|
|
*/
|
|
isVisible?: boolean;
|
|
panelId?: string | null;
|
|
/** Legacy message-only target for non-Dockview hosts. */
|
|
pendingScrollToMessageId?: string | null;
|
|
/** Identity-bearing target for mobile/non-Dockview prompt navigation. */
|
|
pendingScrollTarget?: PendingMessageScrollTarget | null;
|
|
/** Called after a non-Dockview scroll target reaches a rendered row. */
|
|
onPendingScrollConsumed?: (messageId: string) => void;
|
|
};
|
|
|
|
type ScrollTargetConsumptionParams = {
|
|
resolvedSessionId: string | null;
|
|
isVisible: boolean;
|
|
panelId: string | null;
|
|
messageListRef: RefObject<MessageListHandle | null>;
|
|
/** Whether the target row is present in the current message snapshot. */
|
|
targetRendered?: boolean;
|
|
isInitialMessagesLoading: boolean;
|
|
/** Rendered-transcript revision: retry a retained target when a row mounts
|
|
* after the initial load or when older pages are prepended. */
|
|
renderedMessageCount: number;
|
|
};
|
|
|
|
function shouldDeferTargetWindowLoad(
|
|
messages: readonly { id: string }[],
|
|
targetMessageId: string,
|
|
isInitialMessagesLoading: boolean,
|
|
targetRendered: boolean,
|
|
) {
|
|
if (isInitialMessagesLoading) return true;
|
|
return messages.some((message) => message.id === targetMessageId) && targetRendered;
|
|
}
|
|
|
|
const pendingDockviewOwnerCleanups = new Map<string, number>();
|
|
|
|
function cancelDockviewOwnerCleanup(ownerKey: string) {
|
|
const timer = pendingDockviewOwnerCleanups.get(ownerKey);
|
|
if (timer === undefined) return;
|
|
window.clearTimeout(timer);
|
|
pendingDockviewOwnerCleanups.delete(ownerKey);
|
|
}
|
|
|
|
function scheduleDockviewOwnerCleanup(
|
|
ownerKey: string,
|
|
targetToken: number,
|
|
clearScrollTarget: (token: number) => void,
|
|
) {
|
|
cancelDockviewOwnerCleanup(ownerKey);
|
|
const timer = window.setTimeout(() => {
|
|
if (pendingDockviewOwnerCleanups.get(ownerKey) !== timer) return;
|
|
pendingDockviewOwnerCleanups.delete(ownerKey);
|
|
clearScrollTarget(targetToken);
|
|
}, 0);
|
|
pendingDockviewOwnerCleanups.set(ownerKey, timer);
|
|
}
|
|
|
|
type DockviewOwnerLifecycleOptions = {
|
|
resolvedSessionId: string | null;
|
|
panelId: string | null;
|
|
scrollTarget: TranscriptScrollTarget | null;
|
|
clearScrollTarget: (token: number) => void;
|
|
clearScrollTargetForOwner: (sessionId: string, panelId: string) => void;
|
|
cancelReassertion: () => void;
|
|
};
|
|
|
|
function useDockviewOwnerLifecycle({
|
|
resolvedSessionId,
|
|
panelId,
|
|
scrollTarget,
|
|
clearScrollTarget,
|
|
clearScrollTargetForOwner,
|
|
cancelReassertion,
|
|
}: DockviewOwnerLifecycleOptions) {
|
|
const previousOwnerRef = useRef<{ sessionId: string; panelId: string } | null>(null);
|
|
const ownerTargetRef = useRef<TranscriptScrollTarget | null>(null);
|
|
ownerTargetRef.current =
|
|
panelId && scrollTarget?.sessionId === resolvedSessionId && scrollTarget.hostPanelId === panelId
|
|
? scrollTarget
|
|
: null;
|
|
const ownerKey = ownerTargetRef.current
|
|
? `${ownerTargetRef.current.sessionId}\u0000${ownerTargetRef.current.hostPanelId}\u0000${ownerTargetRef.current.token}`
|
|
: null;
|
|
|
|
useEffect(() => {
|
|
if (!panelId) return;
|
|
const previousOwner = previousOwnerRef.current;
|
|
previousOwnerRef.current = resolvedSessionId ? { sessionId: resolvedSessionId, panelId } : null;
|
|
if (
|
|
previousOwner &&
|
|
(previousOwner.sessionId !== resolvedSessionId || previousOwner.panelId !== panelId)
|
|
) {
|
|
clearScrollTargetForOwner(previousOwner.sessionId, previousOwner.panelId);
|
|
cancelReassertion();
|
|
}
|
|
}, [cancelReassertion, clearScrollTargetForOwner, panelId, resolvedSessionId]);
|
|
|
|
useEffect(() => {
|
|
if (ownerKey) cancelDockviewOwnerCleanup(ownerKey);
|
|
}, [ownerKey]);
|
|
|
|
useEffect(() => {
|
|
if (!panelId) return;
|
|
return () => {
|
|
cancelReassertion();
|
|
const target = ownerTargetRef.current;
|
|
if (!target || target.sessionId !== resolvedSessionId || target.hostPanelId !== panelId)
|
|
return;
|
|
const cleanupKey = `${target.sessionId}\u0000${target.hostPanelId}\u0000${target.token}`;
|
|
scheduleDockviewOwnerCleanup(cleanupKey, target.token, clearScrollTarget);
|
|
};
|
|
}, [cancelReassertion, clearScrollTarget, panelId, resolvedSessionId]);
|
|
}
|
|
|
|
type DockviewTargetRefs = {
|
|
activeTargetKey: MutableRefObject<string | null>;
|
|
reassertionAttempted: MutableRefObject<Set<string>>;
|
|
requestKeys: MutableRefObject<Set<string>>;
|
|
completedAround: MutableRefObject<Set<string>>;
|
|
scrollSucceededTarget: MutableRefObject<string | null>;
|
|
reassertionTimer: MutableRefObject<number | null>;
|
|
mounted: MutableRefObject<boolean>;
|
|
lifecycle: MutableRefObject<{
|
|
resolvedSessionId: string | null;
|
|
isVisible: boolean;
|
|
panelId: string | null;
|
|
}>;
|
|
};
|
|
|
|
type DockviewTargetAttemptOptions = {
|
|
target: TranscriptScrollTarget;
|
|
targetKey: string;
|
|
messageListRef: RefObject<MessageListHandle | null>;
|
|
isInitialMessagesLoading: boolean;
|
|
targetRendered: boolean;
|
|
appStore: StoreApi<AppState>;
|
|
refs: DockviewTargetRefs;
|
|
setJumpLoading: (loading: boolean) => void;
|
|
clearScrollTarget: (token: number) => void;
|
|
isCurrentTarget: () => boolean;
|
|
};
|
|
|
|
function attemptDockviewScrollTarget(options: DockviewTargetAttemptOptions) {
|
|
const {
|
|
target,
|
|
targetKey,
|
|
messageListRef,
|
|
isInitialMessagesLoading,
|
|
targetRendered,
|
|
appStore,
|
|
refs,
|
|
setJumpLoading,
|
|
clearScrollTarget,
|
|
isCurrentTarget,
|
|
} = options;
|
|
if (
|
|
refs.completedAround.current.has(targetKey) &&
|
|
(refs.reassertionTimer.current !== null || refs.reassertionAttempted.current.has(targetKey))
|
|
) {
|
|
return;
|
|
}
|
|
if (!isCurrentTarget()) return;
|
|
const didScroll = messageListRef.current?.scrollToMessage(target.messageId, {
|
|
align: "start",
|
|
});
|
|
const consume = () => clearScrollTarget(target.token);
|
|
const scheduleReassertion = () =>
|
|
scheduleTargetReassertion({
|
|
targetKey,
|
|
timerRef: refs.reassertionTimer,
|
|
attemptedRef: refs.reassertionAttempted,
|
|
completedAroundRef: refs.completedAround,
|
|
isCurrentTarget,
|
|
scroll: () => {
|
|
messageListRef.current?.scrollToMessage(target.messageId, {
|
|
align: "start",
|
|
behavior: "auto",
|
|
});
|
|
},
|
|
consume,
|
|
});
|
|
if (didScroll) {
|
|
refs.scrollSucceededTarget.current = targetKey;
|
|
if (refs.completedAround.current.has(targetKey)) scheduleReassertion();
|
|
else if (!refs.requestKeys.current.has(targetKey)) consume();
|
|
return;
|
|
}
|
|
const sessionMessages = appStore.getState().messages.bySession[target.sessionId] ?? [];
|
|
if (
|
|
shouldDeferTargetWindowLoad(
|
|
sessionMessages,
|
|
target.messageId,
|
|
isInitialMessagesLoading,
|
|
targetRendered,
|
|
) ||
|
|
refs.requestKeys.current.has(targetKey) ||
|
|
refs.completedAround.current.has(targetKey)
|
|
) {
|
|
return;
|
|
}
|
|
requestMessageWindowAround({
|
|
sessionId: target.sessionId,
|
|
messageId: target.messageId,
|
|
targetKey,
|
|
store: appStore,
|
|
requestKeysRef: refs.requestKeys,
|
|
mountedRef: refs.mounted,
|
|
isCurrentTarget,
|
|
setLoading: setJumpLoading,
|
|
onMerged: () => {
|
|
refs.completedAround.current.add(targetKey);
|
|
if (refs.scrollSucceededTarget.current === targetKey) scheduleReassertion();
|
|
},
|
|
onCancelled: consume,
|
|
});
|
|
}
|
|
|
|
type DockviewTargetEffectOptions = ScrollTargetConsumptionParams & {
|
|
scrollTarget: TranscriptScrollTarget | null;
|
|
appStore: StoreApi<AppState>;
|
|
refs: DockviewTargetRefs;
|
|
setJumpLoading: (loading: boolean) => void;
|
|
clearScrollTarget: (token: number) => void;
|
|
cancelReassertion: () => void;
|
|
};
|
|
|
|
function useDockviewTargetEffect(options: DockviewTargetEffectOptions) {
|
|
const {
|
|
resolvedSessionId,
|
|
isVisible,
|
|
panelId,
|
|
messageListRef,
|
|
targetRendered = false,
|
|
isInitialMessagesLoading,
|
|
renderedMessageCount,
|
|
scrollTarget,
|
|
appStore,
|
|
refs,
|
|
setJumpLoading,
|
|
clearScrollTarget,
|
|
cancelReassertion,
|
|
} = options;
|
|
useEffect(() => {
|
|
if (
|
|
!isVisible ||
|
|
!scrollTarget ||
|
|
!panelId ||
|
|
scrollTarget.sessionId !== resolvedSessionId ||
|
|
scrollTarget.hostPanelId !== panelId
|
|
) {
|
|
return;
|
|
}
|
|
const targetKey = `${scrollTarget.sessionId}\u0000${scrollTarget.hostPanelId}\u0000${scrollTarget.token}`;
|
|
if (refs.activeTargetKey.current !== targetKey) {
|
|
refs.activeTargetKey.current = targetKey;
|
|
refs.reassertionAttempted.current.delete(targetKey);
|
|
refs.scrollSucceededTarget.current = null;
|
|
cancelReassertion();
|
|
}
|
|
const isCurrentTarget = () => {
|
|
const latest = useDockviewStore.getState().scrollTarget;
|
|
return (
|
|
refs.mounted.current &&
|
|
refs.lifecycle.current.isVisible &&
|
|
refs.lifecycle.current.resolvedSessionId === scrollTarget.sessionId &&
|
|
refs.lifecycle.current.panelId === scrollTarget.hostPanelId &&
|
|
latest?.token === scrollTarget.token &&
|
|
latest.sessionId === scrollTarget.sessionId &&
|
|
latest.hostPanelId === scrollTarget.hostPanelId
|
|
);
|
|
};
|
|
let frame = requestAnimationFrame(() => {
|
|
frame = requestAnimationFrame(() => {
|
|
attemptDockviewScrollTarget({
|
|
target: scrollTarget,
|
|
targetKey,
|
|
messageListRef,
|
|
isInitialMessagesLoading,
|
|
targetRendered,
|
|
appStore,
|
|
refs,
|
|
setJumpLoading,
|
|
clearScrollTarget,
|
|
isCurrentTarget,
|
|
});
|
|
});
|
|
});
|
|
return () => cancelAnimationFrame(frame);
|
|
}, [
|
|
appStore,
|
|
cancelReassertion,
|
|
clearScrollTarget,
|
|
isInitialMessagesLoading,
|
|
isVisible,
|
|
messageListRef,
|
|
panelId,
|
|
refs,
|
|
renderedMessageCount,
|
|
resolvedSessionId,
|
|
scrollTarget,
|
|
setJumpLoading,
|
|
targetRendered,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Consumes Dockview prompt-history targets. Around-window targets stay owned
|
|
* through their first rendered placement and one delayed reassertion.
|
|
*/
|
|
export function useScrollTargetConsumption({
|
|
resolvedSessionId,
|
|
isVisible,
|
|
panelId,
|
|
messageListRef,
|
|
isInitialMessagesLoading,
|
|
targetRendered = false,
|
|
renderedMessageCount,
|
|
}: ScrollTargetConsumptionParams) {
|
|
const scrollTarget = useDockviewStore((state) => state.scrollTarget);
|
|
const clearScrollTarget = useDockviewStore((state) => state.clearScrollTarget);
|
|
const clearScrollTargetForOwner = useDockviewStore((state) => state.clearScrollTargetForOwner);
|
|
const appStore = useAppStoreApi();
|
|
const [jumpLoading, setJumpLoading] = useState(false);
|
|
const refs = useMemo<DockviewTargetRefs>(
|
|
() => ({
|
|
activeTargetKey: { current: null },
|
|
reassertionAttempted: { current: new Set<string>() },
|
|
requestKeys: { current: new Set<string>() },
|
|
completedAround: { current: new Set<string>() },
|
|
scrollSucceededTarget: { current: null },
|
|
reassertionTimer: { current: null },
|
|
mounted: { current: true },
|
|
lifecycle: { current: { resolvedSessionId, isVisible, panelId } },
|
|
}),
|
|
[],
|
|
);
|
|
refs.lifecycle.current = { resolvedSessionId, isVisible, panelId };
|
|
const cancelReassertion = useCallback(
|
|
() => cancelTargetReassertion(refs.reassertionTimer),
|
|
[refs],
|
|
);
|
|
useEffect(() => {
|
|
refs.mounted.current = true;
|
|
return () => {
|
|
refs.mounted.current = false;
|
|
cancelReassertion();
|
|
};
|
|
}, [cancelReassertion, refs]);
|
|
useDockviewOwnerLifecycle({
|
|
resolvedSessionId,
|
|
panelId,
|
|
scrollTarget,
|
|
clearScrollTarget,
|
|
clearScrollTargetForOwner,
|
|
cancelReassertion,
|
|
});
|
|
useEffect(() => {
|
|
if (isVisible) return;
|
|
cancelReassertion();
|
|
if (
|
|
scrollTarget &&
|
|
panelId &&
|
|
scrollTarget.sessionId === resolvedSessionId &&
|
|
scrollTarget.hostPanelId === panelId
|
|
) {
|
|
const targetKey = `${scrollTarget.sessionId}\u0000${scrollTarget.hostPanelId}\u0000${scrollTarget.token}`;
|
|
refs.completedAround.current.delete(targetKey);
|
|
if (refs.scrollSucceededTarget.current === targetKey) clearScrollTarget(scrollTarget.token);
|
|
}
|
|
}, [
|
|
cancelReassertion,
|
|
clearScrollTarget,
|
|
isVisible,
|
|
panelId,
|
|
refs,
|
|
resolvedSessionId,
|
|
scrollTarget,
|
|
]);
|
|
useDockviewTargetEffect({
|
|
resolvedSessionId,
|
|
isVisible,
|
|
panelId,
|
|
messageListRef,
|
|
targetRendered,
|
|
isInitialMessagesLoading,
|
|
renderedMessageCount,
|
|
scrollTarget,
|
|
appStore,
|
|
refs,
|
|
setJumpLoading,
|
|
clearScrollTarget,
|
|
cancelReassertion,
|
|
});
|
|
return jumpLoading;
|
|
}
|
|
|
|
// eslint-disable-next-line complexity, max-lines-per-function -- composes many sub-panels; each concern already factored into its own hook
|
|
export const TaskChatPanel = memo(function TaskChatPanel({
|
|
onSend,
|
|
sessionId = null,
|
|
taskId: taskIdHint = null,
|
|
statusTaskId = null,
|
|
onOpenFile,
|
|
showRequestChangesTooltip = false,
|
|
onRequestChangesTooltipDismiss,
|
|
onOpenFileAtLine,
|
|
hideSessionsDropdown,
|
|
embedded = false,
|
|
isVisible = true,
|
|
panelId = null,
|
|
pendingScrollToMessageId = null,
|
|
pendingScrollTarget,
|
|
onPendingScrollConsumed,
|
|
hideLaunchQueueStatus = false,
|
|
hideWipQueueStatus = false,
|
|
}: TaskChatPanelProps) {
|
|
const isArchived = useIsTaskArchived();
|
|
const chatInputRef = useRef<ChatInputContainerHandle>(null);
|
|
const launchErrorContext = useTaskLaunchErrorContext();
|
|
const summaryTaskId = statusTaskId ?? taskIdHint ?? launchErrorContext?.taskId ?? null;
|
|
const launchStatusSummary = useTaskStatusSummary(
|
|
summaryTaskId,
|
|
launchErrorContext?.taskId === summaryTaskId ? launchErrorContext.statusSummary : undefined,
|
|
);
|
|
const { t } = useTranslation();
|
|
useSettingsData(true);
|
|
const panelState = useChatPanelState({
|
|
sessionId,
|
|
taskId: taskIdHint,
|
|
disableWorkbenchEffects: embedded,
|
|
onOpenFile,
|
|
onOpenFileAtLine,
|
|
});
|
|
const { isSending, handleSubmit } = useSubmitHandler(panelState, onSend);
|
|
const {
|
|
resolvedSessionId,
|
|
session,
|
|
taskId,
|
|
isWorking,
|
|
messagesLoading,
|
|
historyRefreshPending,
|
|
isInitialMessagesLoading,
|
|
historyStatus,
|
|
historyError,
|
|
retryHistory,
|
|
groupedItems,
|
|
allMessages,
|
|
footerActionMessages,
|
|
permissionsByToolCallId,
|
|
childrenByParentToolCallId,
|
|
agentMessageCount,
|
|
pendingClarification,
|
|
pendingClarificationGroup,
|
|
} = panelState;
|
|
const taskLaunchError = statusSummaryTaskError(launchStatusSummary);
|
|
const lateAnswer = useLateClarificationMessage(pendingClarificationGroup?.[0]);
|
|
const launchErrorOwned = Boolean(taskLaunchError);
|
|
const showAgentStartHint = useComposerAgentStartHint(
|
|
resolvedSessionId,
|
|
session?.state,
|
|
allMessages,
|
|
footerActionMessages,
|
|
);
|
|
const { handleCancelTurn } = useChatPanelHandlers(resolvedSessionId, chatInputRef, {
|
|
enableFocusShortcut: !embedded,
|
|
});
|
|
const { clarificationKey, handleClarificationResolved } = useClarificationKey(agentMessageCount);
|
|
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
const messageListRef = useRef<MessageListHandle>(null);
|
|
const dividerBeforeItemKey = useUnreadDividerBeforeItemKey(
|
|
resolvedSessionId,
|
|
isVisible,
|
|
groupedItems,
|
|
isInitialMessagesLoading,
|
|
);
|
|
// Kanban previews intentionally pass `isVisible=false` so they do not
|
|
// advance the read cursor, but their transcript is rendered in a visible
|
|
// non-Dockview host. Keep read visibility separate from scroll geometry.
|
|
const transcriptIsVisible = panelId === null || isVisible;
|
|
const dockviewTargetMessageId = useDockviewStore(
|
|
(state) => state.scrollTarget?.messageId ?? null,
|
|
);
|
|
const isDockviewJumpLoading = useScrollTargetConsumption({
|
|
resolvedSessionId,
|
|
isVisible,
|
|
panelId,
|
|
messageListRef,
|
|
isInitialMessagesLoading,
|
|
targetRendered: Boolean(
|
|
dockviewTargetMessageId && isMessageRowRendered(groupedItems, dockviewTargetMessageId),
|
|
),
|
|
renderedMessageCount: allMessages.length,
|
|
});
|
|
const { isLoading: isPendingJumpLoading } = usePendingMessageScroll({
|
|
messageListRef,
|
|
sessionId: resolvedSessionId,
|
|
messageId: pendingScrollToMessageId,
|
|
target: pendingScrollTarget,
|
|
onConsumed: onPendingScrollConsumed,
|
|
readinessKey: `${allMessages.length}:${isInitialMessagesLoading}:${
|
|
allMessages[0]?.id ?? ""
|
|
}:${allMessages.at(-1)?.id ?? ""}`,
|
|
isInitialMessagesLoading,
|
|
isVisible,
|
|
});
|
|
const isJumpLoading = isDockviewJumpLoading || isPendingJumpLoading;
|
|
const lastPromptMessageId = useMemo(() => getLastUserMessageId(allMessages), [allMessages]);
|
|
const lastPromptMessage = useMemo(
|
|
() =>
|
|
lastPromptMessageId
|
|
? (allMessages.find((message) => message.id === lastPromptMessageId) ?? null)
|
|
: null,
|
|
[allMessages, lastPromptMessageId],
|
|
);
|
|
const [lastPromptEdge, setLastPromptEdge] = useState<LastPromptEdge>("visible");
|
|
const showAnchoredPromptBar = useAppStore((state) => state.userSettings.showAnchoredPromptBar);
|
|
const showScrollToLastPrompt = useAppStore((state) => state.userSettings.showScrollToLastPrompt);
|
|
const showScrollToStart = useAppStore((state) => state.userSettings.showScrollToStart);
|
|
const { isMobile, isFinePointer } = useResponsiveBreakpoint();
|
|
// The anchored bar is a desktop-only, fine-pointer affordance; coarse
|
|
// pointers use the compact scroll control instead.
|
|
const showAnchoredBar = isFinePointer && !isMobile && showAnchoredPromptBar;
|
|
const [anchoredBarHeight, setAnchoredBarHeight] = useState(0);
|
|
const { anchoredBarVisible, scrollButtonEligible, scrollDirection } =
|
|
resolveLastPromptControls(lastPromptEdge);
|
|
const showScrollButton =
|
|
showScrollToLastPrompt && Boolean(lastPromptMessageId) && scrollButtonEligible;
|
|
const scrollToLastPrompt = useCallback(() => {
|
|
if (lastPromptMessageId) {
|
|
messageListRef.current?.scrollToMessage(lastPromptMessageId, { align: "start" });
|
|
}
|
|
}, [lastPromptMessageId]);
|
|
const firstMessageId = useMemo(() => getFirstUserMessageId(allMessages), [allMessages]);
|
|
const [isFirstMessageHidden, setIsFirstMessageHidden] = useState(false);
|
|
const showScrollToStartButton =
|
|
showScrollToStart && Boolean(firstMessageId) && isFirstMessageHidden;
|
|
const { loadMoreRaw, hasMore } = useLazyLoadMessages(resolvedSessionId);
|
|
// A paginated session's `firstMessageId` only reflects the oldest message in
|
|
// the currently loaded page while `hasMore` is true — jumping there directly
|
|
// lands on a partial-page boundary, not the transcript's real start. Drain
|
|
// older pages first so `firstMessageId` (derived from `allMessages` above,
|
|
// which grows as pages prepend) has settled on the true first prompt by the
|
|
// time the scroll fires.
|
|
const [pendingScrollToStart, setPendingScrollToStart] = useState(false);
|
|
const [scrollToStartRequest, setScrollToStartRequest] = useState(0);
|
|
useDrainOlderMessages(resolvedSessionId, pendingScrollToStart && hasMore);
|
|
const completeScrollToStart = useCallback((didScroll: boolean) => {
|
|
setPendingScrollToStart(false);
|
|
if (didScroll) setIsFirstMessageHidden(false);
|
|
}, []);
|
|
usePendingScrollToStart({
|
|
messageListRef,
|
|
firstMessageId,
|
|
hasMore,
|
|
pending: pendingScrollToStart,
|
|
requestKey: scrollToStartRequest,
|
|
onComplete: completeScrollToStart,
|
|
});
|
|
const scrollToStart = useCallback(() => {
|
|
setScrollToStartRequest((request) => request + 1);
|
|
setPendingScrollToStart(true);
|
|
}, []);
|
|
// Search can target backend rows before the visible transcript boundary.
|
|
const navigateSearchHit = useCallback(
|
|
(id: string) => {
|
|
if (!messageListRef.current?.scrollToMessage(id, { align: "center" })) return null;
|
|
return (
|
|
panelRef.current?.querySelector<HTMLElement>(
|
|
`.chat-message-list [id="msg-${CSS.escape(id)}"]`,
|
|
) ?? null
|
|
);
|
|
},
|
|
[messageListRef],
|
|
);
|
|
const search = useSessionSearch(resolvedSessionId, loadMoreRaw, navigateSearchHit);
|
|
const { label: agentLabel, name: agentName } = useSessionAgentIdentity(resolvedSessionId);
|
|
usePanelSearch({
|
|
containerRef: panelRef,
|
|
isOpen: search.isOpen,
|
|
onOpen: search.open,
|
|
onClose: search.close,
|
|
});
|
|
|
|
// The message list has no focus-capturing child (unlike TipTap/xterm in the
|
|
// plan/terminal panels), so clicking a message leaves focus on <body>. Make
|
|
// the panel root itself focusable and route non-interactive clicks to it so
|
|
// Ctrl+F can detect focus within the session panel.
|
|
const handlePanelMouseDown = useCallback(
|
|
(e: React.MouseEvent<HTMLDivElement>) => routePanelMouseDown(e, panelRef),
|
|
[],
|
|
);
|
|
return (
|
|
<PanelRoot
|
|
ref={panelRef}
|
|
data-testid="session-chat"
|
|
data-panel-kind="session"
|
|
data-session-id={resolvedSessionId ?? undefined}
|
|
tabIndex={-1}
|
|
onMouseDown={handlePanelMouseDown}
|
|
className="outline-none"
|
|
>
|
|
{!hideLaunchQueueStatus && <LaunchQueueStatus queue={launchStatusSummary?.launch_queue} />}
|
|
{!hideWipQueueStatus && <WipQueueStatus taskId={summaryTaskId} />}
|
|
<ParkedSessionNote visible={hasWorkflowParkingMarker(session?.metadata)} />
|
|
<PanelBody
|
|
padding={false}
|
|
scroll={false}
|
|
className="relative flex min-h-0 flex-col overflow-hidden"
|
|
>
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
{resolvedSessionId ? (
|
|
<div className="shrink-0 px-2">
|
|
<LaunchWarning sessionId={resolvedSessionId} />
|
|
</div>
|
|
) : null}
|
|
<div className="min-h-0 flex-1">
|
|
<TaskMarkdownFileLinkProvider
|
|
taskId={taskId}
|
|
sessionId={resolvedSessionId}
|
|
worktreePath={getSessionWorkspacePath(session)}
|
|
onOpenFile={onOpenFile}
|
|
>
|
|
<MessageList
|
|
ref={messageListRef}
|
|
items={groupedItems}
|
|
messages={allMessages}
|
|
footerActionMessages={footerActionMessages}
|
|
permissionsByToolCallId={permissionsByToolCallId}
|
|
childrenByParentToolCallId={childrenByParentToolCallId}
|
|
taskId={taskId ?? undefined}
|
|
sessionId={resolvedSessionId}
|
|
messagesLoading={messagesLoading}
|
|
historyRefreshPending={historyRefreshPending}
|
|
historyStatus={historyStatus}
|
|
historyError={historyError}
|
|
onRetryHistory={retryHistory}
|
|
isWorking={isWorking}
|
|
sessionState={session?.state}
|
|
worktreePath={getSessionWorkspacePath(session)}
|
|
onOpenFile={onOpenFile}
|
|
dividerBeforeItemKey={dividerBeforeItemKey}
|
|
lastPromptMessageId={lastPromptMessageId}
|
|
onLastPromptEdgeChange={setLastPromptEdge}
|
|
firstMessageId={firstMessageId}
|
|
onFirstMessageHiddenChange={setIsFirstMessageHidden}
|
|
anchoredBarHeight={showAnchoredBar && lastPromptMessage ? anchoredBarHeight : 0}
|
|
isVisible={transcriptIsVisible}
|
|
launchErrorOwned={launchErrorOwned}
|
|
launchErrorStamp={launchErrorOwned ? taskLaunchError?.stamp : undefined}
|
|
launchErrorOccurredAt={launchErrorOwned ? taskLaunchError?.occurred_at : undefined}
|
|
stickyPromptBar={
|
|
showAnchoredBar && lastPromptMessage ? (
|
|
<AnchoredLastPromptBar
|
|
promptText={lastPromptMessage.content}
|
|
isVisible={anchoredBarVisible}
|
|
onScrollUp={scrollToLastPrompt}
|
|
showScrollToLastPrompt={showScrollToLastPrompt}
|
|
onHeightChange={setAnchoredBarHeight}
|
|
/>
|
|
) : undefined
|
|
}
|
|
/>
|
|
</TaskMarkdownFileLinkProvider>
|
|
</div>
|
|
</div>
|
|
{isJumpLoading && (
|
|
<div
|
|
data-testid="transcript-jump-loading"
|
|
role="status"
|
|
aria-live="polite"
|
|
className="absolute right-3 top-3 rounded-md bg-background px-2 py-1 text-xs text-muted-foreground shadow"
|
|
>
|
|
{t("task:loading")}
|
|
</div>
|
|
)}
|
|
<SessionSearchOverlay search={search} agentLabel={agentLabel} agentName={agentName} />
|
|
</PanelBody>
|
|
<ComposerFooterAllocation>
|
|
{!isArchived && (
|
|
<ClarificationPanelSection
|
|
pending={Boolean(pendingClarification)}
|
|
messages={pendingClarificationGroup}
|
|
agentDisconnected={session?.pending_action === null}
|
|
onResolved={handleClarificationResolved}
|
|
onLateAnswer={lateAnswer.send}
|
|
lateAnswerState={lateAnswer.state}
|
|
shortcutScopeRef={panelRef}
|
|
maxHeightVh={50}
|
|
/>
|
|
)}
|
|
<ChatFooter
|
|
isArchived={isArchived}
|
|
chatInputRef={chatInputRef}
|
|
clarificationKey={clarificationKey}
|
|
onClarificationResolved={handleClarificationResolved}
|
|
handleSubmit={handleSubmit}
|
|
handleCancelTurn={handleCancelTurn}
|
|
showRequestChangesTooltip={showRequestChangesTooltip}
|
|
onRequestChangesTooltipDismiss={onRequestChangesTooltipDismiss}
|
|
panelState={panelState}
|
|
isSending={isSending}
|
|
hideSessionsDropdown={hideSessionsDropdown}
|
|
hidePlanMode={embedded}
|
|
showScrollToLastPrompt={showScrollButton}
|
|
onScrollToLastPrompt={scrollToLastPrompt}
|
|
lastPromptScrollDirection={scrollDirection}
|
|
showScrollToStart={showScrollToStartButton}
|
|
onScrollToStart={scrollToStart}
|
|
statusTaskId={statusTaskId ?? taskIdHint}
|
|
showAgentStartHint={showAgentStartHint}
|
|
launchErrorOwned={launchErrorOwned}
|
|
/>
|
|
</ComposerFooterAllocation>
|
|
</PanelRoot>
|
|
);
|
|
});
|
|
|
|
type ChatFooterProps = {
|
|
isArchived: boolean;
|
|
chatInputRef: RefObject<
|
|
import("@/components/task/chat/chat-input-container").ChatInputContainerHandle | null
|
|
>;
|
|
clarificationKey: number;
|
|
onClarificationResolved: () => void;
|
|
handleSubmit: ReturnType<typeof useSubmitHandler>["handleSubmit"];
|
|
handleCancelTurn: () => Promise<void>;
|
|
showRequestChangesTooltip: boolean;
|
|
onRequestChangesTooltipDismiss?: () => void;
|
|
panelState: ReturnType<typeof useChatPanelState>;
|
|
isSending: boolean;
|
|
hideSessionsDropdown?: boolean;
|
|
hidePlanMode?: boolean;
|
|
showScrollToLastPrompt: boolean;
|
|
onScrollToLastPrompt: () => void;
|
|
lastPromptScrollDirection: "up" | "down";
|
|
showScrollToStart: boolean;
|
|
onScrollToStart: () => void;
|
|
statusTaskId: string | null;
|
|
/** Recovered-idle sessions render the composer hint (see ChatInputArea). */
|
|
showAgentStartHint: boolean;
|
|
launchErrorOwned: boolean;
|
|
};
|
|
|
|
/**
|
|
* Composer footer: renders the chat input area (or the read-only archived
|
|
* banner) and forwards the recovered-idle agent-start-hint visibility from
|
|
* the panel down to the input.
|
|
*/
|
|
function ChatFooter({
|
|
isArchived,
|
|
chatInputRef,
|
|
clarificationKey,
|
|
onClarificationResolved,
|
|
handleSubmit,
|
|
handleCancelTurn,
|
|
showRequestChangesTooltip,
|
|
onRequestChangesTooltipDismiss,
|
|
panelState,
|
|
isSending,
|
|
hideSessionsDropdown,
|
|
hidePlanMode,
|
|
showScrollToLastPrompt,
|
|
onScrollToLastPrompt,
|
|
lastPromptScrollDirection,
|
|
showScrollToStart,
|
|
onScrollToStart,
|
|
statusTaskId,
|
|
showAgentStartHint,
|
|
launchErrorOwned,
|
|
}: ChatFooterProps) {
|
|
const { t } = useTranslation();
|
|
if (isArchived) {
|
|
return (
|
|
<div className="bg-muted/50 flex-shrink-0 px-4 py-3 text-center text-sm text-muted-foreground border-t">
|
|
{t("task:thisTaskIsArchivedAndRead")}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<ChatInputArea
|
|
chatInputRef={chatInputRef}
|
|
clarificationKey={clarificationKey}
|
|
onClarificationResolved={onClarificationResolved}
|
|
handleSubmit={handleSubmit}
|
|
handleCancelTurn={handleCancelTurn}
|
|
showRequestChangesTooltip={showRequestChangesTooltip}
|
|
onRequestChangesTooltipDismiss={onRequestChangesTooltipDismiss}
|
|
panelState={panelState}
|
|
isSending={isSending}
|
|
hideSessionsDropdown={hideSessionsDropdown}
|
|
hidePlanMode={hidePlanMode}
|
|
showScrollToLastPrompt={showScrollToLastPrompt}
|
|
onScrollToLastPrompt={onScrollToLastPrompt}
|
|
lastPromptScrollDirection={lastPromptScrollDirection}
|
|
showScrollToStart={showScrollToStart}
|
|
onScrollToStart={onScrollToStart}
|
|
statusTaskId={statusTaskId}
|
|
showAgentStartHint={showAgentStartHint}
|
|
launchErrorOwned={launchErrorOwned}
|
|
/>
|
|
);
|
|
}
|