diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cffab8bd577..7fad851b2ea 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -9,6 +9,7 @@ import { LoaderIcon, SearchIcon, SquarePenIcon, + StarIcon, TerminalIcon, TriangleAlertIcon, } from "lucide-react"; @@ -367,6 +368,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr } = props; const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); + const isFavorite = useUiStateStore((state) => state.favoriteThreadKeys.includes(threadKey)); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const runningTerminalIds = useThreadRunningTerminalIds({ @@ -823,6 +825,12 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr ) ) : null} + {isFavorite ? ( + + ) : null} {isRemoteThread && !isDesktopLocalThread && ( @@ -1116,6 +1124,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); + const toggleThreadFavorite = useUiStateStore((state) => state.toggleThreadFavorite); const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded); const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo); @@ -2117,6 +2126,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ? [{ id: "new-thread-on-branch", label: `New thread on ${thread.branch}` }] : []), { id: "rename", label: "Rename thread" }, + { + id: "toggle-favorite", + label: useUiStateStore.getState().favoriteThreadKeys.includes(threadKey) + ? "Remove from favorites" + : "Add to favorites", + }, { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, { id: "copy-thread-id", label: "Copy Thread ID" }, @@ -2154,6 +2169,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } + if (clicked === "toggle-favorite") { + toggleThreadFavorite(threadKey); + return; + } + if (clicked === "mark-unread") { markThreadUnread(threadKey, thread.latestTurn?.completedAt); return; @@ -2210,6 +2230,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec memberProjectByScopedKey, project.workspaceRoot, startThreadRename, + toggleThreadFavorite, ], ); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index ee73b570514..dd5c8f30f61 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -32,6 +32,7 @@ import { SearchIcon, ServerIcon, SquarePenIcon, + StarIcon, TerminalIcon, Trash2Icon, Undo2Icon, @@ -445,6 +446,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { [thread.environmentId, thread.id], ); const threadKey = scopedThreadKey(threadRef); + const isFavorite = useUiStateStore((state) => state.favoriteThreadKeys.includes(threadKey)); const isRegeneratingTitle = thread.titleRegeneration != null; const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); @@ -829,6 +831,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { remain visible AND clickable while the row is hovered. Only the time/jump label yields to the settle affordance. */} {prBadge} + {isFavorite ? ( + + ) : null} {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( @@ -937,6 +945,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : ( )} + {isFavorite ? ( + + ) : null} {/* The visible state owns this slot's width: status at rest, actions on hover/focus or while the popover is open. Keeping the hidden state out of flow lets the project label reclaim @@ -1244,6 +1258,7 @@ export default function SidebarV2() { const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); + const toggleThreadFavorite = useUiStateStore((s) => s.toggleThreadFavorite); const routeTarget = useParams({ strict: false, select: (params) => resolveThreadRouteTarget(params), @@ -2335,6 +2350,12 @@ export default function SidebarV2() { ] : []), { id: "rename", label: "Rename thread" }, + { + id: "toggle-favorite", + label: useUiStateStore.getState().favoriteThreadKeys.includes(threadKey) + ? "Remove from favorites" + : "Add to favorites", + }, ...(supportsTitleRegeneration ? [ { @@ -2396,6 +2417,9 @@ export default function SidebarV2() { case "rename": startThreadRename(threadRef, thread.title); return; + case "toggle-favorite": + toggleThreadFavorite(threadKey); + return; case "regenerate-title": { if (isRegeneratingTitle) return; const result = await updateThreadMetadata({ @@ -2480,6 +2504,7 @@ export default function SidebarV2() { projectCwdByKey, serverConfigs, startThreadRename, + toggleThreadFavorite, updateThreadMetadata, ], ); diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 30450287353..f9904f15aa9 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -14,6 +14,7 @@ import { setDefaultAdvertisedEndpointKey, setProjectExpanded, setThreadChangedFilesExpanded, + toggleThreadFavorite, type UiState, } from "./uiStateStore"; @@ -21,6 +22,7 @@ function makeUiState(overrides: Partial = {}): UiState { return { projectExpandedById: {}, projectOrder: [], + favoriteThreadKeys: [], threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, @@ -53,6 +55,15 @@ describe("uiStateStore pure functions", () => { expect(markThreadUnread(next, threadId, null)).toBe(next); }); + it("toggles a favorite thread by its scoped key", () => { + const initialState = makeUiState(); + const favorite = toggleThreadFavorite(initialState, "environment:thread-1"); + + expect(favorite.favoriteThreadKeys).toEqual(["environment:thread-1"]); + expect(toggleThreadFavorite(favorite, "environment:thread-1").favoriteThreadKeys).toEqual([]); + expect(toggleThreadFavorite(initialState, "")).toBe(initialState); + }); + it("resolves project expansion from logical, physical, and legacy preference keys", () => { const physicalKey = "environment:/repo/project"; const legacyKey = legacyProjectCwdPreferenceKey("/repo/project"); @@ -154,6 +165,7 @@ describe("parsePersistedState", () => { invalid: "no" as unknown as boolean, }, projectOrder: ["physical-b", "", "physical-a", "physical-b"], + favoriteThreadKeys: ["environment:thread-1", "", "environment:thread-1"], threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", invalid: "not-a-date", @@ -173,6 +185,7 @@ describe("parsePersistedState", () => { logical: false, }, projectOrder: ["physical-b", "physical-a"], + favoriteThreadKeys: ["environment:thread-1"], threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, @@ -270,6 +283,7 @@ describe("uiStateStore persistence", () => { logical: false, }, projectOrder: ["physical-b", "physical-a"], + favoriteThreadKeys: ["environment:thread-1"], threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, @@ -292,6 +306,7 @@ describe("uiStateStore persistence", () => { logical: false, }, projectOrder: ["physical-b", "physical-a"], + favoriteThreadKeys: ["environment:thread-1"], threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 5d744d540a5..902ec9e07d9 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -20,6 +20,7 @@ const LEGACY_PERSISTED_STATE_KEYS = [ export interface PersistedUiState { projectExpandedById?: Record; projectOrder?: string[]; + favoriteThreadKeys?: string[]; threadLastVisitedAtById?: Record; collapsedProjectCwds?: string[]; expandedProjectCwds?: string[]; @@ -35,6 +36,7 @@ export interface UiProjectState { } export interface UiThreadState { + favoriteThreadKeys: string[]; threadLastVisitedAtById: Record; threadChangedFilesExpandedById: Record>; } @@ -48,6 +50,7 @@ export interface UiState extends UiProjectState, UiThreadState, UiEndpointState const initialState: UiState = { projectExpandedById: {}, projectOrder: [], + favoriteThreadKeys: [], threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, @@ -125,6 +128,7 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { return { projectExpandedById, projectOrder, + favoriteThreadKeys: sanitizeStringArray(parsed.favoriteThreadKeys), threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById), threadChangedFilesExpandedById: parsed.threadChangedFilesExpansionVersion === THREAD_CHANGED_FILES_EXPANSION_VERSION @@ -203,6 +207,7 @@ export function persistState(state: UiState): void { JSON.stringify({ projectExpandedById, projectOrder: state.projectOrder, + favoriteThreadKeys: state.favoriteThreadKeys, threadLastVisitedAtById: state.threadLastVisitedAtById, defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION, @@ -245,6 +250,19 @@ export function markThreadVisited(state: UiState, threadId: string, visitedAt: s }; } +export function toggleThreadFavorite(state: UiState, threadKey: string): UiState { + if (!threadKey) { + return state; + } + const isFavorite = state.favoriteThreadKeys.includes(threadKey); + return { + ...state, + favoriteThreadKeys: isFavorite + ? state.favoriteThreadKeys.filter((key) => key !== threadKey) + : [...state.favoriteThreadKeys, threadKey], + }; +} + export function markThreadUnread( state: UiState, threadId: string, @@ -382,6 +400,7 @@ export function reorderProjects( } interface UiStateStore extends UiState { + toggleThreadFavorite: (threadKey: string) => void; markThreadVisited: (threadId: string, visitedAt: string) => void; markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; @@ -396,6 +415,7 @@ interface UiStateStore extends UiState { export const useUiStateStore = create((set) => ({ ...readPersistedState(), + toggleThreadFavorite: (threadKey) => set((state) => toggleThreadFavorite(state, threadKey)), markThreadVisited: (threadId, visitedAt) => set((state) => markThreadVisited(state, threadId, visitedAt)), markThreadUnread: (threadId, latestTurnCompletedAt) =>