388 lines
13 KiB
TypeScript
388 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type Dispatch,
|
|
type SetStateAction,
|
|
} from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { KanbanBoard } from "./kanban-board";
|
|
import { TaskPreviewPanel } from "./task-preview-panel";
|
|
import { useKanbanPreview } from "@/hooks/use-kanban-preview";
|
|
import { useKanbanLayout } from "@/hooks/use-kanban-layout";
|
|
import { useTaskSession } from "@/hooks/use-task-session";
|
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
|
import { useAppStore } from "@/components/state-provider";
|
|
import { Task } from "./kanban-card";
|
|
import type { KanbanState } from "@/lib/state/slices";
|
|
import { PREVIEW_PANEL } from "@/lib/settings/constants";
|
|
import { linkToTask } from "@/lib/links";
|
|
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
|
|
import {
|
|
useEnsureTaskSession,
|
|
type UseEnsureTaskSessionResult,
|
|
} from "@/hooks/domains/session/use-ensure-task-session";
|
|
|
|
type KanbanWithPreviewProps = {
|
|
initialTaskId?: string;
|
|
initialSessionId?: string;
|
|
};
|
|
|
|
function useUrlSync(selectedTaskId: string | null, selectedTaskSessionId: string | null) {
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
|
|
const url = new URL(window.location.href);
|
|
if (selectedTaskId) {
|
|
url.searchParams.set("taskId", selectedTaskId);
|
|
} else {
|
|
url.searchParams.delete("taskId");
|
|
url.searchParams.delete("sessionId");
|
|
}
|
|
|
|
if (selectedTaskId && selectedTaskSessionId) {
|
|
url.searchParams.set("sessionId", selectedTaskSessionId);
|
|
} else if (selectedTaskId) {
|
|
// Task still open but no active session (e.g. all sessions deleted) —
|
|
// drop any stale sessionId from the URL.
|
|
url.searchParams.delete("sessionId");
|
|
}
|
|
|
|
window.history.replaceState({}, "", url.toString());
|
|
}, [selectedTaskId, selectedTaskSessionId]);
|
|
}
|
|
|
|
function useEscapeKey(isOpen: boolean, close: () => void) {
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape" && isOpen) {
|
|
close();
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [isOpen, close]);
|
|
}
|
|
|
|
function useResizeHandler(
|
|
isResizingRef: React.RefObject<boolean>,
|
|
previewWidthPx: number,
|
|
updatePreviewWidth: (width: number) => void,
|
|
) {
|
|
return useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
(isResizingRef as React.MutableRefObject<boolean>).current = true;
|
|
|
|
const startX = e.clientX;
|
|
const startWidth = previewWidthPx;
|
|
|
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
|
if (!isResizingRef.current) return;
|
|
const deltaX = startX - moveEvent.clientX;
|
|
updatePreviewWidth(startWidth + deltaX);
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
(isResizingRef as React.MutableRefObject<boolean>).current = false;
|
|
window.removeEventListener("mousemove", handleMouseMove);
|
|
window.removeEventListener("mouseup", handleMouseUp);
|
|
};
|
|
|
|
window.addEventListener("mousemove", handleMouseMove);
|
|
window.addEventListener("mouseup", handleMouseUp);
|
|
},
|
|
[isResizingRef, previewWidthPx, updatePreviewWidth],
|
|
);
|
|
}
|
|
|
|
// Holds the user's explicit session pick for the selected task, resetting to null
|
|
// whenever the task changes. Uses React's "store previous props" pattern to avoid
|
|
// a setState-in-effect cascade (https://react.dev/reference/react/useState#storing-information-from-previous-renders).
|
|
function useSessionSelectionReset(
|
|
selectedTaskId: string | null | undefined,
|
|
initialValue: string | null = null,
|
|
): [string | null, Dispatch<SetStateAction<string | null>>] {
|
|
const [value, setValue] = useState<string | null>(initialValue);
|
|
const [prevTaskId, setPrevTaskId] = useState<string | null>(selectedTaskId ?? null);
|
|
const currentTaskId = selectedTaskId ?? null;
|
|
if (prevTaskId !== currentTaskId) {
|
|
setPrevTaskId(currentTaskId);
|
|
// Only reset on a real task-to-task transition. When prevTaskId is null we
|
|
// are hydrating the initial task from the URL — preserve the seeded initialValue.
|
|
if (prevTaskId !== null) {
|
|
setValue(null);
|
|
}
|
|
}
|
|
return [value, setValue];
|
|
}
|
|
|
|
function useSelectedTask(
|
|
selectedTaskId: string | null | undefined,
|
|
kanbanTasks: KanbanState["tasks"],
|
|
snapshots: Record<string, { tasks: KanbanState["tasks"] }>,
|
|
) {
|
|
return useMemo(() => {
|
|
if (!selectedTaskId) return null;
|
|
// The active workflow's tasks live in `kanban.tasks`, but cards from other
|
|
// workflows can also appear in the board (multi-workflow swimlane view via
|
|
// `kanbanMulti.snapshots`). Fall back to those so cross-workflow previews
|
|
// are not auto-closed by the "task no longer exists" guard below.
|
|
const task =
|
|
kanbanTasks.find((t: KanbanState["tasks"][number]) => t.id === selectedTaskId) ??
|
|
findTaskInSnapshots(selectedTaskId, snapshots);
|
|
if (!task) return null;
|
|
return {
|
|
id: task.id,
|
|
title: task.title,
|
|
workflowStepId: task.workflowStepId,
|
|
state: task.state,
|
|
description: task.description,
|
|
position: task.position,
|
|
repositoryId: task.repositoryId,
|
|
primarySessionId: task.primarySessionId,
|
|
};
|
|
}, [selectedTaskId, kanbanTasks, snapshots]);
|
|
}
|
|
|
|
export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWithPreviewProps) {
|
|
const router = useRouter();
|
|
const { isMobile } = useResponsiveBreakpoint();
|
|
|
|
// Get tasks from the kanban store
|
|
const kanbanTasks = useAppStore((state) => state.kanban.tasks);
|
|
const kanbanMultiSnapshots = useAppStore((state) => state.kanbanMulti.snapshots);
|
|
const setKanbanPreviewedTaskId = useAppStore((state) => state.setKanbanPreviewedTaskId);
|
|
|
|
const { selectedTaskId, isOpen, previewWidthPx, open, close, updatePreviewWidth } =
|
|
useKanbanPreview({
|
|
initialTaskId,
|
|
onClose: () => {
|
|
// Cleanup handled by close
|
|
},
|
|
});
|
|
|
|
// Mirror the previewed task id into the store so kanban cards can highlight
|
|
// the currently-previewed card without prop-drilling through swimlanes.
|
|
useEffect(() => {
|
|
setKanbanPreviewedTaskId(isOpen ? (selectedTaskId ?? null) : null);
|
|
}, [isOpen, selectedTaskId, setKanbanPreviewedTaskId]);
|
|
useEffect(() => {
|
|
return () => setKanbanPreviewedTaskId(null);
|
|
}, [setKanbanPreviewedTaskId]);
|
|
|
|
// Use custom hooks for layout and session management
|
|
const { containerRef, shouldFloat, kanbanWidth } = useKanbanLayout(isOpen, previewWidthPx);
|
|
const { sessionId: selectedTaskSessionId } = useTaskSession(selectedTaskId ?? null);
|
|
|
|
// User-selected tab overrides the default primary session pick.
|
|
// Reset when the selected task changes.
|
|
const [userSelectedSessionId, setUserSelectedSessionId] = useSessionSelectionReset(
|
|
selectedTaskId,
|
|
initialSessionId ?? null,
|
|
);
|
|
|
|
// Track resize state
|
|
const isResizingRef = useRef(false);
|
|
|
|
const selectedTask = useSelectedTask(selectedTaskId, kanbanTasks, kanbanMultiSnapshots);
|
|
|
|
// Close panel if selected task no longer exists in either the active
|
|
// workflow's tasks or any loaded multi-workflow snapshot.
|
|
useEffect(() => {
|
|
if (isOpen && selectedTaskId && !selectedTask) {
|
|
close();
|
|
}
|
|
}, [isOpen, selectedTaskId, selectedTask, close]);
|
|
|
|
// Auto-start a session when the preview opens on a task with no session,
|
|
// mirroring the full task page so the preview doesn't dead-end on
|
|
// "No agents yet." The hook no-ops when no agent profile resolves.
|
|
const ensureSession = useEnsureTaskSession(selectedTask, { enabled: isOpen });
|
|
|
|
const handleNavigateToTask = useCallback(
|
|
(task: Task) => {
|
|
router.push(linkToTask(task.id));
|
|
},
|
|
[router],
|
|
);
|
|
|
|
const activeSessionId = selectedTaskId
|
|
? (userSelectedSessionId ?? selectedTask?.primarySessionId ?? selectedTaskSessionId)
|
|
: null;
|
|
|
|
useUrlSync(selectedTaskId ?? null, activeSessionId ?? null);
|
|
|
|
const handlePreviewTaskWithData = useCallback(
|
|
(task: Task) => {
|
|
if (isOpen && selectedTaskId === task.id) {
|
|
close();
|
|
} else {
|
|
open(task.id);
|
|
}
|
|
},
|
|
[isOpen, selectedTaskId, open, close],
|
|
);
|
|
|
|
useEscapeKey(isOpen, close);
|
|
|
|
const handleResizeMouseDown = useResizeHandler(isResizingRef, previewWidthPx, updatePreviewWidth);
|
|
|
|
// On mobile, skip the preview panel entirely — card clicks navigate directly
|
|
if (isMobile) {
|
|
return (
|
|
<div className="h-dvh w-full flex flex-col bg-background">
|
|
<KanbanBoard />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div ref={containerRef} className="h-screen w-full flex flex-col bg-background relative">
|
|
{shouldFloat ? (
|
|
<FloatingPreviewLayout
|
|
kanbanWidth={kanbanWidth}
|
|
previewWidthPx={previewWidthPx}
|
|
selectedTask={selectedTask}
|
|
activeSessionId={activeSessionId}
|
|
ensureSession={ensureSession}
|
|
onPreviewTask={handlePreviewTaskWithData}
|
|
onNavigateToTask={handleNavigateToTask}
|
|
onClose={close}
|
|
onSessionChange={setUserSelectedSessionId}
|
|
onResizeMouseDown={handleResizeMouseDown}
|
|
/>
|
|
) : (
|
|
<InlinePreviewLayout
|
|
kanbanWidth={kanbanWidth}
|
|
previewWidthPx={previewWidthPx}
|
|
isOpen={isOpen}
|
|
selectedTask={selectedTask}
|
|
activeSessionId={activeSessionId}
|
|
ensureSession={ensureSession}
|
|
onPreviewTask={handlePreviewTaskWithData}
|
|
onNavigateToTask={handleNavigateToTask}
|
|
onClose={close}
|
|
onSessionChange={setUserSelectedSessionId}
|
|
onResizeMouseDown={handleResizeMouseDown}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ResizeHandle({ onMouseDown }: { onMouseDown: (e: React.MouseEvent) => void }) {
|
|
return (
|
|
<div
|
|
className="w-1 bg-border hover:bg-primary cursor-col-resize flex-shrink-0 relative group"
|
|
onMouseDown={onMouseDown}
|
|
>
|
|
<div className="absolute inset-y-0 -left-2 -right-2" />
|
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-8 bg-border group-hover:bg-primary rounded-full transition-colors" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type PreviewLayoutProps = {
|
|
kanbanWidth: number;
|
|
previewWidthPx: number;
|
|
selectedTask: Task | null;
|
|
activeSessionId: string | null;
|
|
ensureSession: UseEnsureTaskSessionResult;
|
|
onPreviewTask: (task: Task) => void;
|
|
onNavigateToTask: (task: Task) => void;
|
|
onClose: () => void;
|
|
onSessionChange: (sessionId: string | null) => void;
|
|
onResizeMouseDown: (e: React.MouseEvent) => void;
|
|
};
|
|
|
|
function FloatingPreviewLayout({
|
|
kanbanWidth,
|
|
previewWidthPx,
|
|
selectedTask,
|
|
activeSessionId,
|
|
ensureSession,
|
|
onPreviewTask,
|
|
onNavigateToTask,
|
|
onClose,
|
|
onSessionChange,
|
|
onResizeMouseDown,
|
|
}: PreviewLayoutProps) {
|
|
return (
|
|
<>
|
|
<div className="flex-1 overflow-hidden" style={{ width: `${kanbanWidth}px` }}>
|
|
<KanbanBoard onPreviewTask={onPreviewTask} onOpenTask={onNavigateToTask} />
|
|
</div>
|
|
<div
|
|
className="fixed inset-0 bg-black/30 z-30"
|
|
onClick={onClose}
|
|
aria-label="Close preview"
|
|
/>
|
|
<div
|
|
className="fixed right-0 top-0 bottom-0 z-40 shadow-2xl bg-background flex"
|
|
style={{
|
|
width: `${previewWidthPx}px`,
|
|
maxWidth: `${PREVIEW_PANEL.MAX_WIDTH_VW}vw`,
|
|
}}
|
|
>
|
|
<ResizeHandle onMouseDown={onResizeMouseDown} />
|
|
<div className="flex-1 min-w-0 overflow-hidden">
|
|
<TaskPreviewPanel
|
|
task={selectedTask}
|
|
sessionId={activeSessionId}
|
|
ensureSession={ensureSession}
|
|
onClose={onClose}
|
|
onMaximize={(task) => onNavigateToTask(task)}
|
|
onSessionChange={onSessionChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function InlinePreviewLayout({
|
|
kanbanWidth,
|
|
previewWidthPx,
|
|
isOpen,
|
|
selectedTask,
|
|
activeSessionId,
|
|
ensureSession,
|
|
onPreviewTask,
|
|
onNavigateToTask,
|
|
onClose,
|
|
onSessionChange,
|
|
onResizeMouseDown,
|
|
}: PreviewLayoutProps & { isOpen: boolean }) {
|
|
return (
|
|
<div className="flex-1 flex overflow-hidden">
|
|
<div className="overflow-hidden" style={{ width: `${kanbanWidth}px` }}>
|
|
<KanbanBoard onPreviewTask={onPreviewTask} onOpenTask={onNavigateToTask} />
|
|
</div>
|
|
{isOpen && (
|
|
<div
|
|
className="flex-shrink-0 border-l bg-background flex"
|
|
style={{ width: `${previewWidthPx}px` }}
|
|
>
|
|
<ResizeHandle onMouseDown={onResizeMouseDown} />
|
|
<div className="flex-1 min-w-0 overflow-hidden">
|
|
<TaskPreviewPanel
|
|
task={selectedTask}
|
|
sessionId={activeSessionId}
|
|
ensureSession={ensureSession}
|
|
onClose={onClose}
|
|
onMaximize={(task) => onNavigateToTask(task)}
|
|
onSessionChange={onSessionChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|