Skip to content
Closed
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
20 changes: 19 additions & 1 deletion apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,10 @@ import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups";
import { useTerminalFocus } from "../hooks/useTerminalFocus";
import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
import { registerSettleAndAdvance } from "../settleAndAdvance";
import { startNewThreadFromContext } from "../lib/chatThreadActions";
import {
resolveScopedNewThreadProjectRef,
startNewThreadFromContext,
} from "../lib/chatThreadActions";
import { useClientSettings } from "../hooks/useSettings";
import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
import { useLocalStorage } from "../hooks/useLocalStorage";
Expand Down Expand Up @@ -2961,6 +2964,21 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
const showJumpHints = useThreadJumpHintsVisible(shouldShowJumpHintsNow);

const handleNewThreadClick = (event?: ReactMouseEvent) => {
const scopedProject =
!boringSidebarEnabled && chosenProjectGroups.length === 1 ? chosenProjectGroups[0] : null;
if (scopedProject && !event?.shiftKey) {
if (isMobile) setOpenMobile(false);
void handleNewThread(
resolveScopedNewThreadProjectRef(
{
activeDraftThread: newThreadContext.activeDraftThread,
activeThread: newThreadContext.activeThread ?? undefined,
},
{ representative: scopedProject, memberProjectRefs: scopedProject.memberProjectRefs },
),
);
return;
}
if (
shouldCreateNewThreadInCurrentProject(
event?.shiftKey ?? false,
Expand Down
49 changes: 47 additions & 2 deletions apps/web/src/lib/chatThreadActions.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,28 @@
import { ProviderInstanceId, type ModelSelection } from "@t3tools/contracts";
import { scopeProjectRef } from "@t3tools/client-runtime/environment";
import {
EnvironmentId,
ProjectId,
ProviderInstanceId,
type ModelSelection,
} from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
import { resolveNewThreadModelSelectionOverride } from "./chatThreadActions";
import {
resolveScopedNewThreadProjectRef,
resolveNewThreadModelSelectionOverride,
} from "./chatThreadActions";

const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
const REMOTE_ENVIRONMENT_ID = EnvironmentId.make("environment-2");
const PROJECT_ID = ProjectId.make("project-1");
const FALLBACK_PROJECT_ID = ProjectId.make("project-2");
const REMOTE_PROJECT_ID = ProjectId.make("project-3");
const SCOPE = {
representative: { environmentId: ENVIRONMENT_ID, id: PROJECT_ID },
memberProjectRefs: [
scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID),
scopeProjectRef(REMOTE_ENVIRONMENT_ID, REMOTE_PROJECT_ID),
],
};
const PROJECT_DEFAULT_SELECTION: ModelSelection = {
instanceId: ProviderInstanceId.make("codex"),
model: "project-default",
Expand All @@ -12,6 +33,30 @@ const CARRIED_SELECTION: ModelSelection = {
};

describe("chatThreadActions", () => {
it("keeps a viewed thread inside the scope on its own machine", () => {
const projectRef = resolveScopedNewThreadProjectRef(
{
activeThread: { environmentId: REMOTE_ENVIRONMENT_ID, projectId: REMOTE_PROJECT_ID },
activeDraftThread: null,
},
SCOPE,
);

expect(projectRef).toEqual(scopeProjectRef(REMOTE_ENVIRONMENT_ID, REMOTE_PROJECT_ID));
});

it("uses the scoped project when the viewed thread is outside the scope", () => {
const projectRef = resolveScopedNewThreadProjectRef(
{
activeThread: { environmentId: ENVIRONMENT_ID, projectId: FALLBACK_PROJECT_ID },
activeDraftThread: null,
},
SCOPE,
);

expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID));
});

it("does not carry a non-explicit model from the destination draft back into itself", () => {
expect(
resolveNewThreadModelSelectionOverride({
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/lib/chatThreadActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,30 @@ export function resolveThreadActionProjectRef(
return context.defaultProjectRef;
}

// New chat from a scoped sidebar lands in the scoped project, so the thread
// shows up in the list the user is looking at. The viewed project wins only
// when it is a member of the scope, which keeps the viewed thread's machine.
export function resolveScopedNewThreadProjectRef(
context: Pick<ChatThreadActionContext, "activeDraftThread" | "activeThread">,
scope: {
readonly representative: { readonly environmentId: EnvironmentId; readonly id: ProjectId };
readonly memberProjectRefs: readonly ScopedProjectRef[];
},
): ScopedProjectRef {
const current = context.activeThread ?? context.activeDraftThread;
if (
current &&
scope.memberProjectRefs.some(
(projectRef) =>
projectRef.environmentId === current.environmentId &&
projectRef.projectId === current.projectId,
)
) {
return scopeProjectRef(current.environmentId, current.projectId);
}
return scopeProjectRef(scope.representative.environmentId, scope.representative.id);
}

export async function startNewThreadFromContext(
context: ChatThreadActionContext,
): Promise<boolean> {
Expand Down
2 changes: 2 additions & 0 deletions docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ the current project, or use a project's own new-thread action. A new thread carr
your model and mode selections, unless the destination project has its own model default.
Its branch and workspace mode come from your configured defaults. To continue in
an existing worktree, use **New thread in this worktree** from the branch toolbar.
While the sidebar is filtered to one project, the sidebar's **New thread** button
starts the thread in that project instead of asking which project to use.

On mobile, **New thread** starts in the current thread's project, or else in the
last project you opened or chose on that device. If neither is available, the
Expand Down
Loading