Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
7 changes: 6 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,10 @@ function AppInner() {
meta,
direction,
sortedWorkspacesByProject,
userProjects
userProjects,
readPersistedState(SIDEBAR_FLAT_MODE_KEY, false)
? { multiProjectEnabled: multiProjectWorkspacesEnabled }
: false
);
if (order) void reorderPinnedWorkspaces(order);
},
Expand All @@ -970,6 +974,7 @@ function AppInner() {
workspaceMetadata,
sortedWorkspacesByProject,
userProjects,
multiProjectWorkspacesEnabled,
reorderPinnedWorkspaces,
]
);
Expand Down
39 changes: 38 additions & 1 deletion src/browser/components/AgentListItem/AgentListItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type MockWorkspaceUnreadState = ReturnType<typeof WorkspaceUnreadModule.useWorks
type MockWorkspaceSidebarState = ReturnType<typeof WorkspaceStoreModule.useWorkspaceSidebarState>;

let mockWorkspaceHeartbeatsEnabled = false;
let latestUseDragSpec: (() => { item?: () => Record<string, unknown> }) | null = null;
let mockWorkspaceUnreadState: MockWorkspaceUnreadState;
let mockWorkspaceSidebarState: MockWorkspaceSidebarState;

Expand Down Expand Up @@ -141,6 +142,14 @@ function installAgentListItemTestDoubles() {
spyOn(TooltipModule, "TooltipContent").mockImplementation(((props: { children: ReactNode }) => (
<>{props.children}</>
)) as unknown as typeof TooltipModule.TooltipContent);
spyOn(TooltipModule, "TooltipIfPresent").mockImplementation(((props: {
children: ReactNode;
tooltip?: string;
}) => (
<span data-testid="badge-tooltip" data-tooltip-content={props.tooltip}>
{props.children}
</span>
)) as unknown as typeof TooltipModule.TooltipIfPresent);
spyOn(WorkspaceStatusIndicatorModule, "WorkspaceStatusIndicator").mockImplementation(((props: {
workspaceId: string;
}) => (
Expand Down Expand Up @@ -172,7 +181,10 @@ function installAgentListItemTestDoubles() {

void mock.module("react-dnd", () => ({
...actualReactDnd,
useDrag: () => [{ isDragging: false }, passthroughRef, () => undefined] as const,
useDrag: (spec: () => { item?: () => Record<string, unknown> }) => {
latestUseDragSpec = spec;
return [{ isDragging: false }, passthroughRef, () => undefined] as const;
},
useDrop: () => [{ isPinnedReorderTarget: false }, passthroughRef] as const,
}));

Expand Down Expand Up @@ -275,6 +287,7 @@ function renderWorkspaceItem(
completedChildrenExpanded?: boolean;
onToggleCompletedChildren?: (workspaceId: string) => void;
onSelectWorkspace?: (selection: WorkspaceSelection) => void;
projectBadgeName?: string;
} = {}
) {
const metadata = options.metadata ?? createMetadata();
Expand All @@ -283,6 +296,7 @@ function renderWorkspaceItem(
metadata={metadata}
projectPath={metadata.projectPath}
projectName={metadata.projectName}
projectBadgeName={options.projectBadgeName}
isSelected={options.isSelected ?? false}
isArchiving={options.isArchiving}
depth={options.depth ?? options.rowRenderMeta?.depth}
Expand Down Expand Up @@ -338,6 +352,29 @@ describe("AgentListItem", () => {
mock.restore();
});

test("exposes the full project badge label through the shared tooltip", () => {
// The badge's width cap end-truncates hierarchical "Parent / Sub" names,
// so the shared tooltip wrapper must carry the full label.
const badgeName = "Parent Project With A Long Name / Frontend";
const { view } = renderWorkspaceItem({ projectBadgeName: badgeName });

const badge = view.getByTestId(`workspace-project-badge-${TEST_WORKSPACE_ID}`);
const tooltip = badge.closest('[data-testid="badge-tooltip"]');
expect(tooltip?.getAttribute("data-tooltip-content")).toBe(badgeName);
});

test("falls back to the row's sub-project scope for drag section identity", () => {
// Flat rows omit the sectionId prop (it also drives section indentation),
// so the drag item must carry metadata.subProjectPath instead; drop zones
// rely on it to treat same-section drops as no-ops.
renderWorkspaceItem({
metadata: createMetadata({ subProjectPath: "/tmp/project/features" }),
});
const item = latestUseDragSpec?.().item?.();
expect(item?.workspaceId).toBe(TEST_WORKSPACE_ID);
expect(item?.currentSectionId).toBe("/tmp/project/features");
});

test("suppresses best-of member titles that repeat the group header (D8)", () => {
const candidate = renderWorkspaceItem({
metadata: createMetadata({
Expand Down
77 changes: 67 additions & 10 deletions src/browser/components/AgentListItem/AgentListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {
type VisualState,
} from "./StatusDot";

import { Tooltip, TooltipTrigger, TooltipContent } from "../Tooltip/Tooltip";
import { Tooltip, TooltipTrigger, TooltipContent, TooltipIfPresent } from "../Tooltip/Tooltip";
import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from "../Popover/Popover";
import { PositionedMenu, PositionedMenuItem } from "../PositionedMenu/PositionedMenu";
import {
Expand Down Expand Up @@ -105,6 +105,9 @@ interface AgentListItemBaseProps {
isSelected: boolean;
depth?: number;
sectionId?: string;
// Stable primitives (not an object) so React Compiler can skip unchanged rows.
projectBadgeName?: string;
projectBadgeColor?: string;
}

/** Props for regular (persisted) workspace items */
Expand Down Expand Up @@ -471,7 +474,11 @@ function DraftAgentListItemInner(props: DraftAgentListItemProps) {
role="button"
tabIndex={0}
aria-current={isSelected ? "true" : undefined}
aria-label={`Open workspace draft ${draft.draftNumber}`}
aria-label={
props.projectBadgeName != null
? `Open workspace draft ${draft.draftNumber} (${props.projectBadgeName})`
: `Open workspace draft ${draft.draftNumber}`
}
data-project-path={projectPath}
data-draft-id={draft.draftId}
>
Expand All @@ -490,6 +497,26 @@ function DraftAgentListItemInner(props: DraftAgentListItemProps) {
>
{draft.title}
</span>
{props.projectBadgeName != null && (
// The badge width cap can truncate hierarchical "Parent / Sub"
// names, so the shared tooltip keeps the full label reachable.
<TooltipIfPresent tooltip={props.projectBadgeName}>
<span
data-testid={`workspace-project-badge-draft-${draft.draftId}`}
className="text-secondary max-w-20 shrink-0 truncate rounded border px-1.5 py-0.5 text-[10px] leading-none font-medium"
style={
props.projectBadgeColor != null
? {
backgroundColor: `${props.projectBadgeColor}20`,
borderColor: `${props.projectBadgeColor}40`,
}
: undefined
}
>
{props.projectBadgeName}
</span>
</TooltipIfPresent>
)}
</div>
{hasPromptPreview && (
<span
Expand Down Expand Up @@ -878,7 +905,10 @@ function RegularAgentListItemInner(props: AgentListItemProps) {
type: WORKSPACE_DRAG_TYPE,
workspaceId,
projectPath,
currentSectionId: sectionId,
// Flat rows render without the sectionId prop (no section indent), so
// fall back to the row's own sub-project scope; drop zones use this to
// treat same-section drops as no-ops.
currentSectionId: sectionId ?? metadata.subProjectPath,
pinned: isPinned,
pinnedReorderGroup: props.pinnedReorderGroup,
// Extra fields for custom drag layer preview
Expand All @@ -894,6 +924,7 @@ function RegularAgentListItemInner(props: AgentListItemProps) {
workspaceId,
projectPath,
sectionId,
metadata.subProjectPath,
isDisabled,
isPinned,
props.pinnedReorderGroup,
Expand Down Expand Up @@ -1041,15 +1072,21 @@ function RegularAgentListItemInner(props: AgentListItemProps) {
aria-current={isSelected ? "true" : undefined}
aria-expanded={canToggleCompletedChildren ? isCompletedChildrenExpanded : undefined}
aria-keyshortcuts={canToggleCompletedChildren ? "ArrowRight ArrowLeft" : undefined}
aria-label={
isRemoving
? `Deleting workspace ${displayTitle}`
aria-label={(() => {
// The explicit label overrides descendant badge text, so include the
// project identity whenever the badge is the only visible project cue.
const accessibleTitle =
props.projectBadgeName != null
? `${displayTitle} (${props.projectBadgeName})`
: displayTitle;
return isRemoving
? `Deleting workspace ${accessibleTitle}`
: isInitializing
? `Initializing workspace ${displayTitle}`
? `Initializing workspace ${accessibleTitle}`
: isArchiving
? `Archiving workspace ${displayTitle}`
: `Select workspace ${displayTitle}`
}
? `Archiving workspace ${accessibleTitle}`
: `Select workspace ${accessibleTitle}`;
})()}
aria-describedby={secondaryStatusDescriptionId}
aria-disabled={isDisabled}
data-workspace-path={namedWorkspacePath}
Expand Down Expand Up @@ -1319,6 +1356,26 @@ function RegularAgentListItemInner(props: AgentListItemProps) {
>
{suppressGroupMemberTitle ? memberOnlyLabel : workspaceTitle}
</span>
{props.projectBadgeName != null && (
// The badge width cap can truncate hierarchical "Parent / Sub"
// names, so the shared tooltip keeps the full label reachable.
<TooltipIfPresent tooltip={props.projectBadgeName}>
<span
data-testid={`workspace-project-badge-${workspaceId}`}
className="text-secondary max-w-20 shrink-0 truncate rounded border px-1.5 py-0.5 text-[10px] leading-none font-medium"
style={
props.projectBadgeColor != null
? {
backgroundColor: `${props.projectBadgeColor}20`,
borderColor: `${props.projectBadgeColor}40`,
}
: undefined
}
>
{props.projectBadgeName}
</span>
</TooltipIfPresent>
)}
{groupLabel && !suppressGroupMemberTitle && (
<span
data-testid={`workspace-scope-label-${workspaceId}`}
Expand Down
87 changes: 86 additions & 1 deletion src/browser/components/ProjectSidebar/ProjectSidebar.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { fireEvent, userEvent, waitFor } from "@storybook/test";
import type { AppStory } from "@/browser/stories/meta.js";
import { PIXEL_DUAL_THEME, appMeta, AppWithMocks } from "@/browser/stories/meta.js";
import { expandProjects } from "@/browser/stories/helpers/uiState";
import {
clearWorkspaceSelection,
collapseRightSidebar,
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 { LEFT_SIDEBAR_COLLAPSED_KEY, SIDEBAR_FLAT_MODE_KEY } from "@/common/constants/storage";

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

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

export const FlatChatList: AppStory = {
// The flat list replaces the whole sidebar layout, so validate the compact
// badge/truncation behavior at the phone width alongside the laptop capture.
globals: {
viewport: { value: "mobile2", isRotated: false },
},
parameters: {
pixel: { matrix: { themes: ["dark", "light"], viewports: ["phone", "laptop"] } },
},
render: () => (
<AppWithMocks
setup={() => {
updatePersistedState(SIDEBAR_FLAT_MODE_KEY, true);
Comment thread
ibetitsmike marked this conversation as resolved.
// Keep the sidebar visible at the phone width: no selected workspace
// (mobile shows the chat over the sidebar) and the sidebar expanded.
clearWorkspaceSelection();
collapseRightSidebar();
updatePersistedState(LEFT_SIDEBAR_COLLAPSED_KEY, false);
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 });
}}
/>
),
// Contract: the flat list (badges) and the project management headers are
// actually on screen, so a viewport variant cannot silently snapshot the
// wrong UI (e.g. the sidebar hidden behind a selected chat on mobile).
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
await waitFor(() => {
if (!canvasElement.querySelector('[data-testid="workspace-project-badge-alpha-pinned"]')) {
throw new Error("Expected a project badge on a flat-list chat row");
}
if (
!canvasElement.querySelector(
'button[aria-label="Project options for alpha-application-with-a-long-name"]'
)
) {
throw new Error("Expected project management headers below the flat list");
}
});
},
};
// 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
Loading
Loading