Files
kandev/apps/web/components/threads/threads-board.tsx
T

291 lines
9.9 KiB
TypeScript

"use client";
import {
useLayoutEffect,
useMemo,
useRef,
useState,
type Dispatch,
type SetStateAction,
type ReactNode,
type RefObject,
} from "react";
import { IconColumns } from "@tabler/icons-react";
import type { ActiveThread } from "@/lib/threads/active-threads";
import { useTranslation } from "react-i18next";
import { ThreadColumn } from "./thread-column";
import { useThreadColumnActivation } from "./use-thread-column-activation";
import { useThreadFocusRequest } from "./use-thread-focus-request";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { MobileThreadPicker } from "./mobile-thread-picker";
import { ThreadTaskActionsProvider } from "./thread-task-actions";
import { useThreadSelectionRecovery } from "./use-thread-selection-recovery";
import { resolveThreadLayout, type ThreadLayoutResult } from "./thread-layout";
import type { ThreadLayout } from "@/lib/state/slices/ui/thread-view-types";
import { cn } from "@/lib/utils";
type ThreadsBoardProps = {
threads: ActiveThread[];
isLoading?: boolean;
layout?: ThreadLayout;
autoHideComposer?: boolean;
/** Column a deep link asked for; scrolled into view and ringed on arrival. */
focusedTaskId?: string | null;
/**
* URL-driven callers must pass a stable request identity across target absence.
* Omission keys dismissal to the resolved task ID for legacy callers, so a
* temporary null target resets dismissal rather than retaining URL semantics.
*/
focusRequestKey?: string | null;
/** Session a task-detail link asked the target column to select. */
focusedSessionId?: string | null;
/** Removes a target session query after the target column proves it invalid. */
onInvalidRequestedSession?: (taskId: string, sessionId: string) => void;
onOpenTask: (taskId: string) => void;
/** Composes the page header with the viewport's active phone thread. */
renderHeader?: (activeMobileTaskId: string | null, gridHeightFallback: boolean) => ReactNode;
};
function ThreadsPlaceholder({ testId, children }: { testId: string; children: React.ReactNode }) {
return (
<div
data-testid={testId}
tabIndex={-1}
className="flex h-full min-h-0 w-full flex-col items-center justify-center gap-2 px-6 text-center"
>
{children}
</div>
);
}
function ThreadsEmptyState() {
const { t } = useTranslation();
return (
<ThreadsPlaceholder testId="threads-empty-state">
<IconColumns aria-hidden="true" className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm font-medium">{t("threads:emptyTitle")}</p>
<p className="max-w-md text-sm text-muted-foreground">{t("threads:emptyBody")}</p>
</ThreadsPlaceholder>
);
}
function ThreadsLoadingState() {
const { t } = useTranslation();
return (
<ThreadsPlaceholder testId="threads-loading-state">
<p role="status" aria-live="polite" className="text-sm text-muted-foreground">
{t("threads:loading")}
</p>
</ThreadsPlaceholder>
);
}
function focusThreadPicker(event: Event, column: Element | undefined) {
const trigger = column?.querySelector<HTMLButtonElement>('[data-testid="thread-picker-trigger"]');
if (!trigger) return;
event.preventDefault();
trigger.focus({ preventScroll: true });
}
type BoardSize = { height: number; width: number } | null;
function useBoardContentSize(
boardRef: RefObject<HTMLDivElement | null>,
isEmpty: boolean,
setSize: Dispatch<SetStateAction<BoardSize>>,
) {
useLayoutEffect(() => {
const board = boardRef.current;
if (!board) setSize(null);
if (!board || typeof ResizeObserver === "undefined") return;
let disposed = false;
const observer = new ResizeObserver((entries) => {
if (disposed) return;
const entry = entries[0];
if (entry) {
const { height, width } = entry.contentRect;
setSize((previous) =>
previous?.height === height && previous.width === width ? previous : { height, width },
);
}
});
observer.observe(board);
return () => {
disposed = true;
observer.disconnect();
};
}, [boardRef, isEmpty, setSize]);
}
function useThreadBoardLayout(
orderedIds: readonly string[],
layout: ThreadLayout,
isMobile: boolean,
focusedTaskId: string | null,
) {
const [size, setSize] = useState<BoardSize>(null);
const composition = resolveThreadLayout({
layout,
isMobile,
taskCount: orderedIds.length,
contentHeight: size?.height ?? null,
});
const layoutKey = `${composition.layout}:${composition.rows}:${isMobile}:${size?.width}`;
const activation = useThreadColumnActivation(orderedIds, focusedTaskId, layoutKey);
useBoardContentSize(activation.boardRef, orderedIds.length === 0, setSize);
const rememberThread = useThreadSelectionRecovery(
orderedIds,
activation.boardRef,
isMobile,
layoutKey,
);
return { ...activation, composition, layoutKey, rememberThread };
}
function boardLayoutStyle(composition: ThreadLayoutResult) {
if (composition.layout !== "grid") return undefined;
return {
gridAutoFlow: "column",
gridTemplateRows: `repeat(${composition.rows}, minmax(0, 1fr))`,
gridTemplateColumns: `repeat(${composition.columns}, minmax(360px, 1fr))`,
};
}
function useThreadPicker(
boardRef: RefObject<HTMLDivElement | null>,
isMobile: boolean,
mobileTaskId: string | null,
firstId: string | null,
) {
const [open, setOpen] = useState(false);
if (!isMobile && open) setOpen(false);
const returnFocusTaskId = useRef<string | null>(null);
const taskColumn = (taskId: string | null) =>
Array.from(boardRef.current?.children ?? []).find(
(element) => element.getAttribute("data-thread-column-id") === taskId,
);
function choose(taskId: string) {
returnFocusTaskId.current = taskId;
setOpen(true);
}
function select(taskId: string) {
returnFocusTaskId.current = taskId;
taskColumn(taskId)?.scrollIntoView({ inline: "start", block: "nearest", behavior: "instant" });
setOpen(false);
}
function restoreFocus(event: Event) {
focusThreadPicker(
event,
taskColumn(returnFocusTaskId.current) ?? taskColumn(mobileTaskId) ?? taskColumn(firstId),
);
}
return { open, setOpen, choose, select, restoreFocus };
}
/**
* The deck: every live agent conversation as its own column, scrolled
* horizontally. Columns keep the order the selector gave them, so a thread the
* reader is following does not jump while they read it.
*/
export function ThreadsBoard({
threads,
isLoading = false,
layout = "columns",
autoHideComposer = false,
focusedTaskId = null,
focusRequestKey = focusedTaskId,
focusedSessionId = null,
onInvalidRequestedSession,
onOpenTask,
renderHeader,
}: ThreadsBoardProps) {
const { markedTaskId, activationTaskId, retire } = useThreadFocusRequest(
focusedTaskId,
focusRequestKey,
);
const { isMobile, isFinePointer } = useResponsiveBreakpoint();
const orderedIds = useMemo(() => threads.map((thread) => thread.taskId), [threads]);
const {
boardRef,
registerColumn,
preloadTaskIds,
detailTaskIds,
mobileTaskId,
composition,
layoutKey,
rememberThread,
} = useThreadBoardLayout(orderedIds, layout, isMobile, activationTaskId);
const picker = useThreadPicker(boardRef, isMobile, mobileTaskId, orderedIds[0] ?? null);
return (
<ThreadTaskActionsProvider boardRef={boardRef}>
<div className="flex h-full min-h-0 min-w-0 flex-col">
{renderHeader?.(mobileTaskId, composition.heightFallback)}
{threads.length === 0 ? (
<div className="min-h-0 flex-1">
{isLoading ? <ThreadsLoadingState /> : <ThreadsEmptyState />}
</div>
) : (
<div
data-testid="threads-board"
data-layout={composition.layout}
ref={boardRef}
onWheelCapture={(event) => {
retire();
rememberThread(event);
}}
// Capture phase: a column's own handlers must not be able to swallow the
// interaction that retires the mark.
onPointerDownCapture={(event) => {
retire();
rememberThread(event);
}}
onFocusCapture={(event) => {
retire();
rememberThread(event);
}}
className={cn(
"min-h-0 min-w-0 w-full flex-1 snap-x snap-mandatory overflow-x-auto overflow-y-hidden overscroll-x-contain md:gap-3 md:p-3 md:snap-none",
composition.layout === "grid" ? "grid" : "flex",
)}
style={boardLayoutStyle(composition)}
>
{threads.map((thread) => (
<ThreadColumn
key={thread.taskId}
thread={thread}
autoHideComposer={autoHideComposer && !isMobile && isFinePointer}
mobileNavigation={
isMobile
? {
onChoose: () => picker.choose(thread.taskId),
}
: undefined
}
isFocused={thread.taskId === markedTaskId}
layoutKey={layoutKey}
requestedSessionId={thread.taskId === focusedTaskId ? focusedSessionId : null}
isPreloaded={preloadTaskIds.has(thread.taskId)}
isDetailActive={detailTaskIds.has(thread.taskId)}
onInvalidRequestedSession={onInvalidRequestedSession}
onColumnRef={registerColumn}
onOpenTask={onOpenTask}
/>
))}
</div>
)}
{isMobile && threads.length > 0 && (
<MobileThreadPicker
threads={threads}
selectedTaskId={mobileTaskId}
open={picker.open}
onOpenChange={picker.setOpen}
onSelect={picker.select}
onCloseAutoFocus={picker.restoreFocus}
/>
)}
</div>
</ThreadTaskActionsProvider>
);
}