Skip to content

Commit 8f1ea22

Browse files
committed
feat(web): unify workspace navigation
1 parent cd096b9 commit 8f1ea22

25 files changed

Lines changed: 604 additions & 408 deletions

apps/desktop/src/electron/ElectronMenu.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,10 @@ describe("ElectronMenu", () => {
9898
const electronMenu = yield* ElectronMenu.ElectronMenu;
9999
const selectedItemId = yield* electronMenu.showContextMenu({
100100
window: makeWindow(2),
101-
items: [{ id: "copy", label: "Copy" }],
101+
items: [
102+
{ id: "copy", label: "Copy" },
103+
{ id: "delete", label: "Delete", destructive: true, separatorBefore: true },
104+
],
102105
position: Option.some({ x: 10.8, y: 20.2 }),
103106
});
104107

@@ -110,6 +113,38 @@ describe("ElectronMenu", () => {
110113
enabled: true,
111114
click: buildFromTemplateMock.mock.calls[0]?.[0][0].click,
112115
});
116+
assert.deepEqual(
117+
buildFromTemplateMock.mock.calls[0]?.[0].map(
118+
(item: Electron.MenuItemConstructorOptions) => item.type ?? item.label,
119+
),
120+
["Copy", "separator", "Delete"],
121+
);
122+
}).pipe(Effect.provide(TestLayer)),
123+
);
124+
125+
it.effect("keeps a preceding non-destructive action in the destructive section", () =>
126+
Effect.gen(function* () {
127+
buildFromTemplateMock.mockImplementation(() => ({
128+
popup: (options: Electron.PopupOptions) => options.callback?.(),
129+
}));
130+
131+
const electronMenu = yield* ElectronMenu.ElectronMenu;
132+
yield* electronMenu.showContextMenu({
133+
window: makeWindow(),
134+
items: [
135+
{ id: "copy", label: "Copy" },
136+
{ id: "archive", label: "Archive", separatorBefore: true },
137+
{ id: "delete", label: "Delete", destructive: true },
138+
],
139+
position: Option.none(),
140+
});
141+
142+
assert.deepEqual(
143+
buildFromTemplateMock.mock.calls[0]?.[0].map(
144+
(item: Electron.MenuItemConstructorOptions) => item.type ?? item.label,
145+
),
146+
["Copy", "separator", "Archive", "Delete"],
147+
);
113148
}).pipe(Effect.provide(TestLayer)),
114149
);
115150

apps/desktop/src/electron/ElectronMenu.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
7878
label: sourceItem.label,
7979
destructive: sourceItem.destructive === true,
8080
disabled: sourceItem.disabled === true,
81+
...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}),
8182
};
8283

