Projects
+
)}
+
{projectsLength === 0 && (
No projects yet
)}
@@ -3294,7 +3306,7 @@ export default function LegacySidebar() {
return null;
}
const activeThread = sidebarThreadByKey.get(routeThreadKey);
- if (!activeThread) return null;
+ if (!activeThread || activeThread.projectId === null) return null;
const physicalKey =
projectPhysicalKeyByScopedRef.get(
scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)),
@@ -3307,6 +3319,7 @@ export default function LegacySidebar() {
const threadsByProjectKey = useMemo(() => {
const next = new Map();
for (const thread of sidebarThreads) {
+ if (thread.projectId === null) continue;
const physicalKey =
projectPhysicalKeyByScopedRef.get(
scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
@@ -3437,7 +3450,8 @@ export default function LegacySidebar() {
...project,
id: project.projectKey,
}));
- const sortableThreads = visibleThreads.map((thread) => {
+ const sortableThreads = visibleThreads.flatMap((thread) => {
+ if (thread.projectId === null) return [];
const physicalKey =
projectPhysicalKeyByScopedRef.get(
scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
@@ -3466,42 +3480,50 @@ export default function LegacySidebar() {
const isManualProjectSorting = sidebarProjectSortOrder === "manual";
const visibleSidebarThreadKeys = useMemo(
() =>
- sortedProjects.flatMap((project) => {
- const projectThreads = sortThreads(
- (threadsByProjectKey.get(project.projectKey) ?? []).filter(
- (thread) => thread.archivedAt === null,
- ),
- sidebarThreadSortOrder,
- );
- const projectExpanded = resolveProjectExpanded(
- projectExpandedById,
- projectExpansionPreferenceKeys(project),
- );
- const activeThreadKey = routeThreadKey ?? undefined;
- const pinnedCollapsedThread =
- !projectExpanded && activeThreadKey
- ? (projectThreads.find(
- (thread) =>
- scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) ===
- activeThreadKey,
- ) ?? null)
- : null;
- const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null;
- if (!shouldShowThreadPanel) {
- return [];
- }
- const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey);
- const hasOverflowingThreads = projectThreads.length > sidebarThreadPreviewCount;
- const previewThreads =
- isThreadListExpanded || !hasOverflowingThreads
- ? projectThreads
- : projectThreads.slice(0, sidebarThreadPreviewCount);
- const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads;
- return renderedThreads.map((thread) =>
- scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
- );
- }),
+ sortedProjects
+ .flatMap((project) => {
+ const projectThreads = sortThreads(
+ (threadsByProjectKey.get(project.projectKey) ?? []).filter(
+ (thread) => thread.archivedAt === null,
+ ),
+ sidebarThreadSortOrder,
+ );
+ const projectExpanded = resolveProjectExpanded(
+ projectExpandedById,
+ projectExpansionPreferenceKeys(project),
+ );
+ const activeThreadKey = routeThreadKey ?? undefined;
+ const pinnedCollapsedThread =
+ !projectExpanded && activeThreadKey
+ ? (projectThreads.find(
+ (thread) =>
+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) ===
+ activeThreadKey,
+ ) ?? null)
+ : null;
+ const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null;
+ if (!shouldShowThreadPanel) {
+ return [];
+ }
+ const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey);
+ const hasOverflowingThreads = projectThreads.length > sidebarThreadPreviewCount;
+ const previewThreads =
+ isThreadListExpanded || !hasOverflowingThreads
+ ? projectThreads
+ : projectThreads.slice(0, sidebarThreadPreviewCount);
+ const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads;
+ return renderedThreads.map((thread) =>
+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+ );
+ })
+ .concat(
+ visibleThreads
+ .filter((thread) => thread.projectId === null)
+ .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt))
+ .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
+ ),
[
+ visibleThreads,
sidebarThreadSortOrder,
sidebarThreadPreviewCount,
expandedThreadListsByProject,
diff --git a/apps/web/src/components/NoProjectsHero.tsx b/apps/web/src/components/NoProjectsHero.tsx
index 09bd92c82ec2..5bff0beb6bc4 100644
--- a/apps/web/src/components/NoProjectsHero.tsx
+++ b/apps/web/src/components/NoProjectsHero.tsx
@@ -19,13 +19,20 @@ export function NoProjectsHero() {
What should we work on?
- Add a project to start your first thread.
+ Add a project, or start a quick chat from New thread.
-
+
+
diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts
index 33157e7b4b4b..823a6141b93e 100644
--- a/apps/web/src/components/Sidebar.logic.test.ts
+++ b/apps/web/src/components/Sidebar.logic.test.ts
@@ -43,7 +43,6 @@ import {
sortThreadsForSidebar,
sortProjectsForSidebar,
sortScopedProjectsForSidebar,
- shouldCreateNewThreadInCurrentProject,
THREAD_JUMP_HINT_SHOW_DELAY_MS,
type SidebarListItem,
type SidebarListMarker,
@@ -540,21 +539,6 @@ describe("isSidebarNestedLinkClick", () => {
});
});
-describe("shouldCreateNewThreadInCurrentProject", () => {
- it("creates directly on shift+click in a multi-project setup", () => {
- expect(shouldCreateNewThreadInCurrentProject(true, 2)).toBe(true);
- });
-
- it("opens the picker on a plain click in a multi-project setup", () => {
- expect(shouldCreateNewThreadInCurrentProject(false, 2)).toBe(false);
- });
-
- it("creates directly on any click with a single project", () => {
- expect(shouldCreateNewThreadInCurrentProject(false, 1)).toBe(true);
- expect(shouldCreateNewThreadInCurrentProject(true, 1)).toBe(true);
- });
-});
-
describe("orderItemsByPreferredIds", () => {
it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => {
const ordered = orderItemsByPreferredIds({
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts
index 0150eb473d51..619affe5816c 100644
--- a/apps/web/src/components/Sidebar.logic.ts
+++ b/apps/web/src/components/Sidebar.logic.ts
@@ -363,7 +363,7 @@ type ScopedSidebarProject = SidebarProject & {
type ScopedSidebarThread = ThreadSortInput & {
environmentId: string;
- projectId: string;
+ projectId: string | null;
archivedAt: string | null;
};
@@ -654,17 +654,6 @@ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean {
return nodeClosest(parent, "a[href]") !== null;
}
-// Shift+click on the new thread button creates directly in the current
-// project, skipping the command palette's project picker. With a single
-// project there is nothing to pick, so a plain click already creates
-// immediately and the modifier changes nothing.
-export function shouldCreateNewThreadInCurrentProject(
- shiftKey: boolean,
- projectGroupCount: number,
-): boolean {
- return shiftKey || projectGroupCount <= 1;
-}
-
export function orderItemsByPreferredIds(input: {
items: readonly TItem[];
preferredIds: readonly TId[];
@@ -1148,6 +1137,7 @@ export function sortProjectsForSidebar<
): TProject[] {
const threadsByProjectId = new Map();
for (const thread of threads) {
+ if (thread.projectId === null) continue;
const existing = threadsByProjectId.get(thread.projectId) ?? [];
existing.push(thread);
threadsByProjectId.set(thread.projectId, existing);
@@ -1216,7 +1206,7 @@ export function sortScopedProjectsForSidebar<
`${environmentId}\u0000${projectId}`;
const threadsByProject = new Map();
for (const thread of threads) {
- if (thread.archivedAt !== null) {
+ if (thread.archivedAt !== null || thread.projectId === null) {
continue;
}
const key = scopedKey(thread.environmentId, thread.projectId);
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 78c59e296b90..8762024e92af 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -4,6 +4,7 @@ import {
resolveThreadCurrentPullRequestLink,
visibleThreadPullRequests,
} from "@t3tools/shared/threadPullRequests";
+import { useQuickChatAttachmentStore } from "../quickChatAttachmentStore";
import { useAtomValue } from "@effect/atom-react";
import * as Schema from "effect/Schema";
import {
@@ -167,7 +168,6 @@ import {
type SidebarDropVerb,
resolveSidebarThreadStatus,
searchSidebarThreads,
- shouldCreateNewThreadInCurrentProject,
shouldRecedeSidebarThread,
resolveWorkingStartedAt,
sidebarListItemId,
@@ -2515,7 +2515,8 @@ export default function Sidebar() {
const visible = threads.filter(
(thread) =>
thread.archivedAt === null &&
- (scopedProjectKeys === null ||
+ (thread.projectId === null ||
+ scopedProjectKeys === null ||
scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)),
);
const pinned: EnvironmentThreadShell[] = [];
@@ -2525,6 +2526,10 @@ export default function Sidebar() {
const draggable = new Set();
const activeReorderable = new Set();
for (const thread of visible) {
+ if (thread.projectId === null) {
+ active.push(thread);
+ continue;
+ }
const capabilities = serverConfigs.get(thread.environmentId)?.environment.capabilities;
// Threads on servers without the settlement capability (old server,
// or descriptor not loaded yet) never classify as settled: the user
@@ -2573,7 +2578,9 @@ export default function Sidebar() {
// sort, or mixed-version fleets would render different pinned orders on
// web and mobile from the same data.
const sortedPinned = sortPinnedThreadsForSidebar(pinned);
- const sortedActive = sortThreadsForSidebar(active);
+ const sortedActive = sortThreadsForSidebar(active).toSorted(
+ (left, right) => Number(left.projectId === null) - Number(right.projectId === null),
+ );
return {
pinnedThreads:
optimisticDrop?.section !== "pinned" || optimisticDrop.order === null
@@ -3010,9 +3017,11 @@ export default function Sidebar() {
const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null;
return nextThread
? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id))
- : shell
- ? () =>
- void handleNewThreadRef.current(scopeProjectRef(shell.environmentId, shell.projectId))
+ : shell && shell.projectId !== null
+ ? (() => {
+ const projectRef = scopeProjectRef(shell.environmentId, shell.projectId);
+ return () => void handleNewThreadRef.current(projectRef);
+ })()
: () => void router.navigate({ to: "/" });
},
[navigateToThread, router],
@@ -4002,6 +4011,7 @@ export default function Sidebar() {
const clicked = await settlePromise(() =>
api.contextMenu.show(
buildThreadActionMenuItems({
+ isQuickChat: thread.projectId === null,
branch: thread.branch ?? null,
isPinned,
isSettled,
@@ -4041,11 +4051,17 @@ export default function Sidebar() {
if (projectGroup) openProjectSettings(projectGroup);
return;
}
+ case "attach-to-project": {
+ useQuickChatAttachmentStore.getState().open(threadRef);
+ return;
+ }
case "new-thread-on-branch": {
+ const projectRef = scopeProjectRef(thread.environmentId, thread.projectId);
+ if (!projectRef) return;
// Explicit branch carry-over: reuse the thread's worktree when it
// has one, otherwise its branch on the local checkout.
const result = await settlePromise(() =>
- handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId), {
+ handleNewThreadRef.current(projectRef, {
branch: thread.branch,
worktreePath: thread.worktreePath,
envMode: thread.worktreePath ? "worktree" : "local",
@@ -4291,10 +4307,8 @@ export default function Sidebar() {
// for multi-project setups.
const handleNewThreadClick = useCallback(
(event?: ReactMouseEvent) => {
- // One project: 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)) {
+ // Shift-click creates directly in the current project.
+ if (event?.shiftKey === true && projectGroups.length > 0) {
if (isMobile) setOpenMobile(false);
void startNewThreadFromContext({
activeDraftThread: newThreadContext.activeDraftThread,
@@ -4310,17 +4324,8 @@ export default function Sidebar() {
[isMobile, newThreadContext, projectGroups.length, setOpenMobile],
);
- // The button mirrors chat.new: in multi-project setups both route through
- // the command palette's "New thread in..." picker, and in single-project
- // setups both create immediately. In multi-project setups the label is only
- // the picker's shortcut: falling back to chat.newLocal would advertise the
- // same shortcut for both the picker and direct create. In single-project
- // setups both commands create directly, so chat.newLocal is a valid
- // fallback. The second tooltip line (multi-project only) advertises
- // 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);
+ // New thread opens the picker; shift-click keeps the current-project shortcut.
+ const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.new");
const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal");
return (
<>
@@ -4387,7 +4392,12 @@ export default function Sidebar() {
type="button"
className="relative focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar"
onClick={handleNewThreadClick}
- disabled={projects.length === 0}
+ disabled={
+ projects.length === 0 &&
+ ![...serverConfigs.values()].some(
+ (config) => config.environment.capabilities.quickChats,
+ )
+ }
aria-label="New thread"
/>
}
@@ -4595,9 +4605,11 @@ export default function Sidebar() {
projectByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
}
projectDisplayName={
- projectDisplayNameByKey.get(
- `${thread.environmentId}:${thread.projectId}`,
- ) ?? null
+ (thread.projectId === null
+ ? "Quick chat"
+ : projectDisplayNameByKey.get(
+ `${thread.environmentId}:${thread.projectId}`,
+ )) ?? null
}
environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}
environmentMachine={
@@ -4684,14 +4696,17 @@ export default function Sidebar() {
: "settle"
}
settlementSupported={
+ thread.projectId !== null &&
serverConfigs.get(thread.environmentId)?.environment.capabilities
.threadSettlement === true
}
snoozeSupported={
+ thread.projectId !== null &&
serverConfigs.get(thread.environmentId)?.environment.capabilities
.threadSnooze === true
}
pinningSupported={
+ thread.projectId !== null &&
serverConfigs.get(thread.environmentId)?.environment.capabilities
.threadPinning === true
}
@@ -4734,9 +4749,11 @@ export default function Sidebar() {
null
}
projectDisplayName={
- projectDisplayNameByKey.get(
- `${thread.environmentId}:${thread.projectId}`,
- ) ?? null
+ (thread.projectId === null
+ ? "Quick chat"
+ : projectDisplayNameByKey.get(
+ `${thread.environmentId}:${thread.projectId}`,
+ )) ?? null
}
providerEntryByInstanceId={
providerEntriesByEnvironment.get(thread.environmentId) ??
@@ -4792,9 +4809,22 @@ export default function Sidebar() {
onNavigateToDraft={navigateToDraft}
/>,
];
+ let quickChatsRendered = false;
for (const item of sidebarListItems) {
if (item.kind === "thread") {
- items.push(renderThreadRow(threadByKey.get(item.key)!, item.section));
+ const thread = threadByKey.get(item.key)!;
+ if (thread.projectId === null && !quickChatsRendered) {
+ items.push(
+
+ Quick chats
+ ,
+ );
+ quickChatsRendered = true;
+ }
+ items.push(renderThreadRow(thread, item.section));
continue;
}
switch (item.marker) {
diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
index 54ad13980236..902cd3eaa841 100644
--- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
+++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
@@ -1033,7 +1033,7 @@ export function PullRequestDetailPanel({
* after preparing it, so there is one path from "a task" to "a thread holding it".
*/
const openThreadWithTask = async (
- projectRef: ReturnType,
+ projectRef: NonNullable>,
task: ThreadTask | null,
opened?: { draftId: DraftId },
): Promise<{ draftId: DraftId } | null> => {
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index 4c108b01d0c8..e273aa39a82c 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -1,5 +1,12 @@
+import { useEnvironments } from "../../state/environments";
import { Spinner } from "~/components/ui/spinner";
-import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-react";
+import {
+ ArchiveIcon,
+ ArchiveX,
+ ChevronRightIcon,
+ MessageSquareIcon,
+ SettingsIcon,
+} from "lucide-react";
import { Link, useNavigate } from "@tanstack/react-router";
import type { CSSProperties, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -90,7 +97,6 @@ import {
primaryServerObservabilityAtom,
primaryServerProvidersAtom,
} from "../../state/server";
-import { useProjects } from "../../state/entities";
import { usePrimaryEnvironmentId } from "../../state/environments";
import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
import { formatRelativeTimeLabel } from "../../timestampFormat";
@@ -2836,11 +2842,11 @@ export function GeneralSettingsPanel() {
}
export function ArchivedThreadsPanel() {
- const projects = useProjects();
+ const { environments } = useEnvironments();
const { unarchiveThread, confirmAndDeleteThread } = useThreadActions();
const environmentIds = useMemo(
- () => [...new Set(projects.map((project) => project.environmentId))],
- [projects],
+ () => environments.map((environment) => environment.environmentId),
+ [environments],
);
const {
snapshots: archivedSnapshots,
@@ -2887,7 +2893,30 @@ export function ArchivedThreadsPanel() {
});
}
}
- return groups;
+ const quickChatGroups = archivedSnapshots.flatMap(({ environmentId, snapshot }) => {
+ const quickChats = snapshot.threads
+ .filter((thread) => thread.projectId === null && thread.archivedAt !== null)
+ .map((thread) => ({ ...thread, environmentId }))
+ .toSorted((left, right) =>
+ (right.archivedAt ?? right.createdAt).localeCompare(left.archivedAt ?? left.createdAt),
+ );
+ return quickChats.length === 0
+ ? []
+ : [
+ {
+ project: {
+ id: null,
+ environmentId,
+ title: "Quick chats",
+ cwd: null,
+ faviconPath: null,
+ projectIcon: null,
+ },
+ threads: quickChats,
+ },
+ ];
+ });
+ return [...groups, ...quickChatGroups];
}, [archivedSnapshots]);
const handleArchivedThreadContextMenu = useCallback(
@@ -2970,10 +2999,10 @@ export function ArchivedThreadsPanel() {
) : (
archivedGroups.map(({ project, threads: projectThreads }, index) => (
}
+ icon={project.id === null ? : }
>
{projectThreads.map((thread) => (
> {
return [
+ ...(state.isQuickChat
+ ? [
+ {
+ id: "attach-to-project" as const,
+ label: "Attach to project",
+ icon: "folder",
+ disabled: state.isRunning,
+ },
+ ]
+ : []),
...(state.branch
? [
{
@@ -62,7 +74,7 @@ export function buildThreadActionMenuItems(
},
]
: []),
- ...(state.supports.pinning
+ ...(state.supports.pinning && !state.isQuickChat
? [
state.isPinned
? { id: "unpin" as const, label: "Unpin thread", icon: "pin-off" }
@@ -72,14 +84,14 @@ export function buildThreadActionMenuItems(
// Both lifecycle actions stay available on pinned threads: settling
// clears the pin ("done" beats "keep on top"), and snoozing hides the
// card until wake with the pin intact.
- ...(state.supports.settlement
+ ...(state.supports.settlement && !state.isQuickChat
? [
state.isSettled
? { id: "unsettle" as const, label: "Un-settle thread", icon: "circle-check" }
: { id: "settle" as const, label: "Settle thread", icon: "circle-check" },
]
: []),
- ...(state.supports.snooze
+ ...(state.supports.snooze && !state.isQuickChat
? [
state.isSnoozed
? { id: "unsnooze" as const, label: "Wake thread", icon: "clock" }
@@ -113,14 +125,18 @@ export function buildThreadActionMenuItems(
icon: "copy",
separatorBefore: true,
children: [
- { id: "copy-path", label: "Path", icon: "folder" },
+ ...(!state.isQuickChat
+ ? [{ id: "copy-path" as const, label: "Path", icon: "folder" }]
+ : []),
...(state.branch
? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }]
: []),
{ id: "copy-thread-id", label: "Thread ID", icon: "hash" },
],
},
- { id: "project-settings", label: "Project settings", icon: "settings" },
+ ...(!state.isQuickChat
+ ? [{ id: "project-settings" as const, label: "Project settings", icon: "settings" }]
+ : []),
// Archive removes the thread from the sidebar while keeping its
// conversation under Settings > Archived threads — distinct from Settle
// (stays visible in the Settled shelf) and Delete (clears history for
diff --git a/apps/web/src/hooks/useNewQuickChat.ts b/apps/web/src/hooks/useNewQuickChat.ts
new file mode 100644
index 000000000000..72831a57e9a3
--- /dev/null
+++ b/apps/web/src/hooks/useNewQuickChat.ts
@@ -0,0 +1,60 @@
+import { useAtomValue } from "@effect/atom-react";
+import { quickChatModelSelection } from "@t3tools/client-runtime/operations/quickChats";
+import { DEFAULT_RUNTIME_MODE, type EnvironmentId } from "@t3tools/contracts";
+import { useRouter } from "@tanstack/react-router";
+import { useCallback, useRef } from "react";
+import { newThreadId } from "../lib/utils";
+import { waitForThreadShell } from "../state/entities";
+import { environmentServerConfigsAtom } from "../state/server";
+import { threadEnvironment } from "../state/threads";
+import { useAtomCommand } from "../state/use-atom-command";
+import { toastManager } from "../components/ui/toast";
+
+export function useNewQuickChat() {
+ const configs = useAtomValue(environmentServerConfigsAtom);
+ const create = useAtomCommand(threadEnvironment.create, "Create quick chat");
+ const router = useRouter();
+ const pending = useRef(false);
+ return useCallback(
+ async (environmentId: EnvironmentId) => {
+ if (pending.current) return;
+ const config = configs.get(environmentId);
+ if (!config?.environment.capabilities.quickChats) return;
+ const modelSelection = quickChatModelSelection(config);
+ if (!modelSelection) {
+ toastManager.add({ type: "error", title: "Set up an agent before starting a quick chat" });
+ return;
+ }
+ pending.current = true;
+ const href = router.state.location.href;
+ try {
+ const threadId = newThreadId();
+ const result = await create({
+ environmentId,
+ input: {
+ threadId,
+ projectId: null,
+ title: "New quick chat",
+ modelSelection,
+ runtimeMode: DEFAULT_RUNTIME_MODE,
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ createdAt: new Date().toISOString(),
+ },
+ });
+ if (result._tag !== "Success") return;
+ await waitForThreadShell({ environmentId, threadId });
+ if (router.state.location.href === href) {
+ await router.navigate({
+ to: "/$environmentId/$threadId",
+ params: { environmentId, threadId },
+ });
+ }
+ } finally {
+ pending.current = false;
+ }
+ },
+ [configs],
+ );
+}
diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts
index a66ea21b9891..8632651c3b3e 100644
--- a/apps/web/src/hooks/useThreadActionMenu.ts
+++ b/apps/web/src/hooks/useThreadActionMenu.ts
@@ -1,3 +1,4 @@
+import { useQuickChatAttachmentStore } from "../quickChatAttachmentStore";
import { scopeProjectRef, scopedThreadKey } from "@t3tools/client-runtime/environment";
import {
type AtomCommandResult,
@@ -138,6 +139,7 @@ export function useThreadActionMenu(input: {
const isRegeneratingTitle = thread.titleRegeneration != null;
const snoozePresets = resolveSnoozePresets(now, timestampFormat);
const items = buildThreadActionMenuItems({
+ isQuickChat: thread.projectId === null,
branch: thread.branch ?? null,
isPinned: thread.pinnedAt != null,
isSettled: supports.settlement && thread.settledOverride === "settled",
@@ -206,11 +208,17 @@ export function useThreadActionMenu(input: {
});
return;
}
+ case "attach-to-project": {
+ useQuickChatAttachmentStore.getState().open(threadRef);
+ return;
+ }
case "new-thread-on-branch": {
+ const projectRef = scopeProjectRef(threadRef.environmentId, thread.projectId);
+ if (!projectRef) return;
// Explicit branch carry-over: reuse the thread's worktree when it
// has one, otherwise its branch on the local checkout.
const result = await settlePromise(() =>
- handleNewThread(scopeProjectRef(threadRef.environmentId, thread.projectId), {
+ handleNewThread(projectRef, {
branch: thread.branch,
worktreePath: thread.worktreePath,
envMode: thread.worktreePath ? "worktree" : "local",
diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts
index 1d3aa4c3abba..f9afcde025ce 100644
--- a/apps/web/src/hooks/useThreadActions.ts
+++ b/apps/web/src/hooks/useThreadActions.ts
@@ -282,9 +282,13 @@ export function useThreadActions() {
opts.onArchived?.();
if (shouldNavigateToDraft) {
- const navigationResult = await settlePromise(() =>
- handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)),
- );
+ const navigationResult = await settlePromise(async () => {
+ if (thread.projectId === null) await router.navigate({ to: "/" });
+ else
+ await handleNewThreadRef.current(
+ scopeProjectRef(thread.environmentId, thread.projectId),
+ );
+ });
if (navigationResult._tag === "Failure") {
return navigationResult;
}
@@ -293,7 +297,13 @@ export function useThreadActions() {
return archiveResult;
},
- [archiveThreadMutation, getCurrentRouteThreadRef, markThreadVisited, resolveThreadTarget],
+ [
+ archiveThreadMutation,
+ getCurrentRouteThreadRef,
+ markThreadVisited,
+ resolveThreadTarget,
+ router,
+ ],
);
const unarchiveThread = useCallback(
@@ -329,10 +339,8 @@ export function useThreadActions() {
const shell = readThreadShell(ref);
return shell === null ? [] : [shell];
});
- const threadProject = readProject({
- environmentId: threadRef.environmentId,
- projectId: thread.projectId,
- });
+ const projectRef = scopeProjectRef(threadRef.environmentId, thread.projectId);
+ const threadProject = projectRef ? readProject(projectRef) : null;
const deletedIds =
opts.deletedThreadKeys && opts.deletedThreadKeys.size > 0
? new Set(
@@ -407,10 +415,7 @@ export function useThreadActions() {
refreshArchivedThreadsForEnvironment(threadRef.environmentId);
releaseComposerDraftUploads(threadRef);
clearComposerDraftForThread(threadRef);
- clearProjectDraftThreadById(
- scopeProjectRef(threadRef.environmentId, thread.projectId),
- threadRef,
- );
+ if (projectRef) clearProjectDraftThreadById(projectRef, threadRef);
clearTerminalUiState(threadRef);
if (shouldNavigateToFallback) {
diff --git a/apps/web/src/lib/archivedThreadsState.ts b/apps/web/src/lib/archivedThreadsState.ts
index 2d52383c02c9..fc0e9feef602 100644
--- a/apps/web/src/lib/archivedThreadsState.ts
+++ b/apps/web/src/lib/archivedThreadsState.ts
@@ -13,7 +13,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry";
function archivedSnapshotAtom(environmentId: EnvironmentId) {
return orchestrationEnvironment.archivedShellSnapshot({
environmentId,
- input: {},
+ input: { includeQuickChats: true },
});
}
diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts
index c14a26d03d1c..f9a28d2dcd1f 100644
--- a/apps/web/src/lib/chatThreadActions.ts
+++ b/apps/web/src/lib/chatThreadActions.ts
@@ -14,7 +14,7 @@ type ComposerModelSelectionState = Pick<
interface ThreadContextLike {
environmentId: EnvironmentId;
- projectId: ProjectId;
+ projectId: ProjectId | null;
}
interface NewThreadHandler {
@@ -71,10 +71,10 @@ export function hasExplicitComposerModelSelection(
export function resolveThreadActionProjectRef(
context: ChatThreadActionContext,
): ScopedProjectRef | null {
- if (context.activeThread) {
+ if (context.activeThread?.projectId != null) {
return scopeProjectRef(context.activeThread.environmentId, context.activeThread.projectId);
}
- if (context.activeDraftThread) {
+ if (context.activeDraftThread?.projectId != null) {
return scopeProjectRef(
context.activeDraftThread.environmentId,
context.activeDraftThread.projectId,
diff --git a/apps/web/src/onboarding/firstRun.logic.ts b/apps/web/src/onboarding/firstRun.logic.ts
index 013dbb02d527..dc8963e878a9 100644
--- a/apps/web/src/onboarding/firstRun.logic.ts
+++ b/apps/web/src/onboarding/firstRun.logic.ts
@@ -25,7 +25,7 @@ interface FirstRunWorkspaceInput {
}>;
readonly threads: ReadonlyArray<{
readonly id: string;
- readonly projectId: string;
+ readonly projectId: string | null;
readonly environmentId: string;
readonly latestTurn: unknown;
readonly latestUserMessageAt: string | null;
diff --git a/apps/web/src/quickChatAttachmentStore.ts b/apps/web/src/quickChatAttachmentStore.ts
new file mode 100644
index 000000000000..f95cf55bd103
--- /dev/null
+++ b/apps/web/src/quickChatAttachmentStore.ts
@@ -0,0 +1,18 @@
+import type { ScopedThreadRef } from "@t3tools/contracts";
+import { create } from "zustand";
+
+export const useQuickChatAttachmentStore = create<{
+ threadRef: ScopedThreadRef | null;
+ busy: boolean;
+ open: (threadRef: ScopedThreadRef) => void;
+ close: () => void;
+}>((set, get) => ({
+ threadRef: null,
+ busy: false,
+ open: (threadRef) => {
+ if (!get().busy) set({ threadRef });
+ },
+ close: () => {
+ if (!get().busy) set({ threadRef: null });
+ },
+}));
diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx
index e084e22c2cbb..099d39d7d4c6 100644
--- a/apps/web/src/routes/_chat.tsx
+++ b/apps/web/src/routes/_chat.tsx
@@ -1,14 +1,10 @@
+import { AttachQuickChatDialog } from "../components/AttachQuickChatDialog";
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
import { useAtomValue } from "@effect/atom-react";
-import { useEffect, useMemo } from "react";
+import { useEffect } from "react";
import { isCommandPaletteOpen } from "../commandPaletteBus";
-import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings";
import { openCommandPalette } from "../commandPaletteBus";
-import { useProjects } from "../state/entities";
-import { usePrimaryEnvironmentId } from "../state/environments";
-import { selectProjectGroupingSettings } from "../logicalProject";
-import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping";
import { dispatchPreviewAction } from "../components/preview/previewActionBus";
import { useHandleNewThread } from "../hooks/useHandleNewThread";
import { startNewThreadFromContext } from "../lib/chatThreadActions";
@@ -28,20 +24,6 @@ function ChatRouteGlobalShortcuts() {
const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } =
useHandleNewThread();
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
- const legacySidebarEnabled = useLegacySidebarEnabled();
- const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
- const projects = useProjects();
- const primaryEnvironmentId = usePrimaryEnvironmentId();
- const projectGroupCount = useMemo(
- () =>
- buildSidebarProjectSnapshots({
- projects,
- settings: projectGroupingSettings,
- primaryEnvironmentId,
- resolveEnvironmentLabel: () => null,
- }).length,
- [primaryEnvironmentId, projectGroupingSettings, projects],
- );
const terminalOpen = useTerminalUiStateStore((state) =>
routeThreadRef
? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen
@@ -92,19 +74,7 @@ function ChatRouteGlobalShortcuts() {
if (command === "chat.new") {
event.preventDefault();
event.stopPropagation();
- // The default sidebar routes creation through the command palette
- // whenever there is a real choice to make; the legacy sidebar (and
- // single-project setups) keep the immediate contextual create.
- if (!legacySidebarEnabled && projectGroupCount > 1) {
- openCommandPalette({ open: "new-thread-in" });
- return;
- }
- void startNewThreadFromContext({
- activeDraftThread,
- activeThread: activeThread ?? undefined,
- defaultProjectRef,
- handleNewThread,
- });
+ openCommandPalette({ open: "new-thread-in" });
return;
}
@@ -164,10 +134,8 @@ function ChatRouteGlobalShortcuts() {
keybindings,
defaultProjectRef,
previewOpen,
- projectGroupCount,
routeThreadRef,
selectedThreadKeysSize,
- legacySidebarEnabled,
terminalOpen,
]);
@@ -178,6 +146,7 @@ function ChatRouteLayout() {
return (
<>
+
>
);
diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts
index d9610e20717f..50ad57b465a7 100644
--- a/apps/web/src/state/entities.ts
+++ b/apps/web/src/state/entities.ts
@@ -183,6 +183,30 @@ export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell |
return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref));
}
+/** Wait for creation to reach the shell before navigating to a persisted thread. */
+export function waitForThreadShell(ref: ScopedThreadRef): Promise {
+ const atom = environmentThreadShells.threadShellAtom(ref);
+ const current = appAtomRegistry.get(atom);
+ if (current !== null) return Promise.resolve(current);
+ return new Promise((resolve, reject) => {
+ let unsubscribe: (() => void) | null = null;
+ const timeout = setTimeout(() => {
+ unsubscribe?.();
+ reject(
+ new Error("The new chat has not synced yet. Open it from the sidebar once reconnected."),
+ );
+ }, 10_000);
+ const finish = (thread: EnvironmentThreadShell | null) => {
+ if (thread === null) return;
+ clearTimeout(timeout);
+ unsubscribe?.();
+ resolve(thread);
+ };
+ unsubscribe = appAtomRegistry.subscribe(atom, finish);
+ finish(appAtomRegistry.get(atom));
+ });
+}
+
/** Whether the environment's server understands thread.settle/unsettle.
False for pre-settlement servers (capability defaults false on decode),
so clients under version skew fall back instead of erroring. */
diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts
index 1792c5e9e599..c3a7e564611e 100644
--- a/apps/web/src/state/queries.ts
+++ b/apps/web/src/state/queries.ts
@@ -48,7 +48,7 @@ const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({
getSearchAtom: (environmentId, query) =>
orchestrationEnvironment.threadSearch({
environmentId,
- input: { query },
+ input: { query, includeQuickChats: true },
}),
labelPrefix: "web:thread-search",
});
diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md
index 360057dd56a1..78cf896c01dd 100644
--- a/docs/user/thread-sidebar.md
+++ b/docs/user/thread-sidebar.md
@@ -5,7 +5,7 @@ need a separate branch and working directory.
## Start a thread
-On web and desktop, a new thread keeps the current project and carries your model
+Choose **New thread**, then select a project. The new thread carries 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.
@@ -13,6 +13,20 @@ an existing worktree, use **New thread in this worktree** from the branch toolba
When you change a new thread's project, T3 Code stays in the current environment
if that project exists there. Otherwise it selects an environment that has it.
+### Quick chats
+
+Choose **New thread → Quick chat** to ask an agent a question without adding or
+selecting a project. Quick chats appear below active project threads and keep
+their conversation history. You can rename, archive, restore, and delete them.
+
+To turn a quick chat into project work, finish the current turn and choose
+**Attach to project** from its menu on web or desktop, or above the conversation
+on mobile. Select a project in the same environment. Enable **Create a new
+worktree** and choose its base branch if you want a separate workspace. The chat
+keeps its history and subsequent turns use the selected workspace.
+
+Quick chats use the agent's normal permission settings.
+
### Start in the background
In a desktop browser or the desktop app, press `Cmd+Enter` on macOS or `Ctrl+Enter`
diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json
index 2bacdfcb228a..9cae42374e20 100644
--- a/packages/client-runtime/package.json
+++ b/packages/client-runtime/package.json
@@ -3,6 +3,10 @@
"private": true,
"type": "module",
"exports": {
+ "./operations/quickChats": {
+ "types": "./src/operations/quickChats.ts",
+ "default": "./src/operations/quickChats.ts"
+ },
"./load-balancing": {
"types": "./src/load-balancing.ts",
"default": "./src/load-balancing.ts"
diff --git a/packages/client-runtime/src/environment/scoped.ts b/packages/client-runtime/src/environment/scoped.ts
index 7894c7ba5329..965113d4f66d 100644
--- a/packages/client-runtime/src/environment/scoped.ts
+++ b/packages/client-runtime/src/environment/scoped.ts
@@ -11,8 +11,16 @@ import {
export function scopeProjectRef(
environmentId: EnvironmentIdType,
projectId: ProjectIdType,
-): ScopedProjectRef {
- return { environmentId, projectId };
+): ScopedProjectRef;
+export function scopeProjectRef(
+ environmentId: EnvironmentIdType,
+ projectId: ProjectIdType | null,
+): ScopedProjectRef | null;
+export function scopeProjectRef(
+ environmentId: EnvironmentIdType,
+ projectId: ProjectIdType | null,
+): ScopedProjectRef | null {
+ return projectId === null ? null : { environmentId, projectId };
}
export function scopeThreadRef(
diff --git a/packages/client-runtime/src/operations/quickChats.ts b/packages/client-runtime/src/operations/quickChats.ts
new file mode 100644
index 000000000000..fb0efcbeb068
--- /dev/null
+++ b/packages/client-runtime/src/operations/quickChats.ts
@@ -0,0 +1,23 @@
+import type { ModelSelection, ServerConfig } from "@t3tools/contracts";
+
+/** Select an available environment default for a chat without project defaults. */
+export function quickChatModelSelection(config: ServerConfig): ModelSelection | null {
+ const providers = config.providers.filter(
+ (provider) =>
+ provider.enabled &&
+ provider.installed &&
+ provider.availability !== "unavailable" &&
+ provider.auth.status !== "unauthenticated",
+ );
+ const preferred = config.settings.defaultModelSelection;
+ if (preferred && providers.some((provider) => provider.instanceId === preferred.instanceId)) {
+ return preferred;
+ }
+ for (const provider of providers) {
+ const model =
+ provider.models.find((model) => model.isDefault && !model.isLegacy) ??
+ provider.models.find((model) => !model.isLegacy);
+ if (model) return { instanceId: provider.instanceId, model: model.slug };
+ }
+ return null;
+}
diff --git a/packages/client-runtime/src/state/environmentHttpAuth.test.ts b/packages/client-runtime/src/state/environmentHttpAuth.test.ts
index 2d4152356ad5..86b7e370995e 100644
--- a/packages/client-runtime/src/state/environmentHttpAuth.test.ts
+++ b/packages/client-runtime/src/state/environmentHttpAuth.test.ts
@@ -238,6 +238,9 @@ describe("authenticated environment HTTP requests", () => {
accessToken: "current-token",
},
]);
+ if (loader.name === "shell snapshot") {
+ expect(url.searchParams.get("includeQuickChats")).toBe("true");
+ }
if (loader.name === "older thread history") {
expect(url.searchParams.get("turnLimit")).toBe("20");
expect(url.searchParams.get("beforeCursor")).toBe("older-page");
diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts
index 95d90f9b36f2..8b5855bc8f48 100644
--- a/packages/client-runtime/src/state/shell.ts
+++ b/packages/client-runtime/src/state/shell.ts
@@ -236,7 +236,10 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
// If the authoritative refresh failed, omit the cached cursor so the
// socket fallback sends a complete snapshot for this new session.
if (!canResume || Option.isNone(current.snapshot)) {
- return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {};
+ return {
+ includeQuickChats: true,
+ ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}),
+ };
}
if (!supportsCompletionMarker) {
// Without a completion marker there is no synchronized signal for a
@@ -249,6 +252,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
}
return {
afterSequence: current.snapshot.value.snapshotSequence,
+ includeQuickChats: true,
...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}),
};
}),
diff --git a/packages/client-runtime/src/state/shellSnapshotHttp.ts b/packages/client-runtime/src/state/shellSnapshotHttp.ts
index aa1ad9081e05..12e34675a73f 100644
--- a/packages/client-runtime/src/state/shellSnapshotHttp.ts
+++ b/packages/client-runtime/src/state/shellSnapshotHttp.ts
@@ -35,7 +35,8 @@ export const fetchEnvironmentShellSnapshot = Effect.fn(
method: "GET",
url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/orchestration/shell"),
timeoutMs: input.timeoutMs ?? DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS,
- request: ({ client, headers }) => client.orchestration.shellSnapshot({ headers }),
+ request: ({ client, headers }) =>
+ client.orchestration.shellSnapshot({ headers, payload: { includeQuickChats: "true" } }),
});
});
diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts
index ce9aa94b1f06..a0f5b3993648 100644
--- a/packages/client-runtime/src/state/threadReducer.ts
+++ b/packages/client-runtime/src/state/threadReducer.ts
@@ -255,6 +255,7 @@ export function applyThreadDetailEvent(
kind: "updated",
thread: {
...thread,
+ ...(event.payload.projectId !== undefined ? { projectId: event.payload.projectId } : {}),
...(event.payload.title !== undefined ? { title: event.payload.title } : {}),
...(event.payload.titleRegeneration !== undefined
? { titleRegeneration: event.payload.titleRegeneration }
diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts
index 03f92a612e4a..dc7a8c41dc25 100644
--- a/packages/client-runtime/src/state/threadShell.ts
+++ b/packages/client-runtime/src/state/threadShell.ts
@@ -95,6 +95,7 @@ export function createEnvironmentThreadShellAtoms(input: {
return Atom.make((get) => {
const grouped = new Map();
for (const thread of get(environmentThreadsAtom(environmentId))) {
+ if (thread.projectId === null) continue;
const refs = grouped.get(thread.projectId);
const ref = { environmentId, threadId: thread.id };
if (refs === undefined) {
diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts
index 3a4a9d284a18..b81e1c68f316 100644
--- a/packages/client-runtime/src/state/threadSort.ts
+++ b/packages/client-runtime/src/state/threadSort.ts
@@ -139,7 +139,7 @@ export function sortThreads
export function getLatestThreadForProject<
T extends {
readonly id: string;
- readonly projectId: ProjectId;
+ readonly projectId: ProjectId | null;
readonly archivedAt: string | null;
} & ThreadSortInput,
>(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null {
diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts
index 9dcc844e713a..06d0860cc917 100644
--- a/packages/contracts/src/environment.ts
+++ b/packages/contracts/src/environment.ts
@@ -120,6 +120,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
threadPinReorder: Schema.optionalKey(Schema.Boolean),
/** Server persists manual Active order through thread.active.reorder. */
threadActiveReorder: Schema.optionalKey(Schema.Boolean),
+ /** Project-free conversations and attaching them to a project. */
+ quickChats: Schema.optionalKey(Schema.Boolean),
/** Server understands regenerateTitle on thread.meta.update. Absent on
older servers, so clients hide the action instead of sending it. */
threadTitleRegeneration: Schema.optionalKey(Schema.Boolean),
diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts
index 9cdd21393fea..f7a65e441947 100644
--- a/packages/contracts/src/environmentHttp.ts
+++ b/packages/contracts/src/environmentHttp.ts
@@ -515,6 +515,7 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr
.add(
HttpApiEndpoint.get("shellSnapshot", "/api/orchestration/shell", {
headers: OptionalBearerHeaders,
+ payload: { includeQuickChats: Schema.optional(Schema.Literal("true")) },
success: OrchestrationShellSnapshot,
error: EnvironmentOrchestrationSnapshotErrors,
}).middleware(EnvironmentAuthenticatedAuth),
diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts
index 56a29b8f65f0..4617f26f4c48 100644
--- a/packages/contracts/src/orchestration.ts
+++ b/packages/contracts/src/orchestration.ts
@@ -695,7 +695,7 @@ export type ThreadPullRequestLink = typeof ThreadPullRequestLink.Type;
export const OrchestrationThread = Schema.Struct({
id: ThreadId,
- projectId: ProjectId,
+ projectId: Schema.NullOr(ProjectId),
title: TrimmedNonEmptyString,
modelSelection: ModelSelection,
runtimeMode: RuntimeMode,
@@ -781,7 +781,7 @@ export type OrchestrationProjectShell = typeof OrchestrationProjectShell.Type;
export const OrchestrationThreadShell = Schema.Struct({
id: ThreadId,
- projectId: ProjectId,
+ projectId: Schema.NullOr(ProjectId),
title: TrimmedNonEmptyString,
modelSelection: ModelSelection,
runtimeMode: RuntimeMode,
@@ -884,6 +884,8 @@ export const OrchestrationShellStreamItem = Schema.Union([
export type OrchestrationShellStreamItem = typeof OrchestrationShellStreamItem.Type;
export const OrchestrationSubscribeShellInput = Schema.Struct({
+ /** Opt in to nullable project IDs; older clients receive project threads only. */
+ includeQuickChats: Schema.optionalKey(Schema.Boolean),
/**
* When provided, the server skips the initial full shell snapshot and instead
* replays shell events after this sequence before streaming live events.
@@ -1014,7 +1016,7 @@ const ThreadCreateCommand = Schema.Struct({
type: Schema.Literal("thread.create"),
commandId: CommandId,
threadId: ThreadId,
- projectId: ProjectId,
+ projectId: Schema.NullOr(ProjectId),
title: TrimmedNonEmptyString,
modelSelection: ModelSelection,
runtimeMode: RuntimeMode,
@@ -1128,6 +1130,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({
type: Schema.Literal("thread.meta.update"),
commandId: CommandId,
threadId: ThreadId,
+ projectId: Schema.optional(ProjectId),
title: Schema.optional(TrimmedNonEmptyString),
regenerateTitle: Schema.optional(Schema.Literal(true)),
modelSelection: Schema.optional(ModelSelection),
@@ -1176,7 +1179,7 @@ const ThreadInteractionModeSetCommand = Schema.Struct({
});
const ThreadTurnStartBootstrapCreateThread = Schema.Struct({
- projectId: ProjectId,
+ projectId: Schema.NullOr(ProjectId),
title: TrimmedNonEmptyString,
modelSelection: ModelSelection,
runtimeMode: RuntimeMode,
@@ -1575,7 +1578,7 @@ export const ProjectDeletedPayload = Schema.Struct({
export const ThreadCreatedPayload = Schema.Struct({
threadId: ThreadId,
- projectId: ProjectId,
+ projectId: Schema.NullOr(ProjectId),
title: TrimmedNonEmptyString,
modelSelection: ModelSelection,
runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE))),
@@ -1655,6 +1658,7 @@ export const ThreadPinReorderedPayload = Schema.Struct({
export const ThreadMetaUpdatedPayload = Schema.Struct({
threadId: ThreadId,
+ projectId: Schema.optional(ProjectId),
// Order updates use this existing event so older clients can ignore the
// new field while continuing to decode the event stream.
activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
@@ -2107,6 +2111,7 @@ export type OrchestrationThreadSearchSource = typeof OrchestrationThreadSearchSo
// The server's SQLite client is synchronous and single-connection. Bound both
// scan input and response size so a search cannot monopolize that connection.
export const OrchestrationSearchThreadsInput = Schema.Struct({
+ includeQuickChats: Schema.optionalKey(Schema.Boolean),
query: TrimmedString.check(Schema.isMinLength(2), Schema.isMaxLength(200)),
limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 50 }))),
});
@@ -2114,7 +2119,7 @@ export type OrchestrationSearchThreadsInput = typeof OrchestrationSearchThreadsI
export const OrchestrationThreadSearchMatch = Schema.Struct({
threadId: ThreadId,
- projectId: ProjectId,
+ projectId: Schema.NullOr(ProjectId),
source: OrchestrationThreadSearchSource,
snippet: Schema.String.check(Schema.isMaxLength(240)),
messageCreatedAt: Schema.NullOr(IsoDateTime),
@@ -2196,7 +2201,7 @@ export const OrchestrationRpcSchemas = {
output: OrchestrationSearchThreadsResult,
},
getArchivedShellSnapshot: {
- input: Schema.Struct({}),
+ input: Schema.Struct({ includeQuickChats: Schema.optionalKey(Schema.Boolean) }),
output: OrchestrationShellSnapshot,
},
subscribeThread: {
From b71a2498c987cd2a6b5a891f5c203f6a3c3e7efa Mon Sep 17 00:00:00 2001
From: Wout Stiens <71498452+StiensWout@users.noreply.github.com>
Date: Tue, 8 Sep 2026 11:22:30 +0200
Subject: [PATCH 02/15] feat(web): add quick chat shortcut to thread picker
---
.../src/components/CommandPalette.logic.ts | 2 ++
apps/web/src/components/CommandPalette.tsx | 23 ++++++++++++++++++-
.../src/components/CommandPaletteResults.tsx | 6 +++--
docs/user/thread-sidebar.md | 2 ++
4 files changed, 30 insertions(+), 3 deletions(-)
diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts
index 9107c7665f69..ddfbbf48cb53 100644
--- a/apps/web/src/components/CommandPalette.logic.ts
+++ b/apps/web/src/components/CommandPalette.logic.ts
@@ -138,6 +138,8 @@ export interface CommandPaletteItem {
/** Optional content rendered inline after the title text (before the timestamp). */
readonly titleTrailingContent?: ReactNode;
readonly shortcutCommand?: KeybindingCommand;
+ /** Primary-modifier shortcut available only while this item is visible in the palette. */
+ readonly shortcutKey?: string;
}
export interface CommandPaletteActionItem extends CommandPaletteItem {
diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx
index 708173d20001..ea8696b38ae9 100644
--- a/apps/web/src/components/CommandPalette.tsx
+++ b/apps/web/src/components/CommandPalette.tsx
@@ -1099,10 +1099,11 @@ function OpenCommandPaletteDialog(props: {
const preferredId =
activeThread?.environmentId ?? activeDraftThread?.environmentId ?? primaryEnvironmentId;
const preferred = eligible.find((environment) => environment.environmentId === preferredId);
- return (preferred ? [preferred] : eligible).map((environment) => ({
+ return (preferred ? [preferred] : eligible).map((environment, index) => ({
kind: "action",
value: `new-quick-chat:${environment.environmentId}`,
title: "New quick chat",
+ ...(index === 0 ? { shortcutKey: "0" } : {}),
description: preferred || eligible.length === 1 ? "No project needed" : environment.label,
searchTerms: ["quick chat", "question", "no project"],
icon: ,
@@ -2355,6 +2356,26 @@ function OpenCommandPaletteDialog(props: {
}
function handleKeyDown(event: KeyboardEvent): void {
+ if (
+ isPrimaryModifierPressed(event) &&
+ !event.altKey &&
+ !event.shiftKey &&
+ !event.nativeEvent.isComposing
+ ) {
+ const matchingItem = displayedGroups
+ .flatMap((group) => group.items)
+ .find(
+ (item) =>
+ item.shortcutKey !== undefined &&
+ (event.key === item.shortcutKey || event.code === `Digit${item.shortcutKey}`),
+ );
+ if (matchingItem) {
+ event.preventDefault();
+ event.stopPropagation();
+ if (!event.repeat) executeItem(matchingItem);
+ return;
+ }
+ }
const command = resolveShortcutCommand(event, keybindings, {
platform: navigator.platform,
context: { modelPickerOpen: false },
diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx
index bbdbc28b0609..06fbaf401bba 100644
--- a/apps/web/src/components/CommandPaletteResults.tsx
+++ b/apps/web/src/components/CommandPaletteResults.tsx
@@ -14,7 +14,7 @@ import {
CommandList,
CommandShortcut,
} from "./ui/command";
-import { cn } from "~/lib/utils";
+import { cn, isMacPlatform } from "~/lib/utils";
function foldAsciiCase(value: string): string {
return value.replace(/[A-Z]/g, (character) => character.toLowerCase());
@@ -166,7 +166,9 @@ function CommandPaletteResultRow(props: {
}) {
const shortcutLabel = props.item.shortcutCommand
? shortcutLabelForCommand(props.keybindings, props.item.shortcutCommand)
- : null;
+ : props.item.shortcutKey
+ ? `${isMacPlatform(navigator.platform) ? "⌘" : "Ctrl+"}${props.item.shortcutKey}`
+ : null;
return (
Date: Tue, 8 Sep 2026 11:51:22 +0200
Subject: [PATCH 03/15] fix: recover quick chat attachments and reuse workspace
controls
---
.../threads/NewTaskContextPickerScreens.tsx | 2 +-
.../threads/QuickChatProjectAttachment.tsx | 218 ++++++++++++++----
.../features/threads/ThreadRouteScreen.tsx | 3 +-
.../state/quick-chat-attachment-storage.ts | 21 ++
.../Layers/ProviderCommandReactor.ts | 7 +-
.../orchestration/decider.quick-chats.test.ts | 33 +++
apps/server/src/orchestration/decider.ts | 8 +-
.../src/components/AttachQuickChatDialog.tsx | 203 ++++++++++++----
.../BranchToolbarBranchSelector.tsx | 96 ++++++--
apps/web/src/components/ChatView.tsx | 14 +-
apps/web/src/components/CommandPalette.tsx | 16 +-
.../src/components/LegacyQuickChatList.tsx | 12 +-
apps/web/src/components/chat/ChatComposer.tsx | 15 +-
.../chat/ComposerPrimaryActions.tsx | 57 +++--
apps/web/src/quickChatAttachmentStorage.ts | 7 +
docs/user/thread-sidebar.md | 11 +-
.../src/operations/quickChats.test.ts | 95 ++++++++
.../src/operations/quickChats.ts | 70 +++++-
packages/client-runtime/src/state/vcs.ts | 11 +-
19 files changed, 723 insertions(+), 176 deletions(-)
create mode 100644 apps/mobile/src/state/quick-chat-attachment-storage.ts
create mode 100644 apps/web/src/quickChatAttachmentStorage.ts
create mode 100644 packages/client-runtime/src/operations/quickChats.test.ts
diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx
index ef066feca5b5..0857944cb37b 100644
--- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx
+++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx
@@ -111,7 +111,7 @@ function ToggleRow(props: {
);
}
-function BranchSelectionRow(props: {
+export function BranchSelectionRow(props: {
readonly badge: string | null;
readonly branch: VcsRef;
readonly disabled: boolean;
diff --git a/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx b/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx
index 36332db6ae62..681cf61d1a6b 100644
--- a/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx
+++ b/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx
@@ -1,28 +1,58 @@
import { useRef, useState } from "react";
-import { Alert, Modal, Pressable, ScrollView, Switch, Text, TextInput, View } from "react-native";
-import type { ScopedThreadRef, VcsCreateWorktreeResult } from "@t3tools/contracts";
+import { Alert, Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
+import type { ScopedThreadRef, VcsRef } from "@t3tools/contracts";
import { useProjects, useThreadShell } from "../../state/entities";
import { useAtomCommand } from "../../state/use-atom-command";
import { threadEnvironment } from "../../state/threads";
import { vcsEnvironment } from "../../state/vcs";
+import {
+ prepareQuickChatWorktree,
+ type PendingQuickChatAttachment,
+} from "@t3tools/client-runtime/operations/quickChats";
+import { quickChatAttachmentStorage } from "../../state/quick-chat-attachment-storage";
+import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
+import { usePaginatedBranches } from "../../state/queries";
+import { BranchSelectionRow } from "./NewTaskContextPickerScreens";
import { uuidv4 } from "../../lib/uuid";
export function QuickChatProjectAttachment({ threadRef }: { threadRef: ScopedThreadRef }) {
const [open, setOpen] = useState(false);
- const [projectId, setProjectId] = useState("");
- const [newWorktree, setNewWorktree] = useState(false);
- const [baseBranch, setBaseBranch] = useState("HEAD");
+ const [saved] = useState(() => {
+ try {
+ return { pending: quickChatAttachmentStorage.load(threadRef), error: null };
+ } catch {
+ return {
+ pending: null,
+ error: "Could not load the pending attachment. Check device storage before retrying.",
+ };
+ }
+ });
+ const [projectId, setProjectId] = useState(saved.pending?.projectId ?? "");
+ const [workspaceMode, setWorkspaceMode] = useState<"local" | "existing" | "new">(
+ saved.pending ? "new" : "local",
+ );
+ const newWorktree = workspaceMode === "new";
+ const [existingRef, setExistingRef] = useState(null);
+ const [branchQuery, setBranchQuery] = useState("");
+ const [baseBranch, setBaseBranch] = useState(saved.pending?.baseBranch ?? "");
const [busy, setBusy] = useState(false);
const pending = useRef(false);
- const [prepared, setPrepared] = useState(null);
+ const [prepared, setPrepared] = useState(saved.pending);
const projects = useProjects().filter(
(project) => project.environmentId === threadRef.environmentId,
);
const thread = useThreadShell(threadRef);
const createWorktree = useAtomCommand(vcsEnvironment.createWorktree, "Create worktree");
const update = useAtomCommand(threadEnvironment.updateMetadata, "Attach quick chat");
+ const listRefs = useAtomQueryRunner(vcsEnvironment.readRefs, { refresh: true });
const project = projectId ? projects.find((project) => project.id === projectId) : projects[0];
+ const branchState = usePaginatedBranches({
+ environmentId: threadRef.environmentId,
+ cwd: open && workspaceMode !== "local" ? (project?.workspaceRoot ?? null) : null,
+ query: branchQuery,
+ });
const unavailable =
+ saved.error !== null ||
!thread ||
thread.projectId !== null ||
thread.archivedAt !== null ||
@@ -37,23 +67,63 @@ export function QuickChatProjectAttachment({ threadRef }: { threadRef: ScopedThr
pending.current = true;
setBusy(true);
try {
- let worktree = prepared;
- if (newWorktree && !worktree) {
- const result = await createWorktree({
+ let worktree = null;
+ if (newWorktree) {
+ const attachment = prepared ?? {
+ projectId: project.id,
+ workspaceRoot: project.workspaceRoot,
+ baseBranch: baseBranch.trim(),
+ branch: `t3/quick-chat-${uuidv4().slice(0, 8)}`,
+ };
+ await quickChatAttachmentStorage.save(threadRef, attachment);
+ setPrepared(attachment);
+ worktree = await prepareQuickChatWorktree({
+ pending: attachment,
+ listRefs: async () => {
+ const result = await listRefs({
+ environmentId: threadRef.environmentId,
+ input: {
+ cwd: attachment.workspaceRoot,
+ query: attachment.branch,
+ refKind: "local",
+ refresh: true,
+ },
+ });
+ if (result._tag === "Failure")
+ throw new Error(
+ "Could not check the prepared worktree. Check the connection and retry.",
+ );
+ return result.value;
+ },
+ createWorktree: async (input) => {
+ const result = await createWorktree({ environmentId: threadRef.environmentId, input });
+ if (result._tag === "Failure")
+ throw new Error(
+ "Could not confirm worktree creation. Retry to recover the same branch.",
+ );
+ return result.value;
+ },
+ });
+ }
+ if (workspaceMode === "existing") {
+ if (!existingRef) return;
+ const result = await listRefs({
environmentId: threadRef.environmentId,
input: {
cwd: project.workspaceRoot,
- refName: baseBranch.trim(),
- newRefName: `t3/quick-chat-${uuidv4().slice(0, 8)}`,
- path: null,
+ query: existingRef.name,
+ refKind: "local",
+ refresh: true,
},
});
- if (result._tag === "Failure") {
- Alert.alert("Could not create worktree", "The chat is still unattached.");
- return;
- }
- worktree = result.value.worktree;
- setPrepared(worktree);
+ if (result._tag === "Failure")
+ throw new Error("Could not check the selected worktree. Retry when connected.");
+ const ref = result.value.refs.find(
+ (candidate) => candidate.name === existingRef.name && !candidate.isRemote,
+ );
+ if (!ref?.worktreePath || ref.worktreePath === project.workspaceRoot)
+ throw new Error("This worktree is no longer available. Select another worktree.");
+ worktree = { refName: ref.name, path: ref.worktreePath };
}
const result = await update({
environmentId: threadRef.environmentId,
@@ -73,7 +143,13 @@ export function QuickChatProjectAttachment({ threadRef }: { threadRef: ScopedThr
);
return;
}
+ quickChatAttachmentStorage.clear(threadRef);
setOpen(false);
+ } catch (cause) {
+ Alert.alert(
+ "Could not prepare attachment",
+ cause instanceof Error ? cause.message : "Check device storage and retry.",
+ );
} finally {
pending.current = false;
setBusy(false);
@@ -107,11 +183,16 @@ export function QuickChatProjectAttachment({ threadRef }: { threadRef: ScopedThr
>
Attach to project
+ {saved.error && {saved.error}}
{projects.map((candidate) => (
setProjectId(candidate.id)}
+ onPress={() => {
+ setProjectId(candidate.id);
+ setBaseBranch("");
+ setExistingRef(null);
+ }}
style={{ paddingVertical: 14 }}
>
@@ -123,38 +204,75 @@ export function QuickChatProjectAttachment({ threadRef }: { threadRef: ScopedThr
{projects.length === 0 && (
Add a project on this environment first.
)}
-
- Create a new worktree
- Workspace
+ {(["local", "existing", "new"] as const).map((mode) => (
+
-
- {newWorktree && (
+ onPress={() => setWorkspaceMode(mode)}
+ style={{ paddingVertical: 12 }}
+ >
+
+ {workspaceMode === mode ? "● " : "○ "}
+ {mode === "local"
+ ? "Local checkout"
+ : mode === "existing"
+ ? "Existing worktree"
+ : "New worktree"}
+
+
+ ))}
+ {workspaceMode !== "local" && (
<>
- Base branch
+
+ {newWorktree ? "Base branch" : "Worktree"}
+
+ {branchState.refs.map((ref, index) => (
+ {
+ if (newWorktree) setBaseBranch(ref.name);
+ else setExistingRef(ref);
+ }}
+ />
+ ))}
+ {branchState.isPending && Loading branches…}
+ {branchState.data?.nextCursor != null && (
+ branchState.loadNext()}>
+ Load more branches
+
+ )}
>
)}
{unavailable && (
@@ -168,7 +286,13 @@ export function QuickChatProjectAttachment({ threadRef }: { threadRef: ScopedThr
Cancel
void attach()}
>
diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
index 1e771bd8d852..857609137abe 100644
--- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
@@ -641,7 +641,8 @@ function ThreadRouteContent(
: undefined,
onOpenFilesInspector:
fileInspector.supported && selectedThreadCwd !== null ? handleOpenFilesInspector : undefined,
- onOpenGitInspector: fileInspector.supported ? handleOpenGitInspector : undefined,
+ onOpenGitInspector:
+ fileInspector.supported && selectedThreadCwd !== null ? handleOpenGitInspector : undefined,
currentBranch: selectedThread?.branch ?? null,
gitStatus: gitStatus.data,
gitOperationLabel: gitState.gitOperationLabel,
diff --git a/apps/mobile/src/state/quick-chat-attachment-storage.ts b/apps/mobile/src/state/quick-chat-attachment-storage.ts
new file mode 100644
index 000000000000..48790c508fbf
--- /dev/null
+++ b/apps/mobile/src/state/quick-chat-attachment-storage.ts
@@ -0,0 +1,21 @@
+import { createQuickChatAttachmentStorage } from "@t3tools/client-runtime/operations/quickChats";
+import { Directory, File, Paths } from "expo-file-system";
+import { writeFileAtomically } from "../lib/atomic-file";
+
+function attachmentFile(key: string) {
+ const directory = new Directory(Paths.document, "quick-chat-attachments");
+ directory.create({ idempotent: true, intermediates: true });
+ return new File(directory, `${key}.json`);
+}
+
+export const quickChatAttachmentStorage = createQuickChatAttachmentStorage({
+ getItem: (key) => {
+ const file = attachmentFile(key);
+ return file.exists ? file.textSync() : null;
+ },
+ setItem: (key, value) => writeFileAtomically(attachmentFile(key), value),
+ removeItem: (key) => {
+ const file = attachmentFile(key);
+ if (file.exists) file.delete();
+ },
+});
diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
index 695a4f94f06c..d501663dee6a 100644
--- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
+++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
@@ -1,5 +1,5 @@
import * as Path from "effect/Path";
-import { ServerConfig } from "../../config.ts";
+import * as ServerConfig from "../../config.ts";
import {
type ChatAttachment,
CommandId,
@@ -327,7 +327,7 @@ const make = Effect.gen(function* () {
const providerRegistry = yield* ProviderRegistry;
const gitWorkflow = yield* GitWorkflowService;
const fileSystem = yield* FileSystem.FileSystem;
- const serverConfig = yield* ServerConfig;
+ const serverConfig = yield* ServerConfig.ServerConfig;
const path = yield* Path.Path;
const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
const textGeneration = yield* TextGeneration;
@@ -507,7 +507,8 @@ const make = Effect.gen(function* () {
new ProviderAdapterRequestError({
provider: "unknown",
method: "thread.turn.start",
- detail: `Could not prepare quick chat directory: ${cause.message}`,
+ detail: "Could not prepare the quick chat directory.",
+ cause,
}),
),
);
diff --git a/apps/server/src/orchestration/decider.quick-chats.test.ts b/apps/server/src/orchestration/decider.quick-chats.test.ts
index a01a07ea8674..54d3e34742ef 100644
--- a/apps/server/src/orchestration/decider.quick-chats.test.ts
+++ b/apps/server/src/orchestration/decider.quick-chats.test.ts
@@ -173,6 +173,39 @@ it.layer(NodeServices.layer)("quick chats", (it) => {
expect(result._tag).toBe("Failure");
}),
);
+ it.effect("rejects attachment to a deleted project", () =>
+ Effect.gen(function* () {
+ let model = yield* createChat;
+ for (const command of [
+ {
+ type: "project.create",
+ commandId: CommandId.make("create-project"),
+ projectId,
+ title: "Project",
+ workspaceRoot: "/tmp/project",
+ createdAt: now,
+ },
+ { type: "project.delete", commandId: CommandId.make("delete-project"), projectId },
+ ] as const) {
+ const events = yield* decideOrchestrationCommand({ readModel: model, command });
+ for (const event of Array.isArray(events) ? events : [events])
+ model = yield* projectEvent(model, { ...event, sequence: model.snapshotSequence + 1 });
+ }
+ const result = yield* Effect.result(
+ decideOrchestrationCommand({
+ readModel: model,
+ command: {
+ type: "thread.meta.update",
+ commandId: CommandId.make("attach-deleted"),
+ threadId,
+ projectId,
+ },
+ }),
+ );
+ expect(result._tag).toBe("Failure");
+ if (result._tag === "Failure") expect(String(result.failure)).toContain("deleted project");
+ }),
+ );
it.effect("rejects attachment while a turn is queued", () =>
Effect.gen(function* () {
const model = yield* createChat;
diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts
index 568d70e2e51d..b60387865c23 100644
--- a/apps/server/src/orchestration/decider.ts
+++ b/apps/server/src/orchestration/decider.ts
@@ -910,7 +910,13 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
detail: "Only an idle, unarchived quick chat can be attached to a project.",
});
}
- yield* requireProject({ readModel, command, projectId: command.projectId });
+ const project = yield* requireProject({ readModel, command, projectId: command.projectId });
+ if (project.deletedAt !== null) {
+ return yield* new OrchestrationCommandInvariantError({
+ commandType: command.type,
+ detail: "A quick chat cannot be attached to a deleted project.",
+ });
+ }
}
if (
thread.projectId === null &&
diff --git a/apps/web/src/components/AttachQuickChatDialog.tsx b/apps/web/src/components/AttachQuickChatDialog.tsx
index 15c563ffe230..248179b024a7 100644
--- a/apps/web/src/components/AttachQuickChatDialog.tsx
+++ b/apps/web/src/components/AttachQuickChatDialog.tsx
@@ -1,6 +1,12 @@
import { randomHex } from "../lib/utils";
import { useEffect, useState } from "react";
-import { type ScopedThreadRef, type VcsCreateWorktreeResult } from "@t3tools/contracts";
+import { type ScopedThreadRef, type VcsRef } from "@t3tools/contracts";
+import {
+ prepareQuickChatWorktree,
+ type PendingQuickChatAttachment,
+} from "@t3tools/client-runtime/operations/quickChats";
+import { quickChatAttachmentStorage } from "../quickChatAttachmentStorage";
+import { useAtomQueryRunner } from "../state/use-atom-query-runner";
import { useQuickChatAttachmentStore } from "../quickChatAttachmentStore";
import { useProjects, useThreadShell } from "../state/entities";
import { useAtomCommand } from "../state/use-atom-command";
@@ -8,23 +14,40 @@ import { threadEnvironment } from "../state/threads";
import { vcsEnvironment } from "../state/vcs";
import { Dialog, DialogPopup, DialogHeader, DialogTitle, DialogFooter } from "./ui/dialog";
import { Button } from "./ui/button";
-import { Input } from "./ui/input";
+import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector";
+import { Select, SelectTrigger, SelectValue, SelectPopup, SelectItem } from "./ui/select";
function AttachmentForm({ threadRef }: { threadRef: ScopedThreadRef }) {
const projects = useProjects().filter(
(project) => project.environmentId === threadRef.environmentId,
);
const thread = useThreadShell(threadRef);
- const [projectId, setProjectId] = useState(projects[0]?.id ?? "");
- const [newWorktree, setNewWorktree] = useState(false);
- const [baseBranch, setBaseBranch] = useState("HEAD");
- const [error, setError] = useState(null);
- const [prepared, setPrepared] = useState(null);
+ const [saved] = useState(() => {
+ try {
+ return { pending: quickChatAttachmentStorage.load(threadRef), error: null };
+ } catch {
+ return {
+ pending: null,
+ error: "Could not load the pending attachment. Check browser storage before retrying.",
+ };
+ }
+ });
+ const [projectId, setProjectId] = useState(saved.pending?.projectId ?? projects[0]?.id ?? "");
+ const [workspaceMode, setWorkspaceMode] = useState<"local" | "existing" | "new">(
+ saved.pending ? "new" : "local",
+ );
+ const newWorktree = workspaceMode === "new";
+ const [existingRef, setExistingRef] = useState(null);
+ const [baseBranch, setBaseBranch] = useState(saved.pending?.baseBranch ?? "");
+ const [error, setError] = useState(saved.error);
+ const [prepared, setPrepared] = useState(saved.pending);
const busy = useQuickChatAttachmentStore((state) => state.busy);
const update = useAtomCommand(threadEnvironment.updateMetadata, "Attach quick chat");
const createWorktree = useAtomCommand(vcsEnvironment.createWorktree, "Create worktree");
+ const listRefs = useAtomQueryRunner(vcsEnvironment.readRefs, { refresh: true });
const project = projects.find((candidate) => candidate.id === projectId);
const unavailable =
+ saved.error !== null ||
!thread ||
thread.projectId !== null ||
thread.archivedAt !== null ||
@@ -44,23 +67,63 @@ function AttachmentForm({ threadRef }: { threadRef: ScopedThreadRef }) {
useQuickChatAttachmentStore.setState({ busy: true });
setError(null);
try {
- let worktree = prepared;
- if (newWorktree && !worktree) {
- const result = await createWorktree({
+ let worktree = null;
+ if (newWorktree) {
+ const pending = prepared ?? {
+ projectId: project.id,
+ workspaceRoot: project.workspaceRoot,
+ baseBranch: baseBranch.trim(),
+ branch: `t3/quick-chat-${randomHex(4)}`,
+ };
+ await quickChatAttachmentStorage.save(threadRef, pending);
+ setPrepared(pending);
+ worktree = await prepareQuickChatWorktree({
+ pending,
+ listRefs: async () => {
+ const result = await listRefs({
+ environmentId: threadRef.environmentId,
+ input: {
+ cwd: pending.workspaceRoot,
+ query: pending.branch,
+ refKind: "local",
+ refresh: true,
+ },
+ });
+ if (result._tag === "Failure")
+ throw new Error(
+ "Could not check the prepared worktree. Check the connection and retry.",
+ );
+ return result.value;
+ },
+ createWorktree: async (input) => {
+ const result = await createWorktree({ environmentId: threadRef.environmentId, input });
+ if (result._tag === "Failure")
+ throw new Error(
+ "Could not confirm worktree creation. Retry to recover the same branch.",
+ );
+ return result.value;
+ },
+ });
+ }
+ if (workspaceMode === "existing") {
+ if (!existingRef) return;
+ const result = await listRefs({
environmentId: threadRef.environmentId,
input: {
cwd: project.workspaceRoot,
- refName: baseBranch.trim(),
- newRefName: `t3/quick-chat-${randomHex(4)}`,
- path: null,
+ query: existingRef.name,
+ refKind: "local",
+ refresh: true,
},
});
- if (result._tag === "Failure") {
- setError("Could not create the worktree. The chat is still unattached.");
- return;
- }
- worktree = result.value.worktree;
- setPrepared(worktree);
+ if (result._tag === "Failure")
+ throw new Error("Could not check the selected worktree. Retry when connected.");
+ const ref = result.value.refs.find(
+ (candidate) => candidate.name === existingRef.name && !candidate.isRemote,
+ );
+ if (!ref?.worktreePath || ref.worktreePath === project.workspaceRoot)
+ throw new Error("This worktree is no longer available. Select another worktree.");
+ worktree = { refName: ref.name, path: ref.worktreePath };
}
const result = await update({
environmentId: threadRef.environmentId,
@@ -79,7 +142,14 @@ function AttachmentForm({ threadRef }: { threadRef: ScopedThreadRef }) {
);
return;
}
+ quickChatAttachmentStorage.clear(threadRef);
useQuickChatAttachmentStore.setState({ threadRef: null });
+ } catch (cause) {
+ setError(
+ cause instanceof Error
+ ? cause.message
+ : "Could not prepare the attachment. Check storage and retry.",
+ );
} finally {
useQuickChatAttachmentStore.setState({ busy: false });
}
@@ -93,37 +163,76 @@ function AttachmentForm({ threadRef }: { threadRef: ScopedThreadRef }) {
-
- {newWorktree && (
-
@@ -5765,7 +5760,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
preserveComposerFocusOnPointerDown
onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
onInterrupt={handleInterruptPrimaryAction}
- onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
+ onImplementPlanInNewThread={onImplementPlanInNewThread}
/>
) : null}
@@ -5871,7 +5866,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
showSendWhileRunning={isMobileViewport}
onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
onInterrupt={handleInterruptPrimaryAction}
- onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
+ onImplementPlanInNewThread={onImplementPlanInNewThread}
compactDisabled={
compactDisabled || noProviderAvailable || isSendBusy || isConnecting
}
diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx
index 91c54b75ed03..579ca833cfbe 100644
--- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx
+++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx
@@ -34,7 +34,7 @@ interface ComposerPrimaryActionsProps {
showSendWhileRunning?: boolean;
onPreviousPendingQuestion: () => void;
onInterrupt: () => void;
- onImplementPlanInNewThread: () => void;
+ onImplementPlanInNewThread: (() => void) | undefined;
}
const formatPendingPrimaryActionLabel = (input: {
@@ -185,36 +185,43 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
-
+
+
+
+
+
+
+ )}
);
}
diff --git a/apps/web/src/quickChatAttachmentStorage.ts b/apps/web/src/quickChatAttachmentStorage.ts
new file mode 100644
index 000000000000..090abf903a38
--- /dev/null
+++ b/apps/web/src/quickChatAttachmentStorage.ts
@@ -0,0 +1,7 @@
+import { createQuickChatAttachmentStorage } from "@t3tools/client-runtime/operations/quickChats";
+
+export const quickChatAttachmentStorage = createQuickChatAttachmentStorage({
+ getItem: (key) => window.localStorage.getItem(key),
+ setItem: (key, value) => window.localStorage.setItem(key, value),
+ removeItem: (key) => window.localStorage.removeItem(key),
+});
diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md
index 305496f7ea37..c2bfa3ccfcd4 100644
--- a/docs/user/thread-sidebar.md
+++ b/docs/user/thread-sidebar.md
@@ -23,9 +23,14 @@ In the New thread picker, press **Ctrl+0** to start a quick chat, or **Cmd+0** o
To turn a quick chat into project work, finish the current turn and choose
**Attach to project** from its menu on web or desktop, or above the conversation
-on mobile. Select a project in the same environment. Enable **Create a new
-worktree** and choose its base branch if you want a separate workspace. The chat
-keeps its history and subsequent turns use the selected workspace.
+on mobile. Select a project in the same environment, then choose its local
+checkout, an existing worktree, or a new worktree. Use the branch picker to
+select an existing worktree or the base for a new one. The chat keeps its history
+and subsequent turns use the selected workspace. If attachment fails, reopening
+the dialog restores the prepared worktree for retry.
+
+Project tools such as the file browser, terminal, and Git controls become
+available after attachment.
Quick chats use the agent's normal permission settings.
diff --git a/packages/client-runtime/src/operations/quickChats.test.ts b/packages/client-runtime/src/operations/quickChats.test.ts
new file mode 100644
index 000000000000..649f24810120
--- /dev/null
+++ b/packages/client-runtime/src/operations/quickChats.test.ts
@@ -0,0 +1,95 @@
+import {
+ EnvironmentId,
+ ProjectId,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ ThreadId,
+ type ServerProvider,
+ type VcsListRefsResult,
+} from "@t3tools/contracts";
+import { expect, it } from "vite-plus/test";
+import {
+ createQuickChatAttachmentStorage,
+ prepareQuickChatWorktree,
+ quickChatModelSelection,
+} from "./quickChats";
+
+const ready: ServerProvider = {
+ instanceId: ProviderInstanceId.make("ready"),
+ driver: ProviderDriverKind.make("codex"),
+ enabled: true,
+ installed: true,
+ version: null,
+ status: "ready",
+ auth: { status: "authenticated" },
+ checkedAt: "2026-01-01T00:00:00Z",
+ models: [{ slug: "model", name: "Model", isCustom: false, capabilities: {} }],
+ slashCommands: [],
+ skills: [],
+};
+
+it("skips an errored preferred provider and selects an available agent", () => {
+ const failed: ServerProvider = {
+ ...ready,
+ instanceId: ProviderInstanceId.make("failed"),
+ status: "error",
+ };
+ const settings = { defaultModelSelection: { instanceId: failed.instanceId, model: "model" } };
+ expect(quickChatModelSelection({ providers: [failed, ready], settings })).toEqual({
+ instanceId: ready.instanceId,
+ model: "model",
+ });
+ expect(quickChatModelSelection({ providers: [failed], settings })).toBeNull();
+});
+
+it("recovers the same worktree after losing its creation response and reloading storage", async () => {
+ const disk = new Map