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
83 changes: 83 additions & 0 deletions apps/web/src/components/CommandPalette.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,28 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
}

describe("buildProjectActionItems", () => {
it("keeps offline checkouts visible while skipping them during keyboard navigation", () => {
const local = makeProject();
const remote = makeProject({
id: ProjectId.make("remote-copy"),
environmentId: EnvironmentId.make("offline"),
});
const items = buildProjectActionItems({
projects: [remote, local].map((project) => ({ ...project, displayName: "shared-repo" })),
valuePrefix: "new-thread-in",
icon: () => null,
runProject: async () => undefined,
disabled: (project) => project.environmentId === remote.environmentId,
});
const groups = [{ value: "projects", label: "Projects", items }];
const { rows, itemValues } = buildCommandPaletteRows(groups);

expect(rows.filter((row) => row.kind === "item")).toHaveLength(2);
expect(itemValues).toEqual([`new-thread-in:${local.environmentId}:${local.id}`]);
expect(findHighlightedCommandPaletteItem(groups, items[0]!.value)).toBeNull();
expect(findHighlightedCommandPaletteItem(groups, items[1]!.value)).toBe(items[1]);
});

it("shows the grouped display name but keeps the real title for icons", () => {
const project = makeProject({ title: "fleet", workspaceRoot: "/Users/theo/Code/p/fleet" });
const iconTitles: string[] = [];
Expand Down Expand Up @@ -823,6 +845,67 @@ it.each([
});

describe("filterCommandPaletteGroups", () => {
it.each(["", "shared"])(
"refreshes an open checkout picker while keeping its query %s",
(query) => {
const remote = makeProject({ environmentId: EnvironmentId.make("remote") });
const local = makeProject({ environmentId: EnvironmentId.make("local") });
const runProject = vi.fn(async () => undefined);
const offline = buildProjectActionItems({
projects: [remote, local].map((project) => ({ ...project, displayName: "shared-repo" })),
valuePrefix: "new-thread-in",
icon: () => null,
runProject,
disabled: (project) => project.environmentId === remote.environmentId,
renderDescription: (project) =>
project.environmentId === remote.environmentId ? "Reconnecting" : "Connected",
});
const activeGroups = [
{
value: "projects",
label: "Projects",
items: enumerateCommandPaletteItems([offline[1]!, offline[0]!]),
},
];
const connected = buildProjectActionItems({
projects: [remote, local].map((project) => ({ ...project, displayName: "shared-repo" })),
valuePrefix: "new-thread-in",
icon: () => null,
runProject,
disabled: () => false,
renderDescription: () => "Connected",
});
const groups = filterCommandPaletteGroups({
activeGroups,
projectThreadItems: connected,
query,
isInSubmenu: true,
projectSearchItems: [],
threadSearchItems: [],
});
expect(buildCommandPaletteRows(groups).itemValues).toEqual([
connected[1]!.value,
connected[0]!.value,
]);
expect(groups[0]?.items.map((item) => item.shortcutCommand)).toEqual(
activeGroups[0]?.items.map((item) => item.shortcutCommand),
);
expect(findHighlightedCommandPaletteItem(groups, offline[0]!.value)?.description).toBe(
"Connected",
);
const disconnected = filterCommandPaletteGroups({
activeGroups: groups,
projectThreadItems: offline,
query,
isInSubmenu: true,
projectSearchItems: [],
threadSearchItems: [],
});
expect(buildCommandPaletteRows(disconnected).itemValues).toEqual([offline[1]!.value]);
expect(disconnected[0]?.items[1]?.description).toBe("Reconnecting");
},
);

it("sorts secondary settings results after other matches", () => {
const item = (value: string, title: string, secondary?: boolean) =>
({
Expand Down
27 changes: 24 additions & 3 deletions apps/web/src/components/CommandPalette.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ export function buildProjectActionItems(input: {
runProject: (project: CommandPaletteProject) => Promise<void>;
searchTerms?: (project: CommandPaletteProject) => ReadonlyArray<string>;
renderDescription?: (project: CommandPaletteProject) => ReactNode;
disabled?: (project: CommandPaletteProject) => boolean;
shortcutCommand?: KeybindingCommand;
}): CommandPaletteActionItem[] {
return input.projects.map((project) => ({
Expand All @@ -288,6 +289,7 @@ export function buildProjectActionItems(input: {
title: project.displayName,
description: input.renderDescription?.(project) ?? project.workspaceRoot,
icon: input.icon(project),
...(input.disabled ? { disabled: input.disabled(project) } : {}),
...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}),
run: async () => {
await input.runProject(project);
Expand Down Expand Up @@ -440,6 +442,7 @@ function rankCommandPaletteItemMatch(

export function filterCommandPaletteGroups(input: {
activeGroups: ReadonlyArray<CommandPaletteGroup>;
projectThreadItems?: ReadonlyArray<CommandPaletteActionItem>;
query: string;
isInSubmenu: boolean;
projectSearchItems: ReadonlyArray<CommandPaletteActionItem>;
Expand All @@ -449,16 +452,34 @@ export function filterCommandPaletteGroups(input: {
const isActionsFilter = input.query.startsWith(">");
const searchQuery = isActionsFilter ? input.query.slice(1) : input.query;
const normalizedQuery = normalizeSearchText(searchQuery);
const projectThreadItems = input.projectThreadItems;
const activeGroups =
input.isInSubmenu && projectThreadItems
? input.activeGroups.map((group) => {
if (group.value !== "projects") return group;
const itemOrder = new Map(group.items.map((item, index) => [item.value, index]));
return {
...group,
items: enumerateCommandPaletteItems(
projectThreadItems.toSorted(
(left, right) =>
(itemOrder.get(left.value) ?? itemOrder.size) -
(itemOrder.get(right.value) ?? itemOrder.size),
),
),
};
})
: input.activeGroups;

if (normalizedQuery.length === 0) {
if (isActionsFilter) {
return input.activeGroups.filter((group) => group.value === "actions");
return activeGroups.filter((group) => group.value === "actions");
}
return [...input.activeGroups];
return [...activeGroups];
}
const queryTokens = normalizedQuery.split(" ");

let baseGroups = [...input.activeGroups];
let baseGroups = [...activeGroups];
if (isActionsFilter) {
baseGroups = baseGroups.filter((group) => group.value === "actions");
} else if (!input.isInSubmenu) {
Expand Down
85 changes: 53 additions & 32 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,28 @@ function OpenCommandPaletteDialog(props: {
}),
[contextualProjectRef, projectGroups],
);
const isEnvironmentReachable = useCallback(
(environmentId: EnvironmentId) =>
environments.some(
(environment) =>
environment.environmentId === environmentId &&
canCreateProjectInEnvironment(environment.connection.phase),
),
[environments],
);
const threadPickerProjects = useMemo(
() =>
buildSidebarProjectPickerEntries({
groups: projectGroups,
preferredProjectRef: contextualProjectRef,
expandCheckouts: true,
isEnvironmentReachable,
}).map(({ group, targetProject }) => ({
...targetProject,
displayName: group.displayName,
})),
[contextualProjectRef, isEnvironmentReachable, projectGroups],
);
const pickerProjects = useMemo(
() =>
projectPickerEntries.map(({ group, targetProject }) => ({
Expand Down Expand Up @@ -1314,25 +1336,23 @@ function OpenCommandPaletteDialog(props: {
enumerateCommandPaletteItems([
...buildProjectActionItems({
// The no-project home shows once, as the "No project" item below.
projects: pickerProjects.filter(
projects: threadPickerProjects.filter(
(project) => !isScratchProject(project, scratchWorkspaceRootFor(project.environmentId)),
),
valuePrefix: "new-thread-in",
searchTerms: (project) => {
const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`);
const location = projectEnvironmentLocationById.get(project.environmentId);
return [
...(group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ??
[]),
...(location ? [location.label] : []),
];
},
disabled: (project) => !isEnvironmentReachable(project.environmentId),
searchTerms: (project) => [
projectEnvironmentLocationById.get(project.environmentId)?.label ?? "Remote",
],
renderDescription: (project) => {
const location = projectEnvironmentLocationById.get(project.environmentId) ?? {
kind: "remote",
label: "Remote",
machine: "server" as const,
};
const environment = environments.find(
(candidate) => candidate.environmentId === project.environmentId,
);
return (
<span className="flex min-w-0 items-center gap-1">
<span className="inline-flex min-w-0 items-center gap-1">
Expand All @@ -1347,24 +1367,22 @@ function OpenCommandPaletteDialog(props: {
</span>
<CommandPaletteMetaDot />
<span className="truncate">{project.workspaceRoot}</span>
{isEnvironmentReachable(project.environmentId) ? null : (
<>
<CommandPaletteMetaDot />
<span>
{environment ? connectionStatusText(environment.connection) : "Unavailable"}
</span>
</>
)}
</span>
);
},
icon: projectFaviconIcon,
runProject: async (project) => {
const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`);
const contextualRefBelongsToGroup =
contextualProjectRef !== null &&
group?.memberProjectRefs.some(
(projectRef) =>
projectRef.environmentId === contextualProjectRef.environmentId &&
projectRef.projectId === contextualProjectRef.projectId,
);
await handleNewThread(
contextualRefBelongsToGroup
? contextualProjectRef
: scopeProjectRef(project.environmentId, project.id),
);
await handleNewThread(scopeProjectRef(project.environmentId, project.id), {
environmentSelection: "manual",
});
},
}),
...(scratchTargetEnvironmentId === null
Expand All @@ -1382,11 +1400,11 @@ function OpenCommandPaletteDialog(props: {
]),
]),
[
contextualProjectRef,
environments,
handleNewThread,
pickerProjects,
isEnvironmentReachable,
threadPickerProjects,
projectEnvironmentLocationById,
projectGroupByTargetKey,
scratchTargetEnvironmentId,
scratchWorkspaceRootFor,
startScratchThread,
Expand Down Expand Up @@ -1852,12 +1870,14 @@ function OpenCommandPaletteDialog(props: {
currentProjectEnvironmentId && currentProjectId
? `new-thread-in:${currentProjectEnvironmentId}:${currentProjectId}`
: null;
const prioritized = currentPrefix
? [
...projectThreadItems.filter((item) => item.value === currentPrefix),
...projectThreadItems.filter((item) => item.value !== currentPrefix),
]
: projectThreadItems;
const prioritized =
currentPrefix &&
projectThreadItems.some((item) => item.value === currentPrefix && !item.disabled)
? [
...projectThreadItems.filter((item) => item.value === currentPrefix),
...projectThreadItems.filter((item) => item.value !== currentPrefix),
]
: projectThreadItems;
pushPaletteView({
addonIcon: <SquarePenIcon className={ADDON_ICON_CLASS} />,
groups: [
Expand Down Expand Up @@ -2331,6 +2351,7 @@ function OpenCommandPaletteDialog(props: {

const filteredGroups = filterCommandPaletteGroups({
activeGroups,
projectThreadItems,
query: deferredQuery,
isInSubmenu: currentView !== null,
projectSearchItems: projectSearchItems,
Expand Down
14 changes: 9 additions & 5 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4791,12 +4791,16 @@ export default function Sidebar() {
// falling back to the top project) — same resolution the command palette
// uses. The command palette already offers a "New thread in..." submenu
// for multi-project setups.
const projectChoiceCount = projectGroups.reduce(
(count, group) => count + group.memberProjects.length,
0,
);
const handleNewThreadClick = useCallback(
(event?: ReactMouseEvent) => {
// One project: nothing to pick, create immediately. Shift+click creates
// One checkout: nothing to pick, create immediately. Shift+click creates
// directly in the current project even with several projects, skipping
// the palette picker.
if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) {
if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectChoiceCount)) {
if (isMobile) setOpenMobile(false);
void startNewThreadFromContext({
activeDraftThread: newThreadContext.activeDraftThread,
Expand All @@ -4809,7 +4813,7 @@ export default function Sidebar() {
if (isMobile) setOpenMobile(false);
openCommandPalette({ open: "new-thread-in" });
},
[isMobile, newThreadContext, projectGroups.length, setOpenMobile],
[isMobile, newThreadContext, projectChoiceCount, setOpenMobile],
);

// The button mirrors chat.new: in multi-project setups both route through
Expand All @@ -4822,7 +4826,7 @@ export default function Sidebar() {
// shift+click and its keyboard twin chat.newLocal for direct create.
const newThreadShortcutLabel =
shortcutLabelForCommand(keybindings, "chat.new") ??
(projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined);
(projectChoiceCount <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined);
const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal");
return (
<>
Expand Down Expand Up @@ -4974,7 +4978,7 @@ export default function Sidebar() {
newThreadDisabled={projects.length === 0}
newThreadShortcutLabel={newThreadShortcutLabel}
newThreadInProjectShortcutLabel={newThreadInProjectShortcutLabel}
showNewThreadInProjectHint={projectGroups.length > 1}
showNewThreadInProjectHint={projectChoiceCount > 1}
searchInputRef={threadSearchInputRef}
searchQuery={threadSearchQuery}
onSearchQueryChange={(value) => {
Expand Down
17 changes: 14 additions & 3 deletions apps/web/src/components/ThreadRouteView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { scopedThreadKey } from "@t3tools/client-runtime/environment";
import type { ScopedThreadRef } from "@t3tools/contracts";
import { useNavigate } from "@tanstack/react-router";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";

import ChatView from "./ChatView";
Expand Down Expand Up @@ -38,6 +38,7 @@ import {
*/
export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) {
const navigate = useNavigate();
const archiveDraftRetry = useLocation({ select: (location) => location.state.archiveDraftRetry });
const draftId = target.kind === "draft" ? target.draftId : null;
const draftSession = useComposerDraftStore((store) =>
draftId === null ? null : store.getDraftSession(draftId),
Expand Down Expand Up @@ -151,11 +152,21 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) {
if (renderState === "missing") {
const { clearPendingFileDropsForThread } = useSidebarPendingFileDropStore.getState();
clearPendingFileDropsForThread(target.threadRef);
if (environmentHasAnyThreads) {
if (archiveDraftRetry && scopedThreadKey(archiveDraftRetry.threadRef) === serverThreadKey) {
void navigate({ to: "/", replace: true, state: { archiveDraftRetry } });
} else if (environmentHasAnyThreads) {
void navigate({ to: "/", replace: true });
}
}
}, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, target]);
}, [
archiveDraftRetry,
bootstrapComplete,
environmentHasAnyThreads,
navigate,
renderState,
serverThreadKey,
target,
]);

useEffect(() => {
if (target.kind !== "server" || !serverThreadStarted || !draftThread) {
Expand Down
Loading
Loading