8384
if (sourceItem.children) {
@@ -141,10 +142,24 @@ export const make = Effect.gen(function* () {
141142
): Electron.MenuItemConstructorOptions[] => {
142143
const template: Electron.MenuItemConstructorOptions[] = [];
143144
let hasInsertedDestructiveSeparator = false;
145+
let sectionStartedByExplicitSeparator = false;
146+
const appendSeparator = () => {
147+
if (template.length === 0 || template.at(-1)?.type === "separator") return;
148+
template.push({ type: "separator" });
149+
};
144150

145151
for (const item of entries) {
146-
if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
147-
template.push({ type: "separator" });
152+
if (item.separatorBefore) {
153+
appendSeparator();
154+
sectionStartedByExplicitSeparator = true;
155+
}
156+
if (
157+
item.destructive &&
158+
!hasInsertedDestructiveSeparator &&
159+
!sectionStartedByExplicitSeparator &&
160+
template.length > 0
161+
) {
162+
appendSeparator();
148163
hasInsertedDestructiveSeparator = true;
149164
}
150165

apps/web/src/components/ChatView.tsx

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,6 @@ import {
169169
WifiOffIcon,
170170
} from "lucide-react";
171171
import { cn, randomHex } from "~/lib/utils";
172-
import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
173172
import { stackedThreadToast, toastManager } from "./ui/toast";
174173
import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
175174
import { type NewProjectScriptInput } from "./ProjectScriptsControl";
@@ -258,6 +257,7 @@ import { ChatHeader } from "./chat/ChatHeader";
258257
import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
259258
import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
260259
import { NoActiveThreadState } from "./NoActiveThreadState";
260+
import { WorkspacePageHeader } from "./WorkspacePageContainer";
261261
import {
262262
resolveEffectiveEnvMode,
263263
resolveLocalCheckoutBranchMismatch,
@@ -6211,20 +6211,11 @@ function ChatViewContent(props: ChatViewProps) {
62116211
data-chat-column-maximized-away={rightPanelMaximized ? "true" : "false"}
62126212
>
62136213
{/* Top bar */}
6214-
<header
6214+
<WorkspacePageHeader
62156215
data-chat-header
6216-
className={cn(
6217-
"bg-background transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none",
6218-
isElectron
6219-
? cn(
6220-
"drag-region relative flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 sm:px-5",
6221-
reserveTitleBarControlInset &&
6222-
!inlineRightPanelOwnsTitleBar &&
6223-
"wco:pr-[var(--workspace-native-controls-inset)]",
6224-
)
6225-
: "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)]",
6226-
COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
6227-
)}
6216+
electron={isElectron}
6217+
reserveNativeControls={reserveTitleBarControlInset && !inlineRightPanelOwnsTitleBar}
6218+
className="relative bg-background"
62286219
>
62296220
{!rightPanelOpen ? panelLayoutControls : null}
62306221
<ChatHeader
@@ -6255,7 +6246,7 @@ function ChatViewContent(props: ChatViewProps) {
62556246
onUpdateProjectScript={updateProjectScript}
62566247
onDeleteProjectScript={deleteProjectScript}
62576248
/>
6258-
</header>
6249+
</WorkspacePageHeader>
62596250

62606251
<ThreadErrorBanner
62616252
error={visibleThreadError}

apps/web/src/components/NoActiveThreadState.tsx

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,23 @@
11
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty";
22
import { SidebarInset } from "./ui/sidebar";
33
import { isElectron } from "../env";
4-
import { cn } from "~/lib/utils";
5-
import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
4+
import { WorkspacePageHeader } from "./WorkspacePageContainer";
65

76
export function NoActiveThreadState() {
87
return (
98
<SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
109
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
11-
<header
12-
className={cn(
13-
"border-b border-border px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5",
14-
isElectron
15-
? "drag-region flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center"
16-
: "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center",
17-
COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
18-
)}
19-
>
10+
<WorkspacePageHeader electron={isElectron} className="border-b border-border">
2011
{isElectron ? (
21-
<span className="text-xs text-muted-foreground/50 wco:pr-[var(--workspace-native-controls-inset)]">
22-
No active thread
23-
</span>
12+
<span className="text-xs text-muted-foreground/50">No active thread</span>
2413
) : (
2514
<div className="flex items-center gap-2">
2615
<span className="text-sm font-medium text-foreground md:text-muted-foreground/60">
2716
No active thread
2817
</span>
2918
</div>
3019
)}
31-
</header>
20+
</WorkspacePageHeader>
3221

3322
<Empty className="flex-1">
3423
<div className="w-full max-w-lg px-8 py-12">

apps/web/src/components/Sidebar.logic.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,10 @@ describe("shouldCreateNewThreadInCurrentProject", () => {
442442
expect(shouldCreateNewThreadInCurrentProject(false, 2)).toBe(false);
443443
});
444444

445+
it("creates directly when the sidebar is scoped to a project", () => {
446+
expect(shouldCreateNewThreadInCurrentProject(false, 2, true)).toBe(true);
447+
});
448+
445449
it("creates directly on any click with a single project", () => {
446450
expect(shouldCreateNewThreadInCurrentProject(false, 1)).toBe(true);
447451
expect(shouldCreateNewThreadInCurrentProject(true, 1)).toBe(true);

apps/web/src/components/Sidebar.logic.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,8 +299,9 @@ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean {
299299
export function shouldCreateNewThreadInCurrentProject(
300300
shiftKey: boolean,
301301
projectGroupCount: number,
302+
hasProjectScope = false,
302303
): boolean {
303-
return shiftKey || projectGroupCount <= 1;
304+
return hasProjectScope || shiftKey || projectGroupCount <= 1;
304305
}
305306

306307
export function orderItemsByPreferredIds<TItem, TId>(input: {

0 commit comments

Comments
 (0)