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 697d13e7c472..668f0115dcb7 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.test.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.test.ts @@ -120,7 +120,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"]); }); @@ -144,3 +144,29 @@ 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 }); +}); + +it("does not match every archived quick chat through a section-label substring", () => { + const quick = makeThread({ id: ThreadId.make("quick"), projectId: null, title: "Passkeys" }); + const input = { + snapshots: [makeSnapshot([], [quick])], + environmentLabels: {}, + environmentId, + sortOrder: "newest" as const, + }; + expect(buildArchivedThreadGroups({ ...input, searchQuery: "ui" })).toEqual([]); + expect(buildArchivedThreadGroups({ ...input, searchQuery: "quick chats" })).toHaveLength(1); +}); diff --git a/apps/mobile/src/features/archive/archivedThreadList.ts b/apps/mobile/src/features/archive/archivedThreadList.ts index 6146bba20447..edd1fb48c46c 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 || + query === "quick chats" || + matchesQuery(thread.title, query) || + matchesQuery(environmentLabel, query), + ) + .sort( + (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 6be34cc17f83..3d94a6ff69b7 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -676,21 +676,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, @@ -759,7 +750,7 @@ export function HomeScreen(props: HomeScreenProps) { () => buildThreadListV2ListItems({ items: threadListV2Layout.items, - pendingTasks: v2PendingTasks, + pendingTasks: threadListV2Enabled ? v2PendingTasks : [], snoozedCount: threadListV2Layout.snoozedCount, snoozedShelfExpanded, snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, @@ -768,7 +759,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( @@ -777,6 +774,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, @@ -860,14 +861,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} @@ -958,7 +966,7 @@ export function HomeScreen(props: HomeScreenProps) { ); const renderItem = useCallback( - ({ item }: LegendListRenderItemProps) => { + ({ item }: { readonly item: HomeListItem }) => { switch (item.type) { case "header": return ( @@ -1056,7 +1064,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 @@ -1200,15 +1226,28 @@ 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} ListHeaderComponent={listHeader} - ListEmptyComponent={listEmpty} + ListEmptyComponent={threadListV2Items.length === 0 ? listEmpty : null} style={{ flex: 1 }} automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED} contentInsetAdjustmentBehavior={NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"} diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index ef066feca5b5..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; @@ -111,7 +111,7 @@ function ToggleRow(props: { ); } -function BranchSelectionRow(props: { +export function BranchSelectionRow(props: { readonly badge: string | null; readonly branch: VcsRef; readonly disabled: boolean; @@ -142,7 +142,7 @@ 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/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..caa241834d22 --- /dev/null +++ b/apps/mobile/src/features/threads/QuickChatCreationActions.tsx @@ -0,0 +1,87 @@ +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 { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; +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") { + if (!isAtomCommandInterrupted(result)) Alert.alert("Could not create quick chat"); + return; + } + if (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..52b3aa3e1dc1 --- /dev/null +++ b/apps/mobile/src/features/threads/QuickChatProjectAttachment.tsx @@ -0,0 +1,503 @@ +import { useRef, useState } from "react"; +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"; +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, PickerSurface, SelectionRow } from "./NewTaskContextPickerScreens"; +import { uuidv4 } from "../../lib/uuid"; + +export function QuickChatProjectAttachment({ + threadRef, + onClose, +}: { + 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 }; + } 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(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: page === "branch" ? (project?.workspaceRoot ?? null) : null, + query: branchQuery, + }); + const unavailable = + saved.error !== null || + !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 = null; + if (newWorktree) { + const attachment = prepared ?? { + projectId: project.id, + workspaceRoot: project.workspaceRoot, + baseBranch: baseBranch.trim(), + branch: `t3/quick-chat-${uuidv4()}`, + }; + 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, + query: existingRef.name, + refKind: "local", + refresh: true, + }, + }); + 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, + 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; + } + quickChatAttachmentStorage.clear(threadRef); + onClose(); + } catch (cause) { + Alert.alert( + "Could not prepare attachment", + cause instanceof Error ? cause.message : "Check device storage and retry.", + ); + } finally { + pending.current = false; + 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 ( + + + + + + {pageTitle} + + + + {page === "overview" ? ( + <> + + + + 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"} + + + + + + + } + label={`on ${environment?.environmentLabel ?? "this environment"}`} + maxWidth={260} + showChevron={false} + static + /> + + {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. + + )} + {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"} + + + + + ) : ( + + {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 + + )} + + )} + + )} + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index a7ce89927f43..5818893e02ea 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -291,8 +291,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], ); @@ -510,19 +512,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, @@ -569,7 +564,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 @@ -589,7 +583,7 @@ function ThreadNavigationSidebarPane( ); const items: SidebarListItem[] = buildThreadListV2ListItems({ items: threadListV2Layout.items, - pendingTasks: v2PendingTasks, + pendingTasks: threadListV2Enabled ? v2PendingTasks : [], snoozedCount: threadListV2Layout.snoozedCount, snoozedShelfExpanded, snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, @@ -605,7 +599,7 @@ function ThreadNavigationSidebarPane( hiddenCount: threadListV2Layout.hiddenSettledCount, }); } - return items; + return threadListV2Enabled ? items : [...listLayout.items, ...items]; }, [ listLayout.items, nowMinute, @@ -823,11 +817,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" || @@ -883,6 +879,10 @@ function ThreadNavigationSidebarPane( /> ); } + case "v2-quick-chats-header": + return ( + Quick chats + ); case "v2-thread": { const thread = item.item.thread; const movePlanner = item.item.pinned @@ -926,14 +926,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 55f122d6389a..6f4966f0f38d 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, @@ -238,12 +239,30 @@ 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), ); const inspectorMode = (() => { if (inspectorSelection?.routeThreadIdentity === routeThreadIdentity) { - if (inspectorSelection.mode === "files" && selectedThreadCwd === null) { + if (inspectorSelection.mode !== "route" && selectedThreadCwd === null) { return null; } return inspectorSelection.mode; @@ -270,7 +289,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 }; @@ -282,7 +301,7 @@ function ThreadRouteContent( return { ...current, routeThreadIdentity }; }); - }, [props.renderInspector, routeThreadIdentity]); + }, [props.renderInspector, routeThreadIdentity, selectedThreadCwd]); useFocusEffect( useCallback(() => { @@ -637,7 +656,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, @@ -704,6 +724,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", @@ -725,11 +752,16 @@ function ThreadRouteContent( onPress: () => handleOpenTerminal(null), }); } - actions.push({ - accessibilityLabel: "Open git controls", - icon: "point.topleft.down.curvedto.point.bottomright.up", - onPress: handleOpenGitInspector, - }); + if ( + selectedThreadProject?.workspaceRoot && + (!fileInspector.supported || selectedThreadCwd !== null) + ) { + 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", @@ -739,6 +771,8 @@ function ThreadRouteContent( } return actions; }, [ + isQuickChat, + handleOpenQuickChatAttachment, fileInspector.supported, handleOpenFilesInspector, handleOpenTerminal, @@ -835,11 +869,20 @@ function ThreadRouteContent( const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null; const renderThreadRouteBody = (showActionControls: boolean) => ( <> - + {selectedThreadProject && ( + + )} + {isQuickChat && attachmentThreadIdentity === routeThreadIdentity && ( + setAttachmentThreadIdentity(null)} + /> + )} {activeInspectorRenderer ? : null} (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems) + ? () => + selectedThreadProject + ? layout.usesSplitView + ? threadCenterHeaderItems + : compactRightHeaderItems + : isQuickChat + ? quickChatHeaderItems + : [] : undefined, unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, }} diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 0ee78b2e3fa6..d2c4d16045a8 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -1276,3 +1276,29 @@ describe("mobile move availability", () => { expect(assignments![0]!.orderKey < "dd").toBe(true); }); }); + +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 3629e63df462..b14a4fe03f7d 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -181,7 +181,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" && @@ -268,6 +268,7 @@ export interface ThreadListV2SettledShelfListItem { } export type ThreadListV2ListItem = + | { readonly type: "v2-quick-chats-header"; readonly key: "v2-quick-chats-header" } | ThreadListV2ThreadListItem | ThreadListV2PendingListItem | ThreadListV2SnoozedShelfListItem @@ -329,6 +330,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; } @@ -394,7 +403,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 ( @@ -409,6 +422,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. @@ -479,7 +496,9 @@ export function buildThreadListV2Items(input: { isLast: false, }); } - for (const thread of orderedActive) { + for (const thread of [...orderedActive].sort( + (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/quick-chat-attachment-storage.ts b/apps/mobile/src/state/quick-chat-attachment-storage.ts new file mode 100644 index 000000000000..d0f6230a0428 --- /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, `${encodeURIComponent(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/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 85f7fb3c3c92..5684989ef8b7 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -159,7 +159,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 bf02cd90fbdf..00eddf1fc585 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, threadPullRequestLinking: true, environmentIcon: 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 2cb4ed7566ad..21fbea59dc33 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"; @@ -60,7 +62,10 @@ const asMessageId = (value: string): MessageId => MessageId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); -function makeOrchestrationLayer(databasePath?: string) { +function makeOrchestrationLayer( + databasePath?: string, + onProjected?: (event: OrchestrationEvent) => void, +) { const persistence = databasePath ? makeSqlitePersistenceLive(databasePath) : SqlitePersistenceMemory; @@ -70,7 +75,23 @@ function makeOrchestrationLayer(databasePath?: string) { 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( @@ -453,6 +474,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), ); @@ -548,6 +570,185 @@ 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", () => { + 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"); + 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, + taskId: "racing-task", + taskType: "subagent", + status: undefined, + kind: "started", + }); + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("quick-project-create"), + projectId, + title: "Project", + workspaceRoot: cwd, + 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); + } + 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* 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, + ).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"), + threadId, + projectId, + }); + expect( + (yield* snapshots.getSnapshot()).threads.find((thread) => thread.id === threadId) + ?.projectId, + ).toBe(projectId); + }).pipe( + Effect.scoped, + Effect.provide( + makeOrchestrationLayer(undefined, (event) => { + if (event.commandId === CommandId.make("quick-attach-race")) reportBackgroundWork(); + }), + ), + ); + }); + effectIt.effect( "rejects persisted changes and live background work without blocking unrelated threads", () => @@ -1588,6 +1789,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), ), ); @@ -1737,6 +1941,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 6557888c38ae..c9f4dcf60ad3 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 * as 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.ProviderService); + const quickChatWorkspace = yield* makeQuickChatWorkspace; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); let commandReadModel = createEmptyReadModel(yield* nowIso); @@ -203,7 +209,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 +220,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 = @@ -247,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* () { @@ -280,6 +341,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, @@ -289,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), @@ -297,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/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index e4638a329b6c..28fd92b5d617 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -795,6 +795,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 e262bce34aaf..45a7c69fd791 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -2196,6 +2196,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 5f82a26e2a36..d13753e8f1d3 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -166,10 +166,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), @@ -881,7 +882,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 @@ -911,11 +912,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' @@ -2675,6 +2677,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..c93b9844a90d 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,149 @@ describe("ProviderCommandReactor", () => { }), ); + 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"), + 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(); + 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 () => { 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..734542ed89a5 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1,3 +1,4 @@ +import { makeQuickChatWorkspace } from "../quickChatWorkspace.ts"; import { type ChatAttachment, CommandId, @@ -22,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"; @@ -325,6 +327,8 @@ const make = Effect.gen(function* () { const providerRegistry = yield* ProviderRegistry; 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; @@ -478,12 +482,38 @@ 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 = quickChatWorkspace.directory(thread.id); + yield* fileSystem.makeDirectory(cwd, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: "unknown", + method: "thread.turn.start", + detail: "Could not prepare the quick chat directory.", + cause, + }), + ), + ); + 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 +522,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 +737,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 }) @@ -858,7 +884,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() @@ -1032,12 +1062,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 +1327,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 } : {}), @@ -1440,9 +1460,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), + FiberSet.run(pendingTurnStarts), + ); }); const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( @@ -1835,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.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..93ec89b09ba5 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.catchTags({ 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/ThreadPullRequestReactor.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.ts index b694bacbbb33..9c895fb6e144 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.ts @@ -136,7 +136,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 = PullRequestService.repositoryIdentityOf(project); 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 61c512e6fd91..2a06d9f1ba26 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts @@ -213,3 +213,7 @@ describe("resolveAutoSettlementAt", () => { ).toBe(true); }); }); + +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 1df7855d1e04..0bc1947f6eb8 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -93,7 +93,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 d925df5d98f1..e7d9410c1821 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -111,7 +111,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 && @@ -189,7 +189,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..98e914f06cfe --- /dev/null +++ b/apps/server/src/orchestration/decider.quick-chats.test.ts @@ -0,0 +1,260 @@ +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"); + }), + ); + 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, + }, + 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; + 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 304defb80c89..9b8694da574e 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -350,11 +350,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, @@ -876,6 +879,43 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); + if (command.projectId !== undefined) { + const attachmentAt = yield* nowIso; + if ( + thread.projectId !== null || + thread.deletedAt !== 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.", + }); + } + 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 && + 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.", + }); + } const branch = command.branch !== undefined && command.expectedBranch !== undefined && @@ -893,6 +933,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 c048247f4128..1bfee9bc3171 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -501,6 +501,7 @@ export function projectEvent( Effect.map((payload) => ({ ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { + ...(payload.projectId !== undefined ? { projectId: payload.projectId } : {}), ...(payload.title !== undefined ? { title: payload.title } : {}), ...(payload.activeOrderKey !== undefined ? { activeOrderKey: payload.activeOrderKey } 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/quickChatWorkspace.test.ts b/apps/server/src/orchestration/quickChatWorkspace.test.ts new file mode 100644 index 000000000000..9cb9d3640065 --- /dev/null +++ b/apps/server/src/orchestration/quickChatWorkspace.test.ts @@ -0,0 +1,126 @@ +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)), +); + +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 new file mode 100644 index 000000000000..3c7afb5b5635 --- /dev/null +++ b/apps/server/src/orchestration/quickChatWorkspace.ts @@ -0,0 +1,142 @@ +// 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"; +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 * as 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.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)); + 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.", + }); + } + 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; + 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/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 bc176f62cc3c..65a2fd8dce95 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -1,3 +1,4 @@ +import Migration0050 from "./Migrations/050_QuickChats.ts"; /** * Migration runner with an inline loader. * @@ -122,6 +123,7 @@ const migrationEntries = [ [47, "ProjectionProjectIcon", Migration0047], [48, "ProjectionThreadBranchPullRequest", Migration0048], [49, "ProjectionThreadsActiveOrderKey", Migration0049], + [50, "QuickChats", Migration0050], ] 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/050_QuickChats.ts b/apps/server/src/persistence/Migrations/050_QuickChats.ts new file mode 100644 index 000000000000..a1ac2251ecfb --- /dev/null +++ b/apps/server/src/persistence/Migrations/050_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 2f32b6524d7b..7529206014c1 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -8213,6 +8213,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/server.ts b/apps/server/src/server.ts index fd8ee4a4f699..81cacf4346b4 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -126,7 +126,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, @@ -422,8 +425,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/apps/server/src/ws.ts b/apps/server/src/ws.ts index 740be1330817..cdaad04d2ea3 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, @@ -1579,13 +1583,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 }), ), @@ -2435,7 +2448,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..2e5b30cdbcd9 --- /dev/null +++ b/apps/web/src/components/AttachQuickChatDialog.tsx @@ -0,0 +1,318 @@ +import { randomHex } from "../lib/utils"; +import { useEffect, useState } from "react"; +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"; +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 { 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 [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 = projectId + ? projects.find((candidate) => candidate.id === projectId) + : projects[0]; + const unavailable = + saved.error !== null || + !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 = null; + if (newWorktree) { + const pending = prepared ?? { + projectId: project.id, + workspaceRoot: project.workspaceRoot, + baseBranch: baseBranch.trim(), + branch: `t3/quick-chat-${randomHex(16)}`, + }; + 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, + query: existingRef.name, + refKind: "local", + refresh: true, + }, + }); + 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, + 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; + } + 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 }); + } + } + + return ( + <> + + Attach to project + +
+ + + {project && workspaceMode !== "local" && ( +
+ {newWorktree ? "Base branch" : "Worktree"} + {}} + selection={{ + projectId: project.id, + mode: newWorktree ? "base" : "worktree", + value: newWorktree ? baseBranch || null : (existingRef?.name ?? null), + disabled: busy || prepared !== null, + onSelect: (ref) => { + if (newWorktree) setBaseBranch(ref.name); + else setExistingRef(ref); + }, + }} + /> +
+ )} + {projects.length === 0 && ( +

Add a project on this environment first.

+ )} + {unavailable && ( +

Finish the current turn and background work before attaching.

+ )} + {error && ( +

+ {error} +

+ )} + {prepared && !busy && ( +
+ +

Any created worktree remains available under Existing worktree.

+
+ )} +
+ + + + + + ); +} + +export function AttachQuickChatDialog() { + const threadRef = useQuickChatAttachmentStore((state) => state.threadRef); + return ( + { + if (!open) useQuickChatAttachmentStore.getState().close(); + }} + > + + {threadRef && ( + + )} + + + ); +} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 0dad406985a7..0b43dc20629b 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -4,7 +4,13 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import type { ContextMenuItem, EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; +import type { + ContextMenuItem, + EnvironmentId, + VcsRef, + ThreadId, + ProjectId, +} from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { ChevronDownIcon, GitBranchIcon, SearchIcon } from "lucide-react"; import { @@ -66,6 +72,14 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; interface BranchToolbarBranchSelectorProps { className?: string; + /** Select a workspace for attachment without mutating the current thread or checkout. */ + selection?: { + projectId: ProjectId; + value: string | null; + mode: "base" | "worktree"; + disabled: boolean; + onSelect: (ref: VcsRef) => void; + }; environmentId: EnvironmentId; threadId: ThreadId; draftId?: DraftId; @@ -85,6 +99,7 @@ function toBranchActionErrorMessage(error: unknown): string { export function BranchToolbarBranchSelector({ className, + selection, environmentId, threadId, draftId, @@ -123,19 +138,24 @@ export function BranchToolbarBranchSelector({ const serverSession = serverThread?.session ?? null; const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - const activeProjectRef = serverThread - ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) - : draftThread - ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) - : null; + const activeProjectRef = selection + ? scopeProjectRef(environmentId, selection.projectId) + : serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; const activeProject = useProject(activeProjectRef); const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); - const activeThreadBranch = - activeThreadBranchOverride !== undefined + const activeThreadBranch = selection + ? selection.value + : activeThreadBranchOverride !== undefined ? activeThreadBranchOverride : (serverThread?.branch ?? draftThread?.branch ?? null); - const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const activeWorktreePath = selection + ? null + : (serverThread?.worktreePath ?? draftThread?.worktreePath ?? null); const activeProjectCwd = activeProject?.workspaceRoot ?? null; const branchCwd = activeWorktreePath ?? activeProjectCwd; const hasServerThread = serverThread !== null; @@ -152,7 +172,7 @@ export function BranchToolbarBranchSelector({ // --------------------------------------------------------------------------- const setThreadBranch = useCallback( (branch: string | null, worktreePath: string | null, automatic = false) => { - if (!activeThreadId || !activeProject) return; + if (selection || !activeThreadId || !activeProject) return; if (serverSession && worktreePath !== activeWorktreePath) { void stopThreadSession({ environmentId, @@ -188,6 +208,7 @@ export function BranchToolbarBranchSelector({ }, [ activeThreadId, + selection, activeProject, serverSession, activeWorktreePath, @@ -247,12 +268,14 @@ export function BranchToolbarBranchSelector({ [branchStatusQuery.data?.sourceControlProvider], ); const SourceControlIcon = sourceControlPresentation.Icon; - const canonicalActiveBranch = resolveBranchToolbarValue({ - envMode: effectiveEnvMode, - activeWorktreePath, - activeThreadBranch, - currentGitBranch, - }); + const canonicalActiveBranch = selection + ? selection.value + : resolveBranchToolbarValue({ + envMode: effectiveEnvMode, + activeWorktreePath, + activeThreadBranch, + currentGitBranch, + }); const branchNames = useMemo(() => refs.map((refName) => refName.name), [refs]); const branchByName = useMemo( () => new Map(refs.map((refName) => [refName.name, refName] as const)), @@ -264,7 +287,7 @@ export function BranchToolbarBranchSelector({ effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath; const checkoutPullRequestItemValue = prReference && onCheckoutPullRequestRequest ? `__checkout_pull_request__:${prReference}` : null; - const canCreateBranch = !isSelectingWorktreeBase && trimmedBranchQuery.length > 0; + const canCreateBranch = !selection && !isSelectingWorktreeBase && trimmedBranchQuery.length > 0; // The ref is created under its sanitized name, so the collision check has to // use that name too. Matching on the raw query would offer to create a ref // that already exists whenever sanitizing changes the name. @@ -390,6 +413,11 @@ export function BranchToolbarBranchSelector({ }; const selectBranch = (refName: VcsRef) => { + if (selection) { + if (!selection.disabled) selection.onSelect(refName); + setIsBranchMenuOpen(false); + return; + } if (!branchCwd || !activeProjectCwd || isBranchActionPending) return; if (isSelectingWorktreeBase) { @@ -496,8 +524,15 @@ export function BranchToolbarBranchSelector({ ? null : (defaultBranchName ?? currentGitBranch); + useEffect(() => { + if (selection?.mode !== "base" || selection.value || selection.disabled) return; + const candidate = refs.find((ref) => ref.isDefault) ?? refs.find((ref) => ref.current); + if (candidate) selection.onSelect(candidate); + }, [refs, selection]); + useEffect(() => { if ( + selection || effectiveEnvMode !== "worktree" || activeWorktreePath || activeThreadBranch || @@ -510,6 +545,7 @@ export function BranchToolbarBranchSelector({ activeThreadBranch, activeWorktreePath, effectiveEnvMode, + selection, setThreadBranch, worktreeBaseBranchCandidate, ]); @@ -604,13 +640,15 @@ export function BranchToolbarBranchSelector({ void branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); }, [deferredTrimmedBranchQuery, isBranchMenuOpen]); - const triggerLabel = resolveBranchTriggerLabel({ - activeWorktreePath, - effectiveEnvMode, - resolvedActiveBranch, - resolvedActiveBranchIsRemote, - startFromOrigin, - }); + const triggerLabel = selection + ? (selection.value ?? (selection.mode === "base" ? "Select base branch" : "Select worktree")) + : resolveBranchTriggerLabel({ + activeWorktreePath, + effectiveEnvMode, + resolvedActiveBranch, + resolvedActiveBranchIsRemote, + startFromOrigin, + }); // PR pill shown next to the branch selector when the active branch has one. const branchPrBranch = resolveBranchToolbarPrBranch({ @@ -696,6 +734,10 @@ export function BranchToolbarBranchSelector({ index={index} value={itemValue} className="pe-1.5" + disabled={ + selection?.mode === "worktree" && + (!refName.worktreePath || refName.worktreePath === activeProjectCwd) + } onClick={() => selectBranch(refName)} onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} > @@ -709,6 +751,7 @@ export function BranchToolbarBranchSelector({ return ( handleBranchContextMenu(event, resolvedActiveBranch)} > } // No press-scale: the popup aligns live to this trigger, so a // momentary 0.97 shrink would drag the open popup ~3px sideways. className="min-w-0 max-w-full font-normal text-muted-foreground/70 text-xs! hover:text-foreground/80 active:scale-100" - disabled={isInitialBranchesLoadPending || isBranchActionPending} + disabled={selection?.disabled || isInitialBranchesLoadPending || isBranchActionPending} > { - if (!activeThreadRef) return; + if (!activeThreadRef || !activeProject) return; const nextOpen = !terminalUiState.terminalOpen; if (nextOpen && terminalUiState.terminalIds.length === 0) { if (!activeThreadId || !activeProject) { @@ -4421,13 +4421,13 @@ export default function ChatView(props: ChatViewProps) { [activeThreadRef, diffOpen, onDiffPanelOpen], ); const toggleRightPanel = useCallback(() => { - if (!activeThreadRef) return; + if (!activeThreadRef || !activeProject) return; if (rightPanelOpen) { closePreviewPanel(); return; } useRightPanelStore.getState().toggleVisibility(activeThreadRef); - }, [activeThreadRef, closePreviewPanel, rightPanelOpen]); + }, [activeThreadRef, activeProject, closePreviewPanel, rightPanelOpen]); const toggleRightPanelMaximized = useCallback(() => { if (!canMaximizeRightPanel) return; setMaximizedRightPanelThreadKey((threadKey) => @@ -5762,7 +5762,6 @@ export default function ChatView(props: ChatViewProps) { const compactThreadUnavailable = !activeThread || !activeThreadHasCompactableConversation || - !activeProject || !isServerThread || !manualCompactionProviderAvailable || isWorking || @@ -5777,11 +5776,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 ( @@ -6644,7 +6641,7 @@ export default function ChatView(props: ChatViewProps) { } return; } - if (!activeProject) { + if (!activeProject && activeThread.projectId !== null) { toastManager.add( stackedThreadToast({ type: "warning", @@ -6657,14 +6654,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; @@ -6958,7 +6961,7 @@ export default function ChatView(props: ChatViewProps) { const bootstrap = isLocalDraftThread || baseBranchForWorktree ? { - ...(isLocalDraftThread + ...(isLocalDraftThread && activeProject ? { createThread: { projectId: activeProject.id, @@ -6972,7 +6975,7 @@ export default function ChatView(props: ChatViewProps) { }, } : {}), - ...(baseBranchForWorktree + ...(baseBranchForWorktree && activeProject ? { prepareWorktree: { projectCwd: activeProject.workspaceRoot, @@ -7025,7 +7028,7 @@ export default function ChatView(props: ChatViewProps) { releaseDraftAttachments(composerAttachmentsSnapshot); } acknowledgeActiveThreadWoke(); - if (backgroundThreadRef) { + if (backgroundThreadRef && activeProject) { markPromotedDraftThreadByRef(backgroundThreadRef); try { const nextDraft = await handleNewThread( @@ -8095,7 +8098,7 @@ export default function ChatView(props: ChatViewProps) { isServerThread={isServerThread} activeProject={activeProject} openInCwd={gitCwd} - activeProjectScripts={activeProjectScripts} + activeProjectScripts={activeProject ? activeProjectScripts : undefined} preferredScriptId={ activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null } @@ -8366,7 +8369,9 @@ export default function ChatView(props: ChatViewProps) { onPageScrollRelease={onComposerPageScrollRelease} onSend={onSend} onInterrupt={onInterrupt} - onImplementPlanInNewThread={onImplementPlanInNewThread} + onImplementPlanInNewThread={ + activeProject ? onImplementPlanInNewThread : undefined + } onRespondToApproval={onRespondToApproval} onSelectActivePendingUserInputOption={ onSelectActivePendingUserInputOption @@ -8398,7 +8403,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 d146120f719d..9fdb3940fbf5 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,5 +1,7 @@ "use client"; +import { useNewQuickChat } from "../hooks/useNewQuickChat"; + import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { canCreateProjectInEnvironment, @@ -67,6 +69,7 @@ import { useAtomValue } from "@effect/atom-react"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useActiveProjectTarget } from "../hooks/useActiveProjectTarget"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { useClientSettings } from "../hooks/useSettings"; @@ -392,6 +395,7 @@ function overlayModeForCommand(command: string | null): SearchOverlayMode | null } export function CommandPalette({ children }: { children: ReactNode }) { + const hasActiveProject = useActiveProjectTarget() !== null; const [state, dispatch] = useReducer(reduceCommandPaletteUiState, { open: false, mode: "command", @@ -399,8 +403,11 @@ export function CommandPalette({ children }: { children: ReactNode }) { }); const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []); const toggleMode = useCallback( - (mode: SearchOverlayMode) => dispatch({ _tag: "ToggleMode", mode }), - [], + (mode: SearchOverlayMode) => { + if ((mode === "files" || mode === "content") && !hasActiveProject) return; + dispatch({ _tag: "ToggleMode", mode }); + }, + [hasActiveProject], ); const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); @@ -1071,6 +1078,34 @@ 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, 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: , + run: async () => { + await newQuickChat(environment.environmentId); + }, + })); + }, [ + environments, + activeThread?.environmentId, + activeDraftThread?.environmentId, + primaryEnvironmentId, + newQuickChat, + ]); + const projectThreadItems = useMemo( () => enumerateCommandPaletteItems( @@ -1498,7 +1533,10 @@ function OpenCommandPaletteDialog(props: { }, [clearOpenIntent, openAddProjectFlow, openIntent]); useLayoutEffect(() => { - if (openIntent?.kind !== "new-thread-in" || projectThreadItems.length === 0) { + if ( + openIntent?.kind !== "new-thread-in" || + (projectThreadItems.length === 0 && quickChatItems.length === 0) + ) { return; } clearOpenIntent(); @@ -1524,6 +1562,7 @@ function OpenCommandPaletteDialog(props: { label: "Projects", items: enumerateCommandPaletteItems(prioritized), }, + { value: "quick-chat", label: "Quick chat", items: quickChatItems }, ], }); }, [ @@ -1533,10 +1572,13 @@ function OpenCommandPaletteDialog(props: { currentProjectId, openIntent, projectThreadItems, + quickChatItems, pushPaletteView, ]); - const actionItems: Array = []; + const actionItems: Array = [ + ...quickChatItems, + ]; if (projects.length > 0) { const activeProjectTitle = @@ -1573,7 +1615,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 }, + ], }); } @@ -1594,6 +1639,7 @@ function OpenCommandPaletteDialog(props: { actionItems.push({ kind: "action", value: "action:open-file-picker", + disabled: currentProjectId === null, searchTerms: ["go to file", "open file", "file picker", "find file", "quick open"], title: "Go to file", icon: , @@ -1607,6 +1653,7 @@ function OpenCommandPaletteDialog(props: { actionItems.push({ kind: "action", value: "action:search-project-contents", + disabled: currentProjectId === null, searchTerms: ["search project", "find in files", "grep", "content search", "text search"], title: "Search project contents", icon: , @@ -2245,6 +2292,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 ( { + setTitle(thread.title); + setRenaming(true); + }, + }); + return ( +
  • + {renaming ? ( + 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({ + 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 07ec97e46551..e78b847b71c0 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -1,3 +1,4 @@ +import { LegacyQuickChatList } from "./LegacyQuickChatList"; import { Spinner } from "~/components/ui/spinner"; import { ArchiveIcon, @@ -1246,6 +1247,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)), ); @@ -2155,7 +2157,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)), ); @@ -2189,7 +2192,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", @@ -2945,6 +2948,14 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
    Projects
    + )} + {projectsLength === 0 && (
    No projects yet
    )} @@ -3206,7 +3218,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)), @@ -3219,6 +3231,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)), @@ -3349,7 +3362,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)), @@ -3378,42 +3392,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.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts index 1058dfcd8859..4e6fa326ee2e 100644 --- a/apps/web/src/components/Sidebar.drag.test.ts +++ b/apps/web/src/components/Sidebar.drag.test.ts @@ -253,6 +253,24 @@ describe("sidebar collision detection", () => { }); describe("sidebar drag projection", () => { + it("keeps quick chats below their measured heading while reordering project rows", () => { + const items = [ + pinnedHeader, + divider, + thread("project-a", "active"), + thread("project-b", "active"), + marker("quick-chats-header"), + thread("quick-chat", "active"), + settledHeader, + ]; + const transforms = preview( + { items, settledOrder: [], settledExpanded: false }, + "project-a", + "project-b", + ); + expect(transforms.get("quick-chat")).toEqual(stationary); + expect(transforms.get(sidebarMarkerId("quick-chats-header"))).toEqual(stationary); + }); const pinned = [ pinnedHeader, thread("p1", "pinned"), diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts index 0aef9dec14b1..c1751f54ebd0 100644 --- a/apps/web/src/components/Sidebar.drag.ts +++ b/apps/web/src/components/Sidebar.drag.ts @@ -172,8 +172,24 @@ export function createSidebarSortingStrategy(input: { groups.settled = visible.map((key) => ({ kind: "thread", key, section: "settled" })); const projected: SidebarListItem[] = []; const marker = (name: SidebarListMarker) => projected.push({ kind: "marker", marker: name }); + const quickChatsStart = items.findIndex( + (item) => item.kind === "marker" && item.marker === "quick-chats-header", + ); + const quickChatKeys = new Set( + quickChatsStart < 0 + ? [] + : items + .slice(quickChatsStart + 1) + .flatMap((item) => + item.kind === "thread" && item.section === "active" ? [item.key] : [], + ), + ); const section = (name: "active" | "settled") => { - if (groups[name].length > 0) projected.push(...groups[name]); + if (name === "active" && quickChatKeys.size > 0) { + projected.push(...groups.active.filter((item) => !quickChatKeys.has(item.key))); + marker("quick-chats-header"); + projected.push(...groups.active.filter((item) => quickChatKeys.has(item.key))); + } else if (groups[name].length > 0) projected.push(...groups[name]); else marker(`${name}-placeholder`); }; marker("pinned-header"); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 4a0584821a9b..8ca3b0664209 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 9b59675e2eb1..d1dceb328985 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -107,6 +107,7 @@ export type SidebarListMarker = /** The boundary between pinned and active rows. */ | "pinned-divider" | "snoozed-header" + | "quick-chats-header" | "settled-header"; export function sidebarMarkerId(marker: SidebarListMarker): string { @@ -362,7 +363,7 @@ type ScopedSidebarProject = SidebarProject & { type ScopedSidebarThread = ThreadSortInput & { environmentId: string; - projectId: string; + projectId: string | null; archivedAt: string | null; }; @@ -653,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[]; @@ -1144,6 +1134,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); @@ -1212,7 +1203,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 801c206df662..a531da8511c8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { useQuickChatAttachmentStore } from "../quickChatAttachmentStore"; import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { @@ -156,7 +157,6 @@ import { type SidebarDropVerb, resolveSidebarThreadStatus, searchSidebarThreadsByTitle, - shouldCreateNewThreadInCurrentProject, shouldRecedeSidebarThread, resolveWorkingStartedAt, sidebarListItemId, @@ -2412,7 +2412,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[] = []; @@ -2422,6 +2423,16 @@ export default function Sidebar() { const draggable = new Set(); 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; + } 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 @@ -2470,7 +2481,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 @@ -2865,9 +2878,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], @@ -3181,7 +3196,14 @@ export default function Sidebar() { items.push({ kind: "marker", marker: "pinned-divider" }); const activeRows = rowsOf(activeThreads, "active"); items.push({ kind: "marker", marker: "active-placeholder" }); - items.push(...activeRows); + const firstQuickChatIndex = activeThreads.findIndex((thread) => thread.projectId === null); + if (firstQuickChatIndex >= 0) { + items.push(...activeRows.slice(0, firstQuickChatIndex)); + items.push({ kind: "marker", marker: "quick-chats-header" }); + items.push(...activeRows.slice(firstQuickChatIndex)); + } else { + items.push(...activeRows); + } if (snoozedThreads.length > 0) { items.push({ kind: "marker", marker: "snoozed-header" }); items.push(...rowsOf(visibleSnoozedThreads, "snoozed")); @@ -3594,6 +3616,9 @@ export default function Sidebar() { const thread = threadByKeyRef.current.get(threadKey); return thread ? [thread] : []; }); + const settleableThreads = selectedThreads.filter( + (thread) => thread.projectId !== null && thread.settledOverride !== "settled", + ); const canSnoozeSelection = selectedThreads.every( (thread) => serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && @@ -3627,7 +3652,9 @@ export default function Sidebar() { api.contextMenu.show( [ ...(unpinMenuItem ? [unpinMenuItem] : []), - { id: "settle", label: `Settle (${count})` }, + ...(settleableThreads.length > 0 + ? [{ id: "settle", label: `Settle (${settleableThreads.length})` }] + : []), ...(canSnoozeSelection ? [ { @@ -3744,10 +3771,12 @@ export default function Sidebar() { // are already explicitly settled are skipped: nothing to do on a // valid mixed selection. Pinned rows ARE included: the decider // clears the pin as part of settling, so they park like the rest. - const coSettlingKeys = new Set(threadKeys); - for (const threadKey of threadKeys) { - const thread = threadByKeyRef.current.get(threadKey); - if (!thread || thread.settledOverride === "settled") continue; + const coSettlingKeys = new Set( + settleableThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ); + for (const thread of settleableThreads) { attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys }); } clearSelection(); @@ -3857,6 +3886,7 @@ export default function Sidebar() { const clicked = await settlePromise(() => api.contextMenu.show( buildThreadActionMenuItems({ + isQuickChat: thread.projectId === null, branch: thread.branch ?? null, isPinned, isSettled, @@ -3896,11 +3926,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", @@ -4146,10 +4182,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, @@ -4165,17 +4199,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 ( <> @@ -4242,7 +4267,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" /> } @@ -4450,9 +4480,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={ @@ -4538,14 +4570,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 } @@ -4588,9 +4623,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) ?? @@ -4647,10 +4684,22 @@ export default function Sidebar() { ]; for (const item of sidebarListItems) { if (item.kind === "thread") { - items.push(renderThreadRow(threadByKey.get(item.key)!, item.section)); + const thread = threadByKey.get(item.key)!; + items.push(renderThreadRow(thread, item.section)); continue; } switch (item.marker) { + case "quick-chats-header": + items.push( + + Quick chats + , + ); + break; case "pinned-header": items.push( void; onInterrupt: () => void; - onImplementPlanInNewThread: () => void; + onImplementPlanInNewThread: (() => void) | undefined; onCompactContext?: (() => void) | undefined; compactDisabled: boolean; compactDisabledReason: string | null; @@ -1341,7 +1341,7 @@ export interface ChatComposerProps { // Callbacks onSend: (e?: { preventDefault: () => void }, intent?: ComposerSubmissionIntent) => void; onInterrupt: () => void; - onImplementPlanInNewThread: () => void; + onImplementPlanInNewThread: (() => void) | undefined; onRespondToApproval: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, @@ -4637,9 +4637,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const handleInterruptPrimaryAction = useCallback(() => { void onInterrupt(); }, [onInterrupt]); - const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => { - void onImplementPlanInNewThread(); - }, [onImplementPlanInNewThread]); // The phone composer collapses when the editor loses focus. Desktop only // rests on a timeline scroll, so losing focus there changes nothing. const scheduleComposerCollapseCheck = useCallback(() => { @@ -5089,9 +5086,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) preserveComposerFocusOnPointerDown onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} - onImplementPlanInNewThread={ - handleImplementPlanInNewThreadPrimaryAction - } + onImplementPlanInNewThread={onImplementPlanInNewThread} /> ) : null}
    @@ -5724,7 +5719,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) preserveComposerFocusOnPointerDown onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} - onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} + onImplementPlanInNewThread={onImplementPlanInNewThread} />
    ) : null} @@ -5829,7 +5824,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/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index de6f32aaa121..b29699b81313 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -978,7 +978,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..6a0b4b44d92b 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,27 @@ 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", + }, + threads: quickChats, + }, + ]; + }); + return [...groups, ...quickChatGroups]; }, [archivedSnapshots]); const handleArchivedThreadContextMenu = useCallback( @@ -2970,10 +2996,16 @@ 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..987922efe3b2 --- /dev/null +++ b/apps/web/src/hooks/useNewQuickChat.ts @@ -0,0 +1,65 @@ +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"; +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") { + 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({ + 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/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/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/__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 e084e22c2cbb..784648e4a81e 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,14 +1,9 @@ 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 +23,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 +73,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 +133,8 @@ function ChatRouteGlobalShortcuts() { keybindings, defaultProjectRef, previewOpen, - projectGroupCount, routeThreadRef, selectedThreadKeysSize, - legacySidebarEnabled, terminalOpen, ]); 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 8e34c9e75606..695d7a86566f 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,33 @@ 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. + +In the New thread picker, press **Ctrl+0** to start a quick chat, or **Cmd+0** on macOS. + +To turn a quick chat into project work, finish the current turn, wait for background +work to finish, and resolve pending approvals or input. Then choose +**Attach to project** from its menu on web or desktop, or above the conversation +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 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 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 368585556cfe..65b088ca4ce7 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.test.ts b/packages/client-runtime/src/operations/quickChats.test.ts new file mode 100644 index 000000000000..064500908751 --- /dev/null +++ b/packages/client-runtime/src/operations/quickChats.test.ts @@ -0,0 +1,139 @@ +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.ts"; + +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(); + 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 () => { + 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(); + const collidingRef = { + environmentId: EnvironmentId.make("environment-chat"), + threadId: ThreadId.make("other"), + }; + const distinctRef = { + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("chat-other"), + }; + await first.save(collidingRef, pending); + expect(reloaded.load(distinctRef)).toBeNull(); + reloaded.clear(distinctRef); + expect(first.load(collidingRef)).toEqual(pending); + reloaded.clear(ref); + expect(first.load(ref)).toBeNull(); +}); + +it("reattaches a saved branch whose worktree was removed without recreating the branch", async () => { + const pending = { + projectId: ProjectId.make("project"), + workspaceRoot: "/project", + baseBranch: "main", + branch: "t3/quick-chat-retry", + }; + const worktree = { path: "/worktree", refName: pending.branch }; + const result = await prepareQuickChatWorktree({ + pending, + listRefs: async () => ({ + refs: [{ name: pending.branch, current: false, isDefault: false, worktreePath: null }], + isRepo: true, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: 1, + }), + createWorktree: async (input) => { + if (input.newRefName) throw new Error("Branch already exists"); + if (input.refName !== pending.branch) throw new Error("Wrong branch"); + return { worktree }; + }, + }); + expect(result).toEqual(worktree); +}); diff --git a/packages/client-runtime/src/operations/quickChats.ts b/packages/client-runtime/src/operations/quickChats.ts new file mode 100644 index 000000000000..206443030e4a --- /dev/null +++ b/packages/client-runtime/src/operations/quickChats.ts @@ -0,0 +1,96 @@ +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: { + 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", + ); + const preferred = config.settings.defaultModelSelection; + if ( + preferred && + providers.some( + (provider) => + provider.instanceId === preferred.instanceId && + provider.models.some((model) => model.slug === preferred.model), + ) + ) { + 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; +} + +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: existing?.name ?? input.pending.baseBranch, + ...(existing ? {} : { newRefName: input.pending.branch }), + path: null, + }); + return result.worktree; +} diff --git a/packages/client-runtime/src/state/environmentHttpAuth.test.ts b/packages/client-runtime/src/state/environmentHttpAuth.test.ts index 114b2c67e544..362094287135 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.test.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.test.ts @@ -237,6 +237,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 a3481fdc729c..7d3fbe214749 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -229,6 +229,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/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, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 14062354047e..62e65d44ce64 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 4d2f80a1101a..ca0f5dc238b7 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -609,7 +609,7 @@ export type ThreadLinkedPullRequest = typeof ThreadLinkedPullRequest.Type; export const OrchestrationThread = Schema.Struct({ id: ThreadId, - projectId: ProjectId, + projectId: Schema.NullOr(ProjectId), title: TrimmedNonEmptyString, modelSelection: ModelSelection, runtimeMode: RuntimeMode, @@ -691,7 +691,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, @@ -791,6 +791,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. @@ -921,7 +923,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, @@ -1035,6 +1037,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), @@ -1067,7 +1070,7 @@ const ThreadInteractionModeSetCommand = Schema.Struct({ }); const ThreadTurnStartBootstrapCreateThread = Schema.Struct({ - projectId: ProjectId, + projectId: Schema.NullOr(ProjectId), title: TrimmedNonEmptyString, modelSelection: ModelSelection, runtimeMode: RuntimeMode, @@ -1447,7 +1450,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))), @@ -1527,6 +1530,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)), @@ -1939,6 +1943,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 }))), }); @@ -1946,7 +1951,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), @@ -2028,7 +2033,7 @@ export const OrchestrationRpcSchemas = { output: OrchestrationSearchThreadsResult, }, getArchivedShellSnapshot: { - input: Schema.Struct({}), + input: Schema.Struct({ includeQuickChats: Schema.optionalKey(Schema.Boolean) }), output: OrchestrationShellSnapshot, }, subscribeThread: {