diff --git a/src/client/app/KannaSidebar.tsx b/src/client/app/KannaSidebar.tsx index 6f65684fb..be5648016 100644 --- a/src/client/app/KannaSidebar.tsx +++ b/src/client/app/KannaSidebar.tsx @@ -3,11 +3,10 @@ import { ArrowLeft, Flower, House, Loader2, PanelLeft, Search, Plus, Settings, S import { useLocation, useNavigate } from "react-router-dom" import { APP_NAME } from "../../shared/branding" import { Button } from "../components/ui/button" -import { Dialog, DialogBody, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog" import { buildChatJumpLocationState, type ChatJumpRole } from "../lib/chat-navigation" -import { formatSidebarAgeLabel } from "../lib/formatters" -import { getSidebarChatTimestamp } from "../lib/sidebarChats" import { cn, normalizeChatId } from "../lib/utils" +import { ArchivedChatsDialog } from "../components/chat-ui/sidebar/ArchivedChatsDialog" +import { ArchivedSection } from "../components/chat-ui/sidebar/ArchivedSection" import { LocalProjectsSection } from "../components/chat-ui/sidebar/LocalProjectsSection" import { FocusModePill } from "../components/chat-ui/sidebar/FocusModePill" import { projectActivity } from "./kannaStateHelpers" @@ -30,6 +29,7 @@ import { } from "./sidebarNumberJump" import { SIDEBAR_VIEW_STORAGE_KEY, SIDEBAR_WIDTH_STORAGE_KEY } from "../lib/storageKeys" import { useAppSettingsStore } from "../stores/appSettingsStore" +import { usePendingSendStore } from "../stores/pendingSendStore" import { useSidebarData } from "../stores/sidebarStore" import { focusSidebarData, @@ -63,7 +63,13 @@ function persistSidebarWidth(width: number) { window.localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(clampSidebarWidth(width))) } -function readStoredSidebarView(): SidebarView { +/** + * The list view to start in. Only ever Chats or Projects: Archived is somewhere + * you visit and get returned from (see `leaveArchivedView`), so it is never + * persisted — and what's stored is exactly the view Archived hands you back to, + * across reloads as well as within a session. + */ +function readStoredSidebarView(): Exclude { if (typeof window === "undefined") return "recents" return window.localStorage.getItem(SIDEBAR_VIEW_STORAGE_KEY) === "projects" ? "projects" : "recents" } @@ -163,13 +169,53 @@ function KannaSidebarImpl({ const [showNumberJumpHints, setShowNumberJumpHints] = useState(false) const [sidebarWidth, setSidebarWidth] = useState(readStoredSidebarWidth) const [isResizingSidebar, setIsResizingSidebar] = useState(false) + // Which project's archived chats the dialog is showing, if any. The + // workspace-wide list is the sidebar's own Archived view, not this. const [archivedProjectId, setArchivedProjectId] = useState(null) const [sidebarView, setSidebarView] = useState(readStoredSidebarView) + // Where Archived hands you back to. Held in a ref because nothing renders it + // — it is read at the moment you leave, which can be from a store + // subscription rather than a render. + const returnViewRef = useRef>(readStoredSidebarView()) const changeSidebarView = useCallback((view: SidebarView) => { setSidebarView(view) + if (view === "archived") return + returnViewRef.current = view if (typeof window !== "undefined") window.localStorage.setItem(SIDEBAR_VIEW_STORAGE_KEY, view) }, []) + + /** + * Leave the Archived view for the one you were in before it — a no-op from + * anywhere else, so callers don't have to check where they are. + * + * The archive is where finished work goes, so anything that puts a chat back + * into circulation has ended your visit: sending a prompt (which unarchives + * the chat server-side, or was a new chat that was never in this list) and + * restoring one. Staying put would leave you looking at a list the chat you + * just acted on has dropped out of. + */ + const leaveArchivedView = useCallback(() => { + setSidebarView((current) => (current === "archived" ? returnViewRef.current : current)) + }, []) + + const handleRestoreChat = useCallback((chatId: string) => { + leaveArchivedView() + onRestoreChat(chatId) + }, [leaveArchivedView, onRestoreChat]) + + // Sends come from the composer, which is not in this tree — the pending-send + // store is the one place both sides already meet. Subscribed only while the + // Archived view is up, so every other view pays nothing for this. + useEffect(() => { + if (sidebarView !== "archived") return + return usePendingSendStore.subscribe((state, previous) => { + if (state.sentAt === previous.sentAt) return + const started = Object.keys(state.sentAt) + .some((chatId) => state.sentAt[chatId] !== previous.sentAt[chatId]) + if (started) leaveArchivedView() + }) + }, [leaveArchivedView, sidebarView]) const resolvedKeybindings = useMemo(() => getResolvedKeybindings(keybindings), [keybindings]) const visibleChats = useMemo( () => getVisibleSidebarChats(data.projectGroups, collapsedSections, expandedGroups), @@ -302,11 +348,11 @@ function KannaSidebarImpl({ onOpenExternalPath={onOpenExternalPath} onForkChat={onForkChat} onArchiveChat={onArchiveChat} - onRestoreChat={onRestoreChat} + onRestoreChat={handleRestoreChat} onDeleteChat={onDeleteChat} /> ) - }, [activeChatId, editorLabel, nowMs, onArchiveChat, onCopyPath, onCreateChat, onDeleteChat, onForkChat, onOpenExternalPath, onRenameChat, onRestoreChat, onShareChat, resolvedKeybindings, selectChat, showNumberJumpHints, threadByChatId, visibleIndexByChatId]) + }, [activeChatId, editorLabel, nowMs, onArchiveChat, onCopyPath, onCreateChat, onDeleteChat, onForkChat, onOpenExternalPath, onRenameChat, handleRestoreChat, onShareChat, resolvedKeybindings, selectChat, showNumberJumpHints, threadByChatId, visibleIndexByChatId]) useEffect(() => { const intervalId = window.setInterval(() => { @@ -723,7 +769,10 @@ function KannaSidebarImpl({ ) : null} - {!isConnecting && ( + {/* Not in the Archived view: there, "no conversations yet" would sit + above a list of the conversations you archived, and the view + states its own emptiness anyway. */} + {!isConnecting && sidebarView !== "archived" && ( (!hasVisibleChats && data.projectGroups.length === 0) // A focused project with no chats: say so, rather than leave the // list blank under a pill naming the project. @@ -740,7 +789,26 @@ function KannaSidebarImpl({ nowMs={nowMs} onSelectChat={selectChat} onOpenArchivedChat={onOpenArchivedChat} - onRestoreChat={onRestoreChat} + onRestoreChat={handleRestoreChat} + onCreateChat={onCreateChat} + onRenameChat={onRenameChat} + onShareChat={onShareChat} + onForkChat={onForkChat} + onArchiveChat={onArchiveChat} + onDeleteChat={onDeleteChat} + onCopyPath={onCopyPath} + onOpenExternalPath={onOpenExternalPath} + /> + ) : null} + + {newSidebarEnabled && sidebarView === "archived" ? ( + - { if (!dialogOpen) setArchivedProjectId(null) }} - > - - - Archived Chats - - {archivedProject?.localPath ?? ""} - - - - {archivedProject?.archivedChats?.length ? ( - archivedProject.archivedChats.map((chat) => ( - - )) - ) : ( -

No archived chats

- )} -
-
-
+ onOpenChat={onOpenArchivedChat} + onRestoreChat={handleRestoreChat} + /> ) } diff --git a/src/client/components/chat-ui/sidebar/ArchivedChatsDialog.tsx b/src/client/components/chat-ui/sidebar/ArchivedChatsDialog.tsx new file mode 100644 index 000000000..f32e1d6fb --- /dev/null +++ b/src/client/components/chat-ui/sidebar/ArchivedChatsDialog.tsx @@ -0,0 +1,87 @@ +import { RotateCcw } from "lucide-react" +import type { SidebarChatRow } from "../../../../shared/types" +import { formatSidebarAgeLabel } from "../../../lib/formatters" +import { getSidebarChatTimestamp } from "../../../lib/sidebarChats" +import { Button } from "../../ui/button" +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "../../ui/dialog" + +/** + * One project's archived chats, opened from its sidebar menu — the Projects + * view's counterpart to the Archived view, which covers the whole workspace. + * + * Opening a chat closes the dialog — you're leaving for it — while restoring + * leaves it open, since putting several chats back is one errand and each row + * drops out of the list on the next snapshot anyway. + */ +export function ArchivedChatsDialog({ + open, + description, + chats, + nowMs, + onOpenChange, + onOpenChat, + onRestoreChat, +}: { + open: boolean + /** Subtitle under the heading: the project's path. */ + description?: string + chats: SidebarChatRow[] + nowMs: number + onOpenChange: (open: boolean) => void + onOpenChat: (chatId: string) => void + onRestoreChat: (chatId: string) => void +}) { + return ( + + + + Archived Chats + {description ?? ""} + + + {chats.length ? ( + chats.map((chat) => ( +
+ + +
+ )) + ) : ( +

No archived chats

+ )} +
+
+
+ ) +} diff --git a/src/client/components/chat-ui/sidebar/ArchivedSection.test.tsx b/src/client/components/chat-ui/sidebar/ArchivedSection.test.tsx new file mode 100644 index 000000000..ac77baf16 --- /dev/null +++ b/src/client/components/chat-ui/sidebar/ArchivedSection.test.tsx @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test" +import { renderToStaticMarkup } from "react-dom/server" +import type { SidebarChatRow } from "../../../../shared/types" +import type { SidebarThread } from "../../../lib/thread-sections" +import { TooltipProvider } from "../../ui/tooltip" +import { ArchivedSection } from "./ArchivedSection" + +function thread( + overrides: Partial & Pick, + archived = true +): SidebarThread { + const row: SidebarChatRow = { + _id: overrides.chatId, + _creationTime: 1, + status: "idle", + unread: false, + localPath: "/tmp/project", + provider: "claude", + lastMessageAt: 1, + hasAutomation: false, + ...overrides, + } + return { + chatId: row.chatId, + title: row.title, + projectId: "project-1", + projectTitle: "Project", + projectLabel: { name: "Project", branchName: "main", repoPath: "acme/Project", text: "Project/main" }, + archived, + lastActivityAt: row.lastMessageAt ?? 1, + row, + } +} + +function render(threads: SidebarThread[]) { + return renderToStaticMarkup( + // The rows are hover-card triggers, and Radix tooltips need their provider. + + undefined} + onRestoreChat={() => undefined} + onCreateChat={() => undefined} + onRenameChat={() => undefined} + onShareChat={() => undefined} + onForkChat={() => undefined} + onArchiveChat={() => undefined} + onDeleteChat={() => undefined} + onCopyPath={() => undefined} + onOpenExternalPath={() => undefined} + /> + + ) +} + +describe("ArchivedSection", () => { + test("lists only archived chats, most recently archived first", () => { + const markup = render([ + thread({ chatId: "active", title: "Still going", archivedAt: undefined }, false), + thread({ chatId: "older", title: "Archived earlier", archivedAt: 100 }), + thread({ chatId: "newer", title: "Archived just now", archivedAt: 900 }), + ]) + + expect(markup).not.toContain("Still going") + expect(markup.indexOf("Archived just now")).toBeLessThan(markup.indexOf("Archived earlier")) + }) + + test("says so when there is nothing archived", () => { + expect(render([thread({ chatId: "active", title: "Still going" }, false)])) + .toContain("No archived chats") + }) +}) diff --git a/src/client/components/chat-ui/sidebar/ArchivedSection.tsx b/src/client/components/chat-ui/sidebar/ArchivedSection.tsx new file mode 100644 index 000000000..0bae63df1 --- /dev/null +++ b/src/client/components/chat-ui/sidebar/ArchivedSection.tsx @@ -0,0 +1,90 @@ +import { memo, useMemo } from "react" +import type { SidebarChatRow } from "../../../../shared/types" +import { getArchivedThreads, type SidebarThread } from "../../../lib/thread-sections" +import { normalizeChatId } from "../../../lib/utils" +import { SectionHeader } from "./ThreadSections" +import { ThreadRow } from "./ThreadRow" + +interface Props { + /** Every thread, active and archived — the archived ones are picked out here. */ + threads: SidebarThread[] + activeChatId: string | null + editorLabel: string + nowMs: number + onOpenArchivedChat: (chatId: string) => void + onRestoreChat: (chatId: string) => void + onCreateChat: (projectId: string) => void + onRenameChat: (chat: SidebarChatRow) => void + onShareChat: (chatId: string) => void + onForkChat: (chat: SidebarChatRow) => void + onArchiveChat: (chat: SidebarChatRow) => void + onDeleteChat: (chat: SidebarChatRow) => void + onCopyPath: (localPath: string) => void + onOpenExternalPath: (action: "open_finder" | "open_editor", localPath: string) => void +} + +/** + * The New Sidebar's Archived view: every archived chat in the workspace, most + * recently archived first. + * + * One flat list with no date buckets — an archive is browsed by "what did I put + * away recently", not by which day each conversation happened on. Rows are the + * same `ThreadRow` the other views use, in their archived mode: selecting one + * opens it without unarchiving, and each row carries a Restore button (and menu + * item) to put it back. + */ +function ArchivedSectionImpl({ + threads, + activeChatId, + editorLabel, + nowMs, + onOpenArchivedChat, + onRestoreChat, + onCreateChat, + onRenameChat, + onShareChat, + onForkChat, + onArchiveChat, + onDeleteChat, + onCopyPath, + onOpenExternalPath, +}: Props) { + const archived = useMemo(() => getArchivedThreads(threads), [threads]) + const normalizedActiveChatId = activeChatId ? normalizeChatId(activeChatId) : null + + return ( +
+ + {archived.length === 0 ? ( +

No archived chats

+ ) : ( +
+ {archived.map((thread) => ( + + ))} +
+ )} +
+ ) +} + +export const ArchivedSection = memo(ArchivedSectionImpl) diff --git a/src/client/components/chat-ui/sidebar/SidebarViewSwitcher.tsx b/src/client/components/chat-ui/sidebar/SidebarViewSwitcher.tsx index 5b10b9ff6..f637ace2f 100644 --- a/src/client/components/chat-ui/sidebar/SidebarViewSwitcher.tsx +++ b/src/client/components/chat-ui/sidebar/SidebarViewSwitcher.tsx @@ -1,28 +1,28 @@ -import { Folder, ListFilter, MessageCircle } from "lucide-react" +import { Archive, Folder, ListFilter, MessageCircle } from "lucide-react" import { InputPopover, PopoverMenuItem } from "../ChatPreferenceControls" /** Which view the sidebar shows when the recent-chats Labs mode is enabled. */ -export type SidebarView = "recents" | "projects" +export type SidebarView = "recents" | "projects" | "archived" /** - * One row's text: the view's name with what it's grouped by trailing it inline - * — two rows in a picker this small read better on one line each. + * One row's text: the name with its qualifier trailing it inline — rows in a + * picker this small read better on one line each. * * Same treatment as `PopoverMenuItem`'s own `description` subtitle. The weight * has to be stated: unlike that slot, this sits *inside* the label, so it would * otherwise inherit its medium weight and read as part of the name. */ -function ViewLabel({ name, grouping }: { name: string; grouping: string }) { +function ViewLabel({ name, detail }: { name: string; detail: string }) { return ( {name} - grouped by {grouping} + {detail} ) } /** - * Swaps the sidebar between its Chats and Projects views. + * Swaps the sidebar between its Chats, Projects and Archived views. * * Sits at the right end of the New Chat row — one fixed spot that doesn't move * with the view or with which section happens to render first. The odd width @@ -54,7 +54,7 @@ export function SidebarViewSwitcher({ }} selected={view === "recents"} icon={} - label={} + label={} /> { @@ -63,7 +63,16 @@ export function SidebarViewSwitcher({ }} selected={view === "projects"} icon={} - label={} + label={} + /> + { + close() + onChange("archived") + }} + selected={view === "archived"} + icon={} + label={} /> )} diff --git a/src/client/components/chat-ui/sidebar/ThreadSections.tsx b/src/client/components/chat-ui/sidebar/ThreadSections.tsx index 0ea2d72b3..80262c735 100644 --- a/src/client/components/chat-ui/sidebar/ThreadSections.tsx +++ b/src/client/components/chat-ui/sidebar/ThreadSections.tsx @@ -27,7 +27,7 @@ import { ThreadRow } from "./ThreadRow" * aligned with the buckets'. `onArchiveAll` adds the "…" button and a * matching right-click menu with Archive All. */ -function SectionHeader({ +export function SectionHeader({ label, onToggle, isExpanded, diff --git a/src/client/lib/thread-sections.test.ts b/src/client/lib/thread-sections.test.ts index 1bd3dd786..b23d681ae 100644 --- a/src/client/lib/thread-sections.test.ts +++ b/src/client/lib/thread-sections.test.ts @@ -5,6 +5,7 @@ import { computeThreadDateBuckets, computeThreadSections, flattenSidebarThreads, + getArchivedThreads, getInProgressThreads, getRecentThreads, getRelevantThreads, @@ -459,6 +460,34 @@ describe("computeSidebarThreadSections", () => { expect(sections.archived.map((thread) => thread.chatId)).toEqual(["archived-new", "archived-old"]) }) + test("archived sorts by when it was archived, not by activity", () => { + const data = makeData([], [ + // Dormant for a month, archived a moment ago: it leads, because this list + // answers "what did I just put away". + makeChatRow({ + chatId: "just-archived", + title: "x", + lastMessageAt: at(2026, 7, 1), + archivedAt: at(2026, 7, 20), + }), + makeChatRow({ + chatId: "archived-earlier", + title: "y", + lastMessageAt: at(2026, 7, 15), + archivedAt: at(2026, 7, 16), + }), + // Pre-dates `archivedAt`: falls back to its activity, so it sorts last. + makeChatRow({ chatId: "legacy", title: "z", lastMessageAt: at(2026, 7, 10) }), + ]) + + const archived = getArchivedThreads(flattenSidebarThreads(data)) + expect(archived.map((thread) => thread.chatId)).toEqual([ + "just-archived", + "archived-earlier", + "legacy", + ]) + }) + test("Relevant drains flagged chats out of the date buckets", () => { const data = makeData([ makeChatRow({ chatId: "dirty-old", title: "a", uncommittedWork: true, lastMessageAt: at(2026, 7, 10) }), diff --git a/src/client/lib/thread-sections.ts b/src/client/lib/thread-sections.ts index 6233abbb3..57902eec1 100644 --- a/src/client/lib/thread-sections.ts +++ b/src/client/lib/thread-sections.ts @@ -523,12 +523,33 @@ export function computeSidebarThreadSections( !thread.archived && thread.row.lastMessageAt != null && !excludeIds.has(thread.chatId)) - const archived = threads + return { + inProgress, + review, + relevant, + buckets: computeThreadDateBuckets(rest, nowMs), + archived: getArchivedThreads(threads), + } +} + +/** + * Archived chats, most recently *archived* first. + * + * Sorted by `archivedAt` rather than by activity, unlike every other section: + * this list answers "what did I just put away", and archiving a long-dormant + * chat should put it on top rather than back where its age would bury it. Rows + * archived before the field existed fall back to their activity. + */ +export function getArchivedThreads(threads: SidebarThread[]): SidebarThread[] { + return threads // Archived chats that never got a message are hidden everywhere (the // server also filters them out of the snapshot; this is defense in depth). .filter((thread) => thread.archived && thread.row.lastMessageAt != null) - .sort((left, right) => right.lastActivityAt - left.lastActivityAt) - return { inProgress, review, relevant, buckets: computeThreadDateBuckets(rest, nowMs), archived } + .sort((left, right) => archivedSortKey(right) - archivedSortKey(left)) +} + +function archivedSortKey(thread: SidebarThread) { + return thread.row.archivedAt ?? thread.lastActivityAt } /** diff --git a/src/server/read-models.ts b/src/server/read-models.ts index a0f76846e..e8bc0a005 100644 --- a/src/server/read-models.ts +++ b/src/server/read-models.ts @@ -272,6 +272,7 @@ export function deriveSidebarData( // and only the hover card reads them (`chat.getPreview`). ...(pendingToolKind ? { pendingToolKind } : {}), ...(uncommittedWork ? { uncommittedWork: true } : {}), + ...(chat.archivedAt ? { archivedAt: chat.archivedAt } : {}), hasAutomation: false, canFork: canForkChat(chat, activeStatuses, drainingChatIds) || undefined, } diff --git a/src/shared/types.ts b/src/shared/types.ts index 2d9d097fb..61dd579f2 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -917,6 +917,12 @@ export interface SidebarChatRow { * whichever one caused it. Drives the muted (non-pulsing) sidebar dot. */ uncommittedWork?: boolean + /** + * When the chat was archived. Set only on rows in `archivedChats`, and only + * the archive list reads it — sorting "recently archived" by last message + * would order by the conversation's age instead of by when it was put away. + */ + archivedAt?: number hasAutomation: boolean canFork?: boolean }