diff --git a/src/App.tsx b/src/App.tsx index ad476343e..099ddf4ce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -594,6 +594,10 @@ function MainApp() { lastAgentMessageByThread, interruptTurn, removeThread, + pinThread, + unpinThread, + isThreadPinned, + getPinTimestamp, renameThread, startThreadForWorkspace, listThreadsForWorkspace, @@ -1185,6 +1189,10 @@ function MainApp() { }); removeImagesForThread(threadId); }, + pinThread, + unpinThread, + isThreadPinned, + getPinTimestamp, onRenameThread: (workspaceId, threadId) => { handleRenameThread(workspaceId, threadId); }, diff --git a/src/features/app/components/PinnedThreadList.tsx b/src/features/app/components/PinnedThreadList.tsx new file mode 100644 index 000000000..19eddb0a0 --- /dev/null +++ b/src/features/app/components/PinnedThreadList.tsx @@ -0,0 +1,101 @@ +import type { CSSProperties, MouseEvent } from "react"; + +import type { ThreadSummary } from "../../../types"; + +type ThreadStatusMap = Record< + string, + { isProcessing: boolean; hasUnread: boolean; isReviewing: boolean } +>; + +type PinnedThreadRow = { + thread: ThreadSummary; + depth: number; + workspaceId: string; +}; + +type PinnedThreadListProps = { + rows: PinnedThreadRow[]; + activeWorkspaceId: string | null; + activeThreadId: string | null; + threadStatusById: ThreadStatusMap; + getThreadTime: (thread: ThreadSummary) => string | null; + isThreadPinned: (workspaceId: string, threadId: string) => boolean; + onSelectThread: (workspaceId: string, threadId: string) => void; + onShowThreadMenu: ( + event: MouseEvent, + workspaceId: string, + threadId: string, + canPin: boolean, + ) => void; +}; + +export function PinnedThreadList({ + rows, + activeWorkspaceId, + activeThreadId, + threadStatusById, + getThreadTime, + isThreadPinned, + onSelectThread, + onShowThreadMenu, +}: PinnedThreadListProps) { + return ( +
+ {rows.map(({ thread, depth, workspaceId }) => { + const relativeTime = getThreadTime(thread); + const indentStyle = + depth > 0 + ? ({ "--thread-indent": `${depth * 14}px` } as CSSProperties) + : undefined; + const status = threadStatusById[thread.id]; + const statusClass = status?.isReviewing + ? "reviewing" + : status?.isProcessing + ? "processing" + : status?.hasUnread + ? "unread" + : "ready"; + const canPin = depth === 0; + const isPinned = canPin && isThreadPinned(workspaceId, thread.id); + + return ( +
onSelectThread(workspaceId, thread.id)} + onContextMenu={(event) => + onShowThreadMenu(event, workspaceId, thread.id, canPin) + } + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelectThread(workspaceId, thread.id); + } + }} + > + + {isPinned && ( + + 📌 + + )} + {thread.name} +
+ {relativeTime && {relativeTime}} +
+ +
+
+ ); + })} +
+ ); +} diff --git a/src/features/app/components/Sidebar.tsx b/src/features/app/components/Sidebar.tsx index 81a7b37ae..cd17de694 100644 --- a/src/features/app/components/Sidebar.tsx +++ b/src/features/app/components/Sidebar.tsx @@ -7,6 +7,7 @@ import { SidebarHeader } from "./SidebarHeader"; import { ThreadList } from "./ThreadList"; import { ThreadLoading } from "./ThreadLoading"; import { WorktreeSection } from "./WorktreeSection"; +import { PinnedThreadList } from "./PinnedThreadList"; import { WorkspaceCard } from "./WorkspaceCard"; import { WorkspaceGroup } from "./WorkspaceGroup"; import { useCollapsedGroups } from "../hooks/useCollapsedGroups"; @@ -54,6 +55,10 @@ type SidebarProps = { onToggleWorkspaceCollapse: (workspaceId: string, collapsed: boolean) => void; onSelectThread: (workspaceId: string, threadId: string) => void; onDeleteThread: (workspaceId: string, threadId: string) => void; + pinThread: (workspaceId: string, threadId: string) => boolean; + unpinThread: (workspaceId: string, threadId: string) => void; + isThreadPinned: (workspaceId: string, threadId: string) => boolean; + getPinTimestamp: (workspaceId: string, threadId: string) => number | null; onRenameThread: (workspaceId: string, threadId: string) => void; onDeleteWorkspace: (workspaceId: string) => void; onDeleteWorktree: (workspaceId: string) => void; @@ -87,6 +92,10 @@ export function Sidebar({ onToggleWorkspaceCollapse, onSelectThread, onDeleteThread, + pinThread, + unpinThread, + isThreadPinned, + getPinTimestamp, onRenameThread, onDeleteWorkspace, onDeleteWorktree, @@ -116,6 +125,9 @@ export function Sidebar({ const { showThreadMenu, showWorkspaceMenu, showWorktreeMenu } = useSidebarMenus({ onDeleteThread, + onPinThread: pinThread, + onUnpinThread: unpinThread, + isThreadPinned, onRenameThread, onReloadWorkspaceThreads, onDeleteWorkspace, @@ -130,6 +142,66 @@ export function Sidebar({ showWeekly, } = getUsageLabels(accountRateLimits); + const pinnedThreadRows = (() => { + type ThreadRow = { thread: ThreadSummary; depth: number }; + const groups: Array<{ + pinTime: number; + workspaceId: string; + rows: ThreadRow[]; + }> = []; + + workspaces.forEach((workspace) => { + const threads = threadsByWorkspace[workspace.id] ?? []; + if (!threads.length) { + return; + } + const { pinnedRows } = getThreadRows( + threads, + true, + workspace.id, + getPinTimestamp, + ); + if (!pinnedRows.length) { + return; + } + let currentRows: ThreadRow[] = []; + let currentPinTime: number | null = null; + + pinnedRows.forEach((row) => { + if (row.depth === 0) { + if (currentRows.length && currentPinTime !== null) { + groups.push({ + pinTime: currentPinTime, + workspaceId: workspace.id, + rows: currentRows, + }); + } + currentRows = [row]; + currentPinTime = getPinTimestamp(workspace.id, row.thread.id); + } else { + currentRows.push(row); + } + }); + + if (currentRows.length && currentPinTime !== null) { + groups.push({ + pinTime: currentPinTime, + workspaceId: workspace.id, + rows: currentRows, + }); + } + }); + + return groups + .sort((a, b) => a.pinTime - b.pinTime) + .flatMap((group) => + group.rows.map((row) => ({ + ...row, + workspaceId: group.workspaceId, + })), + ); + })(); + const worktreesByParent = useMemo(() => { const worktrees = new Map(); workspaces @@ -197,6 +269,23 @@ export function Sidebar({ ref={sidebarBodyRef} >
+ {pinnedThreadRows.length > 0 && ( +
+
+
Pinned
+
+ +
+ )} {groupedWorkspaces.map((group) => { const groupId = group.id; const isGroupCollapsed = Boolean( @@ -218,9 +307,14 @@ export function Sidebar({ const isCollapsed = entry.settings.sidebarCollapsed; const isExpanded = expandedWorkspaces.has(entry.id); const { - rows: threadRows, + unpinnedRows, totalRoots: totalThreadRoots, - } = getThreadRows(threads, isExpanded); + } = getThreadRows( + threads, + isExpanded, + entry.id, + getPinTimestamp, + ); const showThreads = !isCollapsed && threads.length > 0; const isLoadingThreads = threadListLoadingByWorkspace[entry.id] ?? false; @@ -293,6 +387,8 @@ export function Sidebar({ activeThreadId={activeThreadId} getThreadRows={getThreadRows} getThreadTime={getThreadTime} + isThreadPinned={isThreadPinned} + getPinTimestamp={getPinTimestamp} onSelectWorkspace={onSelectWorkspace} onConnectWorkspace={onConnectWorkspace} onToggleWorkspaceCollapse={onToggleWorkspaceCollapse} @@ -306,7 +402,8 @@ export function Sidebar({ {showThreads && ( string | null; + isThreadPinned: (workspaceId: string, threadId: string) => boolean; onToggleExpanded: (workspaceId: string) => void; onLoadOlderThreads: (workspaceId: string) => void; onSelectThread: (workspaceId: string, threadId: string) => void; @@ -31,12 +33,14 @@ type ThreadListProps = { event: MouseEvent, workspaceId: string, threadId: string, + canPin: boolean, ) => void; }; export function ThreadList({ workspaceId, - threadRows, + pinnedRows, + unpinnedRows, totalThreadRoots, isExpanded, nextCursor, @@ -46,59 +50,71 @@ export function ThreadList({ activeThreadId, threadStatusById, getThreadTime, + isThreadPinned, onToggleExpanded, onLoadOlderThreads, onSelectThread, onShowThreadMenu, }: ThreadListProps) { - return ( -
- {threadRows.map(({ thread, depth }) => { - const relativeTime = getThreadTime(thread); - const indentStyle = - depth > 0 - ? ({ "--thread-indent": `${depth * 14}px` } as CSSProperties) - : undefined; - const status = threadStatusById[thread.id]; - const statusClass = status?.isReviewing - ? "reviewing" - : status?.isProcessing - ? "processing" - : status?.hasUnread - ? "unread" - : "ready"; + const renderThreadRow = ({ thread, depth }: ThreadRow) => { + const relativeTime = getThreadTime(thread); + const indentStyle = + depth > 0 + ? ({ "--thread-indent": `${depth * 14}px` } as CSSProperties) + : undefined; + const status = threadStatusById[thread.id]; + const statusClass = status?.isReviewing + ? "reviewing" + : status?.isProcessing + ? "processing" + : status?.hasUnread + ? "unread" + : "ready"; + const canPin = depth === 0; + const isPinned = canPin && isThreadPinned(workspaceId, thread.id); - return ( -
onSelectThread(workspaceId, thread.id)} - onContextMenu={(event) => onShowThreadMenu(event, workspaceId, thread.id)} - role="button" - tabIndex={0} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onSelectThread(workspaceId, thread.id); - } - }} - > - - {thread.name} -
- {relativeTime && {relativeTime}} -
- -
+ return ( +
onSelectThread(workspaceId, thread.id)} + onContextMenu={(event) => + onShowThreadMenu(event, workspaceId, thread.id, canPin) + } + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelectThread(workspaceId, thread.id); + } + }} + > + + {isPinned && 📌} + {thread.name} +
+ {relativeTime && {relativeTime}} +
+ - ); - })} +
+
+ ); + }; + + return ( +
+ {pinnedRows.map((row) => renderThreadRow(row))} + {pinnedRows.length > 0 && unpinnedRows.length > 0 && ( +