From 321cf49ca7ffe1495715b7da26c3c06e23b1e241 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:06:21 +0200 Subject: [PATCH 01/15] feat: add quick chats without a project --- .../archive/ArchivedThreadsScreen.tsx | 26 ++- .../archive/archivedThreadList.test.ts | 16 +- .../features/archive/archivedThreadList.ts | 26 ++- .../archive/useArchivedThreadSnapshots.ts | 2 +- apps/mobile/src/features/home/HomeScreen.tsx | 89 +++++--- .../features/threads/NewTaskRouteScreen.tsx | 6 +- .../threads/QuickChatCreationActions.tsx | 82 +++++++ .../threads/QuickChatProjectAttachment.tsx | 183 +++++++++++++++ .../threads/ThreadNavigationSidebar.tsx | 49 ++-- .../features/threads/ThreadRouteScreen.tsx | 30 ++- .../src/features/threads/threadListV2.test.ts | 26 +++ .../src/features/threads/threadListV2.ts | 25 ++- apps/mobile/src/lib/scopedEntities.ts | 5 +- apps/mobile/src/state/queries.ts | 2 +- apps/mobile/src/state/use-thread-selection.ts | 2 +- apps/server/src/checkpointing/Utils.ts | 3 +- .../src/environment/ServerEnvironment.ts | 1 + .../orchestration/Layers/CheckpointReactor.ts | 6 +- .../Layers/OrchestrationEngine.test.ts | 136 +++++++++++ .../Layers/OrchestrationEngine.ts | 23 +- .../Layers/ProjectionPipeline.ts | 3 + .../Layers/ProjectionSnapshotQuery.test.ts | 13 ++ .../Layers/ProjectionSnapshotQuery.ts | 11 +- .../Layers/ProviderCommandReactor.test.ts | 125 ++++++++++- .../Layers/ProviderCommandReactor.ts | 57 +++-- .../orchestration/ThreadPullRequestReactor.ts | 2 +- .../ThreadSettlementPolicy.test.ts | 4 + .../orchestration/ThreadSettlementPolicy.ts | 3 +- .../orchestration/ThreadSettlementReactor.ts | 4 +- .../orchestration/decider.quick-chats.test.ts | 212 ++++++++++++++++++ apps/server/src/orchestration/decider.ts | 44 +++- apps/server/src/orchestration/http.ts | 32 +-- apps/server/src/orchestration/projector.ts | 1 + .../orchestration/quickChatCompatibility.ts | 26 +++ apps/server/src/orchestration/threadTitles.ts | 2 +- apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/050_QuickChats.test.ts | 27 +++ .../persistence/Migrations/051_QuickChats.ts | 23 ++ .../persistence/Services/ProjectionThreads.ts | 2 +- .../src/project/AgentSessionImporter.ts | 2 +- .../src/provider/Layers/ProviderService.ts | 4 +- apps/server/src/relay/AgentAwarenessRelay.ts | 9 +- apps/server/src/server.test.ts | 101 +++++++++ apps/server/src/ws.ts | 19 +- .../src/components/AttachQuickChatDialog.tsx | 178 +++++++++++++++ apps/web/src/components/ChatView.tsx | 44 ++-- .../src/components/CommandPalette.logic.ts | 3 +- apps/web/src/components/CommandPalette.tsx | 41 +++- .../src/components/LegacyQuickChatList.tsx | 89 ++++++++ apps/web/src/components/LegacySidebar.tsx | 100 +++++---- apps/web/src/components/NoProjectsHero.tsx | 11 +- apps/web/src/components/Sidebar.logic.test.ts | 16 -- apps/web/src/components/Sidebar.logic.ts | 16 +- apps/web/src/components/Sidebar.tsx | 90 +++++--- .../pullRequest/PullRequestDetailPanel.tsx | 2 +- .../components/settings/SettingsPanels.tsx | 45 +++- .../src/components/threadActionMenu.logic.ts | 26 ++- apps/web/src/hooks/useNewQuickChat.ts | 60 +++++ apps/web/src/hooks/useThreadActionMenu.ts | 10 +- apps/web/src/hooks/useThreadActions.ts | 29 ++- apps/web/src/lib/archivedThreadsState.ts | 2 +- apps/web/src/lib/chatThreadActions.ts | 6 +- apps/web/src/onboarding/firstRun.logic.ts | 2 +- apps/web/src/quickChatAttachmentStore.ts | 18 ++ apps/web/src/routes/_chat.tsx | 39 +--- apps/web/src/state/entities.ts | 24 ++ apps/web/src/state/queries.ts | 2 +- docs/user/thread-sidebar.md | 16 +- packages/client-runtime/package.json | 4 + .../client-runtime/src/environment/scoped.ts | 12 +- .../src/operations/quickChats.ts | 23 ++ .../src/state/environmentHttpAuth.test.ts | 3 + packages/client-runtime/src/state/shell.ts | 6 +- .../src/state/shellSnapshotHttp.ts | 3 +- .../client-runtime/src/state/threadReducer.ts | 1 + .../client-runtime/src/state/threadShell.ts | 1 + .../client-runtime/src/state/threadSort.ts | 2 +- packages/contracts/src/environment.ts | 2 + packages/contracts/src/environmentHttp.ts | 1 + packages/contracts/src/orchestration.ts | 19 +- 80 files changed, 2064 insertions(+), 348 deletions(-) create mode 100644 apps/mobile/src/features/threads/QuickChatCreationActions.tsx create mode 100644 apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx create mode 100644 apps/server/src/orchestration/decider.quick-chats.test.ts create mode 100644 apps/server/src/orchestration/quickChatCompatibility.ts create mode 100644 apps/server/src/persistence/Migrations/050_QuickChats.test.ts create mode 100644 apps/server/src/persistence/Migrations/051_QuickChats.ts create mode 100644 apps/web/src/components/AttachQuickChatDialog.tsx create mode 100644 apps/web/src/components/LegacyQuickChatList.tsx create mode 100644 apps/web/src/hooks/useNewQuickChat.ts create mode 100644 apps/web/src/quickChatAttachmentStore.ts create mode 100644 packages/client-runtime/src/operations/quickChats.ts diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 97f9c5b69d0f..337402a631ef 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -52,7 +52,7 @@ type ArchivedThreadListItem = readonly key: string; readonly environmentLabel: string | null; readonly environmentMachine: EnvironmentMachineKind; - readonly project: EnvironmentProject; + readonly project: EnvironmentProject | null; } | { readonly kind: "thread"; @@ -368,22 +368,24 @@ function ArchivedThreadsHeader(props: { function ProjectGroupLabel(props: { readonly environmentLabel: string | null; readonly environmentMachine: EnvironmentMachineKind; - readonly project: EnvironmentProject; + readonly project: EnvironmentProject | null; }) { return ( - + {props.project && ( + + )} - {props.project.title} + {props.project?.title ?? "Quick chats"} {props.environmentLabel ? ( @@ -538,13 +540,13 @@ export function ArchivedThreadsScreen(props: { const listItems = useMemo>(() => { const items: ArchivedThreadListItem[] = []; for (const group of props.groups) { - const environmentLabel = environmentLabelsById.get(group.project.environmentId) ?? null; + const environmentLabel = environmentLabelsById.get(group.threads[0]!.environmentId) ?? null; items.push({ kind: "project", key: `${group.key}:project`, environmentLabel, environmentMachine: resolveEnvironmentMachineKind( - serverConfigs.get(group.project.environmentId) ?? null, + serverConfigs.get(group.threads[0]!.environmentId) ?? null, ), project: group.project, }); diff --git a/apps/mobile/src/features/archive/archivedThreadList.test.ts b/apps/mobile/src/features/archive/archivedThreadList.test.ts index 474bd4481ca8..6658ee5ccbcb 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.test.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.test.ts @@ -121,7 +121,7 @@ describe("buildArchivedThreadGroups", () => { }); expect(result).toHaveLength(1); - expect(result[0]?.project.environmentId).toBe(environmentId); + expect(result[0]?.project?.environmentId).toBe(environmentId); expect(result[0]?.threads.map((thread) => thread.id)).toEqual(["thread-1"]); }); @@ -145,3 +145,17 @@ describe("buildArchivedThreadGroups", () => { expect(result).toEqual([]); }); }); + +it("shows archived quick chats in an environment without projects", () => { + const quick = makeThread({ id: ThreadId.make("quick"), projectId: null, title: "Passkeys" }); + const groups = buildArchivedThreadGroups({ + snapshots: [makeSnapshot([], [quick])], + environmentLabels: {}, + environmentId, + searchQuery: "passkeys", + sortOrder: "newest", + }); + expect(groups).toHaveLength(1); + expect(groups[0]?.project).toBeNull(); + expect(groups[0]?.threads[0]).toMatchObject({ id: quick.id, environmentId, projectId: null }); +}); diff --git a/apps/mobile/src/features/archive/archivedThreadList.ts b/apps/mobile/src/features/archive/archivedThreadList.ts index 6146bba20447..ec29ef73e987 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.ts @@ -15,7 +15,7 @@ export type ArchivedThreadSortOrder = "newest" | "oldest"; export interface ArchivedThreadGroup { readonly key: string; - readonly project: EnvironmentProject; + readonly project: EnvironmentProject | null; readonly threads: ReadonlyArray; } @@ -44,7 +44,7 @@ export function buildArchivedThreadGroups(input: { } const environmentLabel = input.environmentLabels[entry.environmentId] ?? null; - const threadsByProjectId = new Map(); + const threadsByProjectId = new Map(); for (const thread of entry.snapshot.threads) { if (thread.archivedAt === null) { continue; @@ -54,6 +54,26 @@ export function buildArchivedThreadGroups(input: { threadsByProjectId.set(thread.projectId, threads); } + const quickChats = (threadsByProjectId.get(null) ?? []) + .filter( + (thread) => + query.length === 0 || + matchesQuery("Quick chats", query) || + matchesQuery(thread.title, query) || + matchesQuery(environmentLabel, query), + ) + .toSorted( + (left, right) => + (input.sortOrder === "newest" ? -1 : 1) * + (archiveTimestamp(left) - archiveTimestamp(right)), + ); + if (quickChats.length > 0) + groups.push({ + key: `${entry.environmentId}:quick-chats`, + project: null, + threads: quickChats, + }); + for (const rawProject of entry.snapshot.projects) { const project = scopeProject(entry.environmentId, rawProject); const projectThreads = threadsByProjectId.get(project.id) ?? []; @@ -98,7 +118,7 @@ export function buildArchivedThreadGroups(input: { Order.Struct({ timestamp: timestampOrder, title: Order.String, key: Order.String }), (group: ArchivedThreadGroup) => ({ timestamp: group.threads[0] ? archiveTimestamp(group.threads[0]) : 0, - title: group.project.title, + title: group.project?.title ?? "Quick chats", key: group.key, }), ), diff --git a/apps/mobile/src/features/archive/useArchivedThreadSnapshots.ts b/apps/mobile/src/features/archive/useArchivedThreadSnapshots.ts index d18cc230c639..0f313b6aaab8 100644 --- a/apps/mobile/src/features/archive/useArchivedThreadSnapshots.ts +++ b/apps/mobile/src/features/archive/useArchivedThreadSnapshots.ts @@ -13,7 +13,7 @@ import { orchestrationEnvironment } from "../../state/orchestration"; function archivedSnapshotAtom(environmentId: EnvironmentId) { return orchestrationEnvironment.archivedShellSnapshot({ environmentId, - input: {}, + input: { includeQuickChats: true }, }); } diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 4e01ef6077da..6ed30e844cf0 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -680,21 +680,12 @@ export function HomeScreen(props: HomeScreenProps) { snoozeWakeTick, ]); const threadListV2Layout = useMemo(() => { - if (!threadListV2Enabled) - return { - items: [], - hiddenSettledCount: 0, - snoozedCount: 0, - snoozedShelfHeaderIndex: null, - settledCount: 0, - settledShelfHeaderIndex: null, - nextSnoozeWakeAt: null, - }; - // Settled threads are live shells; archived threads keep their original - // "hidden from lists" meaning. return buildThreadListV2Items({ pendingOrder, - threads: props.threads.filter((thread) => thread.archivedAt === null), + threads: props.threads.filter( + (thread) => + thread.archivedAt === null && (threadListV2Enabled || thread.projectId === null), + ), environmentId: props.selectedEnvironmentId, projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, searchQuery: props.searchQuery, @@ -763,7 +754,7 @@ export function HomeScreen(props: HomeScreenProps) { () => buildThreadListV2ListItems({ items: threadListV2Layout.items, - pendingTasks: v2PendingTasks, + pendingTasks: threadListV2Enabled ? v2PendingTasks : [], snoozedCount: threadListV2Layout.snoozedCount, snoozedShelfExpanded, snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, @@ -772,7 +763,13 @@ export function HomeScreen(props: HomeScreenProps) { settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, snoozeLabelNow: `${nowMinute}:00.000Z`, }), - [settledShelfExpanded, snoozedShelfExpanded, threadListV2Layout, v2PendingTasks], + [ + settledShelfExpanded, + snoozedShelfExpanded, + threadListV2Layout, + v2PendingTasks, + threadListV2Enabled, + ], ); const renderV2Item = useCallback( @@ -781,6 +778,10 @@ export function HomeScreen(props: HomeScreenProps) { const showTrailingDivider = nextItem?.type === "v2-thread" || (nextItem?.type === "v2-pending" && !nextItem.showPendingDivider); + if (item.type === "v2-quick-chats-header") + return ( + Quick chats + ); if (item.type === "v2-pending") { const pendingScopeKey = scopedProjectKey( item.pendingTask.environmentId, @@ -864,14 +865,21 @@ export function HomeScreen(props: HomeScreenProps) { onArchiveThread={props.onArchiveThread} onRegenerateThreadTitle={handleRegenerateThreadTitle} titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} - settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} + settlementSupported={ + thread.projectId !== null && settlementEnvironmentIds.has(thread.environmentId) + } onSettleThread={handleSettleThread} - snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} - pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} + snoozeSupported={ + thread.projectId !== null && snoozeEnvironmentIds.has(thread.environmentId) + } + pinningSupported={ + thread.projectId !== null && pinningEnvironmentIds.has(thread.environmentId) + } reorderSupported={ - item.item.pinned + thread.projectId !== null && + (item.item.pinned ? pinReorderEnvironmentIds.has(thread.environmentId) - : activeReorderEnvironmentIds.has(thread.environmentId) + : activeReorderEnvironmentIds.has(thread.environmentId)) } canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null} canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null} @@ -962,7 +970,7 @@ export function HomeScreen(props: HomeScreenProps) { ); const renderItem = useCallback( - ({ item }: LegendListRenderItemProps) => { + ({ item }: { readonly item: HomeListItem }) => { switch (item.type) { case "header": return ( @@ -1060,7 +1068,25 @@ export function HomeScreen(props: HomeScreenProps) { ], ); - const keyExtractor = useCallback((item: HomeListItem) => item.key, []); + const legacyListItems = useMemo( + () => [...listLayout.items, ...threadListV2Items], + [listLayout.items, threadListV2Items], + ); + const renderLegacyListItem = useCallback( + (props: LegendListRenderItemProps) => { + const item = props.item; + if ( + item.type === "header" || + item.type === "thread" || + item.type === "pending-task" || + item.type === "show-more" + ) + return renderItem({ item }); + return renderV2Item({ item, index: props.index - listLayout.items.length }); + }, + [renderItem, renderV2Item, listLayout.items.length], + ); + const keyExtractor = useCallback((item: HomeListItem | ThreadListV2ListItem) => item.key, []); /* Empty states */ // The signal must ignore the search/environment filters: an active query @@ -1224,10 +1250,23 @@ export function HomeScreen(props: HomeScreenProps) { { + if ( + (previous.type === "header" || + previous.type === "thread" || + previous.type === "pending-task" || + previous.type === "show-more") && + (item.type === "header" || + item.type === "thread" || + item.type === "pending-task" || + item.type === "show-more") + ) + return homeListItemsAreEqual(previous, item); + return previous === item; + }} drawDistance={500} estimatedItemSize={ESTIMATED_THREAD_ROW_HEIGHT} extraData={extraData} diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index e9bcb1291e39..920b51edd8b6 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -1,3 +1,4 @@ +import { QuickChatCreationActions } from "./QuickChatCreationActions"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, @@ -102,7 +103,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps attachment.type === "image") ? "images" : "files"} you shared` : null; - const screenTitle = incomingShare ? "Start a task" : "Choose project"; + const screenTitle = incomingShare ? "Start a task" : "New thread"; const projectEmptyState = deriveProjectEmptyState(catalogState); const resumedDestinationKeyRef = useRef(null); const reservedDestinationProject = incomingShare?.destination @@ -316,6 +317,9 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps )} + {!incomingShare && ( + + )} ); diff --git a/apps/mobile/src/features/threads/QuickChatCreationActions.tsx b/apps/mobile/src/features/threads/QuickChatCreationActions.tsx new file mode 100644 index 000000000000..5f4a9952127f --- /dev/null +++ b/apps/mobile/src/features/threads/QuickChatCreationActions.tsx @@ -0,0 +1,82 @@ +import { useRef } from "react"; +import { Alert, Pressable, Text, View } from "react-native"; +import { StackActions, useNavigation } from "@react-navigation/native"; +import { DEFAULT_RUNTIME_MODE, ThreadId, type EnvironmentId } from "@t3tools/contracts"; +import { quickChatModelSelection } from "@t3tools/client-runtime/operations/quickChats"; +import { useServerConfigs } from "../../state/entities"; +import { useEnvironments } from "../../state/environments"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { threadEnvironment } from "../../state/threads"; +import { uuidv4 } from "../../lib/uuid"; + +export function QuickChatCreationActions({ + preferredEnvironmentId, +}: { + preferredEnvironmentId: EnvironmentId | null; +}) { + const configs = useServerConfigs(); + const { environments } = useEnvironments(); + const navigation = useNavigation(); + const create = useAtomCommand(threadEnvironment.create, "Create quick chat"); + const pending = useRef(false); + const eligible = environments.filter( + (environment) => configs.get(environment.environmentId)?.environment.capabilities.quickChats, + ); + const preferred = eligible.find( + (environment) => environment.environmentId === preferredEnvironmentId, + ); + const choices = preferred ? [preferred] : eligible; + async function start(environmentId: EnvironmentId) { + if (pending.current) return; + const config = configs.get(environmentId); + if (!config) return; + const modelSelection = quickChatModelSelection(config); + if (!modelSelection) { + Alert.alert("Set up an agent before starting a quick chat"); + return; + } + pending.current = true; + const routeKey = navigation.getState()?.routes.at(-1)?.key; + try { + const threadId = ThreadId.make(uuidv4()); + 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" && navigation.getState()?.routes.at(-1)?.key === routeKey) + (navigation.getParent() ?? navigation).dispatch( + StackActions.replace("Thread", { environmentId, threadId }), + ); + } finally { + pending.current = false; + } + } + if (choices.length === 0) return null; + return ( + + Quick chat + {choices.map((environment) => ( + void start(environment.environmentId)} + > + + New quick chat{choices.length > 1 ? ` · ${environment.label}` : ""} + + + ))} + + ); +} diff --git a/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx b/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx new file mode 100644 index 000000000000..36332db6ae62 --- /dev/null +++ b/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx @@ -0,0 +1,183 @@ +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 { useProjects, useThreadShell } from "../../state/entities"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { threadEnvironment } from "../../state/threads"; +import { vcsEnvironment } from "../../state/vcs"; +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 [busy, setBusy] = useState(false); + const pending = useRef(false); + const [prepared, setPrepared] = useState(null); + 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 project = projectId ? projects.find((project) => project.id === projectId) : projects[0]; + const unavailable = + !thread || + thread.projectId !== null || + thread.archivedAt !== null || + thread.session?.status === "running" || + thread.session?.status === "starting" || + thread.latestTurn?.state === "running" || + thread.backgroundLiveness != null || + thread.hasPendingApprovals || + thread.hasPendingUserInput; + async function attach() { + if (pending.current || !project || unavailable) return; + pending.current = true; + setBusy(true); + try { + let worktree = prepared; + if (newWorktree && !worktree) { + const result = await createWorktree({ + environmentId: threadRef.environmentId, + input: { + cwd: project.workspaceRoot, + refName: baseBranch.trim(), + newRefName: `t3/quick-chat-${uuidv4().slice(0, 8)}`, + path: null, + }, + }); + if (result._tag === "Failure") { + Alert.alert("Could not create worktree", "The chat is still unattached."); + return; + } + worktree = result.value.worktree; + setPrepared(worktree); + } + const result = await update({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + projectId: project.id, + branch: worktree?.refName ?? null, + worktreePath: worktree?.path ?? null, + }, + }); + if (result._tag === "Failure") { + Alert.alert( + "Could not confirm attachment", + worktree + ? `Retry to use the prepared worktree at ${worktree.path}.` + : "Check the connection and retry.", + ); + return; + } + setOpen(false); + } finally { + pending.current = false; + setBusy(false); + } + } + return ( + <> + setOpen(true)} + className="px-4 py-2" + > + Attach to project + + { + if (!pending.current) setOpen(false); + }} + > + + Attach to project + + {projects.map((candidate) => ( + setProjectId(candidate.id)} + style={{ paddingVertical: 14 }} + > + + {candidate.id === project?.id ? "● " : "○ "} + {candidate.title} + + + ))} + {projects.length === 0 && ( + Add a project on this environment first. + )} + + Create a new worktree + + + {newWorktree && ( + <> + Base branch + + + )} + {unavailable && ( + + Finish the current turn and background work before attaching. + + )} + + + setOpen(false)}> + Cancel + + void attach()} + > + + {busy ? "Attaching…" : "Attach"} + + + + + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index dce02cac1d4b..5f95948f9ec2 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -297,8 +297,10 @@ function ThreadNavigationSidebarPane( ? [] : selectedProjectRefs === null ? threads - : threads.filter((thread) => - selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId)), + : threads.filter( + (thread) => + thread.projectId === null || + selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId)), ), [threadListV2Enabled, selectedProjectRefs, threads], ); @@ -516,19 +518,12 @@ function ThreadNavigationSidebarPane( snoozeWakeTick, ]); const threadListV2Layout = useMemo(() => { - if (!threadListV2Enabled) - return { - items: [], - hiddenSettledCount: 0, - snoozedCount: 0, - snoozedShelfHeaderIndex: null, - settledCount: 0, - settledShelfHeaderIndex: null, - nextSnoozeWakeAt: null, - }; return buildThreadListV2Items({ pendingOrder, - threads: threads.filter((thread) => thread.archivedAt === null), + threads: threads.filter( + (thread) => + thread.archivedAt === null && (threadListV2Enabled || thread.projectId === null), + ), environmentId: options.selectedEnvironmentId, projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, searchQuery: props.searchQuery, @@ -575,7 +570,6 @@ function ThreadNavigationSidebarPane( // range) the boundary string is identical and the chain would die. }, [nextSnoozeWakeAt, snoozeWakeTick]); const listItems = useMemo(() => { - if (!threadListV2Enabled) return listLayout.items; // Queued offline tasks are not thread shells, so the v2 item builder // never sees them; the shared splice puts them below the active block // (mirrors the compact Home v2 list) where they stay visible and @@ -595,7 +589,7 @@ function ThreadNavigationSidebarPane( ); const items: SidebarListItem[] = buildThreadListV2ListItems({ items: threadListV2Layout.items, - pendingTasks: v2PendingTasks, + pendingTasks: threadListV2Enabled ? v2PendingTasks : [], snoozedCount: threadListV2Layout.snoozedCount, snoozedShelfExpanded, snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, @@ -611,7 +605,7 @@ function ThreadNavigationSidebarPane( hiddenCount: threadListV2Layout.hiddenSettledCount, }); } - return items; + return threadListV2Enabled ? items : [...listLayout.items, ...items]; }, [ listLayout.items, nowMinute, @@ -829,11 +823,13 @@ function ThreadNavigationSidebarPane( return previous.count === item.count && previous.expanded === item.expanded; } if ( + previous.type === "v2-quick-chats-header" || previous.type === "v2-thread" || previous.type === "v2-show-more" || previous.type === "v2-pending" || previous.type === "v2-snoozed-shelf" || previous.type === "v2-settled-shelf" || + item.type === "v2-quick-chats-header" || item.type === "v2-thread" || item.type === "v2-show-more" || item.type === "v2-pending" || @@ -889,6 +885,10 @@ function ThreadNavigationSidebarPane( /> ); } + case "v2-quick-chats-header": + return ( + Quick chats + ); case "v2-thread": { const thread = item.item.thread; const movePlanner = item.item.pinned @@ -932,14 +932,21 @@ function ThreadNavigationSidebarPane( onArchiveThread={archiveThread} onRegenerateThreadTitle={regenerateThreadTitle} titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} - settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} + settlementSupported={ + thread.projectId !== null && settlementEnvironmentIds.has(thread.environmentId) + } onSettleThread={settleThread} - snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} - pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} + snoozeSupported={ + thread.projectId !== null && snoozeEnvironmentIds.has(thread.environmentId) + } + pinningSupported={ + thread.projectId !== null && pinningEnvironmentIds.has(thread.environmentId) + } reorderSupported={ - item.item.pinned + thread.projectId !== null && + (item.item.pinned ? pinReorderEnvironmentIds.has(thread.environmentId) - : activeReorderEnvironmentIds.has(thread.environmentId) + : activeReorderEnvironmentIds.has(thread.environmentId)) } canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null} canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 0ff42a077b04..1e771bd8d852 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1,3 +1,4 @@ +import { QuickChatProjectAttachment } from "./QuickChatProjectAttachment"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, @@ -728,11 +729,13 @@ function ThreadRouteContent( onPress: () => handleOpenTerminal(null), }); } - actions.push({ - accessibilityLabel: "Open git controls", - icon: "point.topleft.down.curvedto.point.bottomright.up", - onPress: handleOpenGitInspector, - }); + if (selectedThreadProject?.workspaceRoot) { + actions.push({ + accessibilityLabel: "Open git controls", + icon: "point.topleft.down.curvedto.point.bottomright.up", + onPress: handleOpenGitInspector, + }); + } if (fileInspector.supported && selectedThreadCwd !== null) { actions.push({ accessibilityLabel: "Toggle inspector", @@ -838,7 +841,9 @@ function ThreadRouteContent( const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null; const renderThreadRouteBody = (showActionControls: boolean) => ( <> - + {selectedThreadProject && ( + + )} @@ -855,6 +860,12 @@ function ThreadRouteContent( : undefined } > + {selectedThread?.projectId === null && ( + + )} (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems) + ? () => + selectedThreadProject + ? layout.usesSplitView + ? threadCenterHeaderItems + : compactRightHeaderItems + : [] : undefined, unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, contentStyle: diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 3d9aefb4c4e1..939a03833319 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -1478,3 +1478,29 @@ describe("cross-section thread drops", () => { ).toEqual({ pin: false, unpin: false, unsettle: false, unsnooze: false }); }); }); + +it("keeps quick chats below project work when a project filter is selected", () => { + const projectThread = makeThread({ id: ThreadId.make("project-thread"), title: "Project work" }); + const quick = makeThread({ + id: ThreadId.make("quick"), + title: "Passkeys", + projectId: null, + createdAt: "2026-06-02T00:00:00.000Z", + }); + const layout = buildThreadListV2Items({ + threads: [quick, projectThread], + now: NOW, + environmentId, + projectRefs: [{ environmentId, projectId: ProjectId.make("project-1") }], + searchQuery: "", + }); + const rows = buildThreadListV2ListItems({ items: layout.items, pendingTasks: [] }); + expect(rows.map((row) => (row.type === "v2-thread" ? row.item.thread.id : row.type))).toEqual([ + projectThread.id, + "v2-quick-chats-header", + quick.id, + ]); + expect( + getThreadListV2OrderedSection({ threads: [quick, projectThread], section: "active", now: NOW }), + ).toEqual([projectThread]); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index e1cb8e9ece7f..5afd7027a3f2 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -182,7 +182,7 @@ export function getThreadListV2OrderedSection(input: { readonly queuedThreadKeys?: ReadonlySet; }): EnvironmentThreadShell[] { const threads = input.threads.filter((thread) => { - if (thread.archivedAt !== null) return false; + if (thread.archivedAt !== null || thread.projectId === null) return false; if ( (input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) && thread.settledOverride === "settled" && @@ -269,6 +269,7 @@ export interface ThreadListV2SettledShelfListItem { } export type ThreadListV2ListItem = + | { readonly type: "v2-quick-chats-header"; readonly key: "v2-quick-chats-header" } | ThreadListV2ThreadListItem | ThreadListV2PendingListItem | ThreadListV2SnoozedShelfListItem @@ -330,6 +331,14 @@ export function buildThreadListV2ListItems(input: { }); result.push(...threadItems.slice(settledShelfHeaderIndex)); } + const quickChatIndex = result.findIndex( + (item) => item.type === "v2-thread" && item.item.thread.projectId === null, + ); + if (quickChatIndex >= 0) + result.splice(quickChatIndex, 0, { + type: "v2-quick-chats-header", + key: "v2-quick-chats-header", + }); return result; } @@ -395,7 +404,11 @@ export function buildThreadListV2Items(input: { for (const thread of input.threads) { // Callers pass live shells. The server stamps settledOverride for the tail. if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; - if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) { + if ( + thread.projectId !== null && + projectKeys !== null && + !projectKeys.has(`${thread.environmentId}:${thread.projectId}`) + ) { continue; } if ( @@ -413,6 +426,10 @@ export function buildThreadListV2Items(input: { ) { continue; } + if (thread.projectId === null) { + active.push(thread); + continue; + } const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; // Snooze outranks settlement and pinning until the thread wakes. @@ -483,7 +500,9 @@ export function buildThreadListV2Items(input: { isLast: false, }); } - for (const thread of orderedActive) { + for (const thread of orderedActive.toSorted( + (left, right) => Number(left.projectId === null) - Number(right.projectId === null), + )) { items.push({ thread, variant: "card", diff --git a/apps/mobile/src/lib/scopedEntities.ts b/apps/mobile/src/lib/scopedEntities.ts index 34709957fd48..fe588fc01168 100644 --- a/apps/mobile/src/lib/scopedEntities.ts +++ b/apps/mobile/src/lib/scopedEntities.ts @@ -1,6 +1,9 @@ import { ApprovalRequestId, EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; -export function scopedProjectKey(environmentId: EnvironmentId, projectId: ProjectId): string { +export function scopedProjectKey( + environmentId: EnvironmentId, + projectId: ProjectId | null, +): string { return `${environmentId}:${projectId}`; } diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index 0c0da1f847d5..f6f7d26e4e8f 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -45,7 +45,7 @@ const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({ getSearchAtom: (environmentId, query) => orchestrationEnvironment.threadSearch({ environmentId, - input: { query }, + input: { query, includeQuickChats: true }, }), labelPrefix: "mobile:thread-search", }); diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index d922eb7f6d5c..a6116701d3d6 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -160,7 +160,7 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin } const selectedProjectRef = useMemo( () => - selectedThread === null + selectedThread === null || selectedThread.projectId === null ? null : { environmentId: selectedThread.environmentId, diff --git a/apps/server/src/checkpointing/Utils.ts b/apps/server/src/checkpointing/Utils.ts index adc089f624a8..440d65c4ac71 100644 --- a/apps/server/src/checkpointing/Utils.ts +++ b/apps/server/src/checkpointing/Utils.ts @@ -11,7 +11,7 @@ export function checkpointRefForThreadTurn(threadId: ThreadId, turnCount: number export function resolveThreadWorkspaceCwd(input: { readonly thread: { - readonly projectId: ProjectId; + readonly projectId: ProjectId | null; readonly worktreePath: string | null; }; readonly projects: ReadonlyArray<{ @@ -19,6 +19,7 @@ export function resolveThreadWorkspaceCwd(input: { readonly workspaceRoot: string; }>; }): string | undefined { + if (input.thread.projectId === null) return undefined; const worktreeCwd = input.thread.worktreePath ?? undefined; if (worktreeCwd) { return worktreeCwd; diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 2aab17b27a76..401a3bd08c03 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -229,6 +229,7 @@ export const make = Effect.gen(function* () { threadPinning: true, threadPinReorder: true, threadActiveReorder: true, + quickChats: true, threadTitleRegeneration: true, threadPullRequests: true, pullRequestStackActions: true, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index d4d6b9409808..ca98fcbe5294 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -172,8 +172,9 @@ const make = Effect.gen(function* () { }); const resolveThreadProjects = Effect.fn("resolveThreadProjects")(function* ( - projectId: ProjectId, + projectId: ProjectId | null, ) { + if (projectId === null) return []; const project = yield* projectionSnapshotQuery .getProjectShellById(projectId) .pipe(Effect.map(Option.getOrUndefined)); @@ -186,10 +187,11 @@ const make = Effect.gen(function* () { // a git repository. const resolveCheckpointCwd = Effect.fn("resolveCheckpointCwd")(function* (input: { readonly threadId: ThreadId; - readonly thread: { readonly projectId: ProjectId; readonly worktreePath: string | null }; + readonly thread: { readonly projectId: ProjectId | null; readonly worktreePath: string | null }; readonly projects: ReadonlyArray<{ readonly id: ProjectId; readonly workspaceRoot: string }>; readonly preferSessionRuntime: boolean; }): Effect.fn.Return { + if (input.thread.projectId === null) return undefined; const fromSession = yield* resolveSessionRuntimeForThread(input.threadId); const fromThread = resolveThreadWorkspaceCwd({ thread: input.thread, diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 3929136f90fc..8d4e457648e3 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -564,6 +564,142 @@ describe("OrchestrationEngine", () => { }).pipe(Effect.provide(makeOrchestrationLayer())), ); + it("keeps a quick chat unattached while a question is pending after restart", async () => { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-quick-attachment-")); + const databasePath = NodePath.join(directory, "state.sqlite"); + let system = await createOrchestrationSystem(databasePath); + const threadId = ThreadId.make("quick-pending"); + const projectId = ProjectId.make("quick-project"); + try { + await system.run( + system.engine.dispatch({ + type: "project.create", + commandId: CommandId.make("project"), + projectId, + title: "Project", + workspaceRoot: directory, + createdAt: now(), + }), + ); + await system.run( + system.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("chat"), + threadId, + projectId: null, + title: "Quick chat", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, + runtimeMode: "full-access", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + createdAt: now(), + }), + ); + await system.run( + system.engine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make("question"), + threadId, + createdAt: now(), + activity: { + id: EventId.make("question"), + kind: "user-input.requested", + summary: "Question", + tone: "info", + turnId: null, + createdAt: now(), + payload: { requestId: "pending-question", responseMode: "message", questions: [] }, + }, + }), + ); + await system.dispose(); + system = await createOrchestrationSystem(databasePath); + const error = await system.run( + system.engine + .dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("attach"), + threadId, + projectId, + }) + .pipe(Effect.flip), + ); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(String(error)).toContain("Resolve pending requests"); + expect( + (await system.readModel()).threads.find((thread) => thread.id === threadId)?.projectId, + ).toBeNull(); + } finally { + await system.dispose(); + await NodeFSP.rm(directory, { recursive: true, force: true }); + } + }); + + effectIt.effect("attaches quick chats only after background work finishes", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery; + const liveness = yield* ThreadBackgroundLiveness.ThreadBackgroundLivenessService; + const threadId = ThreadId.make("quick-chat-background"); + const projectId = ProjectId.make("quick-chat-project"); + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("quick-project-create"), + projectId, + title: "Project", + workspaceRoot: "/tmp/quick-chat-project", + createdAt: now(), + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("quick-chat-create"), + threadId, + projectId: null, + title: "Quick chat", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now(), + }); + for (const taskType of ["subagent", "local_bash"]) { + liveness.recordTaskLiveness({ + threadId, + taskId: taskType, + taskType, + status: undefined, + kind: "started", + }); + const result = yield* engine + .dispatch({ + type: "thread.meta.update", + commandId: CommandId.make(`quick-attach-${taskType}`), + threadId, + projectId, + }) + .pipe(Effect.result); + expect(result._tag).toBe("Failure"); + expect( + (yield* snapshots.getSnapshot()).threads.find((thread) => thread.id === threadId) + ?.projectId, + ).toBeNull(); + liveness.clearThreadLiveness(threadId); + } + yield* engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("quick-attach-idle"), + threadId, + projectId, + }); + expect( + (yield* snapshots.getSnapshot()).threads.find((thread) => thread.id === threadId) + ?.projectId, + ).toBe(projectId); + }).pipe(Effect.provide(makeOrchestrationLayer())), + ); + effectIt.effect( "rejects persisted changes and live background work without blocking unrelated threads", () => diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 6557888c38ae..d204c083538c 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -203,7 +203,9 @@ const makeOrchestrationEngine = Effect.gen(function* () { } if ( - envelope.command.type === "thread.auto-settle" && + (envelope.command.type === "thread.auto-settle" || + (envelope.command.type === "thread.meta.update" && + envelope.command.projectId !== undefined)) && threadBackgroundLiveness.getThreadBackgroundLiveness(envelope.command.threadId) !== null ) { return yield* new OrchestrationCommandInvariantError({ @@ -212,6 +214,25 @@ const makeOrchestrationEngine = Effect.gen(function* () { }); } + if ( + envelope.command.type === "thread.meta.update" && + envelope.command.projectId !== undefined + ) { + // Pending requests survive restarts; the command model's activity window does not. + const thread = yield* projectionSnapshotQuery.getThreadShellById( + envelope.command.threadId, + ); + if ( + Option.isSome(thread) && + (thread.value.hasPendingApprovals || thread.value.hasPendingUserInput) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: envelope.command.type, + detail: "Resolve pending requests before attaching the quick chat to a project.", + }); + } + } + // Command snapshots omit activities at startup and cap them while running. // Read this request's durable state before deciding how to send the answer. const userInputActivity = diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index e303e7323729..a7bc254c5650 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -806,6 +806,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } yield* projectionThreadRepository.upsert({ ...existingRow.value, + ...(event.payload.projectId !== undefined + ? { projectId: event.payload.projectId } + : {}), ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), ...(event.payload.activeOrderKey !== undefined ? { activeOrderKey: event.payload.activeOrderKey } diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 5849123c55d6..97a5d811874f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -2272,6 +2272,19 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { (yield* snapshotQuery.searchThreads({ query: "hidden needle" })).matches, [], ); + yield* sql`UPDATE projection_threads SET project_id = NULL WHERE thread_id = 'thread-active'`; + assert.deepStrictEqual( + (yield* snapshotQuery.searchThreads({ query: "user needle" })).matches, + [], + ); + const quickMatches = yield* snapshotQuery.searchThreads({ + query: "user needle", + includeQuickChats: true, + }); + assert.deepStrictEqual( + quickMatches.matches.map((match) => [match.threadId, match.projectId]), + [[ThreadId.make("thread-active"), null]], + ); yield* sql` UPDATE projection_threads SET deleted_at = '2026-05-01T00:00:20.000Z' diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 066c60760ca5..bd6be88e67c0 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -177,10 +177,11 @@ const EventReplayStatsRowSchema = Schema.Struct({ const ProjectionThreadSearchRequest = Schema.Struct({ pattern: Schema.String, limit: Schema.Int, + includeQuickChats: Schema.Boolean, }); const ProjectionThreadSearchRow = Schema.Struct({ threadId: ThreadId, - projectId: ProjectId, + projectId: Schema.NullOr(ProjectId), source: OrchestrationThreadSearchSource, matchText: Schema.String, messageCreatedAt: Schema.NullOr(IsoDateTime), @@ -1003,7 +1004,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const searchActiveThreadRows = SqlSchema.findAll({ Request: ProjectionThreadSearchRequest, Result: ProjectionThreadSearchRow, - execute: ({ pattern, limit }) => + execute: ({ pattern, limit, includeQuickChats }) => sql` WITH ranked AS ( SELECT @@ -1033,11 +1034,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { FROM projection_thread_messages AS messages INNER JOIN projection_threads AS threads ON threads.thread_id = messages.thread_id - INNER JOIN projection_projects AS projects + LEFT JOIN projection_projects AS projects ON projects.project_id = threads.project_id WHERE threads.deleted_at IS NULL AND threads.archived_at IS NULL - AND projects.deleted_at IS NULL + AND (${includeQuickChats ? 1 : 0} OR threads.project_id IS NOT NULL) + AND (threads.project_id IS NULL OR (projects.project_id IS NOT NULL AND projects.deleted_at IS NULL)) AND messages.is_streaming = 0 AND ( messages.role = 'user' @@ -2884,6 +2886,7 @@ pending_approval_requests AS ( const rows = yield* searchActiveThreadRows({ pattern: `%${escapedQuery}%`, limit: input.limit ?? 50, + includeQuickChats: input.includeQuickChats === true, }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index d93fec5a3cf6..d18eb32df02f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -167,6 +167,7 @@ describe("ProviderCommandReactor", () => { async function createHarness(input?: { readonly baseDir?: string; + readonly quickChat?: boolean; readonly threadModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; readonly requiresNewThreadForModelChange?: boolean; @@ -487,23 +488,25 @@ describe("ProviderCommandReactor", () => { const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor)); const runEffect = (effect: Effect.Effect) => runtime!.runPromise(effect); - await Effect.runPromise( - engine.dispatch({ - type: "project.create", - commandId: CommandId.make("cmd-project-create"), - projectId: asProjectId("project-1"), - title: "Provider Project", - workspaceRoot: "/tmp/provider-project", - defaultModelSelection: modelSelection, - createdAt: now, - }), - ); + if (!input?.quickChat) { + await Effect.runPromise( + engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-project-create"), + projectId: asProjectId("project-1"), + title: "Provider Project", + workspaceRoot: "/tmp/provider-project", + defaultModelSelection: modelSelection, + createdAt: now, + }), + ); + } await Effect.runPromise( engine.dispatch({ type: "thread.create", commandId: CommandId.make("cmd-thread-create"), threadId: ThreadId.make("thread-1"), - projectId: asProjectId("project-1"), + projectId: input?.quickChat ? null : asProjectId("project-1"), title: "Thread", modelSelection: modelSelection, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -830,6 +833,104 @@ describe("ProviderCommandReactor", () => { }), ); + effectIt.effect("runs a quick chat and resumes its history after attaching to a worktree", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const resumed = yield* Deferred.make(); + let starts = 0; + const harness = yield* Effect.promise(() => + createHarness({ + quickChat: true, + startSessionEffect: (session) => + Deferred.succeed(++starts === 1 ? started : resumed, undefined).pipe( + Effect.as(session), + ), + }), + ); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("quick-turn"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("quick-message"), + role: "user", + text: "Explain passkeys", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* Deferred.await(started); + yield* Effect.promise(() => harness.drain()); + const snapshot = yield* Effect.promise(() => harness.readModel()); + expect(snapshot.projects).toEqual([]); + expect(snapshot.threads[0]?.projectId).toBeNull(); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + cwd: expect.stringContaining("quick-chats"), + }); + expect(harness.sendTurn).toHaveBeenCalledOnce(); + yield* harness.engine.dispatch({ + type: "project.create", + commandId: CommandId.make("quick-project"), + projectId: ProjectId.make("project-1"), + title: "Project", + workspaceRoot: "/tmp/provider-project", + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("quick-idle"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:01.000Z", + }, + createdAt: "2026-01-01T00:00:01.000Z", + }); + yield* harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("quick-attach"), + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + worktreePath: "/tmp/provider-project-worktree", + branch: "quick-chat", + }); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("quick-resume"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("quick-followup"), + role: "user", + text: "Implement them", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:02.000Z", + }); + yield* Deferred.await(resumed); + yield* Effect.promise(() => harness.drain()); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + cwd: "/tmp/provider-project-worktree", + resumeCursor: { opaque: "resume-1" }, + }); + expect(harness.sendTurn).toHaveBeenCalledTimes(2); + expect( + (yield* Effect.promise(() => harness.readModel())).threads[0]?.messages.map( + (message) => message.text, + ), + ).toEqual(["Explain passkeys", "Implement them"]); + }), + ); + it("reacts to thread.turn.start by ensuring session and sending provider turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 6df08bfadb9c..695a4f94f06c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1,3 +1,5 @@ +import * as Path from "effect/Path"; +import { ServerConfig } from "../../config.ts"; import { type ChatAttachment, CommandId, @@ -325,6 +327,8 @@ const make = Effect.gen(function* () { const providerRegistry = yield* ProviderRegistry; const gitWorkflow = yield* GitWorkflowService; const fileSystem = yield* FileSystem.FileSystem; + const serverConfig = yield* ServerConfig; + const path = yield* Path.Path; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; @@ -478,12 +482,41 @@ const make = Effect.gen(function* () { }); }); - const resolveProject = Effect.fnUntraced(function* (projectId: ProjectId) { + const resolveProject = Effect.fnUntraced(function* (projectId: ProjectId | null) { + if (projectId === null) return undefined; return yield* projectionSnapshotQuery .getProjectShellById(projectId) .pipe(Effect.map(Option.getOrUndefined)); }); + // Quick chats own a stable directory without inheriting a project's workspace. + const resolveSessionCwd = Effect.fn("resolveSessionCwd")(function* (thread: { + readonly id: ThreadId; + readonly projectId: ProjectId | null; + readonly worktreePath: string | null; + }) { + if (thread.projectId === null) { + const cwd = path.join( + serverConfig.stateDir, + "quick-chats", + Buffer.from(thread.id).toString("base64url"), + ); + yield* fileSystem.makeDirectory(cwd, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: "unknown", + method: "thread.turn.start", + detail: `Could not prepare quick chat directory: ${cause.message}`, + }), + ), + ); + return cwd; + } + const project = yield* resolveProject(thread.projectId); + return resolveThreadWorkspaceCwd({ thread, projects: project ? [project] : [] }); + }); + /** * Recreates a thread's worktree from its branch when the directory has * disappeared. Provider sessions resume into the persisted cwd, so a missing @@ -492,7 +525,7 @@ const make = Effect.gen(function* () { */ const ensureThreadWorktree = Effect.fnUntraced(function* (thread: { readonly id: ThreadId; - readonly projectId: ProjectId; + readonly projectId: ProjectId | null; readonly branch: string | null; readonly worktreePath: string | null; }) { @@ -707,11 +740,7 @@ const make = Effect.gen(function* () { }); } } - const project = yield* resolveProject(thread.projectId); - const effectiveCwd = resolveThreadWorkspaceCwd({ - thread, - projects: project ? [project] : [], - }); + const effectiveCwd = yield* resolveSessionCwd(thread); const refreshWorkspaceSnapshot = effectiveCwd ? providerRegistry .refreshWorkspaceSnapshot({ instanceId: desiredInstanceId, cwd: effectiveCwd }) @@ -1032,12 +1061,7 @@ const make = Effect.gen(function* () { if (thread.title !== previousTitle) { return { _tag: "Superseded" } as const; } - const project = yield* resolveProject(thread.projectId); - const cwd = - resolveThreadWorkspaceCwd({ - thread, - projects: project ? [project] : [], - }) ?? process.cwd(); + const cwd = (yield* resolveSessionCwd(thread)) ?? process.cwd(); const { textGenerationModelSelection: modelSelection } = yield* serverSettingsService.getSettings; const generated = yield* textGeneration.generateThreadTitle({ @@ -1302,12 +1326,7 @@ const make = Effect.gen(function* () { const isCompactCommand = isCompactCommandMessage(message); if (!hasOtherUserMessages && !isCompactCommand) { - const project = yield* resolveProject(thread.projectId); - const generationCwd = - resolveThreadWorkspaceCwd({ - thread, - projects: project ? [project] : [], - }) ?? process.cwd(); + const generationCwd = (yield* resolveSessionCwd(thread)) ?? process.cwd(); const generationInput = { messageText: assistantCitationsToPlainText(message.text), ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.ts index 86026632a6af..1b53012dc3d2 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.ts @@ -131,7 +131,7 @@ export const make = Effect.gen(function* () { (group) => Effect.gen(function* () { const first = group[0]!; - const project = projects.get(first.projectId); + const project = first.projectId === null ? undefined : projects.get(first.projectId); if (project === undefined) return finishBackfill(group); const repository = sourceControlRepositorySelector(project.repositoryIdentity); if (first.branch !== null && repository === null) return finishBackfill(group); diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts index 252b99439400..63a74c16cf73 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts @@ -287,3 +287,7 @@ describe("linked request settlement", () => { ); }); }); + +it("keeps idle quick chats out of automatic project settlement", () => { + expect(decide(makeThread({ projectId: null, branch: null, worktreePath: null }))).toBe(false); +}); diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.ts index 92063745eff5..e72e8e9b747a 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -116,7 +116,8 @@ export function resolveAutoSettlementAt(input: { /** Cheap checks that run before any source control lookup. */ export function isAutoSettlementCandidate(thread: OrchestrationThreadShell, now: string): boolean { - if (thread.archivedAt !== null || thread.settledOverride !== null) return false; + if (thread.projectId === null || thread.archivedAt !== null || thread.settledOverride !== null) + return false; if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false; if (thread.session?.status === "starting" || thread.session?.status === "running") return false; if (thread.backgroundLiveness != null) return false; diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 61fc5d4ab863..9ddbdbc03f2a 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -113,7 +113,7 @@ export const make = Effect.gen(function* () { lookupCandidates, (thread) => Effect.gen(function* () { - const project = projects.get(thread.projectId); + const project = thread.projectId === null ? undefined : projects.get(thread.projectId); if (project === undefined || thread.branch === null) return; const worktreeExists = thread.worktreePath !== null && @@ -191,7 +191,7 @@ export const make = Effect.gen(function* () { { cwd, branch: thread.branch }, { refresh: true }, ); - const project = projects.get(thread.projectId); + const project = thread.projectId === null ? undefined : projects.get(thread.projectId); if ( current?.state === "open" && project !== undefined && diff --git a/apps/server/src/orchestration/decider.quick-chats.test.ts b/apps/server/src/orchestration/decider.quick-chats.test.ts new file mode 100644 index 000000000000..a01a07ea8674 --- /dev/null +++ b/apps/server/src/orchestration/decider.quick-chats.test.ts @@ -0,0 +1,212 @@ +import { + CommandId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as DateTime from "effect/DateTime"; +import { decideOrchestrationCommand } from "./decider.ts"; +import { projectEvent } from "./projector.ts"; + +const now = "2026-01-01T00:00:00.000Z"; +const threadId = ThreadId.make("quick-chat"); +const projectId = ProjectId.make("project"); +const create = { + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: null, + title: "Quick chat", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, +} as const; +const empty: OrchestrationReadModel = { + snapshotSequence: 0, + projects: [], + threads: [], + updatedAt: now, +}; +const createChat = Effect.gen(function* () { + const events = yield* decideOrchestrationCommand({ command: create, readModel: empty }); + let model = empty; + for (const event of Array.isArray(events) ? events : [events]) + model = yield* projectEvent(model, { ...event, sequence: model.snapshotSequence + 1 }); + return model; +}); + +it.layer(NodeServices.layer)("quick chats", (it) => { + it.effect("creates a durable conversation without a project", () => + Effect.gen(function* () { + const model = yield* createChat; + expect(model.projects).toEqual([]); + expect(model.threads[0]).toMatchObject({ + id: threadId, + projectId: null, + branch: null, + worktreePath: null, + }); + }), + ); + it.effect("rejects repository metadata on unattached chats", () => + Effect.gen(function* () { + const model = yield* createChat; + const result = yield* Effect.result( + decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("branch"), + threadId, + branch: "main", + }, + readModel: model, + }), + ); + expect(result._tag).toBe("Failure"); + }), + ); + it.effect("attaches an idle chat to a project and preserves its identity", () => + Effect.gen(function* () { + let model = yield* createChat; + const projects = yield* decideOrchestrationCommand({ + readModel: model, + command: { + type: "project.create", + commandId: CommandId.make("project"), + projectId, + title: "Project", + workspaceRoot: "/tmp/project", + createdAt: now, + }, + }); + for (const event of Array.isArray(projects) ? projects : [projects]) + model = yield* projectEvent(model, { ...event, sequence: model.snapshotSequence + 1 }); + const history = [ + { + id: MessageId.make("question"), + role: "user" as const, + text: "Explain passkeys", + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + ]; + model = { + ...model, + threads: model.threads.map((thread) => ({ ...thread, messages: history })), + }; + const events = yield* decideOrchestrationCommand({ + readModel: model, + command: { + type: "thread.meta.update", + commandId: CommandId.make("attach"), + threadId, + projectId, + branch: "feature", + worktreePath: "/tmp/worktree", + }, + }); + for (const event of Array.isArray(events) ? events : [events]) + model = yield* projectEvent(model, { ...event, sequence: model.snapshotSequence + 1 }); + expect(model.threads[0]).toMatchObject({ + id: threadId, + projectId, + title: "Quick chat", + branch: "feature", + worktreePath: "/tmp/worktree", + }); + expect(model.threads[0]?.messages).toEqual(history); + const repeated = yield* Effect.result( + decideOrchestrationCommand({ + readModel: model, + command: { + type: "thread.meta.update", + commandId: CommandId.make("move-again"), + threadId, + projectId, + }, + }), + ); + expect(repeated._tag).toBe("Failure"); + }), + ); + it.effect("rejects attachment while a session is starting", () => + Effect.gen(function* () { + const model = yield* createChat; + const thread = model.threads[0]!; + const result = yield* Effect.result( + decideOrchestrationCommand({ + readModel: { + ...model, + threads: [ + { + ...thread, + session: { + threadId, + status: "starting", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + ], + }, + command: { + type: "thread.meta.update", + commandId: CommandId.make("attach-busy"), + threadId, + projectId, + }, + }), + ); + expect(result._tag).toBe("Failure"); + }), + ); + it.effect("rejects attachment while a turn is queued", () => + Effect.gen(function* () { + const model = yield* createChat; + const queuedAt = DateTime.formatIso(yield* DateTime.now); + const result = yield* Effect.result( + decideOrchestrationCommand({ + readModel: { + ...model, + threads: model.threads.map((thread) => ({ + ...thread, + messages: [ + { + id: MessageId.make("queued"), + role: "user", + text: "Continue", + turnId: null, + streaming: false, + createdAt: queuedAt, + updatedAt: queuedAt, + }, + ], + })), + }, + command: { + type: "thread.meta.update", + commandId: CommandId.make("attach-queued"), + threadId, + projectId, + }, + }), + ); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") + expect(String(result.failure)).toContain("idle, unarchived quick chat"); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index c0787a18d096..568d70e2e51d 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -365,11 +365,14 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.create": { - yield* requireProject({ - readModel, - command, - projectId: command.projectId, - }); + if (command.projectId !== null) { + yield* requireProject({ readModel, command, projectId: command.projectId }); + } else if (command.branch !== null || command.worktreePath !== null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Quick chats cannot have a branch or worktree before attaching to a project.", + }); + } yield* requireThreadAbsent({ readModel, command, @@ -891,6 +894,36 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); + if (command.projectId !== undefined) { + const attachmentAt = yield* nowIso; + if ( + thread.projectId !== null || + thread.archivedAt !== null || + thread.session?.status === "running" || + thread.session?.status === "starting" || + thread.latestTurn?.state === "running" || + openRequests(thread).size > 0 || + hasQueuedTurnStartForThread(thread, attachmentAt) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Only an idle, unarchived quick chat can be attached to a project.", + }); + } + yield* requireProject({ readModel, command, projectId: command.projectId }); + } + if ( + thread.projectId === null && + command.projectId === undefined && + (command.branch != null || + command.worktreePath != null || + command.linkedPullRequest != null) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Attach the quick chat to a project before setting repository metadata.", + }); + } // Old clients only see the derived single link. Unlink that request through // the same command path as modern clients, including stack dismissal, while // retaining other links they cannot see. Historical metadata events still replay unchanged. @@ -982,6 +1015,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" type: "thread.meta-updated", payload: { threadId: command.threadId, + ...(command.projectId !== undefined ? { projectId: command.projectId } : {}), ...(command.title !== undefined ? { title: command.title } : {}), ...(command.regenerateTitle === true ? { diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index f7147106c7a9..013e8a93d2e0 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -8,6 +8,7 @@ import * as Option from "effect/Option"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { projectThreadDetailSnapshot } from "./ActivityPayloadProjection.ts"; +import { projectQuickChatShellSnapshot } from "./quickChatCompatibility.ts"; import { cleanupFailedUploadedAttachments, normalizeDispatchCommand } from "./Normalizer.ts"; import { annotateEnvironmentRequest, @@ -37,13 +38,15 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( // and activity payload in the database has OOM-killed servers, and // the route's only consumer (the project CLI) reads projects alone — // UI clients load the shell and per-thread snapshots instead. - return yield* projectionSnapshotQuery - .getCommandReadModel() - .pipe( - Effect.catch((cause) => - failEnvironmentInternal("orchestration_snapshot_failed", cause), - ), - ); + return yield* projectionSnapshotQuery.getCommandReadModel().pipe( + Effect.map((snapshot) => ({ + ...snapshot, + threads: snapshot.threads.filter((thread) => thread.projectId !== null), + })), + Effect.catch((cause) => + failEnvironmentInternal("orchestration_snapshot_failed", cause), + ), + ); }), ) .handle( @@ -51,13 +54,14 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( Effect.fn("environment.orchestration.shellSnapshot")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationReadScope); - return yield* projectionSnapshotQuery - .getShellSnapshot() - .pipe( - Effect.catch((cause) => - failEnvironmentInternal("orchestration_snapshot_failed", cause), - ), - ); + return yield* projectionSnapshotQuery.getShellSnapshot().pipe( + Effect.map((snapshot) => + projectQuickChatShellSnapshot(snapshot, args.payload.includeQuickChats === "true"), + ), + Effect.catch((cause) => + failEnvironmentInternal("orchestration_snapshot_failed", cause), + ), + ); }), ) .handle( diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 02435ea5ba44..ae7f6f4b2932 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -606,6 +606,7 @@ export function projectEvent( return { ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { + ...(payload.projectId !== undefined ? { projectId: payload.projectId } : {}), ...(payload.title !== undefined ? { title: payload.title } : {}), ...(payload.titleRegeneration !== undefined ? { titleRegeneration: payload.titleRegeneration } diff --git a/apps/server/src/orchestration/quickChatCompatibility.ts b/apps/server/src/orchestration/quickChatCompatibility.ts new file mode 100644 index 000000000000..94e51002e3c0 --- /dev/null +++ b/apps/server/src/orchestration/quickChatCompatibility.ts @@ -0,0 +1,26 @@ +import type { OrchestrationShellSnapshot, OrchestrationShellStreamItem } from "@t3tools/contracts"; + +/** Older clients require a project ID on every thread they receive. */ +export function projectQuickChatShellSnapshot( + snapshot: OrchestrationShellSnapshot, + includeQuickChats: boolean | undefined, +): OrchestrationShellSnapshot { + return includeQuickChats + ? snapshot + : { ...snapshot, threads: snapshot.threads.filter((thread) => thread.projectId !== null) }; +} + +export function projectQuickChatShellItem( + item: OrchestrationShellStreamItem, + includeQuickChats: boolean | undefined, +): OrchestrationShellStreamItem { + if (includeQuickChats) return item; + if (item.kind === "snapshot") { + return { ...item, snapshot: projectQuickChatShellSnapshot(item.snapshot, false) }; + } + if (item.kind === "thread-upserted" && item.thread.projectId === null) { + // Keep the replay cursor advancing even when the thread is hidden. + return { kind: "thread-removed", sequence: item.sequence, threadId: item.thread.id }; + } + return item; +} diff --git a/apps/server/src/orchestration/threadTitles.ts b/apps/server/src/orchestration/threadTitles.ts index c9a9c4f72830..dcf70c9f8f8f 100644 --- a/apps/server/src/orchestration/threadTitles.ts +++ b/apps/server/src/orchestration/threadTitles.ts @@ -2,7 +2,7 @@ export const DEFAULT_THREAD_TITLE = "New thread"; export function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { const trimmedCurrentTitle = currentTitle.trim(); - if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) { + if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE || trimmedCurrentTitle === "New quick chat") { return true; } diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index a728e6e6e22e..45ec72865e63 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -1,3 +1,4 @@ +import Migration0051 from "./Migrations/051_QuickChats.ts"; /** * Migration runner with an inline loader. * @@ -124,6 +125,7 @@ const migrationEntries = [ [48, "ProjectionThreadBranchPullRequest", Migration0048], [49, "ProjectionThreadsActiveOrderKey", Migration0049], [50, "ProjectionThreadPullRequests", Migration0050], + [51, "QuickChats", Migration0051], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/050_QuickChats.test.ts b/apps/server/src/persistence/Migrations/050_QuickChats.test.ts new file mode 100644 index 000000000000..295b4f27ecff --- /dev/null +++ b/apps/server/src/persistence/Migrations/050_QuickChats.test.ts @@ -0,0 +1,27 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import { runMigrations } from "../Migrations.ts"; + +it.layer(NodeSqliteClient.layerMemory())("050_QuickChats", (it) => { + it.effect("preserves project threads and allows project-free rows", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 49 }); + yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, created_at, updated_at) + VALUES ('existing', 'project-1', 'Existing', '{"instanceId":"codex","model":"gpt-5.4"}', 'full-access', '2026-01-01', '2026-01-01')`; + yield* runMigrations(); + yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, created_at, updated_at) + VALUES ('quick', NULL, 'Quick', '{"instanceId":"codex","model":"gpt-5.4"}', 'full-access', '2026-01-02', '2026-01-02')`; + const rows = yield* sql<{ + readonly thread_id: string; + readonly project_id: string | null; + }>`SELECT thread_id, project_id FROM projection_threads ORDER BY thread_id`; + assert.deepEqual(rows, [ + { thread_id: "existing", project_id: "project-1" }, + { thread_id: "quick", project_id: null }, + ]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/051_QuickChats.ts b/apps/server/src/persistence/Migrations/051_QuickChats.ts new file mode 100644 index 000000000000..a1ac2251ecfb --- /dev/null +++ b/apps/server/src/persistence/Migrations/051_QuickChats.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // SQLite cannot drop NOT NULL in place. Replace only the project column, + // retaining thread rows and every unrelated column and index. + yield* sql`DROP INDEX idx_projection_threads_project_id`; + yield* sql`DROP INDEX idx_projection_threads_project_archived_at`; + yield* sql`DROP INDEX idx_projection_threads_project_deleted_created`; + yield* sql`DROP INDEX idx_projection_threads_shell_active`; + yield* sql`DROP INDEX idx_projection_threads_shell_archived`; + yield* sql`ALTER TABLE projection_threads ADD COLUMN nullable_project_id TEXT`; + yield* sql`UPDATE projection_threads SET nullable_project_id = project_id`; + yield* sql`ALTER TABLE projection_threads DROP COLUMN project_id`; + yield* sql`ALTER TABLE projection_threads RENAME COLUMN nullable_project_id TO project_id`; + yield* sql`CREATE INDEX idx_projection_threads_project_id ON projection_threads(project_id)`; + yield* sql`CREATE INDEX idx_projection_threads_project_archived_at ON projection_threads(project_id, archived_at)`; + yield* sql`CREATE INDEX idx_projection_threads_project_deleted_created ON projection_threads(project_id, deleted_at, created_at)`; + yield* sql`CREATE INDEX idx_projection_threads_shell_active ON projection_threads(deleted_at, archived_at, project_id, created_at, thread_id)`; + yield* sql`CREATE INDEX idx_projection_threads_shell_archived ON projection_threads(deleted_at, archived_at, project_id, thread_id)`; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 0a8b2e31c5ab..0ed383cc932a 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -27,7 +27,7 @@ import type { ProjectionRepositoryError } from "../Errors.ts"; export const ProjectionThread = Schema.Struct({ threadId: ThreadId, - projectId: ProjectId, + projectId: Schema.NullOr(ProjectId), title: Schema.String, modelSelection: ModelSelection, runtimeMode: RuntimeMode, diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index 5ebb41a1bb54..1d96d968a606 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -49,7 +49,7 @@ class AgentSessionThreadProjectConflictError extends Schema.TaggedError Effect.logWarning( diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 052a67959ad1..54772a016c64 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -275,7 +275,7 @@ export function resolveAgentAwarenessRelayActiveThreadIds(input: { const projectById = new Map(input.projects.map((project) => [project.id, project])); return input.threads .filter((thread) => { - const project = projectById.get(thread.projectId); + const project = thread.projectId === null ? undefined : projectById.get(thread.projectId); if (!project) { return false; } @@ -406,9 +406,10 @@ export const make = Effect.gen(function* () { }); const thread = yield* snapshotQuery.getThreadShellById(threadId); - const project = Option.isSome(thread) - ? yield* snapshotQuery.getProjectShellById(thread.value.projectId) - : Option.none(); + const project = + Option.isSome(thread) && thread.value.projectId !== null + ? yield* snapshotQuery.getProjectShellById(thread.value.projectId) + : Option.none(); const snapshot = resolveAgentAwarenessRelayPublishSnapshot({ environmentId, threadId, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 01f2dd96b18f..0757117306b6 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -8222,6 +8222,107 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect.each([undefined, true])( + "keeps quick chats compatible with older thread lists (opt in: %s)", + (includeQuickChats) => + Effect.gen(function* () { + const projectThread = makeDefaultOrchestrationThreadShell(); + const quickThread = makeDefaultOrchestrationThreadShell({ + id: ThreadId.make("quick-chat"), + projectId: null, + }); + const snapshot = { + snapshotSequence: 1, + projects: [], + threads: [projectThread, quickThread], + updatedAt: "2026-01-01T00:00:00.000Z", + }; + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + latestSequence: Effect.succeed(1), + readEvents: () => + Stream.make({ + sequence: 1, + eventId: EventId.make("quick-chat-title"), + aggregateKind: "thread", + aggregateId: quickThread.id, + occurredAt: snapshot.updatedAt, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.meta-updated", + payload: { + threadId: quickThread.id, + title: "Quick question", + updatedAt: snapshot.updatedAt, + }, + }), + }, + projectionSnapshotQuery: { + getShellSnapshot: () => Effect.succeed(snapshot), + getArchivedShellSnapshot: () => Effect.succeed(snapshot), + getThreadShellById: () => Effect.succeed(Option.some(quickThread)), + }, + }, + }); + const expectedIds = includeQuickChats + ? [projectThread.id, quickThread.id] + : [projectThread.id]; + const cookie = yield* getAuthenticatedSessionCookieHeader(); + const response = yield* HttpClient.get( + `/api/orchestration/shell${includeQuickChats ? "?includeQuickChats=true" : ""}`, + { headers: { cookie } }, + ); + assert.equal(response.status, 200); + const httpSnapshot = yield* HttpClientResponse.schemaBodyJson(OrchestrationShellSnapshot)( + response, + ); + assert.deepEqual( + httpSnapshot.threads.map((thread) => thread.id), + expectedIds, + ); + const wsUrl = yield* getWsServerUrl("/ws"); + const input = includeQuickChats ? { includeQuickChats } : {}; + const initial = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell](input).pipe( + Stream.take(1), + Stream.runCollect, + ), + ), + ); + assertTrue(initial[0]?.kind === "snapshot"); + assert.deepEqual( + initial[0].snapshot.threads.map((thread) => thread.id), + expectedIds, + ); + const archived = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot](input), + ), + ); + assert.deepEqual( + archived.threads.map((thread) => thread.id), + expectedIds, + ); + const replay = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + ...input, + afterSequence: 0, + requestCompletionMarker: true, + }).pipe(Stream.take(2), Stream.runCollect), + ), + ); + assert.equal(replay[0]?.kind, includeQuickChats ? "thread-upserted" : "thread-removed"); + assertTrue(replay[0] !== undefined && "sequence" in replay[0]); + assert.equal(replay[0].sequence, 1); + assert.deepEqual(replay[1], { kind: "synchronized" }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc orchestration shell snapshot errors", () => Effect.gen(function* () { const projectionError = new PersistenceSqlError({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5e4ca0a18db9..f3f5bca30a35 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,3 +1,7 @@ +import { + projectQuickChatShellItem, + projectQuickChatShellSnapshot, +} from "./orchestration/quickChatCompatibility.ts"; import { sameUsageLimitCommandCoverage, withUsageLimitsCommands, @@ -1597,13 +1601,22 @@ const makeWsRpcLayer = ( }), synchronizedThenLive, ); - }), + }).pipe( + Effect.map((stream) => + stream.pipe( + Stream.map((item) => projectQuickChatShellItem(item, input.includeQuickChats)), + ), + ), + ), { "rpc.aggregate": "orchestration" }, ), - [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]: (_input) => + [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]: (input) => observeRpcEffect( ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, projectionSnapshotQuery.getArchivedShellSnapshot().pipe( + Effect.map((snapshot) => + projectQuickChatShellSnapshot(snapshot, input.includeQuickChats), + ), Effect.tapError((cause) => Effect.logError("orchestration archived shell snapshot load failed", { cause }), ), @@ -2497,7 +2510,7 @@ const makeWsRpcLayer = ( }), ), ); - if (Option.isNone(thread)) { + if (Option.isNone(thread) || thread.value.projectId === null) { return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource, }); diff --git a/apps/web/src/components/AttachQuickChatDialog.tsx b/apps/web/src/components/AttachQuickChatDialog.tsx new file mode 100644 index 000000000000..15c563ffe230 --- /dev/null +++ b/apps/web/src/components/AttachQuickChatDialog.tsx @@ -0,0 +1,178 @@ +import { randomHex } from "../lib/utils"; +import { useEffect, useState } from "react"; +import { type ScopedThreadRef, type VcsCreateWorktreeResult } from "@t3tools/contracts"; +import { useQuickChatAttachmentStore } from "../quickChatAttachmentStore"; +import { useProjects, useThreadShell } from "../state/entities"; +import { useAtomCommand } from "../state/use-atom-command"; +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"; + +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 busy = useQuickChatAttachmentStore((state) => state.busy); + const update = useAtomCommand(threadEnvironment.updateMetadata, "Attach quick chat"); + const createWorktree = useAtomCommand(vcsEnvironment.createWorktree, "Create worktree"); + const project = projects.find((candidate) => candidate.id === projectId); + const unavailable = + !thread || + thread.projectId !== null || + thread.archivedAt !== null || + thread.session?.status === "running" || + thread.session?.status === "starting" || + thread.latestTurn?.state === "running" || + thread.backgroundLiveness != null || + thread.hasPendingApprovals || + thread.hasPendingUserInput; + + useEffect(() => { + if (thread?.projectId != null) useQuickChatAttachmentStore.setState({ threadRef: null }); + }, [thread?.projectId]); + + async function attach() { + if (useQuickChatAttachmentStore.getState().busy || unavailable || !project) return; + useQuickChatAttachmentStore.setState({ busy: true }); + setError(null); + try { + let worktree = prepared; + if (newWorktree && !worktree) { + const result = await createWorktree({ + environmentId: threadRef.environmentId, + input: { + cwd: project.workspaceRoot, + refName: baseBranch.trim(), + newRefName: `t3/quick-chat-${randomHex(4)}`, + path: null, + }, + }); + if (result._tag === "Failure") { + setError("Could not create the worktree. The chat is still unattached."); + return; + } + worktree = result.value.worktree; + setPrepared(worktree); + } + const result = await update({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + projectId: project.id, + branch: worktree?.refName ?? null, + worktreePath: worktree?.path ?? null, + }, + }); + if (result._tag === "Failure") { + setError( + worktree + ? `Could not confirm attachment. Retry to use the prepared worktree at ${worktree.path}.` + : "Could not confirm attachment. Check the connection and retry.", + ); + return; + } + useQuickChatAttachmentStore.setState({ threadRef: null }); + } finally { + useQuickChatAttachmentStore.setState({ busy: false }); + } + } + + return ( + <> + + Attach to project + +
+ + + {newWorktree && ( + + )} + {projects.length === 0 && ( +

Add a project on this environment first.

+ )} + {unavailable && ( +

Finish the current turn and background work before attaching.

+ )} + {error && ( +

+ {error} +

+ )} +
+ + + + + + ); +} + +export function AttachQuickChatDialog() { + const threadRef = useQuickChatAttachmentStore((state) => state.threadRef); + return ( + { + if (!open) useQuickChatAttachmentStore.getState().close(); + }} + > + + {threadRef && ( + + )} + + + ); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f1b5afba94e9..4d95a672a807 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5793,7 +5793,6 @@ export default function ChatView(props: ChatViewProps) { const compactThreadUnavailable = !activeThread || !activeThreadHasCompactableConversation || - !activeProject || !isServerThread || !manualCompactionProviderAvailable || isWorking || @@ -5808,11 +5807,9 @@ export default function ChatView(props: ChatViewProps) { const compactDisabledReason = compactDisabled ? composerHasUnsentContent ? "Send or clear your draft before compacting" - : !activeProject - ? "Choose a project before compacting" - : !manualCompactionProviderAvailable - ? "Compaction is unavailable for this provider" - : "Compacting is unavailable right now" + : !manualCompactionProviderAvailable + ? "Compaction is unavailable for this provider" + : "Compacting is unavailable right now" : null; const resumeCompactionBannerItem = useMemo(() => { if ( @@ -6675,7 +6672,7 @@ export default function ChatView(props: ChatViewProps) { } return; } - if (!activeProject) { + if (!activeProject && activeThread.projectId !== null) { toastManager.add( stackedThreadToast({ type: "warning", @@ -6688,14 +6685,20 @@ export default function ChatView(props: ChatViewProps) { const threadIdForSend = activeThread.id; const isFirstMessage = !isServerThread || activeThread.messages.length === 0; const baseBranchForWorktree = - isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath + isFirstMessage && + activeProject !== null && + sendEnvMode === "worktree" && + !activeThread.worktreePath ? activeThreadBranch : null; // In worktree mode, require an explicit base branch so we don't silently // fall back to local execution when branch selection is missing. const shouldCreateWorktree = - isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath; + isFirstMessage && + activeProject !== null && + sendEnvMode === "worktree" && + !activeThread.worktreePath; if (shouldCreateWorktree && !activeThreadBranch) { setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); return; @@ -6989,7 +6992,7 @@ export default function ChatView(props: ChatViewProps) { const bootstrap = isLocalDraftThread || baseBranchForWorktree ? { - ...(isLocalDraftThread + ...(isLocalDraftThread && activeProject ? { createThread: { projectId: activeProject.id, @@ -7003,7 +7006,7 @@ export default function ChatView(props: ChatViewProps) { }, } : {}), - ...(baseBranchForWorktree + ...(baseBranchForWorktree && activeProject ? { prepareWorktree: { projectCwd: activeProject.workspaceRoot, @@ -7056,7 +7059,7 @@ export default function ChatView(props: ChatViewProps) { releaseDraftAttachments(composerAttachmentsSnapshot); } acknowledgeActiveThreadWoke(); - if (backgroundThreadRef) { + if (backgroundThreadRef && activeProject) { markPromotedDraftThreadByRef(backgroundThreadRef); try { const nextDraft = await handleNewThread( @@ -8495,7 +8498,22 @@ export default function ChatView(props: ChatViewProps) { data-terminal-open={terminalUiState.terminalOpen ? "true" : undefined} className="relative z-0" > - {mountComposerContextStrip && ( + {mountComposerContextStrip && !activeProject && ( + +
+ + )} + {mountComposerContextStrip && activeProject && (
{ - const projectTitle = input.projectTitleById.get(thread.projectId); + const projectTitle = + thread.projectId === null ? "Quick chat" : input.projectTitleById.get(thread.projectId); const descriptionParts: string[] = []; if (projectTitle) { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index c54f919125d3..708173d20001 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,6 +1,7 @@ "use client"; import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; +import { useNewQuickChat } from "../hooks/useNewQuickChat"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { @@ -1090,6 +1091,33 @@ function OpenCommandPaletteDialog(props: { [openProjectFromSearch, pickerProjects, projectGroupByTargetKey], ); + const newQuickChat = useNewQuickChat(); + const quickChatItems = useMemo(() => { + const eligible = environments.filter( + (environment) => environment.serverConfig?.environment.capabilities.quickChats === true, + ); + const preferredId = + activeThread?.environmentId ?? activeDraftThread?.environmentId ?? primaryEnvironmentId; + const preferred = eligible.find((environment) => environment.environmentId === preferredId); + return (preferred ? [preferred] : eligible).map((environment) => ({ + kind: "action", + value: `new-quick-chat:${environment.environmentId}`, + title: "New quick chat", + description: preferred || eligible.length === 1 ? "No project needed" : environment.label, + searchTerms: ["quick chat", "question", "no project"], + icon: , + run: async () => { + await newQuickChat(environment.environmentId); + }, + })); + }, [ + environments, + activeThread?.environmentId, + activeDraftThread?.environmentId, + primaryEnvironmentId, + newQuickChat, + ]); + const projectThreadItems = useMemo( () => enumerateCommandPaletteItems( @@ -1529,7 +1557,7 @@ function OpenCommandPaletteDialog(props: { }, [clearOpenIntent, openAddProjectFlow, openIntent]); useLayoutEffect(() => { - if (openIntent?.kind !== "new-thread-in" || projectThreadItems.length === 0) { + if (openIntent?.kind !== "new-thread-in") { return; } clearOpenIntent(); @@ -1555,6 +1583,7 @@ function OpenCommandPaletteDialog(props: { label: "Projects", items: enumerateCommandPaletteItems(prioritized), }, + { value: "quick-chat", label: "Quick chat", items: quickChatItems }, ], }); }, [ @@ -1564,10 +1593,13 @@ function OpenCommandPaletteDialog(props: { currentProjectId, openIntent, projectThreadItems, + quickChatItems, pushPaletteView, ]); - const actionItems: Array = []; + const actionItems: Array = [ + ...quickChatItems, + ]; if (projects.length > 0) { const activeProjectTitle = @@ -1604,7 +1636,10 @@ function OpenCommandPaletteDialog(props: { title: "New thread in...", icon: , addonIcon: , - groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], + groups: [ + { value: "projects", label: "Projects", items: projectThreadItems }, + { value: "quick-chat", label: "Quick chat", items: quickChatItems }, + ], }); } diff --git a/apps/web/src/components/LegacyQuickChatList.tsx b/apps/web/src/components/LegacyQuickChatList.tsx new file mode 100644 index 000000000000..293e1ed0ffba --- /dev/null +++ b/apps/web/src/components/LegacyQuickChatList.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import { cn } from "../lib/utils"; +import { useNavigate, useParams } from "@tanstack/react-router"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { useThreadShells } from "../state/entities"; +import { useThreadActionMenu } from "../hooks/useThreadActionMenu"; +import { useAtomCommand } from "../state/use-atom-command"; +import { threadEnvironment } from "../state/threads"; + +function QuickChatRow({ thread, selected }: { thread: EnvironmentThreadShell; selected: boolean }) { + const navigate = useNavigate(); + const [renaming, setRenaming] = useState(false); + const [title, setTitle] = useState(thread.title); + const update = useAtomCommand(threadEnvironment.updateMetadata, "Rename quick chat"); + const { openMenu } = useThreadActionMenu({ + threadRef: scopeThreadRef(thread.environmentId, thread.id), + projectCwd: null, + onStartRename: () => { + setTitle(thread.title); + setRenaming(true); + }, + }); + return ( +
  • + {renaming ? ( + setTitle(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") setRenaming(false); + if (event.key === "Enter" && title.trim()) + void update({ + environmentId: thread.environmentId, + input: { threadId: thread.id, title: title.trim() }, + }).then((result) => { + if (result._tag === "Success") setRenaming(false); + }); + }} + /> + ) : ( + + )} +
  • + ); +} + +export function LegacyQuickChatList() { + const route = useParams({ strict: false }); + const threads = useThreadShells() + .filter((thread) => thread.projectId === null && thread.archivedAt === null) + .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + if (threads.length === 0) return null; + return ( +
    +
    Quick chats
    +
      + {threads.map((thread) => ( + + ))} +
    +
    + ); +} diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 3a3936623275..704e86aefd5b 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -5,6 +5,7 @@ import { resolveThreadCurrentPullRequestLink, visibleThreadPullRequests, } from "@t3tools/shared/threadPullRequests"; +import { LegacyQuickChatList } from "./LegacyQuickChatList"; import { Spinner } from "~/components/ui/spinner"; import { ArchiveIcon, @@ -1314,6 +1315,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec project.memberProjects.map((member) => [member.physicalProjectKey, 0] as const), ); for (const thread of projectThreads) { + if (thread.projectId === null) continue; const member = memberProjectByScopedKey.get( scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), ); @@ -2242,7 +2244,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (!api) return; const threadKey = scopedThreadKey(threadRef); const thread = sidebarThreadByKeyRef.current.get(threadKey) ?? null; - if (!thread) return; + if (!thread || thread.projectId === null) return; + const threadProjectRef = scopeProjectRef(thread.environmentId, thread.projectId); const threadProject = memberProjectByScopedKey.get( scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), ); @@ -2276,7 +2279,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec // 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(thread.environmentId, thread.projectId), { + handleNewThread(threadProjectRef, { branch: thread.branch, worktreePath: thread.worktreePath, envMode: thread.worktreePath ? "worktree" : "local", @@ -3033,6 +3036,14 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
    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({ - - - } - > - - - - void onImplementPlanInNewThread()} + {onImplementPlanInNewThread && ( + + + } > - Implement in a new thread - - - + + + + void onImplementPlanInNewThread()} + > + Implement in a new thread + + + + )}
    ); } 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(); + const storage = { + getItem: (key: string) => disk.get(key) ?? null, + setItem: (key: string, value: string) => { + disk.set(key, value); + }, + removeItem: (key: string) => { + disk.delete(key); + }, + }; + const ref = { environmentId: EnvironmentId.make("environment"), threadId: ThreadId.make("chat") }; + const pending = { + projectId: ProjectId.make("project"), + workspaceRoot: "/project", + baseBranch: "main", + branch: "t3/quick-chat-retry", + }; + const first = createQuickChatAttachmentStorage(storage); + await first.save(ref, pending); + const worktree = { path: "/worktree", refName: pending.branch }; + let created = false; + let creations = 0; + const listRefs = async (): Promise => ({ + refs: created + ? [{ name: pending.branch, current: false, isDefault: false, worktreePath: worktree.path }] + : [], + isRepo: true, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: created ? 1 : 0, + }); + const createWorktree = async () => { + creations += 1; + created = true; + throw new Error("Connection lost after creation"); + }; + await expect(prepareQuickChatWorktree({ pending, listRefs, createWorktree })).rejects.toThrow( + "Connection lost", + ); + const reloaded = createQuickChatAttachmentStorage(storage); + const recovered = reloaded.load(ref); + expect(recovered).toEqual(pending); + expect(await prepareQuickChatWorktree({ pending: recovered!, listRefs, createWorktree })).toEqual( + worktree, + ); + expect(creations).toBe(1); + expect(reloaded.load({ ...ref, environmentId: EnvironmentId.make("other") })).toBeNull(); + reloaded.clear(ref); + expect(first.load(ref)).toBeNull(); +}); diff --git a/packages/client-runtime/src/operations/quickChats.ts b/packages/client-runtime/src/operations/quickChats.ts index fb0efcbeb068..70e4c82ea3c5 100644 --- a/packages/client-runtime/src/operations/quickChats.ts +++ b/packages/client-runtime/src/operations/quickChats.ts @@ -1,10 +1,23 @@ -import type { ModelSelection, ServerConfig } from "@t3tools/contracts"; +import { + ProjectId, + type ModelSelection, + type ServerConfig, + type ScopedThreadRef, + type VcsCreateWorktreeInput, + type VcsCreateWorktreeResult, + type VcsListRefsResult, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; /** Select an available environment default for a chat without project defaults. */ -export function quickChatModelSelection(config: ServerConfig): ModelSelection | null { +export function quickChatModelSelection(config: { + providers: ServerConfig["providers"]; + settings: Pick; +}): ModelSelection | null { const providers = config.providers.filter( (provider) => provider.enabled && + provider.status !== "error" && provider.installed && provider.availability !== "unavailable" && provider.auth.status !== "unauthenticated", @@ -21,3 +34,56 @@ export function quickChatModelSelection(config: ServerConfig): ModelSelection | } return null; } + +const PendingQuickChatAttachment = Schema.Struct({ + projectId: ProjectId, + workspaceRoot: Schema.String, + baseBranch: Schema.String, + branch: Schema.String, +}); +export type PendingQuickChatAttachment = typeof PendingQuickChatAttachment.Type; +const decodePendingAttachment = Schema.decodeUnknownSync( + Schema.fromJsonString(PendingQuickChatAttachment), +); + +/** Persist the intended branch before creating it so a lost response remains recoverable. */ +export function createQuickChatAttachmentStorage(storage: { + getItem: (key: string) => string | null; + setItem: (key: string, value: string) => void | Promise; + removeItem: (key: string) => void; +}) { + const key = (ref: ScopedThreadRef) => + `t3-quick-chat-attachment-${encodeURIComponent(ref.environmentId)}-${encodeURIComponent(ref.threadId)}`; + return { + load(ref: ScopedThreadRef) { + const raw = storage.getItem(key(ref)); + return raw === null ? null : decodePendingAttachment(raw); + }, + save(ref: ScopedThreadRef, pending: PendingQuickChatAttachment) { + return storage.setItem(key(ref), JSON.stringify(pending)); + }, + clear(ref: ScopedThreadRef) { + storage.removeItem(key(ref)); + }, + }; +} + +/** Git owns the recovery record after creation; retries reuse its checked-out branch. */ +export async function prepareQuickChatWorktree(input: { + pending: PendingQuickChatAttachment; + listRefs: () => Promise; + createWorktree: (input: VcsCreateWorktreeInput) => Promise; +}): Promise { + const refs = await input.listRefs(); + const existing = refs.refs.find((ref) => ref.name === input.pending.branch && !ref.isRemote); + if (existing?.worktreePath) { + return { path: existing.worktreePath, refName: existing.name }; + } + const result = await input.createWorktree({ + cwd: input.pending.workspaceRoot, + refName: input.pending.baseBranch, + newRefName: input.pending.branch, + path: null, + }); + return result.worktree; +} diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 6c93e6204dc8..146207835a74 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -15,7 +15,11 @@ import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom, AtomRegistry } from "effect/unstable/reactivity"; -import { createEnvironmentRpcCommand, createEnvironmentSubscriptionAtomFamily } from "./runtime.ts"; +import { + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, + createEnvironmentSubscriptionAtomFamily, +} from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; @@ -276,6 +280,11 @@ export function createVcsEnvironmentAtoms( return { listRefs, + // Mutations need a finite, fresh read rather than the reconnecting picker subscription. + readRefs: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:vcs:read-refs", + tag: WS_METHODS.vcsListRefs, + }), status: createEnvironmentSubscriptionAtomFamily(runtime, { label: "environment-data:vcs:status", idleTtlMs: VCS_STATUS_IDLE_TTL_MS, From bf2a6d206b32ecbb31e50ade3fe1827f0143edd4 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:10:10 +0200 Subject: [PATCH 04/15] fix: handle quick chat attachment recovery and sidebar edge cases --- .../threads/QuickChatProjectAttachment.tsx | 23 +++++++ .../features/threads/ThreadRouteScreen.tsx | 6 +- .../state/quick-chat-attachment-storage.ts | 2 +- .../Layers/OrchestrationEngine.test.ts | 60 +++++++++++++++++-- .../Layers/OrchestrationEngine.ts | 14 +++++ .../src/components/AttachQuickChatDialog.tsx | 29 ++++++++- apps/web/src/components/Sidebar.drag.test.ts | 18 ++++++ apps/web/src/components/Sidebar.drag.ts | 18 +++++- apps/web/src/components/Sidebar.logic.ts | 1 + apps/web/src/components/Sidebar.tsx | 47 +++++++++------ docs/user/thread-sidebar.md | 3 +- .../src/operations/quickChats.test.ts | 40 ++++++++++++- .../src/operations/quickChats.ts | 6 +- 13 files changed, 233 insertions(+), 34 deletions(-) diff --git a/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx b/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx index 681cf61d1a6b..083a49c67f21 100644 --- a/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx +++ b/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx @@ -280,6 +280,29 @@ export function QuickChatProjectAttachment({ threadRef }: { threadRef: ScopedThr Finish the current turn and background work before attaching. )} + {prepared && !busy && ( + { + try { + quickChatAttachmentStorage.clear(threadRef); + setPrepared(null); + setProjectId(""); + setWorkspaceMode("local"); + setBaseBranch(""); + setExistingRef(null); + } catch { + Alert.alert("Could not reset attachment", "Check device storage and retry."); + } + }} + style={{ paddingVertical: 16 }} + > + Change attachment target + + Any created worktree remains available under Existing worktree. + + + )} setOpen(false)}> diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 857609137abe..8c1e25911322 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -247,7 +247,7 @@ function ThreadRouteContent( ); const inspectorMode = (() => { if (inspectorSelection?.routeThreadIdentity === routeThreadIdentity) { - if (inspectorSelection.mode === "files" && selectedThreadCwd === null) { + if (inspectorSelection.mode !== "route" && selectedThreadCwd === null) { return null; } return inspectorSelection.mode; @@ -274,7 +274,7 @@ function ThreadRouteContent( useEffect(() => { setInspectorSelection((current) => { if (props.renderInspector === undefined) { - if (current === null || current.mode === "route") { + if (selectedThreadCwd === null || current === null || current.mode === "route") { return null; } return { ...current, routeThreadIdentity }; @@ -286,7 +286,7 @@ function ThreadRouteContent( return { ...current, routeThreadIdentity }; }); - }, [props.renderInspector, routeThreadIdentity]); + }, [props.renderInspector, routeThreadIdentity, selectedThreadCwd]); useFocusEffect( useCallback(() => { diff --git a/apps/mobile/src/state/quick-chat-attachment-storage.ts b/apps/mobile/src/state/quick-chat-attachment-storage.ts index 48790c508fbf..d0f6230a0428 100644 --- a/apps/mobile/src/state/quick-chat-attachment-storage.ts +++ b/apps/mobile/src/state/quick-chat-attachment-storage.ts @@ -5,7 +5,7 @@ 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`); + return new File(directory, `${encodeURIComponent(key)}.json`); } export const quickChatAttachmentStorage = createQuickChatAttachmentStorage({ diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 8d4e457648e3..6c671b72122a 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -63,6 +63,7 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val function makeOrchestrationLayer( databasePath?: string, repositoryIdentityResolver?: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"], + onProjected?: (event: OrchestrationEvent) => void, ) { const persistence = databasePath ? makeSqlitePersistenceLive(databasePath) @@ -73,7 +74,23 @@ function makeOrchestrationLayer( return Layer.mergeAll( OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), - Layer.provide(OrchestrationProjectionPipelineLive), + Layer.provide( + onProjected + ? Layer.effect( + OrchestrationProjectionPipeline, + Effect.gen(function* () { + const pipeline = yield* OrchestrationProjectionPipeline; + return { + ...pipeline, + projectEventDeferred: (event: OrchestrationEvent) => + pipeline + .projectEventDeferred(event) + .pipe(Effect.tap(() => Effect.sync(() => onProjected(event)))), + }; + }), + ).pipe(Layer.provide(OrchestrationProjectionPipelineLive)) + : OrchestrationProjectionPipelineLive, + ), ), OrchestrationProjectionSnapshotQueryLive, ).pipe( @@ -636,13 +653,22 @@ describe("OrchestrationEngine", () => { } }); - effectIt.effect("attaches quick chats only after background work finishes", () => - Effect.gen(function* () { + effectIt.effect("attaches quick chats only after background work finishes", () => { + let reportBackgroundWork = () => {}; + return Effect.gen(function* () { const engine = yield* OrchestrationEngineService; const snapshots = yield* ProjectionSnapshotQuery; const liveness = yield* ThreadBackgroundLiveness.ThreadBackgroundLivenessService; const threadId = ThreadId.make("quick-chat-background"); const projectId = ProjectId.make("quick-chat-project"); + reportBackgroundWork = () => + liveness.recordTaskLiveness({ + threadId, + taskId: "racing-task", + taskType: "subagent", + status: undefined, + kind: "started", + }); yield* engine.dispatch({ type: "project.create", commandId: CommandId.make("quick-project-create"), @@ -687,6 +713,24 @@ describe("OrchestrationEngine", () => { ).toBeNull(); liveness.clearThreadLiveness(threadId); } + const raced = yield* engine + .dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("quick-attach-race"), + threadId, + projectId, + }) + .pipe(Effect.result); + expect(raced._tag).toBe("Failure"); + expect( + (yield* snapshots.getSnapshot()).threads.find((thread) => thread.id === threadId) + ?.projectId, + ).toBeNull(); + const events = yield* Stream.runCollect(engine.readEvents(0)); + expect(events.some((event) => event.commandId === CommandId.make("quick-attach-race"))).toBe( + false, + ); + liveness.clearThreadLiveness(threadId); yield* engine.dispatch({ type: "thread.meta.update", commandId: CommandId.make("quick-attach-idle"), @@ -697,8 +741,14 @@ describe("OrchestrationEngine", () => { (yield* snapshots.getSnapshot()).threads.find((thread) => thread.id === threadId) ?.projectId, ).toBe(projectId); - }).pipe(Effect.provide(makeOrchestrationLayer())), - ); + }).pipe( + Effect.provide( + makeOrchestrationLayer(undefined, undefined, (event) => { + if (event.commandId === CommandId.make("quick-attach-race")) reportBackgroundWork(); + }), + ), + ); + }); effectIt.effect( "rejects persisted changes and live background work without blocking unrelated threads", diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index d204c083538c..973a17501fa2 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -301,6 +301,20 @@ const makeOrchestrationEngine = Effect.gen(function* () { error: null, }); + // Provider ingestion can report new background work while SQL yields. + // Reject before committing so neither the event nor projection moves it. + if ( + envelope.command.type === "thread.meta.update" && + envelope.command.projectId !== undefined && + threadBackgroundLiveness.getThreadBackgroundLiveness(envelope.command.threadId) !== + null + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: envelope.command.type, + detail: `thread ${envelope.command.threadId} has live background work`, + }); + } + return { committedEvents, attachmentCleanups, diff --git a/apps/web/src/components/AttachQuickChatDialog.tsx b/apps/web/src/components/AttachQuickChatDialog.tsx index 248179b024a7..a0fb1ab5d9b5 100644 --- a/apps/web/src/components/AttachQuickChatDialog.tsx +++ b/apps/web/src/components/AttachQuickChatDialog.tsx @@ -45,7 +45,9 @@ function AttachmentForm({ threadRef }: { threadRef: ScopedThreadRef }) { 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 project = projectId + ? projects.find((candidate) => candidate.id === projectId) + : projects[0]; const unavailable = saved.error !== null || !thread || @@ -165,7 +167,7 @@ function AttachmentForm({ threadRef }: { threadRef: ScopedThreadRef }) { Project setTitle(event.target.value)} onKeyDown={(event) => { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5364e59b05f1..ee1e164cb446 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2527,6 +2527,12 @@ export default function Sidebar() { const activeReorderable = new Set(); for (const thread of visible) { if (thread.projectId === null) { + if ( + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadActiveReorder === + true + ) { + activeReorderable.add(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))); + } active.push(thread); continue; } diff --git a/apps/web/src/hooks/useNewQuickChat.ts b/apps/web/src/hooks/useNewQuickChat.ts index 72831a57e9a3..987922efe3b2 100644 --- a/apps/web/src/hooks/useNewQuickChat.ts +++ b/apps/web/src/hooks/useNewQuickChat.ts @@ -1,5 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { quickChatModelSelection } from "@t3tools/client-runtime/operations/quickChats"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import { DEFAULT_RUNTIME_MODE, type EnvironmentId } from "@t3tools/contracts"; import { useRouter } from "@tanstack/react-router"; import { useCallback, useRef } from "react"; @@ -43,7 +44,11 @@ export function useNewQuickChat() { createdAt: new Date().toISOString(), }, }); - if (result._tag !== "Success") return; + if (result._tag !== "Success") { + if (!isAtomCommandInterrupted(result)) + toastManager.add({ type: "error", title: "Could not create quick chat" }); + return; + } await waitForThreadShell({ environmentId, threadId }); if (router.state.location.href === href) { await router.navigate({ diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 23b1e4c58265..b04dd5deabb9 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -15,6 +15,7 @@ import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { resolveServerBackedAppDisplayName } from "../branding.logic"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; +import { AttachQuickChatDialog } from "../components/AttachQuickChatDialog"; import { CommandPalette } from "../components/CommandPalette"; import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; import { FirstRunGate } from "../components/onboarding/FirstRunGate"; @@ -171,6 +172,7 @@ function RootRouteView() { + ); diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 099d39d7d4c6..784648e4a81e 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,4 +1,3 @@ -import { AttachQuickChatDialog } from "../components/AttachQuickChatDialog"; import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; import { useEffect } from "react"; @@ -146,7 +145,6 @@ function ChatRouteLayout() { return ( <> - ); diff --git a/packages/client-runtime/src/operations/quickChats.test.ts b/packages/client-runtime/src/operations/quickChats.test.ts index 841124b164bf..064500908751 100644 --- a/packages/client-runtime/src/operations/quickChats.test.ts +++ b/packages/client-runtime/src/operations/quickChats.test.ts @@ -40,6 +40,12 @@ it("skips an errored preferred provider and selects an available agent", () => { model: "model", }); expect(quickChatModelSelection({ providers: [failed], settings })).toBeNull(); + expect( + quickChatModelSelection({ + providers: [ready], + settings: { defaultModelSelection: { instanceId: ready.instanceId, model: "removed" } }, + }), + ).toEqual({ instanceId: ready.instanceId, model: "model" }); }); it("recovers the same worktree after losing its creation response and reloading storage", async () => { diff --git a/packages/client-runtime/src/operations/quickChats.ts b/packages/client-runtime/src/operations/quickChats.ts index d3c02fcffaae..206443030e4a 100644 --- a/packages/client-runtime/src/operations/quickChats.ts +++ b/packages/client-runtime/src/operations/quickChats.ts @@ -23,7 +23,14 @@ export function quickChatModelSelection(config: { provider.auth.status !== "unauthenticated", ); const preferred = config.settings.defaultModelSelection; - if (preferred && providers.some((provider) => provider.instanceId === preferred.instanceId)) { + if ( + preferred && + providers.some( + (provider) => + provider.instanceId === preferred.instanceId && + provider.models.some((model) => model.slug === preferred.model), + ) + ) { return preferred; } for (const provider of providers) { From 20a240ff192e4ac610e891d17c683ded5d92d618 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:31:08 +0200 Subject: [PATCH 06/15] fix(mobile): support quick chat lists in Hermes --- apps/mobile/src/features/archive/archivedThreadList.ts | 2 +- apps/mobile/src/features/threads/threadListV2.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/archive/archivedThreadList.ts b/apps/mobile/src/features/archive/archivedThreadList.ts index 3417756f8381..edd1fb48c46c 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.ts @@ -62,7 +62,7 @@ export function buildArchivedThreadGroups(input: { matchesQuery(thread.title, query) || matchesQuery(environmentLabel, query), ) - .toSorted( + .sort( (left, right) => (input.sortOrder === "newest" ? -1 : 1) * (archiveTimestamp(left) - archiveTimestamp(right)), diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 5afd7027a3f2..83e8d993892e 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -500,7 +500,7 @@ export function buildThreadListV2Items(input: { isLast: false, }); } - for (const thread of orderedActive.toSorted( + for (const thread of [...orderedActive].sort( (left, right) => Number(left.projectId === null) - Number(right.projectId === null), )) { items.push({ From 2d16f43bf0e748aed4908ea14ec23d7890ac854b Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:36:46 +0200 Subject: [PATCH 07/15] fix(web): adapt quick chat archives to shared project icons --- apps/web/src/components/settings/SettingsPanels.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e273aa39a82c..6a0b4b44d92b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2908,9 +2908,6 @@ export function ArchivedThreadsPanel() { id: null, environmentId, title: "Quick chats", - cwd: null, - faviconPath: null, - projectIcon: null, }, threads: quickChats, }, @@ -3002,7 +2999,13 @@ export function ArchivedThreadsPanel() { key={`${project.environmentId}:${project.id}`} id={index === 0 ? searchableSetting("archive").id : undefined} title={project.title} - icon={project.id === null ? : } + icon={ + project.id === null ? ( + + ) : ( + + ) + } > {projectThreads.map((thread) => ( Date: Tue, 8 Sep 2026 12:51:49 +0200 Subject: [PATCH 08/15] fix: guard quick chat attachment and input edge cases --- .../threads/QuickChatProjectAttachment.tsx | 24 +++++- .../features/threads/ThreadRouteScreen.tsx | 5 +- .../orchestration/decider.quick-chats.test.ts | 77 +++++++++++-------- apps/server/src/orchestration/decider.ts | 1 + .../src/components/LegacyQuickChatList.tsx | 1 + 5 files changed, 74 insertions(+), 34 deletions(-) diff --git a/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx b/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx index fcd741414486..9b92827fc1d2 100644 --- a/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx +++ b/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx @@ -17,7 +17,7 @@ import { uuidv4 } from "../../lib/uuid"; export function QuickChatProjectAttachment({ threadRef }: { threadRef: ScopedThreadRef }) { const [open, setOpen] = useState(false); - const [saved] = useState(() => { + const [saved, setSaved] = useState(() => { try { return { pending: quickChatAttachmentStorage.load(threadRef), error: null }; } catch { @@ -157,7 +157,27 @@ export function QuickChatProjectAttachment({ threadRef }: { threadRef: ScopedThr } return ( <> - setOpen(true)} className="px-4 py-2"> + { + if (saved.error !== null) { + try { + const attachment = quickChatAttachmentStorage.load(threadRef); + setSaved({ pending: attachment, error: null }); + setPrepared(attachment); + setProjectId(attachment?.projectId ?? ""); + setWorkspaceMode(attachment ? "new" : "local"); + setBaseBranch(attachment?.baseBranch ?? ""); + setExistingRef(null); + } catch { + Alert.alert("Could not load attachment", "Check device storage and retry."); + return; + } + } + setOpen(true); + }} + className="px-4 py-2" + > Attach to project handleOpenTerminal(null), }); } - if (selectedThreadProject?.workspaceRoot) { + if ( + selectedThreadProject?.workspaceRoot && + (!fileInspector.supported || selectedThreadCwd !== null) + ) { actions.push({ accessibilityLabel: "Open git controls", icon: "point.topleft.down.curvedto.point.bottomright.up", diff --git a/apps/server/src/orchestration/decider.quick-chats.test.ts b/apps/server/src/orchestration/decider.quick-chats.test.ts index 54d3e34742ef..98e914f06cfe 100644 --- a/apps/server/src/orchestration/decider.quick-chats.test.ts +++ b/apps/server/src/orchestration/decider.quick-chats.test.ts @@ -173,39 +173,54 @@ 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, + for (const deleted of ["project", "thread"] as const) { + it.effect(`rejects attachment involving a deleted ${deleted}`, () => + 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, }, - }), - ); - expect(result._tag).toBe("Failure"); - if (result._tag === "Failure") expect(String(result.failure)).toContain("deleted project"); - }), - ); + deleted === "project" + ? { + type: "project.delete" as const, + commandId: CommandId.make("delete-project"), + projectId, + } + : { + type: "thread.delete" as const, + commandId: CommandId.make("delete-thread"), + threadId, + }, + ] 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" ? "deleted project" : "idle, unarchived quick chat", + ); + }), + ); + } 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 b60387865c23..7886116016a0 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -898,6 +898,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" const attachmentAt = yield* nowIso; if ( thread.projectId !== null || + thread.deletedAt !== null || thread.archivedAt !== null || thread.session?.status === "running" || thread.session?.status === "starting" || diff --git a/apps/web/src/components/LegacyQuickChatList.tsx b/apps/web/src/components/LegacyQuickChatList.tsx index 45bbd60fa6fd..04ae59fd90cb 100644 --- a/apps/web/src/components/LegacyQuickChatList.tsx +++ b/apps/web/src/components/LegacyQuickChatList.tsx @@ -34,6 +34,7 @@ function QuickChatRow({ thread, selected }: { thread: EnvironmentThreadShell; se value={title} onChange={(event) => setTitle(event.target.value)} onKeyDown={(event) => { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; if (event.key === "Escape") setRenaming(false); if (event.key === "Enter" && title.trim()) void update({ From 5044ead191aed5028d275ef49e0c3245dda2b128 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:22:23 +0200 Subject: [PATCH 09/15] fix(mobile): expose quick chat attachment in the native header --- .../threads/QuickChatProjectAttachment.tsx | 340 ++++++++---------- .../features/threads/ThreadRouteScreen.tsx | 36 +- 2 files changed, 192 insertions(+), 184 deletions(-) diff --git a/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx b/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx index 9b92827fc1d2..52b90cd83a27 100644 --- a/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx +++ b/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx @@ -15,9 +15,14 @@ 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 [saved, setSaved] = useState(() => { +export function QuickChatProjectAttachment({ + threadRef, + onClose, +}: { + threadRef: ScopedThreadRef; + onClose: () => void; +}) { + const [saved] = useState(() => { try { return { pending: quickChatAttachmentStorage.load(threadRef), error: null }; } catch { @@ -48,7 +53,7 @@ export function QuickChatProjectAttachment({ threadRef }: { threadRef: ScopedThr 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, + cwd: workspaceMode !== "local" ? (project?.workspaceRoot ?? null) : null, query: branchQuery, }); const unavailable = @@ -144,7 +149,7 @@ export function QuickChatProjectAttachment({ threadRef }: { threadRef: ScopedThr return; } quickChatAttachmentStorage.clear(threadRef); - setOpen(false); + onClose(); } catch (cause) { Alert.alert( "Could not prepare attachment", @@ -156,191 +161,164 @@ export function QuickChatProjectAttachment({ threadRef }: { threadRef: ScopedThr } } return ( - <> - { - if (saved.error !== null) { - try { - const attachment = quickChatAttachmentStorage.load(threadRef); - setSaved({ pending: attachment, error: null }); - setPrepared(attachment); - setProjectId(attachment?.projectId ?? ""); - setWorkspaceMode(attachment ? "new" : "local"); - setBaseBranch(attachment?.baseBranch ?? ""); - setExistingRef(null); - } catch { - Alert.alert("Could not load attachment", "Check device storage and retry."); - return; - } - } - setOpen(true); - }} - className="px-4 py-2" - > - Attach to project - - { - if (!pending.current) setOpen(false); + { + if (!pending.current) onClose(); + }} + > + - - Attach to project - - {saved.error && {saved.error}} - {projects.map((candidate) => ( - { - setProjectId(candidate.id); - setBaseBranch(""); - setExistingRef(null); - }} - style={{ paddingVertical: 14 }} - > - - {candidate.id === project?.id ? "● " : "○ "} - {candidate.title} - - - ))} - {projects.length === 0 && ( - Add a project on this environment first. - )} - Workspace - {(["local", "existing", "new"] as const).map((mode) => ( - setWorkspaceMode(mode)} - style={{ paddingVertical: 12 }} - > - - {workspaceMode === mode ? "● " : "○ "} - {mode === "local" - ? "Local checkout" - : mode === "existing" - ? "Existing worktree" - : "New worktree"} - - - ))} - {workspaceMode !== "local" && ( - <> - - {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 && ( + Attach to project + + {saved.error && {saved.error}} + {projects.map((candidate) => ( + { + setProjectId(candidate.id); + setBaseBranch(""); + setExistingRef(null); + }} + style={{ paddingVertical: 14 }} + > - Finish the current turn and background work, and resolve pending requests before - attaching. + {candidate.id === project?.id ? "● " : "○ "} + {candidate.title} - )} - {prepared && !busy && ( - { - try { - quickChatAttachmentStorage.clear(threadRef); - setPrepared(null); - setProjectId(""); - setWorkspaceMode("local"); - setBaseBranch(""); - setExistingRef(null); - } catch { - Alert.alert("Could not reset attachment", "Check device storage and retry."); - } - }} - style={{ paddingVertical: 16 }} - > - Change attachment target - - Any created worktree remains available under Existing worktree. - - - )} - - - setOpen(false)}> - Cancel + ))} + {projects.length === 0 && ( + Add a project on this environment first. + )} + Workspace + {(["local", "existing", "new"] as const).map((mode) => ( void attach()} + key={mode} + accessibilityRole="radio" + accessibilityState={{ checked: workspaceMode === mode }} + disabled={busy || prepared !== null} + onPress={() => setWorkspaceMode(mode)} + style={{ paddingVertical: 12 }} > - - {busy ? "Attaching…" : "Attach"} + + {workspaceMode === mode ? "● " : "○ "} + {mode === "local" + ? "Local checkout" + : mode === "existing" + ? "Existing worktree" + : "New worktree"} - + ))} + {workspaceMode !== "local" && ( + <> + + {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 && ( + + Finish the current turn and background work, and resolve pending requests before + attaching. + + )} + {prepared && !busy && ( + { + try { + quickChatAttachmentStorage.clear(threadRef); + setPrepared(null); + setProjectId(""); + setWorkspaceMode("local"); + setBaseBranch(""); + setExistingRef(null); + } catch { + Alert.alert("Could not reset attachment", "Check device storage and retry."); + } + }} + style={{ paddingVertical: 16 }} + > + Change attachment target + + Any created worktree remains available under Existing worktree. + + + )} + + + + Cancel + + void attach()} + > + + {busy ? "Attaching…" : "Attach"} + + - - + + ); } diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 923abf691e4f..eef5a83eca33 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -242,6 +242,24 @@ function ThreadRouteContent( const threadId = firstRouteParam(params.threadId); const routeThreadIdentity = environmentIdRaw !== null && threadId !== null ? `${environmentIdRaw}:${threadId}` : null; + const [attachmentThreadIdentity, setAttachmentThreadIdentity] = useState(null); + const handleOpenQuickChatAttachment = useCallback(() => { + setAttachmentThreadIdentity(routeThreadIdentity); + }, [routeThreadIdentity]); + const isQuickChat = selectedThread?.projectId === null; + const quickChatHeaderItems = useMemo( + () => [ + withNativeGlassHeaderItem({ + accessibilityLabel: "Attach to project", + icon: { name: "folder.badge.plus", type: "sfSymbol" as const }, + identifier: "thread-attach-project", + label: "Attach to project", + onPress: handleOpenQuickChatAttachment, + type: "button" as const, + }), + ], + [handleOpenQuickChatAttachment], + ); const [inspectorSelection, setInspectorSelection] = useState( () => (props.renderInspector ? { routeThreadIdentity, mode: "route" } : null), ); @@ -709,6 +727,13 @@ function ThreadRouteContent( if (Platform.OS !== "android") return []; const actions: AndroidHeaderAction[] = []; + if (isQuickChat) { + actions.push({ + accessibilityLabel: "Attach to project", + icon: "folder", + onPress: handleOpenQuickChatAttachment, + }); + } if (props.onReturnToThread) { actions.push({ accessibilityLabel: "Return to chat", @@ -749,6 +774,8 @@ function ThreadRouteContent( } return actions; }, [ + isQuickChat, + handleOpenQuickChatAttachment, fileInspector.supported, handleOpenFilesInspector, handleOpenTerminal, @@ -864,10 +891,11 @@ function ThreadRouteContent( : undefined } > - {selectedThread?.projectId === null && ( + {isQuickChat && attachmentThreadIdentity === routeThreadIdentity && ( setAttachmentThreadIdentity(null)} /> )} {activeInspectorRenderer ? : null} Date: Tue, 8 Sep 2026 13:59:40 +0200 Subject: [PATCH 10/15] fix(mobile): match quick chat attachment to the creation theme --- .../threads/NewTaskContextPickerScreens.tsx | 4 +- .../threads/QuickChatProjectAttachment.tsx | 485 ++++++++++++------ 2 files changed, 334 insertions(+), 155 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index 0857944cb37b..5669b2793d7f 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -37,7 +37,7 @@ import { import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; import { checkoutNewTaskBranch } from "./checkout-new-task-branch"; -function SelectionRow(props: { +export function SelectionRow(props: { readonly icon?: "arrow.triangle.branch" | ReactNode; readonly onPress: () => void; readonly disabled?: boolean; @@ -142,7 +142,7 @@ export function BranchSelectionRow(props: { ); } -function PickerSurface(props: { readonly children: ReactNode }) { +export function PickerSurface(props: { readonly children: ReactNode }) { return {props.children}; } diff --git a/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx b/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx index 52b90cd83a27..52b3aa3e1dc1 100644 --- a/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx +++ b/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx @@ -1,5 +1,15 @@ import { useRef, useState } from "react"; -import { Alert, Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native"; +import { Alert, Modal, Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { AppText as Text, AppTextInput } from "../../components/AppText"; +import { AndroidHeaderIconButton } from "../../components/AndroidScreenHeader"; +import { ComposerInlineControl, ComposerToolbarScroller } from "../../components/ComposerToolbar"; +import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; +import { ProjectFavicon } from "../../components/ProjectFavicon"; +import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; +import { useEnvironmentServerConfig } from "../../state/entities"; +import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; +import { cn } from "../../lib/cn"; import type { ScopedThreadRef, VcsRef } from "@t3tools/contracts"; import { useProjects, useThreadShell } from "../../state/entities"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -12,7 +22,7 @@ import { 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 { BranchSelectionRow, PickerSurface, SelectionRow } from "./NewTaskContextPickerScreens"; import { uuidv4 } from "../../lib/uuid"; export function QuickChatProjectAttachment({ @@ -22,6 +32,13 @@ export function QuickChatProjectAttachment({ threadRef: ScopedThreadRef; onClose: () => void; }) { + const insets = useSafeAreaInsets(); + const [page, setPage] = useState<"overview" | "project" | "workspace" | "branch">("overview"); + const serverConfig = useEnvironmentServerConfig(threadRef.environmentId); + const { connectedEnvironments } = useRemoteConnectionStatus(); + const environment = connectedEnvironments.find( + (candidate) => candidate.environmentId === threadRef.environmentId, + ); const [saved] = useState(() => { try { return { pending: quickChatAttachmentStorage.load(threadRef), error: null }; @@ -53,7 +70,7 @@ export function QuickChatProjectAttachment({ const project = projectId ? projects.find((project) => project.id === projectId) : projects[0]; const branchState = usePaginatedBranches({ environmentId: threadRef.environmentId, - cwd: workspaceMode !== "local" ? (project?.workspaceRoot ?? null) : null, + cwd: page === "branch" ? (project?.workspaceRoot ?? null) : null, query: branchQuery, }); const unavailable = @@ -160,164 +177,326 @@ export function QuickChatProjectAttachment({ setBusy(false); } } + const workspaceLabel = + workspaceMode === "local" + ? "Local checkout" + : newWorktree + ? "New worktree" + : "Existing worktree"; + const branchLabel = newWorktree + ? baseBranch || "Base branch" + : existingRef?.name || "Choose worktree"; + const selectionLocked = busy || prepared !== null; + const attachDisabled = + busy || + unavailable || + !project || + (newWorktree && !baseBranch.trim()) || + (workspaceMode === "existing" && !existingRef); + const pageTitle = + page === "project" + ? "Choose project" + : page === "workspace" + ? "Workspace" + : page === "branch" + ? newWorktree + ? "Base branch" + : "Worktree" + : "Attach to project"; + function goBack() { + if (pending.current) return; + if (page === "overview") onClose(); + else { + setBranchQuery(""); + setPage("overview"); + } + } return ( - { - if (!pending.current) onClose(); - }} - > + - Attach to project - - {saved.error && {saved.error}} - {projects.map((candidate) => ( - { - setProjectId(candidate.id); - setBaseBranch(""); - setExistingRef(null); - }} - style={{ paddingVertical: 14 }} - > - - {candidate.id === project?.id ? "● " : "○ "} - {candidate.title} - - - ))} - {projects.length === 0 && ( - Add a project on this environment first. - )} - Workspace - {(["local", "existing", "new"] as const).map((mode) => ( - setWorkspaceMode(mode)} - style={{ paddingVertical: 12 }} + + + + {pageTitle} + + + + {page === "overview" ? ( + <> + - - {workspaceMode === mode ? "● " : "○ "} - {mode === "local" - ? "Local checkout" - : mode === "existing" - ? "Existing worktree" - : "New worktree"} - - - ))} - {workspaceMode !== "local" && ( - <> - - {newWorktree ? "Base branch" : "Worktree"} - - - {branchState.refs.map((ref, index) => ( - + + Attach this chat + + + to + setPage("project")} + className={cn( + "min-w-0 max-w-[250px] border-b border-foreground-muted active:opacity-65", + selectionLocked && "opacity-45", + )} + > + + {project?.title ?? "a project"} + + + + + + } - disabled={ - busy || - prepared !== null || - (workspaceMode === "existing" && - (!ref.worktreePath || ref.worktreePath === project?.workspaceRoot)) - } - selected={newWorktree ? baseBranch === ref.name : existingRef?.name === ref.name} - onSelect={(ref) => { - if (newWorktree) setBaseBranch(ref.name); - else setExistingRef(ref); - }} + label={`on ${environment?.environmentLabel ?? "this environment"}`} + maxWidth={260} + showChevron={false} + static /> - ))} - {branchState.isPending && Loading branches…} - {branchState.data?.nextCursor != null && ( - branchState.loadNext()}> - Load more branches - + + {saved.error ? ( + + {saved.error} + + ) : unavailable ? ( + + Finish the current turn, background work, and pending requests before attaching. + + ) : null} + {projects.length === 0 && ( + + Add a project on this environment first. + )} - - )} - {unavailable && ( - - Finish the current turn and background work, and resolve pending requests before - attaching. - - )} - {prepared && !busy && ( - { - try { - quickChatAttachmentStorage.clear(threadRef); - setPrepared(null); - setProjectId(""); - setWorkspaceMode("local"); - setBaseBranch(""); - setExistingRef(null); - } catch { - Alert.alert("Could not reset attachment", "Check device storage and retry."); - } - }} - style={{ paddingVertical: 16 }} - > - Change attachment target - - Any created worktree remains available under Existing worktree. - - - )} - - - - Cancel - - void attach()} + {prepared && !busy && ( + + { + try { + quickChatAttachmentStorage.clear(threadRef); + setPrepared(null); + setProjectId(""); + setWorkspaceMode("local"); + setBaseBranch(""); + setExistingRef(null); + } catch { + Alert.alert( + "Could not reset attachment", + "Check device storage and retry.", + ); + } + }} + className="min-h-11 justify-center rounded-full bg-card px-4 active:opacity-70" + > + Change attachment target + + + Any created worktree remains available under Existing worktree. + + + )} + + + + + setPage("workspace")} + maxWidth={180} + showChevron={false} + /> + {workspaceMode !== "local" && ( + { + setBranchQuery(""); + setPage("branch"); + }} + chevronDirection="right" + maxWidth={180} + /> + )} + + + void attach()} + className={cn( + "min-h-12 items-center justify-center rounded-full bg-primary px-5 py-3 active:opacity-70", + attachDisabled && "opacity-45", + )} + > + + {busy ? "Attaching…" : "Attach to project"} + + + + + ) : ( + - - {busy ? "Attaching…" : "Attach"} - - - + {page === "project" && ( + + {projects.map((candidate, index) => ( + + } + selected={candidate.id === project?.id} + isLast={index === projects.length - 1} + disabled={selectionLocked} + onPress={() => { + setProjectId(candidate.id); + setBaseBranch(""); + setExistingRef(null); + setPage("overview"); + }} + /> + ))} + + )} + {page === "workspace" && ( + + {(["local", "existing", "new"] as const).map((mode, index) => ( + { + setWorkspaceMode(mode); + setBranchQuery(""); + setPage(mode === "local" ? "overview" : "branch"); + }} + /> + ))} + + )} + {page === "branch" && ( + <> + + + + {branchState.refs.map((ref, index) => ( + { + if (newWorktree) setBaseBranch(ref.name); + else setExistingRef(ref); + setBranchQuery(""); + setPage("overview"); + }} + /> + ))} + {branchState.isPending ? ( + + Loading branches… + + ) : branchState.error ? ( + + + {branchState.error} + + + Try again + + + ) : branchState.refs.length === 0 ? ( + + {branchQuery ? "No matching branches" : "No branches available"} + + ) : null} + {branchState.data?.nextCursor != null && ( + branchState.loadNext()} + className="min-h-11 items-center justify-center py-3" + > + Load more branches + + )} + + )} + + )} ); From 6a4f1cda311299ca1d00b8adfb85e218c18f4669 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:44:09 +0200 Subject: [PATCH 11/15] fix(server): preserve quick chat files when attaching to projects --- .../Layers/OrchestrationEngine.test.ts | 21 +- .../Layers/OrchestrationEngine.ts | 59 +++++ .../Layers/ProviderCommandReactor.test.ts | 233 +++++++++++------- .../Layers/ProviderCommandReactor.ts | 36 ++- .../Layers/ThreadDeletionReactor.test.ts | 65 +++++ .../Layers/ThreadDeletionReactor.ts | 28 ++- .../orchestration/quickChatWorkspace.test.ts | 112 +++++++++ .../src/orchestration/quickChatWorkspace.ts | 130 ++++++++++ apps/server/src/orchestration/runtimeLayer.ts | 2 +- apps/server/src/server.ts | 8 +- docs/user/thread-sidebar.md | 7 +- 11 files changed, 581 insertions(+), 120 deletions(-) create mode 100644 apps/server/src/orchestration/quickChatWorkspace.test.ts create mode 100644 apps/server/src/orchestration/quickChatWorkspace.ts diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 6c671b72122a..249ef2faad4b 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -20,6 +20,8 @@ import { import * as NodeServices from "@effect/platform-node/NodeServices"; import { it as effectIt } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import { makeQuickChatWorkspace } from "../quickChatWorkspace.ts"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Metric from "effect/Metric"; @@ -486,6 +488,7 @@ describe("OrchestrationEngine", () => { Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(SqlitePersistenceMemory), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-engine-workspace-test-" })), Layer.provideMerge(NodeServices.layer), ); @@ -661,6 +664,12 @@ describe("OrchestrationEngine", () => { const liveness = yield* ThreadBackgroundLiveness.ThreadBackgroundLivenessService; const threadId = ThreadId.make("quick-chat-background"); const projectId = ProjectId.make("quick-chat-project"); + const fs = yield* FileSystem.FileSystem; + const workspace = yield* makeQuickChatWorkspace; + const cwd = yield* fs.makeTempDirectoryScoped(); + const source = workspace.directory(threadId); + yield* fs.makeDirectory(source, { recursive: true }); + yield* fs.writeFileString(`${source}/script.sh`, "echo preserved"); reportBackgroundWork = () => liveness.recordTaskLiveness({ threadId, @@ -674,7 +683,7 @@ describe("OrchestrationEngine", () => { commandId: CommandId.make("quick-project-create"), projectId, title: "Project", - workspaceRoot: "/tmp/quick-chat-project", + workspaceRoot: cwd, createdAt: now(), }); yield* engine.dispatch({ @@ -722,6 +731,9 @@ describe("OrchestrationEngine", () => { }) .pipe(Effect.result); expect(raced._tag).toBe("Failure"); + expect(yield* fs.readFileString(`${source}/script.sh`)).toBe("echo preserved"); + expect(yield* fs.readDirectory(`${cwd}/quick-chat-files`)).toEqual([]); + expect(yield* workspace.pendingNote(threadId, cwd)).toBeNull(); expect( (yield* snapshots.getSnapshot()).threads.find((thread) => thread.id === threadId) ?.projectId, @@ -742,6 +754,7 @@ describe("OrchestrationEngine", () => { ?.projectId, ).toBe(projectId); }).pipe( + Effect.scoped, Effect.provide( makeOrchestrationLayer(undefined, undefined, (event) => { if (event.commandId === CommandId.make("quick-attach-race")) reportBackgroundWork(); @@ -1804,6 +1817,9 @@ describe("OrchestrationEngine", () => { Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-engine-workspace-test-" }), + ), Layer.provide(NodeServices.layer), ), ); @@ -1953,6 +1969,9 @@ describe("OrchestrationEngine", () => { Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-engine-workspace-test-" }), + ), Layer.provide(NodeServices.layer), ), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 973a17501fa2..be834b28d643 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -49,6 +49,9 @@ import { OrchestrationEngineService, type OrchestrationEngineShape, } from "../Services/OrchestrationEngine.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { makeQuickChatWorkspace } from "../quickChatWorkspace.ts"; + const isOrchestrationCommandPreviouslyRejectedError = Schema.is( OrchestrationCommandPreviouslyRejectedError, ); @@ -89,6 +92,9 @@ const makeOrchestrationEngine = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; const crypto = yield* Crypto.Crypto; + // Offline CLI engines have no provider processes; server composition supplies this service. + const providers = yield* Effect.serviceOption(ProviderService); + const quickChatWorkspace = yield* makeQuickChatWorkspace; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); let commandReadModel = createEmptyReadModel(yield* nowIso); @@ -268,6 +274,40 @@ const makeOrchestrationEngine = Effect.gen(function* () { ...planned, metadata: { ...planned.metadata, origin: envelope.origin }, })); + const promotion = + envelope.command.type === "thread.meta.update" && envelope.command.projectId !== undefined + ? yield* Effect.gen(function* () { + const command = envelope.command; + if (command.type !== "thread.meta.update" || command.projectId === undefined) + return null; + const thread = commandReadModel.threads.find( + (thread) => thread.id === command.threadId, + )!; + const project = commandReadModel.projects.find( + (project) => project.id === command.projectId, + )!; + if (Option.isSome(providers)) { + const sessions = yield* providers.value.listSessions(); + if (sessions.some((session) => session.threadId === thread.id)) { + yield* providers.value.stopSession({ threadId: thread.id }); + } + } + return yield* quickChatWorkspace.prepare( + thread.id, + command.worktreePath ?? project.workspaceRoot, + ); + }).pipe( + Effect.mapError( + (cause) => + new OrchestrationCommandInvariantError({ + commandType: envelope.command.type, + detail: + "Could not transfer the quick-chat workspace. Its original files have been kept.", + cause, + }), + ), + ) + : null; const committedCommand = yield* sql .withTransaction( Effect.gen(function* () { @@ -324,6 +364,17 @@ const makeOrchestrationEngine = Effect.gen(function* () { }), ) .pipe( + Effect.onError(() => + promotion + ? promotion.rollback.pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to roll back quick-chat transfer", { + cause: Cause.pretty(cause), + }), + ), + ) + : Effect.void, + ), Effect.catchTag("SqlError", (sqlError) => Effect.fail( toPersistenceSqlError("OrchestrationEngine.processEnvelope:transaction")(sqlError), @@ -332,6 +383,14 @@ const makeOrchestrationEngine = Effect.gen(function* () { ); commandReadModel = committedCommand.nextCommandReadModel; + if (promotion) + yield* promotion.commit.pipe( + Effect.catchCause((cause) => + Effect.logWarning("Quick-chat source cleanup will retry on the next turn", { + cause: Cause.pretty(cause), + }), + ), + ); for (const cleanup of committedCommand.attachmentCleanups) { yield* cleanup; } diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index d18eb32df02f..c93b9844a90d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -833,102 +833,147 @@ describe("ProviderCommandReactor", () => { }), ); - effectIt.effect("runs a quick chat and resumes its history after attaching to a worktree", () => - Effect.gen(function* () { - const started = yield* Deferred.make(); - const resumed = yield* Deferred.make(); - let starts = 0; - const harness = yield* Effect.promise(() => - createHarness({ - quickChat: true, - startSessionEffect: (session) => - Deferred.succeed(++starts === 1 ? started : resumed, undefined).pipe( - Effect.as(session), - ), - }), - ); - yield* harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("quick-turn"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("quick-message"), - role: "user", - text: "Explain passkeys", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: "2026-01-01T00:00:00.000Z", - }); - yield* Deferred.await(started); - yield* Effect.promise(() => harness.drain()); - const snapshot = yield* Effect.promise(() => harness.readModel()); - expect(snapshot.projects).toEqual([]); - expect(snapshot.threads[0]?.projectId).toBeNull(); - expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - cwd: expect.stringContaining("quick-chats"), - }); - expect(harness.sendTurn).toHaveBeenCalledOnce(); - yield* harness.engine.dispatch({ - type: "project.create", - commandId: CommandId.make("quick-project"), - projectId: ProjectId.make("project-1"), - title: "Project", - workspaceRoot: "/tmp/provider-project", - createdAt: "2026-01-01T00:00:00.000Z", - }); - yield* harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.make("quick-idle"), - threadId: ThreadId.make("thread-1"), - session: { + effectIt.effect( + "transfers quick-chat files and adds hidden context only to the next provider turn", + () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const resumed = yield* Deferred.make(); + let starts = 0; + const harness = yield* Effect.promise(() => + createHarness({ + quickChat: true, + startSessionEffect: (session) => + Deferred.succeed(++starts === 1 ? started : resumed, undefined).pipe( + Effect.as(session), + ), + }), + ); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("quick-turn"), threadId: ThreadId.make("thread-1"), - status: "ready", - providerName: "codex", - providerInstanceId: ProviderInstanceId.make("codex"), + message: { + messageId: asMessageId("quick-message"), + role: "user", + text: "Explain passkeys", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: "2026-01-01T00:00:01.000Z", - }, - createdAt: "2026-01-01T00:00:01.000Z", - }); - yield* harness.engine.dispatch({ - type: "thread.meta.update", - commandId: CommandId.make("quick-attach"), - threadId: ThreadId.make("thread-1"), - projectId: ProjectId.make("project-1"), - worktreePath: "/tmp/provider-project-worktree", - branch: "quick-chat", - }); - yield* harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("quick-resume"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("quick-followup"), - role: "user", - text: "Implement them", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: "2026-01-01T00:00:02.000Z", - }); - yield* Deferred.await(resumed); - yield* Effect.promise(() => harness.drain()); - expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ - cwd: "/tmp/provider-project-worktree", - resumeCursor: { opaque: "resume-1" }, - }); - expect(harness.sendTurn).toHaveBeenCalledTimes(2); - expect( - (yield* Effect.promise(() => harness.readModel())).threads[0]?.messages.map( - (message) => message.text, - ), - ).toEqual(["Explain passkeys", "Implement them"]); - }), + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* Deferred.await(started); + yield* Effect.promise(() => harness.drain()); + const snapshot = yield* Effect.promise(() => harness.readModel()); + expect(snapshot.projects).toEqual([]); + expect(snapshot.threads[0]?.projectId).toBeNull(); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + cwd: expect.stringContaining("quick-chats"), + }); + expect(harness.sendTurn).toHaveBeenCalledOnce(); + const startInput = harness.startSession.mock.calls[0]?.[1]; + if ( + typeof startInput !== "object" || + startInput === null || + !("cwd" in startInput) || + typeof startInput.cwd !== "string" + ) + throw new Error("Expected quick-chat workspace"); + const scratchCwd = startInput.cwd; + NodeFS.writeFileSync(NodePath.join(scratchCwd, "example.py"), "print('hello')"); + const worktree = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-promoted-worktree-"), + ); + createdStateDirs.add(worktree); + + yield* harness.engine.dispatch({ + type: "project.create", + commandId: CommandId.make("quick-project"), + projectId: ProjectId.make("project-1"), + title: "Project", + workspaceRoot: "/tmp/provider-project", + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("quick-idle"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:01.000Z", + }, + createdAt: "2026-01-01T00:00:01.000Z", + }); + yield* harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("quick-attach"), + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + worktreePath: worktree, + branch: "quick-chat", + }); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("quick-resume"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("quick-followup"), + role: "user", + text: "Implement them", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:02.000Z", + }); + yield* Deferred.await(resumed); + yield* Effect.promise(() => harness.drain()); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + cwd: worktree, + }); + expect(harness.sendTurn).toHaveBeenCalledTimes(2); + expect(harness.stopSession).toHaveBeenCalledWith({ threadId: ThreadId.make("thread-1") }); + expect(NodeFS.existsSync(scratchCwd)).toBe(false); + const filesPath = NodePath.join( + worktree, + "quick-chat-files", + Buffer.from("thread-1").toString("base64url"), + ); + expect(NodeFS.readFileSync(NodePath.join(filesPath, "example.py"), "utf8")).toBe( + "print('hello')", + ); + expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ + input: expect.stringContaining(filesPath), + }); + expect( + (yield* Effect.promise(() => harness.readModel())).threads[0]?.messages.map( + (message) => message.text, + ), + ).toEqual(["Explain passkeys", "Implement them"]); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("after-promotion-note"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("third-message"), + role: "user", + text: "Continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:03.000Z", + }); + yield* Effect.promise(() => harness.drain()); + expect(harness.sendTurn.mock.calls[2]?.[0]).toMatchObject({ input: "Continue" }); + }), ); it("reacts to thread.turn.start by ensuring session and sending provider turn", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index d501663dee6a..227e78d6af1e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1,5 +1,4 @@ -import * as Path from "effect/Path"; -import * as ServerConfig from "../../config.ts"; +import { makeQuickChatWorkspace } from "../quickChatWorkspace.ts"; import { type ChatAttachment, CommandId, @@ -327,8 +326,7 @@ const make = Effect.gen(function* () { const providerRegistry = yield* ProviderRegistry; const gitWorkflow = yield* GitWorkflowService; const fileSystem = yield* FileSystem.FileSystem; - const serverConfig = yield* ServerConfig.ServerConfig; - const path = yield* Path.Path; + const quickChatWorkspace = yield* makeQuickChatWorkspace; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; @@ -496,11 +494,7 @@ const make = Effect.gen(function* () { readonly worktreePath: string | null; }) { if (thread.projectId === null) { - const cwd = path.join( - serverConfig.stateDir, - "quick-chats", - Buffer.from(thread.id).toString("base64url"), - ); + const cwd = quickChatWorkspace.directory(thread.id); yield* fileSystem.makeDirectory(cwd, { recursive: true }).pipe( Effect.mapError( (cause) => @@ -888,7 +882,11 @@ const make = Effect.gen(function* () { if (input.modelSelection !== undefined) { threadModelSelections.set(input.threadId, input.modelSelection); } - const normalizedInput = toNonEmptyProviderInput(input.messageText); + const cwd = thread.projectId === null ? null : yield* resolveSessionCwd(thread); + const promotionNote = cwd ? yield* quickChatWorkspace.pendingNote(thread.id, cwd) : null; + const normalizedInput = toNonEmptyProviderInput( + promotionNote ? `${input.messageText}\n\n${promotionNote}` : input.messageText, + ); const normalizedAttachments = input.attachments ?? []; const activeSession = yield* providerService .listSessions() @@ -1460,9 +1458,21 @@ const make = Effect.gen(function* () { return; } - yield* providerService - .sendTurn(sendTurnRequest.value) - .pipe(Effect.asVoid, Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped); + yield* providerService.sendTurn(sendTurnRequest.value).pipe( + Effect.tap(() => + quickChatWorkspace.clearNote(thread.id).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Could not acknowledge quick-chat relocation note", { + threadId: thread.id, + cause: Cause.pretty(cause), + }), + ), + ), + ), + Effect.asVoid, + Effect.catchCause(recoverTurnStartFailure), + Effect.forkScoped, + ); }); const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts index f83f1dd1b9fa..50ff2f6c415b 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts @@ -1,3 +1,5 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ServerConfig } from "../../config.ts"; import { CommandId, CorrelationId, @@ -12,6 +14,8 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as FileSystem from "effect/FileSystem"; +import { makeQuickChatWorkspace } from "../quickChatWorkspace.ts"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { describe, expect, it } from "vite-plus/test"; @@ -79,6 +83,65 @@ describe("ThreadDeletionReactor drain", () => { payload: { threadId, deletedAt: now }, }); + effectIt.effect("archive retains files and deletion waits for the provider to stop", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspace = yield* makeQuickChatWorkspace; + const source = workspace.directory(threadId); + yield* fs.makeDirectory(source, { recursive: true }); + yield* fs.writeFileString(`${source}/script.sh`, "echo hello"); + const deleteChat = yield* Deferred.make(); + const stopStarted = yield* Deferred.make(); + const finishStop = yield* Deferred.make(); + const archived: OrchestrationEvent = { + ...deletedEvent(1), + type: "thread.archived", + payload: { threadId, archivedAt: now, updatedAt: now }, + }; + const engine = { + latestSequence: Effect.succeed(0), + streamDomainEvents: Stream.concat( + Stream.make(archived), + Stream.fromEffect(Deferred.await(deleteChat)).pipe(Stream.map(() => deletedEvent(2))), + ), + } as unknown as OrchestrationEngineShape; + const provider = { + stopSession: () => + Deferred.succeed(stopStarted, undefined).pipe(Effect.andThen(Deferred.await(finishStop))), + } as unknown as ProviderServiceShape; + const terminal = { + close: () => Effect.void, + } as unknown as TerminalManager.TerminalManager["Service"]; + yield* Effect.gen(function* () { + const reactor = yield* ThreadDeletionReactor; + yield* reactor.start(); + yield* reactor.drainThrough(1); + expect(yield* fs.readFileString(`${source}/script.sh`)).toBe("echo hello"); + yield* Deferred.succeed(deleteChat, undefined); + yield* Deferred.await(stopStarted); + expect(yield* fs.exists(source)).toBe(true); + yield* Deferred.succeed(finishStop, undefined); + yield* reactor.drainThrough(2); + expect(yield* fs.exists(source)).toBe(false); + }).pipe( + Effect.provide( + ThreadDeletionReactorLive.pipe( + Layer.provide(Layer.succeed(ProviderService, provider)), + Layer.provide(Layer.succeed(TerminalManager.TerminalManager, terminal)), + Layer.provide(Layer.succeed(OrchestrationEngineService, engine)), + ), + ), + ); + }).pipe( + Effect.scoped, + Effect.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-deletion-lifecycle-", + }).pipe(Layer.provideMerge(NodeServices.layer)), + ), + ), + ); + effectIt.effect("waits for a published deletion the subscriber has not consumed yet", () => Effect.gen(function* () { const stops: Array = []; @@ -112,6 +175,8 @@ describe("ThreadDeletionReactor drain", () => { Layer.provide(Layer.succeed(ProviderService, providerService)), Layer.provide(Layer.succeed(TerminalManager.TerminalManager, terminalManager)), Layer.provide(Layer.succeed(OrchestrationEngineService, engine)), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-deletion-test-" })), + Layer.provide(NodeServices.layer), ); yield* Effect.scoped( diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index 14a92a5eaef5..09d029e984c6 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -1,3 +1,4 @@ +import { makeQuickChatWorkspace } from "../quickChatWorkspace.ts"; import type { OrchestrationEvent } from "@t3tools/contracts"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import * as Cause from "effect/Cause"; @@ -41,15 +42,9 @@ export const logCleanupCauseUnlessInterrupted = ({ const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; + const quickChatWorkspace = yield* makeQuickChatWorkspace; const terminalManager = yield* TerminalManager.TerminalManager; - const stopProviderSession = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => - logCleanupCauseUnlessInterrupted({ - effect: providerService.stopSession({ threadId }), - message: "thread deletion cleanup skipped provider session stop", - threadId, - }); - const closeThreadTerminals = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => logCleanupCauseUnlessInterrupted({ effect: terminalManager.close({ threadId, deleteHistory: true }), @@ -61,8 +56,25 @@ const make = Effect.gen(function* () { event: ThreadDeletedEvent, ) { const { threadId } = event.payload; - yield* stopProviderSession(threadId); + let stopped = false; + yield* logCleanupCauseUnlessInterrupted({ + effect: providerService.stopSession({ threadId }).pipe( + Effect.catchTag("ProviderSessionNotFoundError", () => Effect.void), + Effect.tap(() => { + stopped = true; + return Effect.void; + }), + ), + message: "thread deletion cleanup skipped provider session stop", + threadId, + }); yield* closeThreadTerminals(threadId); + if (stopped) + yield* logCleanupCauseUnlessInterrupted({ + effect: quickChatWorkspace.remove(threadId), + message: "thread deletion cleanup skipped quick-chat directory removal", + threadId, + }); }); const processThreadDeletedSafely = (event: ThreadDeletedEvent) => diff --git a/apps/server/src/orchestration/quickChatWorkspace.test.ts b/apps/server/src/orchestration/quickChatWorkspace.test.ts new file mode 100644 index 000000000000..da665178f7c5 --- /dev/null +++ b/apps/server/src/orchestration/quickChatWorkspace.test.ts @@ -0,0 +1,112 @@ +import { ThreadId } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { expect } from "vite-plus/test"; +import { ServerConfig } from "../config.ts"; +import { makeQuickChatWorkspace } from "./quickChatWorkspace.ts"; + +const layer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-quick-workspace-test-" }).pipe( + Layer.provideMerge(NodeServices.layer), +); +const threadId = ThreadId.make("quick-chat"); + +it.effect("transfers files before cleanup and retains the hidden note until acknowledged", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaces = yield* makeQuickChatWorkspace; + const cwd = yield* fs.makeTempDirectoryScoped(); + const source = workspaces.directory(threadId); + yield* fs.makeDirectory(path.join(source, "scripts"), { recursive: true }); + yield* fs.writeFileString(path.join(source, "scripts", "example.py"), "print('hello')"); + yield* fs.writeFileString(path.join(source, ".notes"), "Keep this too"); + yield* fs.symlink("scripts/example.py", path.join(source, "run.py")); + const transfer = yield* workspaces.prepare(threadId, cwd); + const destination = path.join( + cwd, + "quick-chat-files", + Buffer.from(threadId).toString("base64url"), + ); + expect(yield* fs.readFileString(path.join(destination, "scripts", "example.py"))).toBe( + "print('hello')", + ); + expect(yield* fs.readFileString(path.join(destination, ".notes"))).toBe("Keep this too"); + expect(yield* fs.exists(source)).toBe(true); + yield* transfer.commit; + expect(yield* fs.exists(source)).toBe(false); + expect(yield* fs.readLink(path.join(destination, "run.py"))).toBe("scripts/example.py"); + expect(yield* fs.readFileString(path.join(destination, "run.py"))).toBe("print('hello')"); + const restarted = yield* makeQuickChatWorkspace; + expect(yield* restarted.pendingNote(threadId, cwd)).toContain(destination); + expect(yield* restarted.pendingNote(threadId, cwd)).toContain(source); + // Changing the project workspace before the next turn must not lose the handoff. + expect(yield* restarted.pendingNote(threadId, `${cwd}/another-worktree`)).toContain( + destination, + ); + yield* restarted.clearNote(threadId); + expect(yield* restarted.pendingNote(threadId, cwd)).toBeNull(); + yield* restarted.remove(threadId); + expect(yield* fs.exists(destination)).toBe(true); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.effect("rolls back a failed attachment without losing the original files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaces = yield* makeQuickChatWorkspace; + const cwd = yield* fs.makeTempDirectoryScoped(); + const source = workspaces.directory(threadId); + yield* fs.makeDirectory(source, { recursive: true }); + yield* fs.writeFileString(path.join(source, "script.sh"), "echo hello"); + const transfer = yield* workspaces.prepare(threadId, cwd); + yield* transfer.rollback; + expect(yield* fs.readFileString(path.join(source, "script.sh"))).toBe("echo hello"); + expect(yield* fs.readDirectory(path.join(cwd, "quick-chat-files"))).toEqual([]); + expect(yield* workspaces.pendingNote(threadId, cwd)).toBeNull(); + const retry = yield* workspaces.prepare(threadId, cwd); + yield* retry.commit; + expect(yield* fs.exists(source)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.effect("refuses to overwrite a destination and leaves both copies intact", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaces = yield* makeQuickChatWorkspace; + const cwd = yield* fs.makeTempDirectoryScoped(); + const source = workspaces.directory(threadId); + const destination = path.join( + cwd, + "quick-chat-files", + Buffer.from(threadId).toString("base64url"), + ); + yield* fs.makeDirectory(source, { recursive: true }); + yield* fs.makeDirectory(destination, { recursive: true }); + yield* fs.writeFileString(path.join(source, "script.sh"), "new"); + yield* fs.writeFileString(path.join(destination, "script.sh"), "existing"); + expect((yield* Effect.result(workspaces.prepare(threadId, cwd)))._tag).toBe("Failure"); + expect(yield* fs.readFileString(path.join(source, "script.sh"))).toBe("new"); + expect(yield* fs.readFileString(path.join(destination, "script.sh"))).toBe("existing"); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.effect("cleans an empty workspace without creating a project folder", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaces = yield* makeQuickChatWorkspace; + const cwd = yield* fs.makeTempDirectoryScoped(); + yield* fs.makeDirectory(workspaces.directory(threadId), { recursive: true }); + const transfer = yield* workspaces.prepare(threadId, cwd); + yield* transfer.commit; + expect(yield* fs.exists(workspaces.directory(threadId))).toBe(false); + expect(yield* fs.exists(path.join(cwd, "quick-chat-files"))).toBe(false); + expect(yield* workspaces.pendingNote(threadId, cwd)).toContain("contained no files"); + }).pipe(Effect.scoped, Effect.provide(layer)), +); diff --git a/apps/server/src/orchestration/quickChatWorkspace.ts b/apps/server/src/orchestration/quickChatWorkspace.ts new file mode 100644 index 000000000000..0e06ccef0b10 --- /dev/null +++ b/apps/server/src/orchestration/quickChatWorkspace.ts @@ -0,0 +1,130 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import type { ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Cause from "effect/Cause"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { ServerConfig } from "../config.ts"; + +class QuickChatWorkspaceError extends Schema.TaggedError()( + "QuickChatWorkspaceError", + { detail: Schema.String, cause: Schema.optional(Schema.Defect()) }, +) {} + +const PromotionNote = Schema.Struct({ + cwd: Schema.String, + filesPath: Schema.NullOr(Schema.String), +}); +const decodeNote = Schema.decodeUnknownEffect(Schema.fromJsonString(PromotionNote)); +const encodeNote = Schema.encodeEffect(Schema.fromJsonString(PromotionNote)); +const quotePath = Schema.encodeSync(Schema.fromJsonString(Schema.String)); + +/** Owns only quick-chat scratch directories and their pending provider context. */ +export const makeQuickChatWorkspace = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig; + const key = (threadId: ThreadId) => Buffer.from(threadId).toString("base64url"); + const directory = (threadId: ThreadId) => + path.join(config.stateDir, "quick-chats", key(threadId)); + const notePath = (threadId: ThreadId) => + path.join(config.stateDir, "quick-chat-promotions", `${key(threadId)}.json`); + const removeDirectory = (threadId: ThreadId) => + fs.remove(directory(threadId), { recursive: true, force: true }); + const clearNote = (threadId: ThreadId) => + fs + .remove(notePath(threadId), { force: true }) + .pipe(Effect.andThen(fs.remove(`${notePath(threadId)}.tmp`, { force: true }))); + const remove = Effect.fn("QuickChatWorkspace.remove")(function* (threadId: ThreadId) { + yield* removeDirectory(threadId); + yield* clearNote(threadId); + }); + const prepare = Effect.fn("QuickChatWorkspace.prepare")(function* ( + threadId: ThreadId, + cwd: string, + ) { + const source = directory(threadId); + if (yield* fs.exists(notePath(threadId))) { + return yield* new QuickChatWorkspaceError({ + detail: "A previous quick-chat transfer needs recovery before retrying.", + }); + } + if (yield* fs.exists(source)) { + const expected = path.join(yield* fs.realPath(path.dirname(source)), key(threadId)); + if ((yield* fs.realPath(source)) !== expected) + return yield* new QuickChatWorkspaceError({ + detail: "Quick-chat workspace must not be a symbolic link.", + }); + } + const entries = (yield* fs.exists(source)) ? yield* fs.readDirectory(source) : []; + let filesPath: string | null = null; + let ownsDestination = false; + const rollback = Effect.gen(function* () { + if (ownsDestination && filesPath !== null) + yield* fs.remove(filesPath, { recursive: true, force: true }); + yield* clearNote(threadId); + }); + yield* Effect.gen(function* () { + if (entries.length > 0) { + const parent = path.join(cwd, "quick-chat-files"); + yield* fs.makeDirectory(parent, { recursive: true }); + if ( + (yield* fs.realPath(parent)) !== path.join(yield* fs.realPath(cwd), "quick-chat-files") + ) { + return yield* new QuickChatWorkspaceError({ + detail: "The quick-chat-files destination must not be a symbolic link.", + }); + } + const destination = path.join(parent, key(threadId)); + filesPath = destination; + // Reserve a new directory. Never merge into or overwrite project files. + yield* fs.makeDirectory(filesPath); + ownsDestination = true; + // FileSystem.copy cannot preserve relative symlink targets verbatim. + yield* Effect.tryPromise({ + try: async () => { + for (const entry of entries) + await NodeFSP.cp(path.join(source, entry), path.join(destination, entry), { + recursive: true, + force: false, + errorOnExist: true, + preserveTimestamps: true, + verbatimSymlinks: true, + }); + }, + catch: (cause) => + new QuickChatWorkspaceError({ detail: "Could not copy quick-chat files.", cause }), + }).pipe(Effect.uninterruptible); + } + yield* fs.makeDirectory(path.dirname(notePath(threadId)), { recursive: true }); + const temporaryNote = `${notePath(threadId)}.tmp`; + yield* fs.writeFileString(temporaryNote, yield* encodeNote({ cwd, filesPath })); + yield* fs.rename(temporaryNote, notePath(threadId)); + }).pipe( + Effect.onError(() => + rollback.pipe( + Effect.catchCause((cause) => + Effect.logWarning("Could not remove incomplete quick-chat transfer", { + cause: Cause.pretty(cause), + }), + ), + ), + ), + ); + return { rollback, commit: removeDirectory(threadId) }; + }); + const pendingNote = Effect.fn("QuickChatWorkspace.pendingNote")(function* ( + threadId: ThreadId, + cwd: string, + ) { + if (!(yield* fs.exists(notePath(threadId)))) return null; + const note = yield* decodeNote(yield* fs.readFileString(notePath(threadId))); + + // Complete cleanup if the server stopped after the metadata commit. + yield* removeDirectory(threadId); + return `[T3 Code workspace update]\nThis conversation was promoted from a quick chat to a project. Your working directory is now ${quotePath(cwd)}. ${note.filesPath === null ? "The previous quick-chat workspace contained no files." : `All files from your previous workspace ${quotePath(directory(threadId))} are now in ${quotePath(note.filesPath)}. Use this new location for those files; you may organize them into the project as needed.`}\n[/T3 Code workspace update]`; + }); + return { directory, prepare, pendingNote, clearNote, remove }; +}); diff --git a/apps/server/src/orchestration/runtimeLayer.ts b/apps/server/src/orchestration/runtimeLayer.ts index ea02e5e2ebf9..a8e65db7cd14 100644 --- a/apps/server/src/orchestration/runtimeLayer.ts +++ b/apps/server/src/orchestration/runtimeLayer.ts @@ -17,7 +17,7 @@ const OrchestrationProjectionPipelineLayerLive = OrchestrationProjectionPipeline Layer.provide(OrchestrationEventStoreLive), ); -const OrchestrationInfrastructureLayerLive = Layer.mergeAll( +export const OrchestrationInfrastructureLayerLive = Layer.mergeAll( OrchestrationProjectionSnapshotQueryLive, OrchestrationEventInfrastructureLayerLive, OrchestrationProjectionPipelineLayerLive, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3831763a3eac..5866cd7578d4 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -128,7 +128,10 @@ import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinar import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; import * as UsageService from "./usage/UsageService.ts"; -import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; +import { + OrchestrationLayerLive, + OrchestrationInfrastructureLayerLive, +} from "./orchestration/runtimeLayer.ts"; import { clearPersistedServerRuntimeState, makePersistedServerRuntimeState, @@ -426,8 +429,9 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( // Subscribes to `account.rate-limits.updated` so usage bars track live // telemetry instead of waiting for the next status probe. Layer.provideMerge(ProviderUsageLimitsIngestionLive), - Layer.provideMerge(ProviderLayerLive), Layer.provideMerge(OrchestrationLayerLive), + Layer.provideMerge(ProviderLayerLive), + Layer.provideMerge(OrchestrationInfrastructureLayerLive), ); const AntigravityInstallationRefreshLive = Layer.effectDiscard( diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 58f863547622..f7203bbcc5eb 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -33,7 +33,12 @@ 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. +Quick chats use the agent's normal permission settings and can run scripts or +create files in a temporary workspace. Attaching moves those files into +`quick-chat-files/` in the selected workspace and tells the agent where to find +them on the next turn. Archiving keeps the temporary files; deleting a quick +chat removes them. Files already transferred to a project stay there when you +delete the thread. ### Start in the background From 7bec983ff3c940b1b760a556facef4cb3cd57bd2 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:51:21 +0200 Subject: [PATCH 12/15] fix(server): guard quick chat transfer targets and drain acknowledgements --- .../Layers/OrchestrationEngine.ts | 4 ++-- .../Layers/ProviderCommandReactor.ts | 5 ++++- .../Layers/ThreadDeletionReactor.ts | 2 +- .../orchestration/quickChatWorkspace.test.ts | 14 +++++++++++++ .../src/orchestration/quickChatWorkspace.ts | 20 +++++++++++++++---- 5 files changed, 37 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index be834b28d643..c9f4dcf60ad3 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -49,7 +49,7 @@ import { OrchestrationEngineService, type OrchestrationEngineShape, } from "../Services/OrchestrationEngine.ts"; -import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import * as ProviderService from "../../provider/Services/ProviderService.ts"; import { makeQuickChatWorkspace } from "../quickChatWorkspace.ts"; const isOrchestrationCommandPreviouslyRejectedError = Schema.is( @@ -93,7 +93,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; const crypto = yield* Crypto.Crypto; // Offline CLI engines have no provider processes; server composition supplies this service. - const providers = yield* Effect.serviceOption(ProviderService); + const providers = yield* Effect.serviceOption(ProviderService.ProviderService); const quickChatWorkspace = yield* makeQuickChatWorkspace; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 227e78d6af1e..734542ed89a5 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -23,6 +23,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as FileSystem from "effect/FileSystem"; +import * as FiberSet from "effect/FiberSet"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schedule from "effect/Schedule"; @@ -327,6 +328,7 @@ const make = Effect.gen(function* () { const gitWorkflow = yield* GitWorkflowService; const fileSystem = yield* FileSystem.FileSystem; const quickChatWorkspace = yield* makeQuickChatWorkspace; + const pendingTurnStarts = yield* FiberSet.make(); const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; @@ -1471,7 +1473,7 @@ const make = Effect.gen(function* () { ), Effect.asVoid, Effect.catchCause(recoverTurnStartFailure), - Effect.forkScoped, + FiberSet.run(pendingTurnStarts), ); }); @@ -1865,6 +1867,7 @@ const make = Effect.gen(function* () { start, drain: Effect.gen(function* () { yield* worker.drain; + yield* FiberSet.awaitEmpty(pendingTurnStarts); yield* threadTitleRegenerationWorker.drain; }), } satisfies ProviderCommandReactorShape; diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index 09d029e984c6..93ec89b09ba5 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -59,7 +59,7 @@ const make = Effect.gen(function* () { let stopped = false; yield* logCleanupCauseUnlessInterrupted({ effect: providerService.stopSession({ threadId }).pipe( - Effect.catchTag("ProviderSessionNotFoundError", () => Effect.void), + Effect.catchTags({ ProviderSessionNotFoundError: () => Effect.void }), Effect.tap(() => { stopped = true; return Effect.void; diff --git a/apps/server/src/orchestration/quickChatWorkspace.test.ts b/apps/server/src/orchestration/quickChatWorkspace.test.ts index da665178f7c5..9cb9d3640065 100644 --- a/apps/server/src/orchestration/quickChatWorkspace.test.ts +++ b/apps/server/src/orchestration/quickChatWorkspace.test.ts @@ -110,3 +110,17 @@ it.effect("cleans an empty workspace without creating a project folder", () => expect(yield* workspaces.pendingNote(threadId, cwd)).toContain("contained no files"); }).pipe(Effect.scoped, Effect.provide(layer)), ); + +it.effect("rejects attachment into the scratch directory without deleting its files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspace = yield* makeQuickChatWorkspace; + const source = workspace.directory(threadId); + yield* fs.makeDirectory(`${source}/nested`, { recursive: true }); + yield* fs.writeFileString(`${source}/script.sh`, "echo keep"); + for (const cwd of [source, `${source}/nested`]) { + expect((yield* Effect.result(workspace.prepare(threadId, cwd)))._tag).toBe("Failure"); + expect(yield* fs.readFileString(`${source}/script.sh`)).toBe("echo keep"); + } + }).pipe(Effect.scoped, Effect.provide(layer)), +); diff --git a/apps/server/src/orchestration/quickChatWorkspace.ts b/apps/server/src/orchestration/quickChatWorkspace.ts index 0e06ccef0b10..3c7afb5b5635 100644 --- a/apps/server/src/orchestration/quickChatWorkspace.ts +++ b/apps/server/src/orchestration/quickChatWorkspace.ts @@ -1,3 +1,4 @@ +// Node cp preserves relative symlink targets; Effect FileSystem.copy cannot do that. // @effect-diagnostics nodeBuiltinImport:off import * as NodeFSP from "node:fs/promises"; import type { ThreadId } from "@t3tools/contracts"; @@ -6,7 +7,7 @@ import * as Cause from "effect/Cause"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; class QuickChatWorkspaceError extends Schema.TaggedError()( "QuickChatWorkspaceError", @@ -25,7 +26,7 @@ const quotePath = Schema.encodeSync(Schema.fromJsonString(Schema.String)); export const makeQuickChatWorkspace = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; const key = (threadId: ThreadId) => Buffer.from(threadId).toString("base64url"); const directory = (threadId: ThreadId) => path.join(config.stateDir, "quick-chats", key(threadId)); @@ -53,7 +54,19 @@ export const makeQuickChatWorkspace = Effect.gen(function* () { } if (yield* fs.exists(source)) { const expected = path.join(yield* fs.realPath(path.dirname(source)), key(threadId)); - if ((yield* fs.realPath(source)) !== expected) + const resolvedSource = yield* fs.realPath(source); + const relativeTarget = path.relative(resolvedSource, yield* fs.realPath(cwd)); + if ( + relativeTarget === "" || + (!path.isAbsolute(relativeTarget) && + relativeTarget !== ".." && + !relativeTarget.startsWith(`..${path.sep}`)) + ) { + return yield* new QuickChatWorkspaceError({ + detail: "The project workspace must be outside the quick-chat workspace.", + }); + } + if (resolvedSource !== expected) return yield* new QuickChatWorkspaceError({ detail: "Quick-chat workspace must not be a symbolic link.", }); @@ -82,7 +95,6 @@ export const makeQuickChatWorkspace = Effect.gen(function* () { // Reserve a new directory. Never merge into or overwrite project files. yield* fs.makeDirectory(filesPath); ownsDestination = true; - // FileSystem.copy cannot preserve relative symlink targets verbatim. yield* Effect.tryPromise({ try: async () => { for (const entry of entries) From 7e4e11b10823dbf02982712fac6727c7d5e06e4d Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:37:43 +0200 Subject: [PATCH 13/15] fix: reconcile quick chats with project pull request links --- apps/mobile/src/features/home/HomeScreen.tsx | 26 +++++++++---------- apps/server/src/git/linkCreatedPullRequest.ts | 2 +- .../src/mcp/toolkits/pullRequests/handlers.ts | 10 ++++--- .../Layers/ProjectionSnapshotQuery.ts | 16 +++++++----- .../orchestration/PullRequestSyncReactor.ts | 1 + .../orchestration/decider.quick-chats.test.ts | 21 +++++++++++++++ apps/server/src/orchestration/decider.ts | 6 +++++ ...ckChats.test.ts => 051_QuickChats.test.ts} | 4 +-- .../pullRequest/PullRequestThreadLinks.tsx | 2 ++ packages/shared/src/threadPullRequests.ts | 4 +-- 10 files changed, 63 insertions(+), 29 deletions(-) rename apps/server/src/persistence/Migrations/{050_QuickChats.test.ts => 051_QuickChats.test.ts} (91%) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 6ed30e844cf0..5d8431cddd54 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1254,19 +1254,19 @@ export function HomeScreen(props: HomeScreenProps) { renderItem={renderLegacyListItem} keyExtractor={keyExtractor} itemsAreEqual={(previous, item) => { - if ( - (previous.type === "header" || - previous.type === "thread" || - previous.type === "pending-task" || - previous.type === "show-more") && - (item.type === "header" || - item.type === "thread" || - item.type === "pending-task" || - item.type === "show-more") - ) - return homeListItemsAreEqual(previous, item); - return previous === item; - }} + if ( + (previous.type === "header" || + previous.type === "thread" || + previous.type === "pending-task" || + previous.type === "show-more") && + (item.type === "header" || + item.type === "thread" || + item.type === "pending-task" || + item.type === "show-more") + ) + return homeListItemsAreEqual(previous, item); + return previous === item; + }} drawDistance={500} estimatedItemSize={ESTIMATED_THREAD_ROW_HEIGHT} extraData={extraData} diff --git a/apps/server/src/git/linkCreatedPullRequest.ts b/apps/server/src/git/linkCreatedPullRequest.ts index 4eaeea1dc6f9..c74bc5cf6e21 100644 --- a/apps/server/src/git/linkCreatedPullRequest.ts +++ b/apps/server/src/git/linkCreatedPullRequest.ts @@ -70,7 +70,7 @@ export const linkCreatedPullRequest = (input: { const engine = yield* OrchestrationEngine.OrchestrationEngineService; const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const thread = yield* snapshots.getThreadShellById(input.threadId); - if (Option.isNone(thread)) return; + if (Option.isNone(thread) || thread.value.projectId === null) return; const project = Option.getOrUndefined( yield* snapshots.getProjectShellById(thread.value.projectId), ); diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index 1106bf435327..7d20838a0e03 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -169,10 +169,12 @@ const make = Effect.gen(function* () { thread: OrchestrationThreadShell, Failure: typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError, ) => - snapshots.getProjectShellById(thread.projectId).pipe( - Effect.map(Option.getOrUndefined), - Effect.mapError((cause) => new Failure({ cause })), - ); + thread.projectId === null + ? Effect.succeed(undefined) + : snapshots.getProjectShellById(thread.projectId).pipe( + Effect.map(Option.getOrUndefined), + Effect.mapError((cause) => new Failure({ cause })), + ); const dispatchFailure = (Failure: typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError) => diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index bd6be88e67c0..eb54d016f9fe 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -448,7 +448,7 @@ function groupPullRequestRowsByThread( */ function mapThreadPullRequests( pullRequests: ReadonlyArray, - projectId: ProjectId, + projectId: ProjectId | null, identity?: OrchestrationProject["repositoryIdentity"], ): Pick { const linkedPullRequest = legacyLinkedPullRequestOf(pullRequests, projectId, identity); @@ -2229,7 +2229,7 @@ pending_approval_requests AS ( ...mapThreadPullRequests( pullRequestsByThread.get(row.threadId) ?? [], row.projectId, - repositoryIdentities.get(row.projectId), + row.projectId === null ? undefined : repositoryIdentities.get(row.projectId), ), branchPullRequest: row.branchPullRequest, latestTurn: latestTurnByThread.get(row.threadId) ?? null, @@ -2473,7 +2473,7 @@ pending_approval_requests AS ( ...mapThreadPullRequests( pullRequestsByThread.get(row.threadId) ?? [], row.projectId, - repositoryIdentities.get(row.projectId), + row.projectId === null ? undefined : repositoryIdentities.get(row.projectId), ), branchPullRequest: row.branchPullRequest, latestTurn: latestTurnByThread.get(row.threadId) ?? null, @@ -2629,7 +2629,9 @@ pending_approval_requests AS ( ...mapThreadPullRequests( pullRequestsByThread.get(row.threadId) ?? [], row.projectId, - repositoryIdentities.get(row.projectId), + row.projectId === null + ? undefined + : repositoryIdentities.get(row.projectId), ), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, @@ -2791,7 +2793,7 @@ pending_approval_requests AS ( ...mapThreadPullRequests( pullRequestsByThread.get(row.threadId) ?? [], row.projectId, - repositoryIdentities.get(row.projectId), + row.projectId === null ? undefined : repositoryIdentities.get(row.projectId), ), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, @@ -3124,7 +3126,7 @@ pending_approval_requests AS ( ...mapThreadPullRequests( pullRequestRows.map(mapPullRequestRow), threadRow.value.projectId, - pullRequestRows.length === 0 + pullRequestRows.length === 0 || threadRow.value.projectId === null ? null : Option.getOrNull(yield* getProjectShellById(threadRow.value.projectId)) ?.repositoryIdentity, @@ -3421,7 +3423,7 @@ pending_approval_requests AS ( ...mapThreadPullRequests( pullRequestRows.map(mapPullRequestRow), threadRow.value.projectId, - pullRequestRows.length === 0 + pullRequestRows.length === 0 || threadRow.value.projectId === null ? null : Option.getOrNull(yield* getProjectShellById(threadRow.value.projectId)) ?.repositoryIdentity, diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.ts b/apps/server/src/orchestration/PullRequestSyncReactor.ts index 0a39fa5d91d1..01e275337152 100644 --- a/apps/server/src/orchestration/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration/PullRequestSyncReactor.ts @@ -238,6 +238,7 @@ export const make = Effect.gen(function* () { entries: ReadonlyArray, ) { const first = entries[0]!; + if (first.thread.projectId === null) return; const ref = { projectId: first.thread.projectId, host: first.link.host, diff --git a/apps/server/src/orchestration/decider.quick-chats.test.ts b/apps/server/src/orchestration/decider.quick-chats.test.ts index 98e914f06cfe..05b2cc9c9ed5 100644 --- a/apps/server/src/orchestration/decider.quick-chats.test.ts +++ b/apps/server/src/orchestration/decider.quick-chats.test.ts @@ -73,6 +73,27 @@ it.layer(NodeServices.layer)("quick chats", (it) => { expect(result._tag).toBe("Failure"); }), ); + it.effect("rejects pull request links on unattached chats", () => + Effect.gen(function* () { + const model = yield* createChat; + const result = yield* Effect.result( + decideOrchestrationCommand({ + command: { + type: "thread.pull-request.link", + commandId: CommandId.make("link"), + threadId, + host: "github.com", + repository: "org/repo", + number: 1, + url: "https://github.com/org/repo/pull/1", + source: "manual", + }, + readModel: model, + }), + ); + expect(result._tag).toBe("Failure"); + }), + ); it.effect("attaches an idle chat to a project and preserves its identity", () => Effect.gen(function* () { let model = yield* createChat; diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 7886116016a0..d58fc313f0ce 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1056,6 +1056,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); + if (thread.projectId === null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Attach the quick chat to a project before linking a pull request.", + }); + } const key = normalizeThreadPullRequestKey(command); const existing = findPullRequestLink(thread, key); // An explicit link on a dismissed stack member un-dismisses it; any diff --git a/apps/server/src/persistence/Migrations/050_QuickChats.test.ts b/apps/server/src/persistence/Migrations/051_QuickChats.test.ts similarity index 91% rename from apps/server/src/persistence/Migrations/050_QuickChats.test.ts rename to apps/server/src/persistence/Migrations/051_QuickChats.test.ts index 295b4f27ecff..054a6240bf47 100644 --- a/apps/server/src/persistence/Migrations/050_QuickChats.test.ts +++ b/apps/server/src/persistence/Migrations/051_QuickChats.test.ts @@ -4,11 +4,11 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { runMigrations } from "../Migrations.ts"; -it.layer(NodeSqliteClient.layerMemory())("050_QuickChats", (it) => { +it.layer(NodeSqliteClient.layerMemory())("051_QuickChats", (it) => { it.effect("preserves project threads and allows project-free rows", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - yield* runMigrations({ toMigrationInclusive: 49 }); + yield* runMigrations({ toMigrationInclusive: 50 }); yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, created_at, updated_at) VALUES ('existing', 'project-1', 'Existing', '{"instanceId":"codex","model":"gpt-5.4"}', 'full-access', '2026-01-01', '2026-01-01')`; yield* runMigrations(); diff --git a/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx index 9d8dcc2424e6..7776451d46f1 100644 --- a/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx +++ b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx @@ -191,6 +191,7 @@ function ThreadPicker({ (thread) => thread.environmentId === environmentId && thread.archivedAt === null && + thread.projectId !== null && `${thread.title} ${projectNames.get(thread.projectId) ?? ""}` .toLocaleLowerCase() .includes(search), @@ -206,6 +207,7 @@ function ThreadPicker({
    ) : ( candidates.map((thread) => { + if (thread.projectId === null) return null; const linked = linking.isLinked(thread, url); return ( , - projectId: ThreadLinkedPullRequest["projectId"], + projectId: ThreadLinkedPullRequest["projectId"] | null, identity: RepositoryIdentity | null | undefined, ): ThreadLinkedPullRequest | null { - if (!identity) return null; + if (projectId === null || !identity) return null; const host = pullRequestHostOf(identity, identity.provider as SourceControlProviderKind); const repository = sourceControlRepositorySelector(identity); if (repository === null) return null; From a02be9d4d6295845dfba3a7cf1bdc54532cd0199 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:42:15 +0200 Subject: [PATCH 14/15] fix(relay): skip project-free chats in Android push watcher --- infra/relay/scripts/android-push-watch.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/infra/relay/scripts/android-push-watch.ts b/infra/relay/scripts/android-push-watch.ts index 2b902b33f946..f682238d7f78 100644 --- a/infra/relay/scripts/android-push-watch.ts +++ b/infra/relay/scripts/android-push-watch.ts @@ -158,6 +158,7 @@ const main = Effect.gen(function* () { } const next = new Map(); for (const thread of threads.values()) { + if (thread.projectId === null) continue; const project = projects.get(thread.projectId); if (!project || thread.archivedAt) continue; const state = projectThreadAwareness({ From 51222bae3542c0eb882cffce8f28314368064eec Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:46:14 +0200 Subject: [PATCH 15/15] fix(server): refresh repository identity before legacy PR relinking --- .../Layers/OrchestrationEngine.test.ts | 5 +++ .../Layers/OrchestrationEngine.ts | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 249ef2faad4b..6f0eb80ed77b 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -1319,6 +1319,11 @@ describe("OrchestrationEngine", () => { }, ), ); + if (change === "relink") { + expect( + (await system.readModel()).threads[0]?.pullRequests.map((link) => link.number), + ).toEqual([3]); + } const command = { type: "thread.pull-request.sync", commandId: CommandId.make("pr-race-stale-sync"), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index c9f4dcf60ad3..a1de40eb2843 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -246,6 +246,37 @@ const makeOrchestrationEngine = Effect.gen(function* () { envelope.command.type === "thread.user-input.dismiss" ? yield* projectionSnapshotQuery.getUserInputActivity(envelope.command) : Option.none(); + // Repository identities are derived by snapshot queries, not persisted events. + // Refresh the legacy PR view before deciding which existing link to replace. + if ( + (envelope.command.type === "thread.meta.update" && + envelope.command.linkedPullRequest !== undefined) || + envelope.command.type === "thread.pull-request.sync" + ) { + const shell = yield* projectionSnapshotQuery.getThreadShellById( + envelope.command.threadId, + ); + if (Option.isSome(shell) && shell.value.projectId !== null) { + const project = yield* projectionSnapshotQuery.getProjectShellById( + shell.value.projectId, + ); + if (Option.isSome(project)) { + commandReadModel = { + ...commandReadModel, + projects: commandReadModel.projects.map((entry) => + entry.id === project.value.id + ? { ...entry, repositoryIdentity: project.value.repositoryIdentity } + : entry, + ), + threads: commandReadModel.threads.map((entry) => + entry.id === shell.value.id + ? { ...entry, linkedPullRequest: shell.value.linkedPullRequest } + : entry, + ), + }; + } + } + } const eventBase = yield* decideOrchestrationCommand({ command: envelope.command, readModel: commandReadModel,