diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 747e5e809548..1e77512583b2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,6 +1,5 @@ import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts"; import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection"; -import { CollapsibleSectionHeader } from "./ui/collapsible-section-header"; import { ThreadContextDragGhost } from "./chat/ThreadContextDragGhost"; import { dropThreadContext, @@ -12,7 +11,6 @@ import { requestCustomSnooze } from "./CustomSnoozeDialog"; import { useAtomValue } from "@effect/atom-react"; import * as Option from "effect/Option"; import { Atom } from "effect/reactivity"; -import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import * as Schema from "effect/Schema"; import { DndContext, @@ -23,9 +21,8 @@ import { type DragStartEvent, type Modifier, } from "@dnd-kit/core"; -import { SortableContext, useSortable } from "@dnd-kit/sortable"; +import { SortableContext } from "@dnd-kit/sortable"; import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; -import { CSS } from "@dnd-kit/utilities"; import { canSnooze, effectiveSnoozed, @@ -50,16 +47,8 @@ import { type ThreadId, } from "@t3tools/contracts"; +import { FolderIcon, ListFilterIcon, PlusIcon, SettingsIcon } from "lucide-react"; import { - FolderIcon, - ListFilterIcon, - PlusIcon, - SettingsIcon, - SquarePenIcon, - XIcon, -} from "lucide-react"; -import { - memo, useEffect, useLayoutEffect, useReducer, @@ -139,7 +128,6 @@ import { readEnvironmentScope } from "../state/session"; import { buildThreadRouteParams, resolveActiveThreadRouteRef } from "../threadRoutes"; import type { SidebarThreadSummary } from "../types"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; -import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { cn } from "~/lib/utils"; import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; import { @@ -148,7 +136,6 @@ import { threadActionRequiresOperate, } from "./threadActionMenu.logic"; import { - animateSidebarLayoutChanges, applySidebarThreadDrop, filterSidebarV2VisibleThreads, buildBulkTitleRegenerationContextMenuItem, @@ -167,12 +154,10 @@ import { resolveSidebarDropTarget, resolveSidebarDropVerb, resolveSidebarThreadSection, - resolveSidebarRowAccessibility, searchSidebarThreads, shouldCreateNewThreadInCurrentProject, shouldNavigateAfterThreadPark, sidebarListItemId, - sidebarMarkerId, sidebarThreadKeyAtY, sortInboxThreadsByReturn, sortPinnedThreadsForSidebar, @@ -180,7 +165,6 @@ import { sortWorkingThreadsBySend, useThreadJumpHintVisibility, type SidebarListItem, - type SidebarListMarker, type SidebarSection, resolveSidebarRowThreadId, } from "./Sidebar.logic"; @@ -192,7 +176,7 @@ import { import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; import { createSidebarListMotion } from "./Sidebar.motion"; import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze"; -import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; +import { ProjectFavicon } from "./ProjectFavicon"; import { deriveProviderEntriesByEnvironment } from "../providerInstances"; import { useStableCallback } from "../hooks/useStableCallback"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -210,24 +194,21 @@ import { import { SidebarContent, SidebarGroup, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader"; -import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; -import { - composerDraftHasUserContent, - DraftId, - useComposerDraftStore, - type ComposerThreadDraftState, - type DraftSessionState, -} from "../composerDraftStore"; +import { TooltipProvider } from "./ui/tooltip"; +import { composerDraftHasUserContent, DraftId, useComposerDraftStore } from "../composerDraftStore"; import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; import { - SIDEBAR_SORTABLE_DRAG_DISABLED, - draftPenClassName, - draftSurfaceClassName, SidebarSearchResultRow, SortableThreadRow, type SidebarProviderEntry, type SidebarSweepAction, } from "./sidebar/SidebarThreadRow"; +import { SidebarDraftBlock } from "./sidebar/SidebarDraftBlock"; +import { + SidebarDragBoundary, + SidebarSectionHeader, + SidebarSectionPlaceholder, +} from "./sidebar/SidebarListMarkers"; function sameSidebarProviderEntries( left: ReadonlyMap, @@ -756,334 +737,10 @@ function checkThreadOperations( return false; } -function SortableSidebarMarker(props: { - marker: SidebarListMarker; - className?: string; - children?: ReactNode; - "data-testid"?: string; -}) { - const { setNodeRef, transform, transition } = useSortable({ - id: sidebarMarkerId(props.marker), - disabled: SIDEBAR_SORTABLE_DRAG_DISABLED, - animateLayoutChanges: animateSidebarLayoutChanges, - }); - return ( -
  • - {props.children} -
  • - ); -} - -function SidebarSectionPlaceholder(props: { - marker: "active-placeholder" | "settled-placeholder"; - label: string; - showHint: boolean; - isDropTarget: boolean; -}) { - return ( - - {props.showHint ? ( -
    - {props.label} -
    - ) : null} -
    - ); -} - const SIDEBAR_DRAG_DISTANCE = 6; const SIDEBAR_DRAG_LABEL_HEIGHT = 24; -function SidebarDragBoundary(props: { - marker: "pinned-header" | "pinned-divider"; - label: string; - visible: boolean; - isDropTarget: boolean; -}) { - return ( - - {props.visible ? ( -
    - - {props.label} - - -
    - ) : null} -
    - ); -} - -function SidebarSectionHeader(props: { - marker: "working-header" | "snoozed-header" | "settled-header"; - label: string; - className?: string; - dragging?: boolean; - isDropTarget?: boolean; - toggle: { expanded: boolean; onToggle: () => void }; -}) { - const shelf = - props.marker === "working-header" - ? "working" - : props.marker === "snoozed-header" - ? "snoozed" - : "settled"; - const snoozed = shelf === "snoozed"; - return ( - - - {props.label} - - - ); -} - -const SidebarDraftRow = memo(function SidebarDraftRow(props: { - draftId: DraftId; - composer: ComposerThreadDraftState; - project: ProjectFaviconProject | null; - projectDisplayName: string | null; - isActive: boolean; - onNavigate: (draftId: DraftId) => void; - onDiscard: (draftId: DraftId) => void; - onContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; -}) { - const { composer, draftId, onContextMenu, onDiscard, onNavigate } = props; - const promptPreview = - replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label) - .trim() - .split("\n", 1)[0] ?? ""; - // images mirrors persistedAttachments once rehydration finishes; before - // that only the persisted list is populated, hence max not sum. - const attachmentCount = - Math.max(composer.images.length, composer.persistedAttachments.length) + - composer.files.length + - composer.terminalContexts.length + - composer.previewAnnotations.length + - composer.reviewComments.length; - const preview = - promptPreview.length > 0 - ? promptPreview - : `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`; - const accessibility = resolveSidebarRowAccessibility({ - title: preview, - statusLabel: "Unsent draft", - projectDisplayName: props.projectDisplayName, - isActive: props.isActive, - }); - const handleActivate = () => onNavigate(draftId); - const handleKeyDown = (event: ReactKeyboardEvent) => { - // Keys targeting the nested discard button belong to the button: - // preventDefault here would swallow Space's synthesized click and - // navigate instead of discarding. - if ((event.target as HTMLElement).closest("button")) return; - if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) { - event.preventDefault(); - const rect = event.currentTarget.getBoundingClientRect(); - onContextMenu(draftId, { x: rect.left, y: rect.bottom }); - } else if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onNavigate(draftId); - } - }; - const handleContextMenu = (event: ReactMouseEvent) => { - event.preventDefault(); - onContextMenu(draftId, { x: event.clientX, y: event.clientY }); - }; - const handleDiscard = (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onDiscard(draftId); - }; - return ( -
  • -
    - {preview} -
    -
    - - {props.project ? ( - - ) : null} - - {props.projectDisplayName} - - - - - - - } - /> - Discard draft - - -
    -
    - {preview} -
    -
    -
    -
  • - ); -}); - -interface SidebarDraftRowData { - draftId: DraftId; - session: DraftSessionState; - composer: ComposerThreadDraftState; -} - -const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { - projectByKey: ReadonlyMap; - projectDisplayNameByKey: ReadonlyMap; - scopedProjectKeys: ReadonlySet | null; - routeDraftId: string | null; - onNavigateToDraft: (draftId: DraftId) => void; - onDraftContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; -}) { - const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); - const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey); - const [frozenActive, setFrozenActive] = useState<{ - routeDraftId: string | null; - row: SidebarDraftRowData | null; - }>({ routeDraftId: null, row: null }); - if (frozenActive.routeDraftId !== props.routeDraftId) { - let row: SidebarDraftRowData | null = null; - if (props.routeDraftId !== null) { - const draftId = DraftId.make(props.routeDraftId); - const session = draftThreadsByThreadKey[draftId]; - const composer = draftsByThreadKey[draftId]; - row = - session && session.promotedTo == null && composer && composerDraftHasUserContent(composer) - ? { draftId, session, composer } - : null; - } - setFrozenActive({ routeDraftId: props.routeDraftId, row }); - } - const drafts = (() => { - const rows: SidebarDraftRowData[] = []; - for (const [draftKey, session] of Object.entries(draftThreadsByThreadKey)) { - if (session.promotedTo != null) { - continue; - } - if ( - props.scopedProjectKeys !== null && - !props.scopedProjectKeys.has(`${session.environmentId}:${session.projectId}`) - ) { - continue; - } - if (draftKey === props.routeDraftId) { - if (frozenActive.routeDraftId === draftKey && frozenActive.row !== null) { - rows.push(frozenActive.row); - } - continue; - } - const composer = draftsByThreadKey[draftKey]; - if (!composer || !composerDraftHasUserContent(composer)) { - continue; - } - rows.push({ draftId: DraftId.make(draftKey), session, composer }); - } - rows.sort((left, right) => right.session.createdAt.localeCompare(left.session.createdAt)); - return rows; - })(); - if (drafts.length === 0) { - return null; - } - return ( - <> - {drafts.map(({ composer, draftId, session }) => { - const projectKey = `${session.environmentId}:${session.projectId}`; - return ( - - ); - })} -
  • - - ); -}); - export default function Sidebar() { const { projects, diff --git a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx new file mode 100644 index 000000000000..dcb436fc0302 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx @@ -0,0 +1,222 @@ +import { discardComposerDraft } from "../../lib/discardComposerDraft"; +import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import { SquarePenIcon, XIcon } from "lucide-react"; +import { + memo, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, +} from "react"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { cn } from "~/lib/utils"; +import { resolveSidebarRowAccessibility } from "../Sidebar.logic"; +import { ProjectFavicon, type ProjectFaviconProject } from "../ProjectFavicon"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + composerDraftHasUserContent, + DraftId, + useComposerDraftStore, + type ComposerThreadDraftState, + type DraftSessionState, +} from "../../composerDraftStore"; +import { draftPenClassName, draftSurfaceClassName } from "./SidebarThreadRow"; + +const SidebarDraftRow = memo(function SidebarDraftRow(props: { + draftId: DraftId; + composer: ComposerThreadDraftState; + project: ProjectFaviconProject | null; + projectDisplayName: string | null; + isActive: boolean; + onNavigate: (draftId: DraftId) => void; + onDiscard: (draftId: DraftId) => void; + onContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; +}) { + const { composer, draftId, onContextMenu, onDiscard, onNavigate } = props; + const promptPreview = + replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label) + .trim() + .split("\n", 1)[0] ?? ""; + // images mirrors persistedAttachments once rehydration finishes; before + // that only the persisted list is populated, hence max not sum. + const attachmentCount = + Math.max(composer.images.length, composer.persistedAttachments.length) + + composer.files.length + + composer.terminalContexts.length + + composer.previewAnnotations.length + + composer.reviewComments.length; + const preview = + promptPreview.length > 0 + ? promptPreview + : `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`; + const accessibility = resolveSidebarRowAccessibility({ + title: preview, + statusLabel: "Unsent draft", + projectDisplayName: props.projectDisplayName, + isActive: props.isActive, + }); + const handleActivate = () => onNavigate(draftId); + const handleKeyDown = (event: ReactKeyboardEvent) => { + // Keys targeting the nested discard button belong to the button: + // preventDefault here would swallow Space's synthesized click and + // navigate instead of discarding. + if ((event.target as HTMLElement).closest("button")) return; + if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) { + event.preventDefault(); + const rect = event.currentTarget.getBoundingClientRect(); + onContextMenu(draftId, { x: rect.left, y: rect.bottom }); + } else if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onNavigate(draftId); + } + }; + const handleContextMenu = (event: ReactMouseEvent) => { + event.preventDefault(); + onContextMenu(draftId, { x: event.clientX, y: event.clientY }); + }; + const handleDiscard = (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onDiscard(draftId); + }; + return ( +
  • +
    + {preview} +
    +
    + + {props.project ? ( + + ) : null} + + {props.projectDisplayName} + + + + + + + } + /> + Discard draft + + +
    +
    + {preview} +
    +
    +
    +
  • + ); +}); + +interface SidebarDraftRowData { + draftId: DraftId; + session: DraftSessionState; + composer: ComposerThreadDraftState; +} + +export const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { + projectByKey: ReadonlyMap; + projectDisplayNameByKey: ReadonlyMap; + scopedProjectKeys: ReadonlySet | null; + routeDraftId: string | null; + onNavigateToDraft: (draftId: DraftId) => void; + onDraftContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; +}) { + const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); + const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey); + const [frozenActive, setFrozenActive] = useState<{ + routeDraftId: string | null; + row: SidebarDraftRowData | null; + }>({ routeDraftId: null, row: null }); + if (frozenActive.routeDraftId !== props.routeDraftId) { + let row: SidebarDraftRowData | null = null; + if (props.routeDraftId !== null) { + const draftId = DraftId.make(props.routeDraftId); + const session = draftThreadsByThreadKey[draftId]; + const composer = draftsByThreadKey[draftId]; + row = + session && session.promotedTo == null && composer && composerDraftHasUserContent(composer) + ? { draftId, session, composer } + : null; + } + setFrozenActive({ routeDraftId: props.routeDraftId, row }); + } + const drafts = (() => { + const rows: SidebarDraftRowData[] = []; + for (const [draftKey, session] of Object.entries(draftThreadsByThreadKey)) { + if (session.promotedTo != null) { + continue; + } + if ( + props.scopedProjectKeys !== null && + !props.scopedProjectKeys.has(`${session.environmentId}:${session.projectId}`) + ) { + continue; + } + if (draftKey === props.routeDraftId) { + if (frozenActive.routeDraftId === draftKey && frozenActive.row !== null) { + rows.push(frozenActive.row); + } + continue; + } + const composer = draftsByThreadKey[draftKey]; + if (!composer || !composerDraftHasUserContent(composer)) { + continue; + } + rows.push({ draftId: DraftId.make(draftKey), session, composer }); + } + rows.sort((left, right) => right.session.createdAt.localeCompare(left.session.createdAt)); + return rows; + })(); + if (drafts.length === 0) { + return null; + } + return ( + <> + {drafts.map(({ composer, draftId, session }) => { + const projectKey = `${session.environmentId}:${session.projectId}`; + return ( + + ); + })} +
  • + + ); +}); diff --git a/apps/web/src/components/sidebar/SidebarListMarkers.tsx b/apps/web/src/components/sidebar/SidebarListMarkers.tsx new file mode 100644 index 000000000000..0bfee0f6bfa8 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarListMarkers.tsx @@ -0,0 +1,135 @@ +import { CollapsibleSectionHeader } from "../ui/collapsible-section-header"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { type ReactNode } from "react"; +import { cn } from "~/lib/utils"; +import { + animateSidebarLayoutChanges, + sidebarMarkerId, + type SidebarListMarker, +} from "../Sidebar.logic"; +import { SIDEBAR_SORTABLE_DRAG_DISABLED } from "./SidebarThreadRow"; + +function SortableSidebarMarker(props: { + marker: SidebarListMarker; + className?: string; + children?: ReactNode; + "data-testid"?: string; +}) { + const { setNodeRef, transform, transition } = useSortable({ + id: sidebarMarkerId(props.marker), + disabled: SIDEBAR_SORTABLE_DRAG_DISABLED, + animateLayoutChanges: animateSidebarLayoutChanges, + }); + return ( +
  • + {props.children} +
  • + ); +} + +export function SidebarSectionPlaceholder(props: { + marker: "active-placeholder" | "settled-placeholder"; + label: string; + showHint: boolean; + isDropTarget: boolean; +}) { + return ( + + {props.showHint ? ( +
    + {props.label} +
    + ) : null} +
    + ); +} + +export function SidebarDragBoundary(props: { + marker: "pinned-header" | "pinned-divider"; + label: string; + visible: boolean; + isDropTarget: boolean; +}) { + return ( + + {props.visible ? ( +
    + + {props.label} + + +
    + ) : null} +
    + ); +} + +export function SidebarSectionHeader(props: { + marker: "working-header" | "snoozed-header" | "settled-header"; + label: string; + className?: string; + dragging?: boolean; + isDropTarget?: boolean; + toggle: { expanded: boolean; onToggle: () => void }; +}) { + const shelf = + props.marker === "working-header" + ? "working" + : props.marker === "snoozed-header" + ? "snoozed" + : "settled"; + const snoozed = shelf === "snoozed"; + return ( + + + {props.label} + + + ); +}