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 && (
+
+ )}
{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 && (
+
+ )}
+ {unpinnedRows.map((row) => renderThreadRow(row))}
{totalThreadRoots > 3 && (