Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0f4d0c4
feat(sidebar): add flat chat list mode
ibetitsmike Aug 27, 2026
e221c92
fix(sidebar): persist cross-project pinned reorders in flat mode
ibetitsmike Aug 27, 2026
6cee23b
refactor(sidebar): cleanup-gate fixes from milestone audit
ibetitsmike Aug 27, 2026
2866aec
fix(sidebar): address Codex round-1 review findings
ibetitsmike Aug 27, 2026
ccb84d8
fix(sidebar): address Codex round-2 review findings
ibetitsmike Aug 27, 2026
b2155a9
fix(sidebar): gate flat pinned-reorder block by the multi-project exp…
ibetitsmike Aug 27, 2026
d0dc303
fix(sidebar): keep sub-project management reachable in flat mode
ibetitsmike Aug 27, 2026
9977e72
fix(sidebar): badge flat rows with their sub-project identity
ibetitsmike Aug 27, 2026
188deb9
fix(sidebar): hierarchical sub-project badges and pinned block above …
ibetitsmike Aug 27, 2026
66415fa
fix(sidebar): expose full project badge label via shared tooltip
ibetitsmike Aug 27, 2026
d1b9ec9
fix(sidebar): flat sub-project drop targets, draft counts, and safe g…
ibetitsmike Aug 27, 2026
2d603e9
fix(pin): sort corrupted boundary pin timestamps consistently with th…
ibetitsmike Aug 27, 2026
c220bde
fix(sidebar): clamp pin timestamps to sane bounds, notify flat-mode s…
ibetitsmike Aug 27, 2026
6ba6d29
fix(pin): heal saturated pin timestamps on write so ordering keys sta…
ibetitsmike Aug 27, 2026
ce39dd8
fix(sidebar): gate _multi drafts behind the multi-project experiment …
ibetitsmike Aug 27, 2026
7f10426
fix(sidebar): compact capped pin-key ties on reorder and label _multi…
ibetitsmike Aug 27, 2026
cdb95ca
fix(pin): detect capped pin-key collisions by parsed value
ibetitsmike Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import {
EXPANDED_PROJECTS_KEY,
LEFT_SIDEBAR_COLLAPSED_KEY,
LEFT_SIDEBAR_WIDTH_KEY,
SIDEBAR_FLAT_MODE_KEY,
} from "@/common/constants/storage";
import { normalizeToCanonical } from "@/common/utils/ai/models";
import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings";
Expand Down Expand Up @@ -961,7 +962,8 @@ function AppInner() {
meta,
direction,
sortedWorkspacesByProject,
userProjects
userProjects,
readPersistedState(SIDEBAR_FLAT_MODE_KEY, false)
);
if (order) void reorderPinnedWorkspaces(order);
},
Expand Down
27 changes: 27 additions & 0 deletions src/browser/components/AgentListItem/AgentListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ interface AgentListItemBaseProps {
isSelected: boolean;
depth?: number;
sectionId?: string;
projectBadge?: { name: string; color: string };
}

