Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 83 additions & 41 deletions src/client/app/KannaSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand Down Expand Up @@ -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<SidebarView, "archived"> {
if (typeof window === "undefined") return "recents"
return window.localStorage.getItem(SIDEBAR_VIEW_STORAGE_KEY) === "projects" ? "projects" : "recents"
}
Expand Down Expand Up @@ -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<string | null>(null)
const [sidebarView, setSidebarView] = useState<SidebarView>(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<Exclude<SidebarView, "archived">>(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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Failed restores exit archive

The sidebar leaves the Archived view before the asynchronous chat.unarchive command succeeds. If that command fails because of a connection or server error, the chat remains archived, but the user is returned to Chats or Projects and loses the archive context needed to retry.

Knowledge Base Used: Chat workspace experience

Fix in Codex

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),
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -723,7 +769,10 @@ function KannaSidebarImpl({
</div>
) : 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.
Expand All @@ -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" ? (
<ArchivedSection
threads={threads}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Focus hides archived chats

When focus mode is enabled, threads is built from sidebar data already narrowed to the focused project. Passing it here means the workspace-wide Archived view omits archived chats from every other project, preventing users from browsing or restoring the complete archive until they disable focus mode.

Knowledge Base Used: Chat workspace experience

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex

activeChatId={activeChatId}
editorLabel={editorLabel}
nowMs={nowMs}
onOpenArchivedChat={onOpenArchivedChat}
onRestoreChat={handleRestoreChat}
onCreateChat={onCreateChat}
onRenameChat={onRenameChat}
onShareChat={onShareChat}
Expand Down Expand Up @@ -862,43 +930,17 @@ function KannaSidebarImpl({
/>
</div>

<Dialog
<ArchivedChatsDialog
open={Boolean(archivedProject)}
description={archivedProject?.localPath}
chats={archivedProject?.archivedChats ?? []}
nowMs={nowMs}
onOpenChange={(dialogOpen) => {
if (!dialogOpen) setArchivedProjectId(null)
}}
>
<DialogContent size="md">
<DialogHeader>
<DialogTitle>Archived Chats</DialogTitle>
<DialogDescription>
{archivedProject?.localPath ?? ""}
</DialogDescription>
</DialogHeader>
<DialogBody className="space-y-1">
{archivedProject?.archivedChats?.length ? (
archivedProject.archivedChats.map((chat) => (
<button
key={chat.chatId}
type="button"
className="flex w-full items-center justify-between gap-3 rounded-lg border border-border/0 px-3 py-2 text-left transition-colors hover:border-border hover:bg-muted"
onClick={() => {
onOpenArchivedChat(chat.chatId)
setArchivedProjectId(null)
}}
>
<span className="min-w-0 truncate text-sm">{chat.title}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{formatSidebarAgeLabel(getSidebarChatTimestamp(chat), nowMs)}
</span>
</button>
))
) : (
<p className="px-1 py-3 text-sm text-muted-foreground">No archived chats</p>
)}
</DialogBody>
</DialogContent>
</Dialog>
onOpenChat={onOpenArchivedChat}
onRestoreChat={handleRestoreChat}
/>
</>
)
}
Expand Down
87 changes: 87 additions & 0 deletions src/client/components/chat-ui/sidebar/ArchivedChatsDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent size="md">
<DialogHeader>
<DialogTitle>Archived Chats</DialogTitle>
<DialogDescription>{description ?? ""}</DialogDescription>
</DialogHeader>
<DialogBody className="space-y-1">
{chats.length ? (
chats.map((chat) => (
<div
key={chat.chatId}
className="group flex items-center gap-1 rounded-lg border border-border/0 pr-1 transition-colors hover:border-border hover:bg-muted"
>
<button
type="button"
className="flex min-w-0 flex-1 items-center justify-between gap-3 px-3 py-2 text-left"
onClick={() => {
onOpenChat(chat.chatId)
onOpenChange(false)
}}
>
<span className="min-w-0 truncate text-sm">{chat.title}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{formatSidebarAgeLabel(chat.archivedAt ?? getSidebarChatTimestamp(chat), nowMs)}
</span>
</button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 rounded-md text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
title="Restore chat"
aria-label={`Restore ${chat.title}`}
onClick={() => onRestoreChat(chat.chatId)}
>
<RotateCcw className="size-3.5" />
</Button>
</div>
))
) : (
<p className="px-1 py-3 text-sm text-muted-foreground">No archived chats</p>
)}
</DialogBody>
</DialogContent>
</Dialog>
)
}
75 changes: 75 additions & 0 deletions src/client/components/chat-ui/sidebar/ArchivedSection.test.tsx
Original file line number Diff line number Diff line change
@@ -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<SidebarChatRow> & Pick<SidebarChatRow, "chatId" | "title">,
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.
<TooltipProvider>
<ArchivedSection
threads={threads}
activeChatId={null}
editorLabel="VS Code"
nowMs={1_000}
onOpenArchivedChat={() => undefined}
onRestoreChat={() => undefined}
onCreateChat={() => undefined}
onRenameChat={() => undefined}
onShareChat={() => undefined}
onForkChat={() => undefined}
onArchiveChat={() => undefined}
onDeleteChat={() => undefined}
onCopyPath={() => undefined}
onOpenExternalPath={() => undefined}
/>
</TooltipProvider>
)
}

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")
})
})
Loading