Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 4 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
},
"dependencies": {
"@base-ui/react": "^1.2.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lexical/react": "^0.41.0",
"@pierre/diffs": "^1.1.0-beta.16",
"@t3tools/contracts": "workspace:*",
Expand Down
636 changes: 383 additions & 253 deletions apps/web/src/components/Sidebar.tsx

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/web/src/components/ui/collapsible.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ function CollapsiblePanel({ className, ...props }: CollapsiblePrimitive.Panel.Pr
return (
<CollapsiblePrimitive.Panel
className={cn(
"h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-200 data-ending-style:h-0 data-starting-style:h-0",
"h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-200 data-ending-style:h-0 data-starting-style:h-0 data-open:data-ending-style:[height:var(--collapsible-panel-height)]",
className,
)}
data-slot="collapsible-panel"
Expand Down
13 changes: 10 additions & 3 deletions apps/web/src/components/ui/scroll-area.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ function ScrollArea({
children,
scrollFade = false,
scrollbarGutter = false,
hideScrollbars = false,
...props
}: ScrollAreaPrimitive.Root.Props & {
scrollFade?: boolean;
scrollbarGutter?: boolean;
hideScrollbars?: boolean;
}) {
return (
<ScrollAreaPrimitive.Root className={cn("size-full min-h-0", className)} {...props}>
Expand All @@ -22,14 +24,19 @@ function ScrollArea({
scrollFade &&
"mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]",
scrollbarGutter && "data-has-overflow-y:pe-2.5 data-has-overflow-x:pb-2.5",
hideScrollbars && "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)}
data-slot="scroll-area-viewport"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar orientation="vertical" />
<ScrollBar orientation="horizontal" />
<ScrollAreaPrimitive.Corner data-slot="scroll-area-corner" />
{!hideScrollbars && (
<>
<ScrollBar orientation="vertical" />
<ScrollBar orientation="horizontal" />
<ScrollAreaPrimitive.Corner data-slot="scroll-area-corner" />
</>
)}
</ScrollAreaPrimitive.Root>
);
}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/ui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -651,10 +651,10 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof S

function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<ScrollArea className="**:data-[slot=scroll-area-scrollbar]:hidden" scrollFade>
<ScrollArea hideScrollbars scrollFade className="h-auto min-h-0 flex-1">
<div
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
"flex w-full min-w-0 flex-col gap-2 group-data-[collapsible=icon]:overflow-hidden",
className,
)}
data-sidebar="content"
Expand Down
112 changes: 111 additions & 1 deletion apps/web/src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from "@t3tools/contracts";
import { describe, expect, it } from "vitest";

import { markThreadUnread, syncServerReadModel, type AppState } from "./store";
import { markThreadUnread, reorderProjects, syncServerReadModel, type AppState } from "./store";
import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";

function makeThread(overrides: Partial<Thread> = {}): Thread {
Expand Down Expand Up @@ -93,6 +93,22 @@ function makeReadModel(thread: OrchestrationReadModel["threads"][number]): Orche
};
}

function makeReadModelProject(
overrides: Partial<OrchestrationReadModel["projects"][number]>,
): OrchestrationReadModel["projects"][number] {
return {
id: ProjectId.makeUnsafe("project-1"),
title: "Project",
workspaceRoot: "/tmp/project",
defaultModel: "gpt-5.3-codex",
createdAt: "2026-02-27T00:00:00.000Z",
updatedAt: "2026-02-27T00:00:00.000Z",
deletedAt: null,
scripts: [],
...overrides,
};
}

describe("store pure functions", () => {
it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => {
const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z";
Expand Down Expand Up @@ -132,6 +148,46 @@ describe("store pure functions", () => {

expect(next).toEqual(initialState);
});

it("reorderProjects moves a project to a target index", () => {
const project1 = ProjectId.makeUnsafe("project-1");
const project2 = ProjectId.makeUnsafe("project-2");
const project3 = ProjectId.makeUnsafe("project-3");
const state: AppState = {
projects: [
{
id: project1,
name: "Project 1",
cwd: "/tmp/project-1",
model: DEFAULT_MODEL_BY_PROVIDER.codex,
expanded: true,
scripts: [],
},
{
id: project2,
name: "Project 2",
cwd: "/tmp/project-2",
model: DEFAULT_MODEL_BY_PROVIDER.codex,
expanded: true,
scripts: [],
},
{
id: project3,
name: "Project 3",
cwd: "/tmp/project-3",
model: DEFAULT_MODEL_BY_PROVIDER.codex,
expanded: true,
scripts: [],
},
],
threads: [],
threadsHydrated: true,
};

const next = reorderProjects(state, project1, project3);

expect(next.projects.map((project) => project.id)).toEqual([project2, project3, project1]);
});
});

describe("store read model sync", () => {
Expand All @@ -147,4 +203,58 @@ describe("store read model sync", () => {

expect(next.threads[0]?.model).toBe(DEFAULT_MODEL_BY_PROVIDER.codex);
});

it("preserves the current project order when syncing incoming read model updates", () => {
const project1 = ProjectId.makeUnsafe("project-1");
const project2 = ProjectId.makeUnsafe("project-2");
const project3 = ProjectId.makeUnsafe("project-3");
const initialState: AppState = {
projects: [
{
id: project2,
name: "Project 2",
cwd: "/tmp/project-2",
model: DEFAULT_MODEL_BY_PROVIDER.codex,
expanded: true,
scripts: [],
},
{
id: project1,
name: "Project 1",
cwd: "/tmp/project-1",
model: DEFAULT_MODEL_BY_PROVIDER.codex,
expanded: true,
scripts: [],
},
],
threads: [],
threadsHydrated: true,
};
const readModel: OrchestrationReadModel = {
snapshotSequence: 2,
updatedAt: "2026-02-27T00:00:00.000Z",
projects: [
makeReadModelProject({
id: project1,
title: "Project 1",
workspaceRoot: "/tmp/project-1",
}),
makeReadModelProject({
id: project2,
title: "Project 2",
workspaceRoot: "/tmp/project-2",
}),
makeReadModelProject({
id: project3,
title: "Project 3",
workspaceRoot: "/tmp/project-3",
}),
],
threads: [],
};

const next = syncServerReadModel(initialState, readModel);

expect(next.projects.map((project) => project.id)).toEqual([project2, project1, project3]);
});
});
66 changes: 60 additions & 6 deletions apps/web/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const initialState: AppState = {
threadsHydrated: false,
};
const persistedExpandedProjectCwds = new Set<string>();
const persistedProjectOrderCwds: string[] = [];

// ── Persist helpers ──────────────────────────────────────────────────

Expand All @@ -51,13 +52,22 @@ function readPersistedState(): AppState {
try {
const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);
if (!raw) return initialState;
const parsed = JSON.parse(raw) as { expandedProjectCwds?: string[] };
const parsed = JSON.parse(raw) as {
expandedProjectCwds?: string[];
projectOrderCwds?: string[];
};
persistedExpandedProjectCwds.clear();
persistedProjectOrderCwds.length = 0;
for (const cwd of parsed.expandedProjectCwds ?? []) {
if (typeof cwd === "string" && cwd.length > 0) {
persistedExpandedProjectCwds.add(cwd);
}
}
for (const cwd of parsed.projectOrderCwds ?? []) {
if (typeof cwd === "string" && cwd.length > 0 && !persistedProjectOrderCwds.includes(cwd)) {
persistedProjectOrderCwds.push(cwd);
}
}
return { ...initialState };
} catch {
return initialState;
Expand All @@ -75,6 +85,7 @@ function persistState(state: AppState): void {
expandedProjectCwds: state.projects
.filter((project) => project.expanded)
.map((project) => project.cwd),
projectOrderCwds: state.projects.map((project) => project.cwd),
}),
);
if (!legacyKeysCleanedUp) {
Expand Down Expand Up @@ -110,10 +121,17 @@ function mapProjectsFromReadModel(
incoming: OrchestrationReadModel["projects"],
previous: Project[],
): Project[] {
return incoming.map((project) => {
const existing =
previous.find((entry) => entry.id === project.id) ??
previous.find((entry) => entry.cwd === project.workspaceRoot);
const previousById = new Map(previous.map((project) => [project.id, project] as const));
const previousByCwd = new Map(previous.map((project) => [project.cwd, project] as const));
const previousOrderById = new Map(previous.map((project, index) => [project.id, index] as const));
const previousOrderByCwd = new Map(previous.map((project, index) => [project.cwd, index] as const));
const persistedOrderByCwd = new Map(
persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const),
);
const usePersistedOrder = previous.length === 0;

const mappedProjects = incoming.map((project) => {
const existing = previousById.get(project.id) ?? previousByCwd.get(project.workspaceRoot);
return {
id: project.id,
name: project.title,
Expand All @@ -127,8 +145,25 @@ function mapProjectsFromReadModel(
? persistedExpandedProjectCwds.has(project.workspaceRoot)
: true),
scripts: project.scripts.map((script) => ({ ...script })),
};
} satisfies Project;
});

return mappedProjects
.map((project, incomingIndex) => {
const previousIndex = previousOrderById.get(project.id) ?? previousOrderByCwd.get(project.cwd);
const persistedIndex = usePersistedOrder ? persistedOrderByCwd.get(project.cwd) : undefined;
const orderIndex =
previousIndex ??
persistedIndex ??
(usePersistedOrder ? persistedProjectOrderCwds.length : previous.length) + incomingIndex;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ambiguous operator precedence in fallback order calculation

Low Severity

The orderIndex fallback expression relies on + having higher precedence than ?? without explicit parentheses. While the current behavior is correct (the addition only applies when both previousIndex and persistedIndex are nullish), a reader could easily misinterpret this as adding incomingIndex to the result of the entire ?? chain. Wrapping the fallback arithmetic in parentheses would make the intent unambiguous and prevent accidental breakage if the expression is modified later.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Operator precedence bug in order index fallback calculation

Low Severity

The + operator has higher precedence than the ternary ?:, so the fallback orderIndex expression is parsed as usePersistedOrder ? persistedProjectOrderCwds.length : (previous.length + incomingIndex) instead of the likely intended (usePersistedOrder ? persistedProjectOrderCwds.length : previous.length) + incomingIndex. When usePersistedOrder is true, incomingIndex is not added, so all new projects receive the same orderIndex. The sort tiebreaker on incomingIndex masks this today, but the asymmetry is almost certainly unintentional and fragile.

Fix in Cursor Fix in Web

return { project, incomingIndex, orderIndex };
})
.toSorted((a, b) => {
const byOrder = a.orderIndex - b.orderIndex;
if (byOrder !== 0) return byOrder;
return a.incomingIndex - b.incomingIndex;
})
.map((entry) => entry.project);
}

function toLegacySessionStatus(
Expand Down Expand Up @@ -349,6 +384,22 @@ export function setProjectExpanded(
return changed ? { ...state, projects } : state;
}

export function reorderProjects(
state: AppState,
draggedProjectId: Project["id"],
targetProjectId: Project["id"],
): AppState {
if (draggedProjectId === targetProjectId) return state;
const draggedIndex = state.projects.findIndex((project) => project.id === draggedProjectId);
const targetIndex = state.projects.findIndex((project) => project.id === targetProjectId);
if (draggedIndex < 0 || targetIndex < 0) return state;
const projects = [...state.projects];
const [draggedProject] = projects.splice(draggedIndex, 1);
if (!draggedProject) return state;
projects.splice(targetIndex, 0, draggedProject);
return { ...state, projects };
}

export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {
const threads = updateThread(state.threads, threadId, (t) => {
if (t.error === error) return t;
Expand Down Expand Up @@ -384,6 +435,7 @@ interface AppStore extends AppState {
markThreadUnread: (threadId: ThreadId) => void;
toggleProject: (projectId: Project["id"]) => void;
setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void;
reorderProjects: (draggedProjectId: Project["id"], targetProjectId: Project["id"]) => void;
setError: (threadId: ThreadId, error: string | null) => void;
setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void;
}
Expand All @@ -397,6 +449,8 @@ export const useStore = create<AppStore>((set) => ({
toggleProject: (projectId) => set((state) => toggleProject(state, projectId)),
setProjectExpanded: (projectId, expanded) =>
set((state) => setProjectExpanded(state, projectId, expanded)),
reorderProjects: (draggedProjectId, targetProjectId) =>
set((state) => reorderProjects(state, draggedProjectId, targetProjectId)),
setError: (threadId, error) => set((state) => setError(state, threadId, error)),
setThreadBranch: (threadId, branch, worktreePath) =>
set((state) => setThreadBranch(state, threadId, branch, worktreePath)),
Expand Down
3 changes: 0 additions & 3 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,6 @@ export default defineConfig({
// In dev mode, tell the web app where the WebSocket server lives
"import.meta.env.VITE_WS_URL": JSON.stringify(process.env.VITE_WS_URL ?? ""),
},
experimental: {
enableNativePlugin: true,
},
resolve: {
tsconfigPaths: true,
},
Expand Down
Loading