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
13 changes: 13 additions & 0 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import {
useStore,
} from "../store";
import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
import { useUiStateStore } from "../uiStateStore";
import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";
import {
ADDON_ICON_CLASS,
Expand Down Expand Up @@ -406,6 +407,14 @@ function OpenCommandPaletteDialog() {
useHandleNewThread();
const projects = useStore(useShallow(selectProjectsAcrossEnvironments));
const threads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments));
const threadOrderByProject = useUiStateStore((state) => state.threadOrderByProject);
// Flattened manual thread order so "open project" navigates to the thread
// that is first in the sidebar under manual sort; the per-project filter in
// getLatestThreadForProject means only the target project's keys match.
const manualThreadOrder = useMemo(
() => Object.values(threadOrderByProject).flat(),
[threadOrderByProject],
);
const keybindings = useServerKeybindings();
const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]);
const currentView = viewStack.at(-1) ?? null;
Expand Down Expand Up @@ -601,6 +610,7 @@ function OpenCommandPaletteDialog() {
threads.filter((thread) => thread.environmentId === project.environmentId),
project.id,
settings.sidebarThreadSortOrder,
manualThreadOrder,
);
if (latestThread) {
await navigate({
Expand All @@ -618,6 +628,7 @@ function OpenCommandPaletteDialog() {
},
[
handleNewThread,
manualThreadOrder,
navigate,
settings.defaultThreadEnvMode,
settings.sidebarThreadSortOrder,
Expand Down Expand Up @@ -1099,6 +1110,7 @@ function OpenCommandPaletteDialog() {
threads.filter((thread) => thread.environmentId === existing.environmentId),
existing.id,
settings.sidebarThreadSortOrder,
manualThreadOrder,
);
if (latestThread) {
await navigate({
Expand Down Expand Up @@ -1150,6 +1162,7 @@ function OpenCommandPaletteDialog() {
browseEnvironmentPlatform,
currentProjectCwdForBrowse,
handleNewThread,
manualThreadOrder,
navigate,
projects,
setOpen,
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { ProviderDriverKind } from "@t3tools/contracts";
import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";

import {
createThreadJumpHintVisibilityController,
Expand Down Expand Up @@ -811,6 +812,26 @@ describe("getFallbackThreadIdAfterDelete", () => {

expect(fallbackThreadId).toBe(ThreadId.make("thread-next"));
});

it("falls back to the first thread in manual order, not raw store order", () => {
const threads = [
makeThread({ id: ThreadId.make("thread-a"), projectId: ProjectId.make("project-1") }),
makeThread({ id: ThreadId.make("thread-active"), projectId: ProjectId.make("project-1") }),
makeThread({ id: ThreadId.make("thread-c"), projectId: ProjectId.make("project-1") }),
];
const keyFor = (id: string) =>
scopedThreadKey(scopeThreadRef(localEnvironmentId, ThreadId.make(id)));

const fallbackThreadId = getFallbackThreadIdAfterDelete({
threads,
deletedThreadId: ThreadId.make("thread-active"),
sortOrder: "manual",
// Manual order puts thread-c first, ahead of thread-a in store order.
manualThreadOrder: [keyFor("thread-c"), keyFor("thread-active"), keyFor("thread-a")],
});

expect(fallbackThreadId).toBe(ThreadId.make("thread-c"));
});
});
describe("sortProjectsForSidebar", () => {
it("sorts projects by the most recent user message across their threads", () => {
Expand Down
69 changes: 29 additions & 40 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@ import * as React from "react";
import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
import {
getThreadSortTimestamp,
orderItemsByPreferredIds,
sortThreads,
toSortableTimestamp,
type ThreadSortInput,
} from "../lib/threadSort";
import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime";
import type { SidebarThreadSummary, Thread } from "../types";

// Re-exported from lib/threadSort so existing importers keep using this path;
// it lives in the sort lib to stay reusable without an import cycle.
export { orderItemsByPreferredIds };
import { cn } from "../lib/utils";
import { isLatestTurnSettled } from "../session-logic";

Expand Down Expand Up @@ -213,34 +219,6 @@ export function resolveSidebarNewThreadSeedContext(input: {
};
}

export function orderItemsByPreferredIds<TItem, TId>(input: {
items: readonly TItem[];
preferredIds: readonly TId[];
getId: (item: TItem) => TId;
}): TItem[] {
const { getId, items, preferredIds } = input;
if (preferredIds.length === 0) {
return [...items];
}

const itemsById = new Map(items.map((item) => [getId(item), item] as const));
const preferredIdSet = new Set(preferredIds);
const emittedPreferredIds = new Set<TId>();
const ordered = preferredIds.flatMap((id) => {
if (emittedPreferredIds.has(id)) {
return [];
}
const item = itemsById.get(id);
if (!item) {
return [];
}
emittedPreferredIds.add(id);
return [item];
});
const remaining = items.filter((item) => !preferredIdSet.has(getId(item)));
return [...ordered, ...remaining];
}

export function getVisibleSidebarThreadIds<TThreadId>(
renderedProjects: readonly {
shouldShowThreadPanel?: boolean;
Expand Down Expand Up @@ -460,30 +438,41 @@ export function getVisibleThreadsForProject<T extends Pick<Thread, "id">>(input:
}

export function getFallbackThreadIdAfterDelete<
T extends Pick<Thread, "id" | "projectId" | "createdAt" | "updatedAt"> & ThreadSortInput,
T extends Pick<Thread, "id" | "projectId" | "createdAt" | "updatedAt" | "environmentId"> &
ThreadSortInput,
>(input: {
threads: readonly T[];
deletedThreadId: T["id"];
sortOrder: SidebarThreadSortOrder;
deletedThreadIds?: ReadonlySet<T["id"]>;
// Manual order (scoped thread keys) so the fallback matches the thread that
// is actually first in the sidebar under manual sort, not raw store order.
manualThreadOrder?: readonly string[];
}): T["id"] | null {
const { deletedThreadId, deletedThreadIds, sortOrder, threads } = input;
const { deletedThreadId, deletedThreadIds, manualThreadOrder, sortOrder, threads } = input;
const deletedThread = threads.find((thread) => thread.id === deletedThreadId);
if (!deletedThread) {
return null;
}

return (
sortThreads(
threads.filter(
(thread) =>
thread.projectId === deletedThread.projectId &&
thread.id !== deletedThreadId &&
!deletedThreadIds?.has(thread.id),
),
sortOrder,
)[0]?.id ?? null
const candidates = sortThreads(
threads.filter(
(thread) =>
thread.projectId === deletedThread.projectId &&
thread.id !== deletedThreadId &&
!deletedThreadIds?.has(thread.id),
),
sortOrder,
);
const ordered =
sortOrder === "manual" && manualThreadOrder && manualThreadOrder.length > 0
? orderItemsByPreferredIds({
items: candidates,
preferredIds: manualThreadOrder,
getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
})
: candidates;
return ordered[0]?.id ?? null;
}
export function getProjectSortTimestamp(
project: SidebarProject,
Expand Down
Loading
Loading