/** Props for regular (persisted) workspace items */
Expand Down Expand Up @@ -490,6 +491,19 @@ function DraftAgentListItemInner(props: DraftAgentListItemProps) {
>
{draft.title}
</span>
{props.projectBadge && (
<span
data-testid={`workspace-project-badge-draft-${draft.draftId}`}
className="max-w-20 shrink-0 truncate rounded border px-1.5 py-0.5 text-[10px] leading-none font-medium"
style={{
backgroundColor: `${props.projectBadge.color}20`,
color: props.projectBadge.color,
borderColor: `${props.projectBadge.color}40`,
}}
>
{props.projectBadge.name}
</span>
)}
</div>
{hasPromptPreview && (
<span
Expand Down Expand Up @@ -1319,6 +1333,19 @@ function RegularAgentListItemInner(props: AgentListItemProps) {
>
{suppressGroupMemberTitle ? memberOnlyLabel : workspaceTitle}
</span>
{props.projectBadge && (
<span
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
data-testid={`workspace-project-badge-${workspaceId}`}
className="max-w-20 shrink-0 truncate rounded border px-1.5 py-0.5 text-[10px] leading-none font-medium"
style={{
backgroundColor: `${props.projectBadge.color}20`,
color: props.projectBadge.color,
borderColor: `${props.projectBadge.color}40`,
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
}}
>
{props.projectBadge.name}
</span>
)}
{groupLabel && !suppressGroupMemberTitle && (
<span
data-testid={`workspace-scope-label-${workspaceId}`}
Expand Down
54 changes: 54 additions & 0 deletions src/browser/components/ProjectSidebar/ProjectSidebar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { PIXEL_DUAL_THEME, appMeta, AppWithMocks } from "@/browser/stories/meta.
import { expandProjects } from "@/browser/stories/helpers/uiState";
import { createMockORPCClient } from "@/browser/stories/mocks/orpc";
import { createWorkspace, groupWorkspacesByProject } from "@/browser/stories/mocks/workspaces";
import { updatePersistedState } from "@/browser/hooks/usePersistedState";
import { SIDEBAR_FLAT_MODE_KEY } from "@/common/constants/storage";

const PROJECT_PATH = "/home/user/projects/my-app";

Expand Down Expand Up @@ -299,6 +301,58 @@ export const WorkflowRunGroups: AppStory = {
},
};

export const FlatChatList: AppStory = {
parameters: {
pixel: { matrix: PIXEL_DUAL_THEME },
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
},
render: () => (
<AppWithMocks
setup={() => {
updatePersistedState(SIDEBAR_FLAT_MODE_KEY, true);
Comment thread
ibetitsmike marked this conversation as resolved.
const workspaces = [
createWorkspace({
id: "alpha-pinned",
name: "alpha-pinned",
title: "Pinned from a long project name",
projectName: "alpha-application-with-a-long-name",
pinnedAt: "2026-01-02T00:00:00.000Z",
}),
createWorkspace({
id: "beta-pinned",
name: "beta-pinned",
title: "Pinned beta chat",
projectName: "beta-service",
pinnedAt: "2026-01-01T00:00:00.000Z",
}),
createWorkspace({
id: "alpha-recent",
name: "alpha-recent",
title: "Recent alpha work",
projectName: "alpha-application-with-a-long-name",
}),
{
...createWorkspace({
id: "scratch-flat",
name: "scratch-flat",
title: "Scratch idea",
projectName: "Scratch",
projectPath: "/home/user/.xum/scratch/scratch-flat",
}),
kind: "scratch" as const,
},
];
const projects = groupWorkspacesByProject(workspaces);
const alphaPath = "/home/user/projects/alpha-application-with-a-long-name";
const betaPath = "/home/user/projects/beta-service";
const alphaConfig = projects.get(alphaPath);
const betaConfig = projects.get(betaPath);
if (alphaConfig) projects.set(alphaPath, { ...alphaConfig, color: "Blue" });
if (betaConfig) projects.set(betaPath, { ...betaConfig, color: "Green" });
return createMockORPCClient({ projects, workspaces });
}}
/>
),
};
// Pinned chats sort by pinnedAt (user-reorderable), not by name or recency:
// the pinned block deliberately renders as charlie, alpha, bravo while the
// newest unpinned chat stays below the block.
Expand Down
62 changes: 61 additions & 1 deletion src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import * as ReactDndModule from "react-dnd";
import * as ReactDndHtml5BackendModule from "react-dnd-html5-backend";
import * as ReactColorfulModule from "react-colorful";
import { installDom } from "../../../../tests/ui/dom";
import { EXPANDED_PROJECTS_KEY, SIDEBAR_HIDE_SUBAGENTS_KEY } from "@/common/constants/storage";
import {
EXPANDED_PROJECTS_KEY,
SIDEBAR_FLAT_MODE_KEY,
SIDEBAR_HIDE_SUBAGENTS_KEY,
} from "@/common/constants/storage";
import { getDraftScopeId, getInputKey } from "@/common/constants/storage";
import { SCRATCH_PROJECT_CONFIG_KEY, SCRATCH_SIDEBAR_SECTION_ID } from "@/common/constants/scratch";
import { MULTI_PROJECT_SIDEBAR_SECTION_ID } from "@/common/constants/multiProject";
Expand Down Expand Up @@ -123,6 +127,7 @@ interface MockAgentListItemProps {
};
depth?: number;
rowRenderMeta?: AgentRowRenderMeta;
projectBadge?: { name: string; color: string };
delegatedActivity?: { activeCount: number; queuedCount: number };
hiddenSubAgentsSummary?: WorkspaceSubAgentsSummary;
getWorkflowRunName?: (runId: string) => string | undefined;
Expand Down Expand Up @@ -369,6 +374,7 @@ function installProjectSidebarTestDoubles() {
)}
>
<span>{displayTitle}</span>
{props.projectBadge ? <span>{props.projectBadge.name}</span> : null}
{hasCompletedChildren && props.onToggleCompletedChildren ? (
<button
type="button"
Expand Down Expand Up @@ -768,6 +774,60 @@ describe("ProjectSidebar scratch chats", () => {
expect(view.getByText("Explore an idea")).toBeTruthy();
});

test("renders a global pinned-first list with project badges and restores folders when disabled", async () => {
const alpha = {
...createWorkspace("alpha", { title: "Alpha chat" }),
projects: undefined,
projectPath: "/projects/alpha",
projectName: "alpha",
pinnedAt: "2026-01-02T00:00:00.000Z",
};
const beta = {
...createWorkspace("beta", { title: "Beta chat" }),
projects: undefined,
projectPath: "/projects/beta",
projectName: "beta",
pinnedAt: "2026-01-01T00:00:00.000Z",
};
projectContextValue = createProjectContextValue({
userProjects: new Map([
["/projects/alpha", { displayName: "Alpha Project", color: "Blue", workspaces: [] }],
["/projects/beta", { displayName: "Beta Project", color: "Green", workspaces: [] }],
]),
});
updatePersistedState(SIDEBAR_FLAT_MODE_KEY, true);

const view = render(
<ProjectSidebar
collapsed={false}
onToggleCollapsed={() => undefined}
sortedWorkspacesByProject={
new Map([
["/projects/alpha", [alpha]],
["/projects/beta", [beta]],
])
}
workspaceRecency={{ alpha: 1, beta: 2 }}
/>
);

expect(view.queryByLabelText("Expand project alpha")).toBeNull();
expect(view.getByText("Alpha Project")).toBeTruthy();
expect(view.getByText("Beta Project")).toBeTruthy();
const workspaceRows = Array.from(
view.container.querySelectorAll('[data-testid^="agent-item-"]')
);
expect(workspaceRows.map((row) => row.getAttribute("data-testid"))).toEqual([
agentItemTestId("beta"),
agentItemTestId("alpha"),
]);

act(() => updatePersistedState(SIDEBAR_FLAT_MODE_KEY, false));
await waitFor(() => {
expect(view.getByLabelText("Expand project alpha")).toBeTruthy();
expect(view.getByLabelText("Expand project beta")).toBeTruthy();
});
});
test("Ctrl+N from a selected scratch chat opens a scratch draft, not a workdir project draft", () => {
// Scratch rows are bucketed under the scratch config key while the
// selection's projectPath is the app-managed workdir, so the keybind
Expand Down
Loading
Loading