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
10 changes: 1 addition & 9 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,6 @@ function ProjectProjectionRetention() {
export function AppSidebarLayout({ children }: { children: ReactNode }) {
const navigate = useNavigate();
const legacySidebarEnabled = useLegacySidebarEnabled();
// The responsive Sidebar swaps its desktop container for a mobile sheet at
// the breakpoint, which remounts its contents. Keep the selected project
// scope above that boundary so resizing or closing the sheet does not clear
// the user's filter.
const [sidebarProjectScopeKey, setSidebarProjectScopeKey] = useState<string | null>(null);
// Settings routes show the settings nav in place of whichever thread
// sidebar is active.
const pathname = useLocation({ select: (location) => location.pathname });
Expand Down Expand Up @@ -239,10 +234,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
) : legacySidebarEnabled ? (
<LegacyThreadSidebar />
) : (
<ThreadSidebar
projectScopeKey={sidebarProjectScopeKey}
onProjectScopeKeyChange={setSidebarProjectScopeKey}
/>
<ThreadSidebar />
)}
<SidebarRail onDoubleClick={resetSidebarWidth} />
</Sidebar>
Expand Down
21 changes: 18 additions & 3 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ import { useAtomValue } from "@effect/atom-react";
import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";
import { useHandleNewThread } from "../hooks/useHandleNewThread";
import { useClientSettings } from "../hooks/useSettings";
import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings";
import { useTheme } from "../hooks/useTheme";
import { readLocalApi } from "../localApi";
import { desktopLocalBackendId } from "../connection/desktopLocal";
Expand Down Expand Up @@ -161,6 +161,7 @@ import {
buildSidebarProjectPickerEntries,
buildSidebarProjectSnapshots,
} from "../sidebarProjectGrouping";
import { useScopedProjectGroup } from "../sidebarProjectScopeStore";
import type { Project } from "../types";
import { useFocusPullRequestTab, useViewPullRequest } from "../lib/viewPullRequest";
import { getSourceControlPresentation } from "../sourceControlPresentation";
Expand Down Expand Up @@ -652,6 +653,7 @@ function OpenCommandPaletteDialog(props: {
const isActionsOnly = deferredQuery.startsWith(">");
const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);
const clientSettings = useClientSettings();
const legacySidebarEnabled = useLegacySidebarEnabled();
const createProject = useAtomCommand(projectEnvironment.create, {
reportFailure: false,
});
Expand Down Expand Up @@ -786,15 +788,19 @@ function OpenCommandPaletteDialog(props: {
),
[clientSettings.sidebarProjectSortOrder, threads, unsortedProjectGroups],
);
// The sidebar's project filter feeds this, so the palette's "New thread in
// X" action names — and creates in — the project the sidebar is showing.
const scopedProjectGroup = useScopedProjectGroup(projectGroups);
const contextualProjectRef = useMemo(
() =>
resolveThreadActionProjectRef({
activeDraftThread,
activeThread: activeThread ?? undefined,
defaultProjectRef,
handleNewThread,
scopedProjectGroup,
}),
[activeDraftThread, activeThread, defaultProjectRef, handleNewThread],
[activeDraftThread, activeThread, defaultProjectRef, handleNewThread, scopedProjectGroup],
);
const projectPickerEntries = useMemo(
() =>
Expand Down Expand Up @@ -1695,6 +1701,13 @@ function OpenCommandPaletteDialog(props: {
const activeProjectTitle =
projectPickerEntries.find((entry) => entry.isPreferred)?.group.displayName ??
(currentProjectId ? (projectTitleById.get(currentProjectId) ?? null) : null);
// Which command each item is a twin of. Mirrors the chat.new branch in
// the chat route: with the default sidebar and several projects chat.new
// opens this picker, so it belongs on the submenu and the named
// create-here item belongs to chat.newLocal. Otherwise chat.new creates
// directly, so it sits on the named item and the submenu advertises
// nothing.
const picksProject = !legacySidebarEnabled && projectGroups.length > 1;

if (activeProjectTitle) {
actionItems.push({
Expand All @@ -1707,13 +1720,14 @@ function OpenCommandPaletteDialog(props: {
</>
),
icon: <SquarePenIcon className={ITEM_ICON_CLASS} />,
shortcutCommand: "chat.new",
shortcutCommand: picksProject ? "chat.newLocal" : "chat.new",
run: async () => {
await startNewThreadFromContext({
activeDraftThread,
activeThread: activeThread ?? undefined,
defaultProjectRef,
handleNewThread,
scopedProjectGroup,
});
},
});
Expand All @@ -1736,6 +1750,7 @@ function OpenCommandPaletteDialog(props: {
title: "New thread in...",
icon: <SquarePenIcon className={ITEM_ICON_CLASS} />,
addonIcon: <SquarePenIcon className={ADDON_ICON_CLASS} />,
...(picksProject ? { shortcutCommand: "chat.new" as const } : {}),
groups: [{ value: "projects", label: "Projects", items: projectThreadItems }],
});
}
Expand Down
84 changes: 79 additions & 5 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
sortThreadsForSidebar,
sortProjectsForSidebar,
sortScopedProjectsForSidebar,
shouldClearProjectScope,
shouldCreateNewThreadInCurrentProject,
THREAD_JUMP_HINT_SHOW_DELAY_MS,
} from "./Sidebar.logic";
Expand Down Expand Up @@ -435,16 +436,89 @@ describe("isSidebarNestedLinkClick", () => {

describe("shouldCreateNewThreadInCurrentProject", () => {
it("creates directly on shift+click in a multi-project setup", () => {
expect(shouldCreateNewThreadInCurrentProject(true, 2)).toBe(true);
expect(
shouldCreateNewThreadInCurrentProject({
shiftKey: true,
projectGroupCount: 2,
hasProjectScope: false,
}),
).toBe(true);
});

it("opens the picker on a plain click in a multi-project setup", () => {
expect(shouldCreateNewThreadInCurrentProject(false, 2)).toBe(false);
it("opens the picker on a plain click in an unfiltered multi-project setup", () => {
expect(
shouldCreateNewThreadInCurrentProject({
shiftKey: false,
projectGroupCount: 2,
hasProjectScope: false,
}),
).toBe(false);
});

it("creates directly when the sidebar is filtered to one project", () => {
expect(
shouldCreateNewThreadInCurrentProject({
shiftKey: false,
projectGroupCount: 5,
hasProjectScope: true,
}),
).toBe(true);
});

it("creates directly on any click with a single project", () => {
expect(shouldCreateNewThreadInCurrentProject(false, 1)).toBe(true);
expect(shouldCreateNewThreadInCurrentProject(true, 1)).toBe(true);
expect(
shouldCreateNewThreadInCurrentProject({
shiftKey: false,
projectGroupCount: 1,
hasProjectScope: false,
}),
).toBe(true);
expect(
shouldCreateNewThreadInCurrentProject({
shiftKey: true,
projectGroupCount: 1,
hasProjectScope: false,
}),
).toBe(true);
});
});

describe("shouldClearProjectScope", () => {
it("clears a filter whose project group is gone", () => {
expect(
shouldClearProjectScope({
projectScopeKey: "gone",
scopedProjectGroup: null,
projectGroupCount: 3,
}),
).toBe(true);
});

it("waits instead of clearing while projects have not arrived", () => {
expect(
shouldClearProjectScope({
projectScopeKey: "pending",
scopedProjectGroup: null,
projectGroupCount: 0,
}),
).toBe(false);
});

it("leaves a resolved filter and an absent filter alone", () => {
expect(
shouldClearProjectScope({
projectScopeKey: "here",
scopedProjectGroup: { projectKey: "here" },
projectGroupCount: 2,
}),
).toBe(false);
expect(
shouldClearProjectScope({
projectScopeKey: null,
scopedProjectGroup: null,
projectGroupCount: 0,
}),
).toBe(false);
});
});

Expand Down
33 changes: 26 additions & 7 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,13 +294,32 @@ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean {

// Shift+click on the new thread button creates directly in the current
// project, skipping the command palette's project picker. With a single
// project there is nothing to pick, so a plain click already creates
// immediately and the modifier changes nothing.
export function shouldCreateNewThreadInCurrentProject(
shiftKey: boolean,
projectGroupCount: number,
): boolean {
return shiftKey || projectGroupCount <= 1;
// project, or with the sidebar filtered to one, the choice is already made —
// a plain click creates immediately and the modifier changes nothing. The
// button keeps one behavior per state on purpose; chat.newLocal is the way to
// start a thread beside the one on screen while the list is filtered.
export function shouldCreateNewThreadInCurrentProject(input: {
shiftKey: boolean;
projectGroupCount: number;
hasProjectScope: boolean;
}): boolean {
return input.shiftKey || input.hasProjectScope || input.projectGroupCount <= 1;
}

/**
* Whether a project filter names a group that no longer exists and should be
* cleared. An empty group list means projects have not arrived yet rather than
* that the filter is stale, so the filter waits instead of healing — otherwise
* every remount and reconnect would silently drop it.
*/
export function shouldClearProjectScope(input: {
projectScopeKey: string | null;
scopedProjectGroup: unknown | null;
projectGroupCount: number;
}): boolean {
if (input.projectScopeKey === null) return false;
if (input.projectGroupCount === 0) return false;
return input.scopedProjectGroup === null;
}

export function orderItemsByPreferredIds<TItem, TId>(input: {
Expand Down
Loading
Loading