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
1 change: 1 addition & 0 deletions apps/web/src/components/Sidebar.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ describe("sidebar header new thread", () => {
activeDraftThread: null,
activeThread: null,
defaultProjectRef: currentProjectRef,
defaultProjectFallbackReady: true,
handleNewThread: startThread,
routeDraftId: null,
routeThreadRef: null,
Expand Down
41 changes: 37 additions & 4 deletions apps/web/src/components/desktop/SnapShotCoordinator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@ import {
type PersistedComposerImageAttachment,
useComposerDraftStore,
} from "../../composerDraftStore";
import { useHandleNewThread } from "../../hooks/useHandleNewThread";
import {
LAST_USED_PROJECT_STORAGE_KEY,
lastUsedProjectKeySchema,
useHandleNewThread,
} from "../../hooks/useHandleNewThread";
import { scopedProjectKey } from "@t3tools/client-runtime/environment";
import { useLocalStorage } from "../../hooks/useLocalStorage";
import { useStableCallback } from "../../hooks/useStableCallback";
import { getClientSettings, useClientSettings } from "../../hooks/useSettings";
import { readThreadShell } from "../../state/entities";
Expand Down Expand Up @@ -226,10 +232,30 @@ export function SnapShotCoordinator() {
activeDraftThread,
activeThread,
defaultProjectRef,
defaultProjectFallbackReady,
handleNewThread,
routeDraftId,
routeThreadRef,
} = useHandleNewThread();
const [lastUsedProjectKey, setLastUsedProjectKey] = useLocalStorage(
LAST_USED_PROJECT_STORAGE_KEY,
null,
lastUsedProjectKeySchema,
);
const currentProject = activeThread ?? activeDraftThread;
const currentProjectKey = currentProject
? scopedProjectKey({
environmentId: currentProject.environmentId,
projectId: currentProject.projectId,
})
: null;
const rememberProject = useEffectEvent((projectKey: string) => {
if (projectKey !== lastUsedProjectKey) setLastUsedProjectKey(projectKey);
});
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Persist route changes without overwriting project choices from other tabs.
useEffect(() => {
if (currentProjectKey) rememberProject(currentProjectKey);
}, [currentProjectKey]);
const captureSound = useClientSettings((settings) =>
settings.snapShotPlaySound ? settings.snapShotSound : null,
);
Expand Down Expand Up @@ -341,6 +367,7 @@ export function SnapShotCoordinator() {
? resolveExistingSnapShotTarget(capturedTarget, routeThreadRef)
: null;
if (!target) {
if (!defaultProjectFallbackReady) continue;
await dismissSnapShotAnimation(item.id);
reportOnce(
item.id,
Expand Down Expand Up @@ -400,6 +427,7 @@ export function SnapShotCoordinator() {
};

const drainPending = useEffectEvent(() => drain());
const hasDefaultProject = defaultProjectRef !== null;
const handleSnapShotEvent = useEffectEvent(
(bridge: DesktopBridge, event: DesktopSnapShotEvent) => {
if (!getClientSettings().snapShotEnabled) return;
Expand Down Expand Up @@ -470,10 +498,15 @@ export function SnapShotCoordinator() {
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to SnapShot events from the desktop bridge and drains pending ones while enabled
useEffect(() => {
const bridge = getDesktopSnapShotBridge();
if (!bridge || !enabled) return;
void drainPending();
if (
!bridge ||
!enabled ||
(!defaultProjectFallbackReady && !hasDefaultProject && !lastRouteTarget)
)
return;
void Promise.resolve(drainingRef.current).then(() => drainPending());
return bridge.onSnapShotEvent((event) => handleSnapShotEvent(bridge, event));
}, [enabled]);
}, [enabled, defaultProjectFallbackReady, hasDefaultProject, lastRouteTarget]);

// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- attaches window blur, focus and visibilitychange listeners to dismiss or drain SnapShot animations
useEffect(() => {
Expand Down
48 changes: 44 additions & 4 deletions apps/web/src/hooks/useHandleNewThread.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vite-plus/test";
import type { RuntimeMode } from "@t3tools/contracts";
import type { EnvironmentId, ProjectId, RuntimeMode } from "@t3tools/contracts";

const testState = vi.hoisted(() => {
let completeProjectFileRead: (value: null) => void = () => undefined;
Expand Down Expand Up @@ -145,7 +145,8 @@ vi.mock("@effect/atom-react", () => ({
: testServerConfigs(),
}));
vi.mock("@t3tools/client-runtime/environment", () => ({
scopedProjectKey: () => "remote-project",
scopedProjectKey: (ref: { environmentId: string; projectId: string }) =>
`${ref.environmentId}:${ref.projectId}`,
scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }),
scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }),
}));
Expand Down Expand Up @@ -183,7 +184,9 @@ vi.mock("react", () => ({
useCallback: <T>(callback: T) => callback,
useMemo: <T>(factory: () => T) => factory(),
}));
vi.mock("../components/Sidebar.logic", () => ({ orderItemsByPreferredIds: () => [] }));
vi.mock("../components/Sidebar.logic", () => ({
orderItemsByPreferredIds: ({ items }: { items: unknown[] }) => items,
}));
vi.mock("../composerDraftStore", () => {
const useComposerDraftStore = Object.assign(() => null, {
getState: () => testState.draftStore,
Expand Down Expand Up @@ -230,6 +233,7 @@ vi.mock("../state/entities", () => {
useThread: () => null,
};
});
vi.mock("../state/projects", () => ({ defaultProjectFallbackReadyAtom: () => true }));
vi.mock("../state/olderSettledShelf", () => ({ readUnloadedThreadCount: () => 0 }));
vi.mock("../state/presentation", () => ({
environmentSummaries: { connectedEnvironmentIdsAtom: "connected-environment-ids" },
Expand All @@ -247,7 +251,7 @@ vi.mock("../uiStateStore", () => ({
}));
vi.mock("./useSettings", () => ({ getClientSettings: () => ({}) }));

import { useNewThreadHandler } from "./useHandleNewThread";
import { resolveDefaultProjectRef, useNewThreadHandler } from "./useHandleNewThread";

describe.each([
["new", null],
Expand Down Expand Up @@ -365,3 +369,39 @@ describe("useNewThreadHandler", () => {
expect(testState.router.navigate).toHaveBeenCalledOnce();
});
});

describe("default project selection", () => {
it("prefers the last used project in its environment and falls back when unavailable", () => {
const projects = [
{ id: "project", environmentId: "first", workspaceRoot: "/first" },
{ id: "project", environmentId: "last-used", workspaceRoot: "/last-used" },
].map((project) => ({
...project,
id: project.id as ProjectId,
environmentId: project.environmentId as EnvironmentId,
title: project.environmentId,
defaultModelSelection: null,
scripts: [],
createdAt: "2026-11-09T00:00:00.000Z",
updatedAt: "2026-11-09T00:00:00.000Z",
}));
expect(resolveDefaultProjectRef(projects, [], "last-used:project")).toEqual({
environmentId: "last-used",
projectId: "project",
});
expect(
resolveDefaultProjectRef(projects.slice(0, 1), [], "last-used:project", false),
).toBeNull();
expect(resolveDefaultProjectRef(projects.slice(0, 1), [], "last-used:project")).toEqual({
environmentId: "first",
projectId: "project",
});
expect(
resolveDefaultProjectRef(
[projects[0]!, { ...projects[1]!, archivedAt: "2026-11-09T00:00:00.000Z" }],
[],
"last-used:project",
),
).toEqual({ environmentId: "first", projectId: "project" });
});
});
40 changes: 39 additions & 1 deletion apps/web/src/hooks/useHandleNewThread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,26 @@ import { environmentServerConfigsAtom } from "../state/server";
import { resolveThreadRouteTarget } from "../threadRoutes";
import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
import { getClientSettings } from "./useSettings";
import { getLocalStorageItem, useLocalStorage } from "./useLocalStorage";
import * as Schema from "effect/Schema";
import { useAtomValue } from "@effect/atom-react";
import { defaultProjectFallbackReadyAtom } from "../state/projects";
import { useThreadRouteTarget } from "./useThreadRouteTarget";
import { readUnloadedThreadCount } from "../state/olderSettledShelf";
import { resolveProjectDefaultProviderModelSelection } from "../providerInstances";
import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits";

export const LAST_USED_PROJECT_STORAGE_KEY = "t3code:last-used-project:v1";
export const lastUsedProjectKeySchema = Schema.NullOr(Schema.String);

export function readLastUsedProjectKey(): string | null {
try {
return getLocalStorageItem(LAST_USED_PROJECT_STORAGE_KEY, lastUsedProjectKeySchema);
} catch {
return null;
}
}

interface NewThreadWorkspaceOptions {
branch?: string | null;
worktreePath?: string | null;
Expand Down Expand Up @@ -426,7 +441,16 @@ export function createNewThreadHandler(router: AnyRouter) {
export function resolveDefaultProjectRef(
projects: ReadonlyArray<EnvironmentProject>,
projectOrder: readonly string[],
lastUsedProjectKey: string | null,
fallbackReady = true,
): ScopedProjectRef | null {
const lastUsed = projects.find(
(project) =>
project.archivedAt == null &&
scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) === lastUsedProjectKey,
);
if (lastUsed) return scopeProjectRef(lastUsed.environmentId, lastUsed.id);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (!fallbackReady) return null;
const [defaultProject] = orderItemsByPreferredIds({
items: projects.filter((project) => project.archivedAt == null),
preferredIds: projectOrder,
Expand All @@ -441,6 +465,14 @@ export function resolveDefaultProjectRef(

export function useHandleNewThread() {
const projectOrder = useUiStateStore((store) => store.projectOrder);
const [lastUsedProjectKey] = useLocalStorage(
LAST_USED_PROJECT_STORAGE_KEY,
null,
lastUsedProjectKeySchema,
);
const defaultProjectFallbackReady = useAtomValue(
defaultProjectFallbackReadyAtom(lastUsedProjectKey),
);
const routeTarget = useThreadRouteTarget();
const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null;
const routeDraftId = routeTarget?.kind === "draft" ? routeTarget.draftId : null;
Expand All @@ -455,12 +487,18 @@ export function useHandleNewThread() {
);
const projects = useProjects();
const handleNewThread = useNewThreadHandler();
const defaultProjectRef = resolveDefaultProjectRef(projects, projectOrder);
const defaultProjectRef = resolveDefaultProjectRef(
projects,
projectOrder,
lastUsedProjectKey,
defaultProjectFallbackReady,
);

return {
activeDraftThread,
activeThread,
defaultProjectRef,
defaultProjectFallbackReady,
handleNewThread,
routeDraftId,
routeThreadRef,
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/shortcuts/chatRouteCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import { describe, expect, it, vi } from "vite-plus/test";

import * as commandPalette from "../commandPaletteBus";
import * as settings from "../hooks/useSettings";
import * as storage from "../hooks/useLocalStorage";
import * as threadActions from "../lib/chatThreadActions";
import * as entities from "../state/entities";
import * as projectsState from "../state/projects";
import { Atom } from "effect/reactivity";
import type { Project } from "../types";
import { installChatRouteCommands } from "./chatRouteCommands";
import * as dispatcher from "./dispatcher";
Expand Down Expand Up @@ -38,6 +41,10 @@ describe("new thread commands", () => {
using _projects = vi
.spyOn(entities, "readProjects")
.mockReturnValue([project("active"), project("idle")].slice(0, scenario.count));
using _ready = vi
.spyOn(projectsState, "defaultProjectFallbackReadyAtom")
.mockReturnValue(Atom.make(true));
using storedProject = vi.spyOn(storage, "getLocalStorageItem").mockReturnValue("local:active");
using openPicker = vi.spyOn(commandPalette, "openCommandPalette").mockImplementation(() => {});
using startLocal = vi.spyOn(threadActions, "startNewThreadFromContext").mockResolvedValue(true);
using register = vi.spyOn(dispatcher, "registerCommands").mockReturnValue(() => {});
Expand All @@ -50,6 +57,7 @@ describe("new thread commands", () => {
stopPropagation: vi.fn(),
} as unknown as KeyboardEvent;

storedProject.mockReturnValue("local:idle");
commands[scenario.command]!(event);

if (scenario.picker) {
Expand All @@ -58,6 +66,10 @@ describe("new thread commands", () => {
} else {
expect(openPicker).not.toHaveBeenCalled();
expect(startLocal).toHaveBeenCalledOnce();
expect(startLocal.mock.calls[0]![0].defaultProjectRef).toEqual({
environmentId: "local",
projectId: scenario.count === 1 ? "active" : "idle",
});
}
});
});
10 changes: 9 additions & 1 deletion apps/web/src/shortcuts/chatRouteCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import { useComposerDraftStore } from "../composerDraftStore";
import { dispatchPreviewAction, type PreviewAction } from "../components/preview/previewActionBus";
import { isAppModalPage } from "../components/sidebar/mainAppLocation";
import { shouldCreateNewThreadInCurrentProject } from "../components/Sidebar.logic";
import { createNewThreadHandler, resolveDefaultProjectRef } from "../hooks/useHandleNewThread";
import {
createNewThreadHandler,
readLastUsedProjectKey,
resolveDefaultProjectRef,
} from "../hooks/useHandleNewThread";
import { createScratchProjectActions } from "../hooks/useScratchProject";
import { getClientSettings } from "../hooks/useSettings";
import { startNewThreadFromContext } from "../lib/chatThreadActions";
Expand All @@ -17,6 +21,7 @@ import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping";
import { readProjects, readThreadShell } from "../state/entities";
import { defaultProjectFallbackReadyAtom } from "../state/projects";
import { environmentPresentations } from "../state/presentation";
import { primaryEnvironmentIdAtom } from "../state/primaryEnvironment";
import { readEnvironmentScope } from "../state/session";
Expand Down Expand Up @@ -59,6 +64,7 @@ export function installChatRouteCommands(router: AnyRouter): () => void {
: false;
};
const readThreadContext = () => {
const lastUsedProjectKey = readLastUsedProjectKey();
const target = readRouteTarget();
const drafts = useComposerDraftStore.getState();
const activeThread = target?.kind === "server" ? readThreadShell(target.threadRef) : null;
Expand All @@ -73,6 +79,8 @@ export function installChatRouteCommands(router: AnyRouter): () => void {
defaultProjectRef: resolveDefaultProjectRef(
readProjects(),
useUiStateStore.getState().projectOrder,
lastUsedProjectKey,
appAtomRegistry.get(defaultProjectFallbackReadyAtom(lastUsedProjectKey)),
),
handleNewThread,
};
Expand Down
33 changes: 32 additions & 1 deletion apps/web/src/state/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import { WS_METHODS } from "@t3tools/contracts";

import { environmentCatalog } from "../connection/catalog";
import { connectionAtomRuntime } from "../connection/runtime";
import { environmentSnapshotAtom } from "./shell";
import { environmentShell, environmentSnapshotAtom, expectsPrimaryEnvironment } from "./shell";
import { parseScopedProjectKey } from "@t3tools/client-runtime/environment";
import { AVAILABLE_CONNECTION_STATE } from "@t3tools/client-runtime/connection";
import { AsyncResult, Atom } from "effect/reactivity";
import { serverEnvironment } from "./server";

export const environmentProjects = createEnvironmentProjectAtoms({
Expand All @@ -22,3 +25,31 @@ export const projectContentSearch = createEnvironmentRpcQueryAtomFamily(connecti
staleTimeMs: 5_000,
idleTtlMs: 60_000,
});

export const defaultProjectFallbackReadyAtom = Atom.family((projectKey: string | null) =>
Atom.make((get) => {
const remembered = projectKey === null ? null : parseScopedProjectKey(projectKey);
if (!remembered) return true;
const catalog = get(environmentCatalog.catalogValueAtom);
if (!catalog.isReady) return false;
if (
expectsPrimaryEnvironment() &&
!Array.from(catalog.entries.values()).some(
(entry) => entry.target._tag === "PrimaryConnectionTarget",
)
)
return false;
const entry = catalog.entries.get(remembered.environmentId);
if (!entry?.enabled || entry.unsupportedReason !== undefined) return true;
if (get(environmentShell.statusAtom(remembered.environmentId)) === "live") return true;
const connection = AsyncResult.getOrElse(
get(environmentCatalog.stateAtom(remembered.environmentId)),
() => AVAILABLE_CONNECTION_STATE,
);
return (
connection.phase === "offline" ||
connection.phase === "blocked" ||
(connection.phase === "backoff" && connection.attempt > 2)
);
}).pipe(Atom.withLabel(`web-default-project-fallback-ready:${projectKey}`)),
);
Loading
Loading