-
-
Worktree archive behavior
-
- Control whether archived xum-managed worktrees stay on disk, are deleted, or are
- snapshotted so they can be restored on unarchive.
+
+
+
API Debug Logs
+
+ Record the full input and output of every AI API call
+
+
+
-
-
-
- {isBrowserMode && sshHostLoaded && (
-
-
-
SSH Host
-
- SSH hostname for 'Open in Editor' deep links
+ {isBrowserMode && sshHostLoaded && (
+
+
+
SSH Host
+
+ SSH hostname for 'Open in Editor' deep links
+
+
+
) =>
+ handleSshHostChange(e.target.value)
+ }
+ placeholder={window.location.hostname}
+ className="border-border-medium bg-background-secondary h-9 w-40"
+ />
-
-
) =>
- handleSshHostChange(e.target.value)
- }
- placeholder={window.location.hostname}
- className="border-border-medium bg-background-secondary h-9 w-40"
- />
+ )}
- )}
+
Projects
diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts
index a17311df903..81ff199ad92 100644
--- a/src/browser/utils/commandIds.ts
+++ b/src/browser/utils/commandIds.ts
@@ -41,6 +41,7 @@ export const CommandIds = {
navPrev: () => "nav:prev" as const,
navToggleSidebar: () => "nav:toggleSidebar" as const,
navToggleHideSubAgents: () => "nav:toggle-hide-subagents" as const,
+ navToggleFlatChatList: () => "nav:toggle-flat-chat-list" as const,
navToggleTerminalBadge: () => "nav:toggle-terminal-badge" as const,
navRightSidebarFocusTerminal: () => "nav:rightSidebar:focusTerminal" as const,
navRightSidebarSplitHorizontal: () => "nav:rightSidebar:splitHorizontal" as const,
diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts
index 313d96f58a8..b6ae2b3d289 100644
--- a/src/browser/utils/commands/sources.test.ts
+++ b/src/browser/utils/commands/sources.test.ts
@@ -4,7 +4,11 @@ import type { ProjectConfig } from "@/node/config";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
import { GlobalWindow } from "happy-dom";
-import { getModelKey, SIDEBAR_HIDE_SUBAGENTS_KEY } from "@/common/constants/storage";
+import {
+ getModelKey,
+ SIDEBAR_FLAT_MODE_KEY,
+ SIDEBAR_HIDE_SUBAGENTS_KEY,
+} from "@/common/constants/storage";
import { CUSTOM_EVENTS } from "@/common/constants/events";
import type { WorkspaceState } from "@/browser/stores/WorkspaceStore";
import type { APIClient } from "@/browser/contexts/API";
@@ -1330,6 +1334,33 @@ test("workspace generate title command dispatches a title-generation request eve
}
});
+test("toggle flat chat list command flips the persisted sidebar setting", () => {
+ const testWindow = new GlobalWindow();
+ const originalWindow = globalThis.window;
+ const originalDocument = globalThis.document;
+ globalThis.window = testWindow as unknown as Window & typeof globalThis;
+ globalThis.document = testWindow.document as unknown as Document;
+
+ try {
+ const toggle = () => {
+ const action = getActions().find((a) => a.id === "nav:toggle-flat-chat-list");
+ expect(action).toBeDefined();
+ void action!.run();
+ };
+
+ toggle();
+ expect(window.localStorage.getItem(SIDEBAR_FLAT_MODE_KEY)).toBe("true");
+ expect(getActions().find((a) => a.id === "nav:toggle-flat-chat-list")?.subtitle).toContain(
+ "Flat"
+ );
+ toggle();
+ expect(window.localStorage.getItem(SIDEBAR_FLAT_MODE_KEY)).toBe("false");
+ } finally {
+ globalThis.window = originalWindow;
+ globalThis.document = originalDocument;
+ }
+});
+
test("toggle hide sub-agents command flips the persisted sidebar setting", () => {
const testWindow = new GlobalWindow();
const originalWindow = globalThis.window;
diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts
index 94fc01a220c..fba9c4fb94e 100644
--- a/src/browser/utils/commands/sources.ts
+++ b/src/browser/utils/commands/sources.ts
@@ -27,6 +27,7 @@ import {
DEFAULT_TERMINAL_BADGE_CONFIG,
RIGHT_SIDEBAR_COLLAPSED_KEY,
SIDEBAR_HIDE_SUBAGENTS_KEY,
+ SIDEBAR_FLAT_MODE_KEY,
TERMINAL_BADGE_CONFIG_KEY,
normalizeTerminalBadgeConfig,
type TerminalBadgeConfig,
@@ -721,6 +722,16 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi
updatePersistedState(SIDEBAR_HIDE_SUBAGENTS_KEY, (prev) => !prev, false);
},
},
+ {
+ id: CommandIds.navToggleFlatChatList(),
+ title: "Toggle Flat Chat List",
+ subtitle: `Current: ${readPersistedState(SIDEBAR_FLAT_MODE_KEY, false) ? "Flat" : "Grouped"}`,
+ section: section.navigation,
+ keywords: ["flat", "chat", "list", "projects", "folders", "sidebar"],
+ run: () => {
+ updatePersistedState(SIDEBAR_FLAT_MODE_KEY, (prev) => !prev, false);
+ },
+ },
{
id: CommandIds.navToggleTerminalBadge(),
title: "Toggle Terminal Badge",
diff --git a/src/browser/utils/ui/pinnedReorder.test.ts b/src/browser/utils/ui/pinnedReorder.test.ts
index 309297a7a90..ffe0a1fd40b 100644
--- a/src/browser/utils/ui/pinnedReorder.test.ts
+++ b/src/browser/utils/ui/pinnedReorder.test.ts
@@ -139,6 +139,26 @@ describe("locatePinnedBlock", () => {
expect(block).toEqual({ fullOrder: ["mB", "mA"], blockIds: ["mB", "mA"] });
});
+ it("treats pinned roots from every project as one block in flat mode", () => {
+ const a = createWorkspace("a", {
+ pinnedAt: "2026-01-01T00:00:01.000Z",
+ projectPath: "/test/a",
+ });
+ const b = createWorkspace("b", {
+ pinnedAt: "2026-01-01T00:00:00.000Z",
+ projectPath: "/test/b",
+ });
+ const sorted = new Map([
+ ["/test/a", [a]],
+ ["/test/b", [b]],
+ ]);
+
+ expect(locatePinnedBlock(a, sorted, new Map(), true)).toEqual({
+ fullOrder: ["b", "a"],
+ blockIds: ["b", "a"],
+ });
+ });
+
it("treats all pinned scratch rows as one block despite distinct workdir projectPaths", () => {
// Each scratch chat's projectPath is its own app-managed workdir, but the
// sidebar renders them together in the Chats section, so a reorder between
diff --git a/src/browser/utils/ui/pinnedReorder.ts b/src/browser/utils/ui/pinnedReorder.ts
index 74e4e47dc33..cd5bf86c19e 100644
--- a/src/browser/utils/ui/pinnedReorder.ts
+++ b/src/browser/utils/ui/pinnedReorder.ts
@@ -56,10 +56,19 @@ function collectFlatSectionRows(
export function locatePinnedBlock(
meta: FrontendWorkspaceMetadata,
sortedWorkspacesByProject: Map,
- userProjects: Map
+ userProjects: Map,
+ flatMode = false
): PinnedBlock | null {
if (!isWorkspacePinned(meta)) return null;
+ if (flatMode) {
+ const pinnedIds = collectFlatSectionRows(sortedWorkspacesByProject, () => true)
+ .filter((row) => row.parentWorkspaceId == null && isWorkspacePinned(row))
+ .map((row) => row.id);
+ if (!pinnedIds.includes(meta.id)) return null;
+ return { fullOrder: pinnedIds, blockIds: pinnedIds };
+ }
+
// Scratch chats render as one flat "Chats" section, but each row's
// projectPath is its own app-managed workdir, so the per-projectPath
// partitioning below would isolate every row into a block of one and
@@ -130,9 +139,10 @@ export function computePinnedMoveOrderForWorkspace(
meta: FrontendWorkspaceMetadata,
direction: PinnedMoveDirection,
sortedWorkspacesByProject: Map,
- userProjects: Map
+ userProjects: Map,
+ flatMode = false
): string[] | null {
- const block = locatePinnedBlock(meta, sortedWorkspacesByProject, userProjects);
+ const block = locatePinnedBlock(meta, sortedWorkspacesByProject, userProjects, flatMode);
if (!block) return null;
return computePinnedMoveOrder(block, meta.id, direction);
}
diff --git a/src/browser/utils/ui/workspaceFiltering.test.ts b/src/browser/utils/ui/workspaceFiltering.test.ts
index 3061aac164a..70054ab166c 100644
--- a/src/browser/utils/ui/workspaceFiltering.test.ts
+++ b/src/browser/utils/ui/workspaceFiltering.test.ts
@@ -4,6 +4,7 @@ import {
formatDaysThreshold,
AGE_THRESHOLDS_DAYS,
buildSortedWorkspacesByProject,
+ buildSortedWorkspacesFlat,
orderMultiProjectSectionRows,
computeWorkspaceDepthMap,
computeAgentRowRenderMeta,
@@ -628,6 +629,60 @@ describe("buildSortedWorkspacesByProject", () => {
});
});
+describe("buildSortedWorkspacesFlat", () => {
+ it("sorts all workspace kinds globally and keeps children under their parent", () => {
+ const projects = new Map([
+ ["/project/a", { workspaces: [{ path: "/a/pinned-late", id: "pinned-late" }] }],
+ ["/project/b", { workspaces: [{ path: "/b/pinned-early", id: "pinned-early" }] }],
+ ]);
+ const parent = {
+ ...createWorkspace("parent", "/project/a"),
+ projects: [
+ { projectPath: "/project/a", projectName: "a" },
+ { projectPath: "/project/b", projectName: "b" },
+ ],
+ };
+ const metadata = new Map([
+ [
+ "pinned-late",
+ {
+ ...createWorkspace("pinned-late", "/project/a"),
+ pinnedAt: "2026-01-02T00:00:00.000Z",
+ },
+ ],
+ [
+ "pinned-early",
+ {
+ ...createWorkspace("pinned-early", "/project/b"),
+ pinnedAt: "2026-01-01T00:00:00.000Z",
+ },
+ ],
+ ["parent", parent],
+ [
+ "child",
+ createWorkspace("child", { projectPath: "/project/a", parentWorkspaceId: "parent" }),
+ ],
+ ["scratch", { ...createWorkspace("scratch", "/scratch/path"), kind: "scratch" }],
+ ["recent", createWorkspace("recent", "/project/b")],
+ ]);
+
+ const result = buildSortedWorkspacesFlat(projects, metadata, {
+ parent: 300,
+ child: 1,
+ scratch: 200,
+ recent: 100,
+ });
+
+ expect(result.map((workspace) => workspace.id)).toEqual([
+ "pinned-early",
+ "pinned-late",
+ "parent",
+ "child",
+ "scratch",
+ "recent",
+ ]);
+ });
+});
describe("buildSortedWorkspacesByProject pinning", () => {
const now = Date.now();
const projectsWithIds = (ids: string[]): Map =>
diff --git a/src/browser/utils/ui/workspaceFiltering.ts b/src/browser/utils/ui/workspaceFiltering.ts
index 38805dd1fb0..101b2dfa594 100644
--- a/src/browser/utils/ui/workspaceFiltering.ts
+++ b/src/browser/utils/ui/workspaceFiltering.ts
@@ -932,6 +932,37 @@ function comparePinnedPlacement(
return null;
}
+function sortWorkspaceRows(
+ workspaces: FrontendWorkspaceMetadata[],
+ workspaceRecency: Record
+): void {
+ workspaces.sort((a, b) => {
+ const pinnedPlacement = comparePinnedPlacement(a, b);
+ if (pinnedPlacement !== null) {
+ return pinnedPlacement;
+ }
+
+ const aTimestamp = workspaceRecency[a.id] ?? 0;
+ const bTimestamp = workspaceRecency[b.id] ?? 0;
+ if (aTimestamp !== bTimestamp) {
+ return bTimestamp - aTimestamp;
+ }
+
+ const aCreatedAt = parseTimestampMs(a.createdAt);
+ const bCreatedAt = parseTimestampMs(b.createdAt);
+ if (aCreatedAt !== bCreatedAt) {
+ return bCreatedAt - aCreatedAt;
+ }
+
+ const nameOrder = compareStringsAsc(a.name, b.name);
+ if (nameOrder !== 0) {
+ return nameOrder;
+ }
+
+ return compareStringsAsc(a.id, b.id);
+ });
+}
+
/**
* Build a map of project paths to sorted workspace metadata lists.
* Includes both persisted workspaces (from config) and workspaces from
@@ -976,31 +1007,7 @@ export function buildSortedWorkspacesByProject(
// IMPORTANT: Include deterministic tie-breakers so Storybook visual snapshots can't
// flip ordering when multiple workspaces have equal recency.
for (const metadataList of result.values()) {
- metadataList.sort((a, b) => {
- const pinnedPlacement = comparePinnedPlacement(a, b);
- if (pinnedPlacement !== null) {
- return pinnedPlacement;
- }
-
- const aTimestamp = workspaceRecency[a.id] ?? 0;
- const bTimestamp = workspaceRecency[b.id] ?? 0;
- if (aTimestamp !== bTimestamp) {
- return bTimestamp - aTimestamp;
- }
-
- const aCreatedAt = parseTimestampMs(a.createdAt);
- const bCreatedAt = parseTimestampMs(b.createdAt);
- if (aCreatedAt !== bCreatedAt) {
- return bCreatedAt - aCreatedAt;
- }
-
- const nameOrder = compareStringsAsc(a.name, b.name);
- if (nameOrder !== 0) {
- return nameOrder;
- }
-
- return compareStringsAsc(a.id, b.id);
- });
+ sortWorkspaceRows(metadataList, workspaceRecency);
}
// Ensure child workspaces appear directly below their parents.
@@ -1011,6 +1018,36 @@ export function buildSortedWorkspacesByProject(
return result;
}
+/** Build one globally sorted workspace tree for the optional flat sidebar. */
+export function buildSortedWorkspacesFlat(
+ projects: Map,
+ workspaceMetadata: Map,
+ workspaceRecency: Record
+): FrontendWorkspaceMetadata[] {
+ const workspaces: FrontendWorkspaceMetadata[] = [];
+ const includedIds = new Set();
+
+ for (const config of projects.values()) {
+ for (const workspace of config.workspaces) {
+ if (!workspace.id || includedIds.has(workspace.id)) continue;
+ const metadata = workspaceMetadata.get(workspace.id);
+ if (metadata) {
+ workspaces.push(metadata);
+ includedIds.add(workspace.id);
+ }
+ }
+ }
+
+ for (const [id, metadata] of workspaceMetadata) {
+ if (!includedIds.has(id)) {
+ workspaces.push(metadata);
+ }
+ }
+
+ sortWorkspaceRows(workspaces, workspaceRecency);
+ return flattenWorkspaceTree(workspaces);
+}
+
/**
* Order rows for the flat Multi-Project section. The rows are collected across
* per-primary-project buckets of the sorted map, so without this pass two
diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts
index c564da3d8a5..57687b6fe6c 100644
--- a/src/common/constants/storage.ts
+++ b/src/common/constants/storage.ts
@@ -743,6 +743,12 @@ export const LEFT_SIDEBAR_COLLAPSED_KEY = "sidebarCollapsed";
*/
export const SIDEBAR_AGE_GROUPING_KEY = "sidebarAgeGrouping";
+/**
+ * When true, show all sidebar chats in one list instead of project folders.
+ * Format: "sidebarFlatMode" (boolean, default false)
+ */
+export const SIDEBAR_FLAT_MODE_KEY = "sidebarFlatMode";
+
/**
* Hide sub-agent rows in the left sidebar and summarize their activity on
* parent rows instead.
From e221c92ec99b75492eaf6299461db7452378520c Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 15:54:07 +0000
Subject: [PATCH 02/17] fix(sidebar): persist cross-project pinned reorders in
flat mode
reorderPinned scoped the re-deal to the first id's project bucket, so a
flat-mode drag spanning projects hit the <2 pinned early-return (or only
rewrote one bucket) and the optimistic client order reverted on reload.
Scope the reorder to the union of buckets referenced by the input ids:
grouped drags keep single-bucket behavior, flat drags re-deal the whole
unified block's timestamp pool.
---
src/node/services/workspaceService.test.ts | 120 +++++++++++++++++++++
src/node/services/workspaceService.ts | 66 +++++++-----
2 files changed, 158 insertions(+), 28 deletions(-)
diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts
index ec69f044d2d..e0512081861 100644
--- a/src/node/services/workspaceService.test.ts
+++ b/src/node/services/workspaceService.test.ts
@@ -13614,6 +13614,126 @@ describe("WorkspaceService reorderPinned", () => {
});
});
+describe("WorkspaceService reorderPinned across projects", () => {
+ const projectA = "/tmp/project-a";
+ const projectB = "/tmp/project-b";
+ const idA1 = "ws-a1";
+ const idA2 = "ws-a2";
+ const idB1 = "ws-b1";
+ const idB2 = "ws-b2";
+
+ let workspaceService: WorkspaceService;
+ let configState: ProjectsConfig;
+ let historyService: HistoryService;
+ let cleanupHistory: () => Promise;
+
+ const findEntry = (id: string) => {
+ for (const [projectPath, project] of configState.projects) {
+ const entry = project.workspaces.find((w) => w.id === id);
+ if (entry) return { projectPath, entry };
+ }
+ return undefined;
+ };
+
+ /** Pinned ids across all projects in effective order (pinnedAt asc), as the flat sidebar sorts them. */
+ const globalPinnedOrder = () =>
+ [...configState.projects.values()]
+ .flatMap((project) => project.workspaces)
+ .filter((w) => w.id && w.pinnedAt && !w.parentWorkspaceId && !w.archivedAt)
+ .sort((a, b) => Date.parse(a.pinnedAt ?? "") - Date.parse(b.pinnedAt ?? ""))
+ .map((w) => w.id);
+
+ beforeEach(async () => {
+ // Interleaved global pin order: a1, b1, a2, b2.
+ configState = {
+ projects: new Map([
+ [
+ projectA,
+ {
+ workspaces: [
+ { path: `${projectA}/${idA1}`, id: idA1, pinnedAt: "2026-01-01T00:00:00.000Z" },
+ { path: `${projectA}/${idA2}`, id: idA2, pinnedAt: "2026-01-01T00:00:20.000Z" },
+ ],
+ },
+ ],
+ [
+ projectB,
+ {
+ workspaces: [
+ { path: `${projectB}/${idB1}`, id: idB1, pinnedAt: "2026-01-01T00:00:10.000Z" },
+ { path: `${projectB}/${idB2}`, id: idB2, pinnedAt: "2026-01-01T00:00:30.000Z" },
+ ],
+ },
+ ],
+ ]),
+ };
+
+ ({ historyService, cleanup: cleanupHistory } = await createTestHistoryService());
+
+ const mockConfig: Partial = {
+ srcDir: "/tmp/src",
+ getSessionDir: mock(() => "/tmp/test/sessions"),
+ findWorkspace: mock((id: string) => {
+ const found = findEntry(id);
+ if (!found) return null;
+ return {
+ projectPath: found.projectPath,
+ workspacePath: found.entry.path,
+ parentWorkspaceId: found.entry.parentWorkspaceId,
+ };
+ }),
+ editConfig: mock((fn: (config: ProjectsConfig) => ProjectsConfig) => {
+ configState = fn(configState);
+ return Promise.resolve();
+ }),
+ getAllWorkspaceMetadata: mock(() => Promise.resolve([])),
+ loadConfigOrDefault: mock(() => configState),
+ };
+
+ workspaceService = createWorkspaceServiceForTest({
+ config: mockConfig,
+ historyService,
+ });
+ });
+
+ afterEach(async () => {
+ await cleanupHistory();
+ });
+
+ test("persists a flat-mode reorder spanning project buckets", async () => {
+ const maxBefore = Math.max(
+ ...[idA1, idA2, idB1, idB2].map((id) => Date.parse(findEntry(id)?.entry.pinnedAt ?? ""))
+ );
+
+ // Drag b1 above a1 in the unified pinned block.
+ const result = await workspaceService.reorderPinned([idB1, idA1, idA2, idB2]);
+ expect(result.success).toBe(true);
+ expect(globalPinnedOrder()).toEqual([idB1, idA1, idA2, idB2]);
+
+ // The timestamp pool is re-dealt, not inflated.
+ const maxAfter = Math.max(
+ ...[idA1, idA2, idB1, idB2].map((id) => Date.parse(findEntry(id)?.entry.pinnedAt ?? ""))
+ );
+ expect(maxAfter).toBe(maxBefore);
+ });
+
+ test("grouped-mode reorder of one bucket leaves other buckets' timestamps untouched", async () => {
+ const b1Before = findEntry(idB1)?.entry.pinnedAt;
+ const b2Before = findEntry(idB2)?.entry.pinnedAt;
+
+ const result = await workspaceService.reorderPinned([idA2, idA1]);
+ expect(result.success).toBe(true);
+
+ // Project A flipped within its own timestamp pool.
+ const a1 = Date.parse(findEntry(idA1)?.entry.pinnedAt ?? "");
+ const a2 = Date.parse(findEntry(idA2)?.entry.pinnedAt ?? "");
+ expect(a2).toBeLessThan(a1);
+ // Project B was not referenced, so its entries are byte-identical.
+ expect(findEntry(idB1)?.entry.pinnedAt).toBe(b1Before);
+ expect(findEntry(idB2)?.entry.pinnedAt).toBe(b2Before);
+ });
+});
+
describe("WorkspaceService archive lifecycle hooks", () => {
const workspaceId = "ws-archive";
const projectPath = "/tmp/project";
diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts
index 6393454098e..2790717ae0f 100644
--- a/src/node/services/workspaceService.ts
+++ b/src/node/services/workspaceService.ts
@@ -8129,11 +8129,15 @@ export class WorkspaceService extends EventEmitter {
}
/**
- * Reorder the pinned block of one project bucket. `workspaceIds` is the full
- * desired pinned order for that bucket as the client sees it. Defensive
- * contract: unknown/unpinned ids are dropped, currently-pinned ids omitted
- * from the input keep their relative order and are appended, so concurrent
- * pin/unpin from other clients is absorbed instead of erroring.
+ * Reorder a pinned block. `workspaceIds` is the full desired pinned order
+ * for that block as the client sees it: one project bucket in grouped mode,
+ * or the unified cross-project block in flat sidebar mode. The reorder
+ * scope is the union of config buckets referenced by the input ids, so a
+ * grouped drag never disturbs other buckets while a flat drag re-deals the
+ * whole unified block. Defensive contract: unknown/unpinned ids are
+ * dropped, currently-pinned ids omitted from the input keep their relative
+ * order and are appended, so concurrent pin/unpin from other clients is
+ * absorbed instead of erroring.
*
* Persistence model: pinnedAt is an ordering key, so reordering re-deals the
* existing pool of pinnedAt timestamps onto the new order (see
@@ -8142,30 +8146,34 @@ export class WorkspaceService extends EventEmitter {
*/
async reorderPinned(workspaceIds: string[]): Promise> {
try {
- // Derive the config bucket from the first resolvable id so clients never
- // need internal bucket keys (e.g. the multi-project bucket). Nothing
- // resolvable means the client acted on stale state: a benign no-op.
- // Const (not narrowed let) so the editConfig closure sees type string.
- const projectPath = workspaceIds
- .map((id) => this.config.findWorkspace(id)?.projectPath)
- .find((path) => path !== undefined);
- if (projectPath === undefined) {
+ // Resolve buckets from the ids so clients never need internal bucket
+ // keys (e.g. the multi-project bucket). Nothing resolvable means the
+ // client acted on stale state: a benign no-op.
+ const projectPaths = new Set();
+ for (const id of workspaceIds) {
+ const path = this.config.findWorkspace(id)?.projectPath;
+ if (path !== undefined) {
+ projectPaths.add(path);
+ }
+ }
+ if (projectPaths.size === 0) {
return Ok(undefined);
}
const changedIds: string[] = [];
await this.config.editConfig((config) => {
- const projectConfig = config.projects.get(projectPath);
- if (!projectConfig) {
- return config;
- }
+ const bucketConfigs = [...projectPaths]
+ .map((path) => config.projects.get(path))
+ .filter((bucket) => bucket !== undefined);
- // Current pinned roots of the bucket, in effective pin order.
+ // Current pinned roots across the referenced buckets, in effective pin order.
const pinnedEntries: Array<{ id: string; pinnedAt: string }> = [];
- for (const entry of projectConfig.workspaces) {
- if (!entry.id || !entry.pinnedAt) continue;
- if (!isWorkspacePinned(entry)) continue;
- pinnedEntries.push({ id: entry.id, pinnedAt: entry.pinnedAt });
+ for (const bucket of bucketConfigs) {
+ for (const entry of bucket.workspaces) {
+ if (!entry.id || !entry.pinnedAt) continue;
+ if (!isWorkspacePinned(entry)) continue;
+ pinnedEntries.push({ id: entry.id, pinnedAt: entry.pinnedAt });
+ }
}
if (pinnedEntries.length < 2) {
return config;
@@ -8198,12 +8206,14 @@ export class WorkspaceService extends EventEmitter {
pinnedEntries.map((entry) => [entry.id, entry.pinnedAt])
);
const changes = reassignPinnedTimestamps(desiredOrder, currentPinnedAtById);
- for (const entry of projectConfig.workspaces) {
- if (!entry.id) continue;
- const nextPinnedAt = changes.get(entry.id);
- if (nextPinnedAt !== undefined) {
- entry.pinnedAt = nextPinnedAt;
- changedIds.push(entry.id);
+ for (const bucket of bucketConfigs) {
+ for (const entry of bucket.workspaces) {
+ if (!entry.id) continue;
+ const nextPinnedAt = changes.get(entry.id);
+ if (nextPinnedAt !== undefined) {
+ entry.pinnedAt = nextPinnedAt;
+ changedIds.push(entry.id);
+ }
}
}
return config;
From 6cee23bc30c5f65ab0ac366734de4348c8703fad Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 16:34:11 +0000
Subject: [PATCH 03/17] refactor(sidebar): cleanup-gate fixes from milestone
audit
- render the project badge on draft rows (prop was threaded but unused)
- gate flat-list derivation behind the flag so grouped mode skips the
global sort/flatten work
- simplify buildSortedWorkspacesFlat to take rows directly (the two-pass
config merge reproduced Array.from(map.values()) before a global sort)
- dedupe project badge resolution; inline single-use GroupedSidebarSection
- drop leftover divide-y padding on the API Debug Logs row
- document flat mode in locatePinnedBlock's JSDoc
---
.../AgentListItem/AgentListItem.tsx | 13 +
.../ProjectSidebar/ProjectSidebar.tsx | 263 +++++++++---------
.../Settings/Sections/GeneralSection.tsx | 4 +-
src/browser/utils/ui/pinnedReorder.ts | 5 +-
.../utils/ui/workspaceFiltering.test.ts | 6 +-
src/browser/utils/ui/workspaceFiltering.ts | 28 +-
6 files changed, 151 insertions(+), 168 deletions(-)
diff --git a/src/browser/components/AgentListItem/AgentListItem.tsx b/src/browser/components/AgentListItem/AgentListItem.tsx
index 11f6b2244df..5461d26f7e9 100644
--- a/src/browser/components/AgentListItem/AgentListItem.tsx
+++ b/src/browser/components/AgentListItem/AgentListItem.tsx
@@ -491,6 +491,19 @@ function DraftAgentListItemInner(props: DraftAgentListItemProps) {
>
{draft.title}
+ {props.projectBadge && (
+
+ {props.projectBadge.name}
+
+ )}
{hasPromptPreview && (
{props.children} : null;
-}
-
// Custom drag layer to show a semi-transparent preview and enforce grabbing cursor
interface ProjectDragItem {
type: "PROJECT";
@@ -1744,14 +1740,9 @@ const ProjectSidebarInner: React.FC