From 6e43fb6a73a7d6d28727b4aebceca4892224dc89 Mon Sep 17 00:00:00 2001 From: Brian Anglin Date: Thu, 3 Sep 2026 20:05:25 -0700 Subject: [PATCH] Add an Archived view for unarchiving chats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archiving was a one-way door in practice: a chat could be restored only from the context menu of a row in the Chats view's collapsed Archived section, or from one project's Show Archived dialog. Neither is where you look when the question is "what did I put away, and can I have it back". Archived joins Chats and Projects in the sidebar's view switcher, listing every archived chat in the workspace as the same rows the other views use — so restoring is the row's own hover button or menu item, and selecting one still opens it without unarchiving. Sorted by a new `archivedAt` on the sidebar row rather than by activity, which is the difference between "what did I just put away" and "which conversation is oldest": archiving a long-dormant chat should put it on top, not bury it. The Chats view's Archived section picks up the same ordering. Rows archived before the field existed fall back to activity. Acting on a chat ends the visit — sending a prompt (which unarchives it server-side, or was a new chat that was never in this list) or restoring one returns you to the view you came from, with the chat still selected. That is also why Archived is session-only: what's persisted is the view it hands you back to, so a reload can't strand you in an archive with nowhere to return to. 🌸 Shipped with Kanna — https://kanna.sh Co-Authored-By: Kanna Kanna-Agent: claude/opus[1m] --- src/client/app/KannaSidebar.tsx | 124 ++++++++++++------ .../chat-ui/sidebar/ArchivedChatsDialog.tsx | 87 ++++++++++++ .../chat-ui/sidebar/ArchivedSection.test.tsx | 75 +++++++++++ .../chat-ui/sidebar/ArchivedSection.tsx | 90 +++++++++++++ .../chat-ui/sidebar/SidebarViewSwitcher.tsx | 27 ++-- .../chat-ui/sidebar/ThreadSections.tsx | 2 +- src/client/lib/thread-sections.test.ts | 29 ++++ src/client/lib/thread-sections.ts | 27 +++- src/server/read-models.ts | 1 + src/shared/types.ts | 6 + 10 files changed, 414 insertions(+), 54 deletions(-) create mode 100644 src/client/components/chat-ui/sidebar/ArchivedChatsDialog.tsx create mode 100644 src/client/components/chat-ui/sidebar/ArchivedSection.test.tsx create mode 100644 src/client/components/chat-ui/sidebar/ArchivedSection.tsx 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 }