);
}
function isAtBottom(scrollParent: HTMLElement | null): boolean {
if (!scrollParent) return true; // window scroll case — be conservative.
const remaining = scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight;
return remaining <= AUTOSCROLL_THRESHOLD_PX;
}
function scrollToBottom(scrollParent: HTMLElement | null): void {
if (!scrollParent) return;
scrollParent.scrollTop = scrollParent.scrollHeight;
}
/**
* Auto-scroll the chat container to the bottom when new content arrives,
* but only if the user was already near the bottom (within ~80px) at the
* time of the change.
*
* Triggers on:
* - a new active session entry first appearing (active count grows)
* - new messages arriving in any session for this task
*
* Uses a scroll listener to track the user's "at-bottom" intent. Reads
* the latest value before scrolling so we never yank focus from a user
* who has scrolled up.
*/
function useChatAutoScroll(
scrollParent: HTMLElement | null,
sessions: TaskSession[],
taskId: string,
): void {
const activeSessionCount = sessions.filter(
(s) => s.state === "RUNNING" || s.state === "WAITING_FOR_INPUT",
).length;
// Sum messages + command counts across all task sessions — single scalar
// that grows whenever new content streams in.
const totalContentSignal = useAppStore((s) => {
let sum = 0;
for (const session of sessions) {
sum += s.messages.bySession[session.id]?.length ?? 0;
sum += selectCommandCount(s, session.id);
}
return sum;
});
const wasAtBottomRef = useRef(true);
useEffect(() => {
if (!scrollParent) return;
const handler = () => {
wasAtBottomRef.current = isAtBottom(scrollParent);
};
handler();
scrollParent.addEventListener("scroll", handler, { passive: true });
return () => scrollParent.removeEventListener("scroll", handler);
}, [scrollParent]);
useEffect(() => {
if (wasAtBottomRef.current) {
scrollToBottom(scrollParent);
// After programmatic scroll, we are still "at bottom" by definition.
wasAtBottomRef.current = true;
}
}, [scrollParent, activeSessionCount, totalContentSignal, taskId]);
}
/**
* Scrolls the comment matching `location.hash` (e.g. `#comment-cm-A`)
* into view once it has rendered. Runs whenever the comments list
* changes so deeplinks land on the target even when comments load
* after first paint. Cleared after first match so it doesn't fight
* the user when they scroll away.
*/
function useCommentHashScroll(comments: TaskComment[]): void {
const targetIdRef = useRef(null);
useEffect(() => {
if (typeof window === "undefined") return;
const hash = window.location.hash;
if (!hash.startsWith("#comment-")) return;
targetIdRef.current = hash.slice(1);
}, []);
useEffect(() => {
const targetId = targetIdRef.current;
if (!targetId) return;
const el = document.getElementById(targetId);
if (!el) return;
el.scrollIntoView({ behavior: "smooth", block: "center" });
targetIdRef.current = null;
}, [comments]);
}
function ChatEntries({ taskId, entries }: { taskId: string; entries: ChatEntry[] }) {
return (
<>
{entries.map((entry) => {
if (entry.kind === "comment") {
return (
);
}
if (entry.kind === "timeline") {
return ;
}
if (entry.kind === "decision") {
return ;
}
if (entry.kind === "error") {
return ;
}
// Session entries are intentionally not rendered in the Chat
// tab: every agent message below already shows the same agent
// name + "worked for Xs" footer, so the collapsed session
// header is pure duplicate noise. The per-agent tabs render the
// full transcript when the user wants it.
return null;
})}
>
);
}
export function TaskChat({
taskId,
comments,
timeline = [],
sessions = [],
decisions = [],
reviewers = [],
approvers = [],
scrollParent,
readOnly = false,
onCommentsChanged,
taskTitle,
taskDescription,
}: TaskChatProps) {
const [showOlder, setShowOlder] = useState(false);
const groups = useMemo(
() => groupSessionsForTimeline(sessions, reviewers, approvers),
[sessions, reviewers, approvers],
);
// Per office-task-session-lifecycle/spec.md §G, office (per-agent) groups
// render inline in the chat timeline alongside any kanban / quick-chat
// groups — one collapsible entry per (task, agent) pair, ordered by
// most-recent activity. The per-agent sibling tabs duplicate the same
// chat for users who want a focused per-agent view.
const { visible: visibleGroups, older: olderGroups } = useMemo(
() => partitionGroups(groups),
[groups],
);
const renderedGroups = useMemo(
() => (showOlder ? [...olderGroups, ...visibleGroups] : visibleGroups),
[showOlder, olderGroups, visibleGroups],
);
const turnCtx = useMemo(() => buildCommentTurnContext(comments, sessions), [comments, sessions]);
const runErrors = useMemo(() => buildRunErrorsFromSessions(sessions), [sessions]);
const laterAgentReplyMap = useMemo(() => buildLaterAgentReplyMap(comments), [comments]);
const entries = useMemo(
() =>
mergeChatEntries({
comments,
timeline,
groups: renderedGroups,
decisions,
turnCtx,
runErrors,
laterAgentReplyMap,
}),
[comments, timeline, renderedGroups, decisions, turnCtx, runErrors, laterAgentReplyMap],
);
useChatAutoScroll(scrollParent ?? null, sessions, taskId);
useCommentHashScroll(comments);
const showOlderToggle = olderGroups.length > 0 && !showOlder;
const isEmpty = entries.length === 0;
return (