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) =>