diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 9ca17dbc2dd..a630fbbab39 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -244,6 +244,46 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { ).toHaveLength(160); }); + it("resolves standalone chats without requiring a project snapshot", () => { + const environmentId = "env-1" as EnvironmentId; + const threadId = "chat-1" as ThreadId; + const thread = { + id: threadId, + projectId: null, + title: "Standalone chat", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + session: null, + latestTurn: { + turnId: "turn-chat" as TurnId, + state: "completed", + requestedAt: "2026-05-25T00:00:00.000Z", + startedAt: "2026-05-25T00:00:00.000Z", + completedAt: "2026-05-25T00:01:00.000Z", + assistantMessageId: null, + }, + updatedAt: "2026-05-25T00:01:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + } as OrchestrationThreadShell; + + expect( + AgentAwarenessRelay.resolveAgentAwarenessRelayPublishSnapshot({ + environmentId, + threadId, + thread: Option.some(thread), + project: Option.none(), + }), + ).toMatchObject({ + projectId: null, + reason: "snapshot", + state: { + projectTitle: "Chats", + threadTitle: "Standalone chat", + phase: "completed", + }, + }); + }); + it("resolves a null publish state when a thread or project snapshot disappeared", () => { const environmentId = "env-1" as EnvironmentId; const threadId = "thread-1" as ThreadId; @@ -291,6 +331,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { const environmentId = "env-1" as EnvironmentId; const projectId = "project-1" as ProjectId; const activeThreadId = "thread-active" as ThreadId; + const activeChatId = "chat-active" as ThreadId; const idleThreadId = "thread-idle" as ThreadId; const baseThread = { @@ -338,6 +379,19 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { ...baseThread, id: idleThreadId, }, + { + ...baseThread, + id: activeChatId, + projectId: null, + latestTurn: { + turnId: "turn-chat" as TurnId, + state: "running", + requestedAt: now, + startedAt: now, + completedAt: null, + assistantMessageId: null, + }, + }, { ...baseThread, id: "thread-missing-project" as ThreadId, @@ -353,7 +407,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }, ], }), - ).toEqual([activeThreadId]); + ).toEqual([activeThreadId, activeChatId]); }); it("signs the activity publish JWT and rejects tampering", async () => { diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 107fde46fe0..b17cd5775e0 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -53,6 +53,20 @@ export class AgentAwarenessRelay extends Context.Service< } >()("t3/relay/AgentAwarenessRelay") {} +const STANDALONE_CHATS_ACTIVITY_GROUP = { title: "Chats" } as const; + +function threadAwareness(input: { + readonly environmentId: EnvironmentId; + readonly thread: OrchestrationThreadShell; + readonly project: Pick | null; +}) { + return projectThreadAwareness({ + environmentId: input.environmentId, + project: input.project ?? STANDALONE_CHATS_ACTIVITY_GROUP, + thread: input.thread, + }); +} + export function eventThreadId(event: OrchestrationEvent): ThreadId | null { const payload = event.payload as { readonly threadId?: unknown }; if (typeof payload.threadId === "string") { @@ -246,6 +260,19 @@ export function resolveAgentAwarenessRelayPublishSnapshot(input: { reason: "thread-not-found", }; } + if (input.thread.value.projectId === null) { + return { + projectId: null, + state: sanitizeRelayAgentActivityState( + threadAwareness({ + environmentId: input.environmentId, + project: null, + thread: input.thread.value, + }), + ), + reason: "snapshot", + }; + } if (Option.isNone(input.project)) { return { projectId: input.thread.value.projectId, @@ -256,7 +283,7 @@ export function resolveAgentAwarenessRelayPublishSnapshot(input: { return { projectId: input.thread.value.projectId, state: sanitizeRelayAgentActivityState( - projectThreadAwareness({ + threadAwareness({ environmentId: input.environmentId, project: input.project.value, thread: input.thread.value, @@ -275,14 +302,20 @@ export function resolveAgentAwarenessRelayActiveThreadIds(input: { return input.threads .filter((thread) => { if (thread.projectId === null) { - return false; + return ( + threadAwareness({ + environmentId: input.environmentId, + project: null, + thread, + }) !== null + ); } const project = projectById.get(thread.projectId); if (!project) { return false; } return ( - projectThreadAwareness({ + threadAwareness({ environmentId: input.environmentId, project, thread, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 7c77ded455f..99583f9143e 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -36,7 +36,7 @@ export function buildLocalDraftThread( id: threadId, environmentId: draftThread.environmentId, projectId: draftThread.projectId, - title: "New thread", + title: draftThread.projectId === null ? "New chat" : "New thread", modelSelection: fallbackModelSelection, runtimeMode: draftThread.runtimeMode, interactionMode: draftThread.interactionMode, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6d91c4b1834..1a7dc3c1551 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1224,7 +1224,7 @@ function ChatViewContent(props: ChatViewProps) { [mountedTerminalThreadKeys], ); - const fallbackDraftProjectRef = draftThread + const fallbackDraftProjectRef = draftThread?.projectId ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; const fallbackDraftProject = useProject(fallbackDraftProjectRef); @@ -4177,12 +4177,13 @@ function ChatViewContent(props: ChatViewProps) { let turnStartSucceeded = false; if (failure === null && turnAttachmentsResult._tag === "Success") { const bootstrap = - (isLocalDraftThread && activeProject) || baseBranchForWorktree + isLocalDraftThread || baseBranchForWorktree ? { - ...(isLocalDraftThread && activeProject + ...(isLocalDraftThread ? { createThread: { - projectId: activeProject.id, + projectId: activeProject?.id ?? null, + ...(activeProject ? {} : { context: { kind: "standalone" as const } }), title, modelSelection: threadCreateModelSelection, runtimeMode, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f10b69d5410..17a4f7a0904 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -38,7 +38,6 @@ import type { SidebarThreadGroupingMode, ThreadId, } from "@t3tools/contracts"; -import { DEFAULT_MODEL, ProviderInstanceId } from "@t3tools/contracts"; import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, MIN_SIDEBAR_THREAD_PREVIEW_COUNT, @@ -95,7 +94,7 @@ import { useOpenPrLink } from "../lib/openPullRequestLink"; import { startNewThreadInProjectFromContext } from "../lib/chatThreadActions"; import { isTerminalFocused } from "../lib/terminalFocus"; import { sortThreads } from "../lib/threadSort"; -import { isMacPlatform, newThreadId } from "../lib/utils"; +import { isMacPlatform, newDraftId, newThreadId } from "../lib/utils"; import { readLocalApi } from "../localApi"; import { derivePhysicalProjectKey, @@ -116,7 +115,6 @@ import { useDesktopUpdateState } from "../state/desktopUpdate"; import { useProject, useProjects, - useServerConfigs, useThreadShells, useThreadShellsForProjectRefs, } from "../state/entities"; @@ -3939,7 +3937,6 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( export default function Sidebar() { const projects = useProjects(); - const serverConfigs = useServerConfigs(); const sidebarThreads = useThreadShells(); const projectSidebarThreads = useMemo( () => sidebarThreads.filter((thread) => thread.projectId !== null), @@ -4024,7 +4021,6 @@ export default function Sidebar() { const shortcutModifiers = useShortcutModifierState(); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); - const createThread = useAtomCommand(threadEnvironment.create, { reportFailure: false }); const environmentLabelById = useMemo( () => new Map( @@ -4186,42 +4182,25 @@ export default function Sidebar() { }); return; } - const providers = serverConfigs.get(primaryEnvironmentId)?.providers ?? []; - const provider = - providers.find( - (candidate) => candidate.enabled && candidate.installed && candidate.models.length > 0, - ) ?? providers.find((candidate) => candidate.enabled && candidate.installed); + const draftId = newDraftId(); const threadId = newThreadId(); - const modelSelection = { - instanceId: provider?.instanceId ?? ProviderInstanceId.make("codex"), - model: provider?.models[0]?.slug ?? DEFAULT_MODEL, - }; - void createThread({ - environmentId: primaryEnvironmentId, - input: { - threadId, - projectId: null, - context: { kind: "standalone" }, - title: "New chat", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }, - }).then((result) => { - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - toastManager.add({ - type: "error", - title: "Could not start chat", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }); - return; - } - navigateToThread(scopeThreadRef(primaryEnvironmentId, threadId)); + const draftStore = useComposerDraftStore.getState(); + draftStore.setStandaloneDraftThreadId(primaryEnvironmentId, draftId, { + threadId, + createdAt: new Date().toISOString(), + }); + draftStore.applyStickyState(draftId); + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { + clearSelection(); + } + if (isMobile) { + setOpenMobile(false); + } + void navigate({ + to: "/draft/$draftId", + params: { draftId }, }); - }, [createThread, navigateToThread, primaryEnvironmentId, serverConfigs]); + }, [clearSelection, isMobile, navigate, primaryEnvironmentId, setOpenMobile]); const projectDnDSensors = useSensors( useSensor(PointerSensor, { diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index bc1b7107306..8af188f386d 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -767,6 +767,28 @@ describe("composerDraftStore project draft thread mapping", () => { }); }); + it("stores standalone chats as projectless drafts until first send", () => { + const store = useComposerDraftStore.getState(); + + store.setStandaloneDraftThreadId(TEST_ENVIRONMENT_ID, draftId, { + threadId, + createdAt: "2026-01-01T00:00:00.000Z", + }); + + expect(useComposerDraftStore.getState().getDraftSession(draftId)).toMatchObject({ + threadId, + environmentId: TEST_ENVIRONMENT_ID, + projectId: null, + branch: null, + worktreePath: null, + envMode: "local", + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-01-01T00:00:00.000Z", + }); + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); + }); + it("clears only matching project draft mapping entries", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index fdb8bfe7b18..9d2b2f4a12a 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -207,7 +207,7 @@ type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & const PersistedDraftThreadState = Schema.Struct({ threadId: ThreadId, environmentId: Schema.String, - projectId: ProjectId, + projectId: Schema.NullOr(ProjectId), logicalProjectKey: Schema.optionalKey(Schema.String), createdAt: Schema.String, runtimeMode: RuntimeMode, @@ -286,7 +286,7 @@ export interface ComposerThreadDraftState { export interface DraftSessionState { threadId: ThreadId; environmentId: EnvironmentId; - projectId: ProjectId; + projectId: ProjectId | null; logicalProjectKey: string; createdAt: string; runtimeMode: RuntimeMode; @@ -303,8 +303,9 @@ export type DraftThreadState = DraftSessionState; /** * Draft session metadata paired with its stable draft-session identity. */ -interface ProjectDraftSession extends DraftSessionState { +interface ProjectDraftSession extends Omit { draftId: DraftId; + projectId: ProjectId; } /** @@ -376,6 +377,17 @@ interface ComposerDraftStoreState { interactionMode?: ProviderInteractionMode; }, ) => void; + /** Creates a standalone draft session that is promoted on first send. */ + setStandaloneDraftThreadId: ( + environmentId: EnvironmentId, + draftId: DraftId, + options?: { + threadId?: ThreadId; + createdAt?: string; + runtimeMode?: RuntimeMode; + interactionMode?: ProviderInteractionMode; + }, + ) => void; /** Updates mutable draft-session metadata without touching composer content. */ setDraftThreadContext: ( threadRef: ComposerThreadTarget, @@ -1301,10 +1313,14 @@ function isComposerThreadKeyInUse(mappings: Record, threadKey: s function toProjectDraftSession( draftId: DraftId, draftSession: DraftSessionState, -): ProjectDraftSession { +): ProjectDraftSession | null { + if (draftSession.projectId === null) { + return null; + } return { draftId, ...draftSession, + projectId: draftSession.projectId, }; } @@ -1509,21 +1525,27 @@ function normalizePersistedDraftThreads( promotedToRecord.threadId as ThreadId, ) : null; - if (typeof projectId !== "string" || projectId.length === 0 || environmentId === undefined) { + if ( + (projectId !== null && (typeof projectId !== "string" || projectId.length === 0)) || + environmentId === undefined + ) { continue; } const normalizedEnvironmentId = environmentId as EnvironmentId; + const normalizedProjectId = projectId === null ? null : (projectId as ProjectId); draftThreadsByThreadKey[threadKey] = { threadId, environmentId: normalizedEnvironmentId, - projectId: projectId as ProjectId, + projectId: normalizedProjectId, logicalProjectKey: typeof candidateDraftThread.logicalProjectKey === "string" && candidateDraftThread.logicalProjectKey.length > 0 ? candidateDraftThread.logicalProjectKey - : parsedThreadRef - ? projectDraftKey(scopeProjectRef(normalizedEnvironmentId, projectId as ProjectId)) - : threadKeyOrId, + : normalizedProjectId === null + ? `standalone:${normalizedEnvironmentId}:${threadKey}` + : parsedThreadRef + ? projectDraftKey(scopeProjectRef(normalizedEnvironmentId, normalizedProjectId)) + : threadKeyOrId, createdAt: typeof createdAt === "string" && createdAt.length > 0 ? createdAt @@ -2144,12 +2166,14 @@ function toHydratedDraftThreadState( projectId: persistedDraftThread.projectId, logicalProjectKey: persistedDraftThread.logicalProjectKey ?? - projectDraftKey( - scopeProjectRef( - persistedDraftThread.environmentId as EnvironmentId, - persistedDraftThread.projectId, - ), - ), + (persistedDraftThread.projectId === null + ? `standalone:${persistedDraftThread.environmentId}:${persistedDraftThread.threadId}` + : projectDraftKey( + scopeProjectRef( + persistedDraftThread.environmentId as EnvironmentId, + persistedDraftThread.projectId, + ), + )), createdAt: persistedDraftThread.createdAt, runtimeMode: persistedDraftThread.runtimeMode, interactionMode: persistedDraftThread.interactionMode, @@ -2307,6 +2331,38 @@ const composerDraftStore = create()( options, ); }, + setStandaloneDraftThreadId: (environmentId, draftId, options) => { + if (environmentId.length === 0 || draftId.length === 0) { + return; + } + set((state) => { + const existing = state.draftThreadsByThreadKey[draftId]; + const nextDraftThread: DraftThreadState = { + threadId: options?.threadId ?? existing?.threadId ?? ThreadId.make(draftId), + environmentId, + projectId: null, + logicalProjectKey: `standalone:${environmentId}:${draftId}`, + createdAt: options?.createdAt ?? existing?.createdAt ?? new Date().toISOString(), + runtimeMode: options?.runtimeMode ?? existing?.runtimeMode ?? DEFAULT_RUNTIME_MODE, + interactionMode: + options?.interactionMode ?? existing?.interactionMode ?? DEFAULT_INTERACTION_MODE, + branch: null, + worktreePath: null, + envMode: "local", + startFromOrigin: false, + promotedTo: null, + }; + if (draftThreadsEqual(existing, nextDraftThread)) { + return state; + } + return { + draftThreadsByThreadKey: { + ...state.draftThreadsByThreadKey, + [draftId]: nextDraftThread, + }, + }; + }); + }, setDraftThreadContext: (threadRef, options) => { const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; if (threadKey.length === 0) { @@ -2317,19 +2373,16 @@ const composerDraftStore = create()( if (!existing) { return state; } - const nextProjectRef = options.projectRef ?? { - environmentId: existing.environmentId, - projectId: existing.projectId, - }; + const nextEnvironmentId = options.projectRef?.environmentId ?? existing.environmentId; + const nextProjectId = options.projectRef?.projectId ?? existing.projectId; if ( - nextProjectRef.projectId.length === 0 || - nextProjectRef.environmentId.length === 0 + (nextProjectId !== null && nextProjectId.length === 0) || + nextEnvironmentId.length === 0 ) { return state; } const projectChanged = - nextProjectRef.environmentId !== existing.environmentId || - nextProjectRef.projectId !== existing.projectId; + nextEnvironmentId !== existing.environmentId || nextProjectId !== existing.projectId; const nextWorktreePath = options.worktreePath === undefined ? projectChanged @@ -2350,8 +2403,8 @@ const composerDraftStore = create()( : options.startFromOrigin; const nextDraftThread: DraftThreadState = { threadId: existing.threadId, - environmentId: nextProjectRef.environmentId, - projectId: nextProjectRef.projectId, + environmentId: nextEnvironmentId, + projectId: nextProjectId, logicalProjectKey: existing.logicalProjectKey, createdAt: options.createdAt === undefined diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 72ef8d21d7c..3d4191e1e6a 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -233,7 +233,7 @@ export function useHandleNewThread() { : null; const newThreadProjectRef = activeThread?.projectId ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) - : activeDraftThread + : activeDraftThread?.projectId ? scopeProjectRef(activeDraftThread.environmentId, activeDraftThread.projectId) : defaultProjectRef; const newThreadProject = newThreadProjectRef diff --git a/apps/web/src/interactionSounds.test.ts b/apps/web/src/interactionSounds.test.ts index 73930d8a696..409ec00b4e6 100644 --- a/apps/web/src/interactionSounds.test.ts +++ b/apps/web/src/interactionSounds.test.ts @@ -62,6 +62,33 @@ describe("interaction sounds", () => { ]); }); + it("plays completion feedback for standalone chats", () => { + const running = makeThread({ + projectId: null, + title: "Standalone chat", + latestTurn: { + turnId: TurnId.make("turn-chat"), + state: "running", + requestedAt: "2026-07-11T12:00:00.000Z", + startedAt: "2026-07-11T12:00:01.000Z", + completedAt: null, + assistantMessageId: null, + }, + }); + const completed = makeThread({ + ...running, + latestTurn: { + ...running.latestTurn!, + state: "completed", + completedAt: "2026-07-11T12:00:05.000Z", + }, + }); + + expect(deriveThreadFeedbackEvents(captureThreadSoundState([running]), [completed])).toEqual([ + { cue: "success", thread: completed }, + ]); + }); + it("plays the completion cue at 110% of its original gain", () => { expect(COMPLETION_SOUND_VOLUME).toBe(1.1); }); diff --git a/docs/personal-fork-changes.md b/docs/personal-fork-changes.md index 9c92b1fa9e0..fba4d684124 100644 --- a/docs/personal-fork-changes.md +++ b/docs/personal-fork-changes.md @@ -37,6 +37,15 @@ Turning a flag off preserves upstream behavior. - With sidebar worktree navigation enabled, right-clicking a worktree label opens an actions menu for starting a chat in that checkout or renaming its branch. +## Projectless standalone chats + +- Creating a standalone chat opens a local draft immediately, matching project-thread creation; + the server thread is materialized atomically with the first message instead of blocking + navigation on an empty-thread request. +- Standalone chats participate in the same desktop completion sounds and macOS notifications as + project threads. When agent-activity publishing is enabled, they also publish completion and + attention states to connected mobile clients under the generic `Chats` activity group. + ## Native macOS sidebar - Inactive thread titles are regular weight and subdued. The focused thread, multi-selected @@ -66,7 +75,8 @@ Turning a flag off preserves upstream behavior. ## macOS completion notifications and sounds - With macOS completion notifications enabled, the Electron host posts a native, silent macOS - notification whenever a turn transitions to completed. Clicking it reveals the app and opens the - exact environment-scoped thread. Web and non-macOS runtimes retain upstream behavior. + notification whenever a project thread or standalone chat transitions to completed. Clicking it + reveals the app and opens the exact environment-scoped conversation. Web and non-macOS runtimes + retain upstream behavior. - The synthesized completion cue plays at 110% of its original gain; attention cues retain their original gain. Disabling completion sounds preserves the upstream silent behavior.