Skip to content
Open
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
3 changes: 3 additions & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ const clientSettings: ClientSettings = {
"environment-1:/tmp/project-a": "separate",
},
sidebarProjectSortOrder: "manual",
sidebarFoldOlderThreads: true,
sidebarOlderAfterDays: 3,
sidebarThreadLayout: "grouped",
sidebarThreadSortOrder: "created_at",
sidebarThreadPreviewCount: 6,
timestampFormat: "24-hour",
Expand Down
35 changes: 32 additions & 3 deletions apps/web/src/commandPaletteContext.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,33 @@
import { createContext, use, type ReactNode } from "react";
import type { SidebarProjectGroupMember, SidebarProjectSnapshot } from "./sidebarProjectGrouping";

export type NewThreadProjectSelectionHandler = (
project: SidebarProjectGroupMember,
) => void | Promise<void>;

const OpenAddProjectCommandPaletteContext = createContext<(() => void) | null>(null);
const OpenNewThreadProjectCommandPaletteContext = createContext<
| ((
projects: readonly SidebarProjectSnapshot[],
onSelectProject?: NewThreadProjectSelectionHandler,
) => void)
| null
>(null);

export function OpenAddProjectCommandPaletteProvider(props: {
readonly children: ReactNode;
readonly openAddProject: () => void;
readonly openNewThreadProjectPicker: (
projects: readonly SidebarProjectSnapshot[],
onSelectProject?: NewThreadProjectSelectionHandler,
) => void;
}) {
return (
<OpenAddProjectCommandPaletteContext value={props.openAddProject}>
{props.children}
</OpenAddProjectCommandPaletteContext>
<OpenNewThreadProjectCommandPaletteContext value={props.openNewThreadProjectPicker}>
<OpenAddProjectCommandPaletteContext value={props.openAddProject}>
{props.children}
</OpenAddProjectCommandPaletteContext>
</OpenNewThreadProjectCommandPaletteContext>
);
}

Expand All @@ -21,6 +39,17 @@ export function useOpenAddProjectCommandPalette(): () => void {
return openAddProject;
}

export function useOpenNewThreadProjectCommandPalette(): (
projects: readonly SidebarProjectSnapshot[],
onSelectProject?: NewThreadProjectSelectionHandler,
) => void {
const openNewThreadProjectPicker = use(OpenNewThreadProjectCommandPaletteContext);
if (!openNewThreadProjectPicker) {
throw new Error("Command palette actions must be used inside CommandPalette");
}
return openNewThreadProjectPicker;
}

/** Read at event time so the chat tree does not subscribe to transient dialog state. */
export function isCommandPaletteOpen(): boolean {
return (
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Sidebar, SidebarProvider, SidebarRail, SidebarTrigger, useSidebar } fro
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";

const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width";
const THREAD_SIDEBAR_DEFAULT_WIDTH = 20 * 16;
const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;
const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px";
Expand Down Expand Up @@ -66,6 +67,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
isMacosDesktop && !isWindowFullscreen
? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties)
: undefined;
const sidebarProviderStyle = {
...macosWindowControlsStyle,
"--sidebar-width": `${THREAD_SIDEBAR_DEFAULT_WIDTH}px`,
} as CSSProperties;

useEffect(() => {
if (!isMacosDesktop) return;
Expand Down Expand Up @@ -102,7 +107,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
}, [navigate]);

return (
<SidebarProvider className="h-dvh! min-h-0!" defaultOpen style={macosWindowControlsStyle}>
<SidebarProvider className="h-dvh! min-h-0!" defaultOpen style={sidebarProviderStyle}>
<Sidebar
side="left"
collapsible="offcanvas"
Expand Down
173 changes: 156 additions & 17 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ import {
type ReactNode,
} from "react";
import { useAtomValue } from "@effect/atom-react";
import { OpenAddProjectCommandPaletteProvider } from "../commandPaletteContext";
import {
OpenAddProjectCommandPaletteProvider,
type NewThreadProjectSelectionHandler,
} from "../commandPaletteContext";
import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";
import { useHandleNewThread } from "../hooks/useHandleNewThread";
Expand Down Expand Up @@ -127,6 +130,7 @@ import { stackedThreadToast, toastManager } from "./ui/toast";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";
import type { ChatComposerHandle } from "./chat/ChatComposer";
import type { SidebarProjectGroupMember, SidebarProjectSnapshot } from "../sidebarProjectGrouping";

const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = [];

Expand Down Expand Up @@ -334,9 +338,13 @@ function errorMessage(error: unknown): string {
return "An error occurred.";
}

interface CommandPaletteOpenIntent {
readonly kind: "add-project";
}
type CommandPaletteOpenIntent =
| { readonly kind: "add-project" }
| {
readonly kind: "new-thread-project";
readonly projects: readonly SidebarProjectSnapshot[];
readonly onSelectProject?: NewThreadProjectSelectionHandler;
};

interface CommandPaletteUiState {
readonly open: boolean;
Expand All @@ -347,6 +355,11 @@ type CommandPaletteUiAction =
| { readonly _tag: "SetOpen"; readonly open: boolean }
| { readonly _tag: "Toggle" }
| { readonly _tag: "OpenAddProject" }
| {
readonly _tag: "OpenNewThreadProject";
readonly projects: readonly SidebarProjectSnapshot[];
readonly onSelectProject?: NewThreadProjectSelectionHandler;
}
| { readonly _tag: "ClearOpenIntent" };

function reduceCommandPaletteUiState(
Expand All @@ -363,6 +376,15 @@ function reduceCommandPaletteUiState(
return { open: !state.open, openIntent: null };
case "OpenAddProject":
return { open: true, openIntent: { kind: "add-project" } };
case "OpenNewThreadProject":
return {
open: true,
openIntent: {
kind: "new-thread-project",
projects: action.projects,
...(action.onSelectProject ? { onSelectProject: action.onSelectProject } : {}),
},
};
case "ClearOpenIntent":
return state.openIntent ? { ...state, openIntent: null } : state;
}
Expand All @@ -376,6 +398,18 @@ export function CommandPalette({ children }: { children: ReactNode }) {
const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []);
const toggleOpen = useCallback(() => dispatch({ _tag: "Toggle" }), []);
const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []);
const openNewThreadProjectPicker = useCallback(
(
projects: readonly SidebarProjectSnapshot[],
onSelectProject?: NewThreadProjectSelectionHandler,
) =>
dispatch({
_tag: "OpenNewThreadProject",
projects,
...(onSelectProject ? { onSelectProject } : {}),
}),
[],
);
const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []);
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const composerHandleRef = useRef<ChatComposerHandle | null>(null);
Expand Down Expand Up @@ -411,7 +445,10 @@ export function CommandPalette({ children }: { children: ReactNode }) {
}, [keybindings, terminalOpen, toggleOpen]);

return (
<OpenAddProjectCommandPaletteProvider openAddProject={openAddProject}>
<OpenAddProjectCommandPaletteProvider
openAddProject={openAddProject}
openNewThreadProjectPicker={openNewThreadProjectPicker}
>
<ComposerHandleContext value={composerHandleRef}>
<CommandDialog open={state.open} onOpenChange={setOpen}>
{children}
Expand Down Expand Up @@ -667,6 +704,21 @@ function OpenCommandPaletteDialog(props: {
[openProjectFromSearch, projects],
);

const startNewThreadInProject = useCallback(
async (project: { readonly environmentId: EnvironmentId; readonly id: ProjectId }) => {
await startNewThreadInProjectFromContext(
{
activeDraftThread,
activeThread: activeThread ?? undefined,
defaultProjectRef,
handleNewThread,
},
scopeProjectRef(project.environmentId, project.id),
);
},
[activeDraftThread, activeThread, defaultProjectRef, handleNewThread],
);

const projectThreadItems = useMemo(
() =>
buildProjectActionItems({
Expand All @@ -680,21 +732,94 @@ function OpenCommandPaletteDialog(props: {
className={ITEM_ICON_CLASS}
/>
),
runProject: async (project) => {
await startNewThreadInProjectFromContext(
{
activeDraftThread,
activeThread: activeThread ?? undefined,
defaultProjectRef,
handleNewThread,
},
scopeProjectRef(project.environmentId, project.id),
);
},
runProject: startNewThreadInProject,
}),
[activeDraftThread, activeThread, defaultProjectRef, handleNewThread, projects],
[projects, startNewThreadInProject],
);

const newThreadProjectPickerGroups = useMemo<CommandPaletteView["groups"]>(() => {
if (openIntent?.kind !== "new-thread-project") return [];

const memberItem = (member: SidebarProjectGroupMember): CommandPaletteActionItem => ({
kind: "action",
value: `focus-new-thread:${member.physicalProjectKey}`,
searchTerms: [
member.title,
member.workspaceRoot,
member.environmentLabel ?? "",
member.environmentId,
],
title: member.title,
description: `${member.environmentLabel ?? member.environmentId} · ${member.workspaceRoot}`,
icon: (
<ProjectFavicon
environmentId={member.environmentId}
cwd={member.workspaceRoot}
className={ITEM_ICON_CLASS}
/>
),
run: async () => {
if (openIntent.onSelectProject) {
await openIntent.onSelectProject(member);
return;
}
await startNewThreadInProject(member);
},
});

return [
{
value: "focus-new-thread-projects",
label: "Projects",
items: openIntent.projects.map(
(project): CommandPaletteActionItem | CommandPaletteSubmenuItem => {
const members = project.memberProjects.map(memberItem);
const shared = {
value: `focus-new-thread-group:${project.projectKey}`,
searchTerms: [
project.displayName,
...project.memberProjects.flatMap((member) => [
member.title,
member.workspaceRoot,
member.environmentLabel ?? "",
]),
],
title: project.displayName,
description:
members.length > 1
? `${members.length} projects`
: (project.memberProjects[0]?.workspaceRoot ?? project.workspaceRoot),
icon: (
<ProjectFavicon
environmentId={project.environmentId}
cwd={project.workspaceRoot}
className={ITEM_ICON_CLASS}
/>
),
};

if (members.length === 1 && members[0]) {
return { ...shared, kind: "action", run: members[0].run };
}

return {
...shared,
kind: "submenu",
addonIcon: <SquarePenIcon className={ADDON_ICON_CLASS} />,
groups: [
{
value: `members:${project.projectKey}`,
label: "Choose project",
items: members,
},
],
};
},
),
},
];
}, [openIntent, startNewThreadInProject]);

const allThreadItems = useMemo(
() =>
buildThreadActionItems({
Expand Down Expand Up @@ -960,6 +1085,20 @@ function OpenCommandPaletteDialog(props: {
openAddProjectFlow();
}, [clearOpenIntent, openAddProjectFlow, openIntent]);

useLayoutEffect(() => {
if (openIntent?.kind !== "new-thread-project") return;
clearOpenIntent();
const onlyItem = newThreadProjectPickerGroups[0]?.items[0];
if (newThreadProjectPickerGroups[0]?.items.length === 1 && onlyItem?.kind === "submenu") {
pushView(onlyItem);
return;
}
pushPaletteView({
addonIcon: <SquarePenIcon className={ADDON_ICON_CLASS} />,
groups: newThreadProjectPickerGroups,
});
}, [clearOpenIntent, newThreadProjectPickerGroups, openIntent]);

const actionItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = [];

if (projects.length > 0) {
Expand Down
Loading
Loading