From 9ca37bba240989df205e468e6bf87197d0beb7aa Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 25 Jul 2026 09:12:53 -0400 Subject: [PATCH 1/3] feat: add Chat pseudo-project for codebase-free conversations Adds a reserved "Chat" pseudo-project rooted at `/chats` for one-off conversations that are not tied to a repository, and hides the project-scoped surfaces that make no sense without a codebase. Server: - Derive `chatsDir` from the base dir and create it alongside the other runtime directories. - Ensure the Chat project exists at startup via the normal `project.create` command path (idempotent, non-fatal on failure). - Resolve a `kind` discriminator at read time from the workspace root so it needs no migration and survives base-dir changes. Client: - Hide diff, files, terminal, branch/worktree strip, git actions, scripts and open-in-editor for Chat threads (web and mobile). - Show a chat-bubble icon and no filesystem path for Chat; keep it out of project lists, with a dedicated "Start a new chat" action instead. - Collapse the sidebar card's meta row when a thread has no branch, PR or diff so the provider icon sits inline instead of leaving a gap. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/features/home/homeThreadList.ts | 6 +- .../features/threads/ThreadGitControls.tsx | 12 +- .../features/threads/ThreadRouteScreen.tsx | 34 ++++-- apps/server/src/config.ts | 3 + .../Layers/ProjectionSnapshotQuery.test.ts | 76 +++++++++++++ .../Layers/ProjectionSnapshotQuery.ts | 35 +++++- apps/server/src/project/ProjectKind.test.ts | 16 +++ apps/server/src/project/ProjectKind.ts | 16 +++ apps/server/src/serverRuntimeStartup.test.ts | 96 ++++++++++++++++ apps/server/src/serverRuntimeStartup.ts | 43 +++++++ apps/web/src/components/ChatView.tsx | 47 +++++--- .../components/CommandPalette.logic.test.ts | 44 +++++++- .../src/components/CommandPalette.logic.ts | 14 ++- apps/web/src/components/CommandPalette.tsx | 66 +++++++++-- .../src/components/CommandPaletteResults.tsx | 2 +- apps/web/src/components/ProjectFavicon.tsx | 12 +- apps/web/src/components/RightPanelTabs.tsx | 14 ++- apps/web/src/components/Sidebar.tsx | 6 +- apps/web/src/components/SidebarV2.tsx | 105 ++++++++++++------ apps/web/src/components/chat/ChatHeader.tsx | 25 +++-- .../src/components/chat/DraftHeroHeadline.tsx | 10 +- .../components/settings/SettingsPanels.tsx | 12 +- apps/web/src/rightPanelStore.ts | 26 +++++ apps/web/src/sidebarProjectGrouping.ts | 3 +- packages/client-runtime/src/state/models.ts | 24 ++++ packages/contracts/src/orchestration.ts | 11 ++ 26 files changed, 660 insertions(+), 98 deletions(-) create mode 100644 apps/server/src/project/ProjectKind.test.ts create mode 100644 apps/server/src/project/ProjectKind.ts diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 21084f0f5fe..1f364f73bfe 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -3,6 +3,7 @@ import { derivePhysicalProjectKey, deriveProjectGroupLabel, } from "@t3tools/client-runtime/state/project-grouping"; +import { projectDisplayTitle } from "@t3tools/client-runtime/state/models"; import type { EnvironmentProject, EnvironmentThreadShell, @@ -113,7 +114,10 @@ export function buildHomeProjectScopes(input: { const representative = projects[0]!; return { key, - title: deriveProjectGroupLabel({ representative, members: projects }), + title: + projects.length === 1 + ? projectDisplayTitle(representative) + : deriveProjectGroupLabel({ representative, members: projects }), representative, projects, projectRefs: projectRefsByGroup.get(key) ?? [], diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx index 31b65f49353..010aa544777 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -392,17 +392,21 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit export function useThreadGitRightHeaderItems(props: ThreadGitControlsProps): HeaderItems { const actionItems = useThreadGitHeaderActionItems(props); + const hidden = props.showActionControls === false; return useMemo( - () => [actionItems.git, actionItems.files, actionItems.terminal] as HeaderItems, - [actionItems], + () => + hidden ? [] : ([actionItems.git, actionItems.files, actionItems.terminal] as HeaderItems), + [actionItems, hidden], ); } export function useThreadGitCenterHeaderItems(props: ThreadGitControlsProps): HeaderItems { const actionItems = useThreadGitHeaderActionItems(props); + const hidden = props.showActionControls === false; return useMemo( - () => [actionItems.files, actionItems.git, actionItems.terminal] as HeaderItems, - [actionItems], + () => + hidden ? [] : ([actionItems.files, actionItems.git, actionItems.terminal] as HeaderItems), + [actionItems, hidden], ); } diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 7fb4740ddce..c4dbcb6ff8a 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -8,6 +8,7 @@ import { import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import * as Option from "effect/Option"; import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts"; +import { isChatsProject } from "@t3tools/client-runtime/state/models"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -206,11 +207,20 @@ function ThreadRouteContent( const [inspectorSelection, setInspectorSelection] = useState( () => (props.renderInspector ? { routeThreadIdentity, mode: "route" } : null), ); + // The reserved Chats pseudo-project has no real codebase, so files/git + // inspectors, terminals and git controls are hidden for its threads. + const selectedProjectIsChats = isChatsProject(selectedThreadProject); const inspectorMode = (() => { if (inspectorSelection?.routeThreadIdentity === routeThreadIdentity) { if (inspectorSelection.mode === "files" && selectedThreadCwd === null) { return null; } + if ( + selectedProjectIsChats && + (inspectorSelection.mode === "files" || inspectorSelection.mode === "git") + ) { + return null; + } return inspectorSelection.mode; } return null; @@ -594,22 +604,29 @@ function ThreadRouteContent( environmentId: environmentIdRaw ?? "", threadId: threadId ?? "", auxiliaryPaneControl: - !layout.usesSplitView && fileInspector.supported && selectedThreadCwd !== null + !layout.usesSplitView && + fileInspector.supported && + selectedThreadCwd !== null && + !selectedProjectIsChats ? { accessibilityLabel: "Toggle inspector", onPress: handleToggleInspector, } : undefined, onOpenFilesInspector: - fileInspector.supported && selectedThreadCwd !== null ? handleOpenFilesInspector : undefined, - onOpenGitInspector: fileInspector.supported ? handleOpenGitInspector : undefined, + fileInspector.supported && selectedThreadCwd !== null && !selectedProjectIsChats + ? handleOpenFilesInspector + : undefined, + onOpenGitInspector: + fileInspector.supported && !selectedProjectIsChats ? handleOpenGitInspector : undefined, currentBranch: selectedThread?.branch ?? null, gitStatus: gitStatus.data, gitOperationLabel: gitState.gitOperationLabel, - canOpenTerminal: Boolean(selectedThreadProject?.workspaceRoot), - canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot), - projectScripts: selectedThreadProject?.scripts ?? [], + canOpenTerminal: Boolean(selectedThreadProject?.workspaceRoot) && !selectedProjectIsChats, + canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot) && !selectedProjectIsChats, + projectScripts: selectedProjectIsChats ? [] : (selectedThreadProject?.scripts ?? []), terminalSessions: terminalMenuSessions, + showActionControls: !selectedProjectIsChats, showDirectFileControl: layout.usesSplitView, onOpenTerminal: handleOpenTerminal, onOpenNewTerminal: handleOpenNewTerminal, @@ -743,7 +760,10 @@ function ThreadRouteContent( const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null; const renderThreadRouteBody = (showActionControls: boolean) => ( <> - + diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 3b081c95c34..1cbfd568992 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -32,6 +32,7 @@ export interface ServerDerivedPaths { readonly settingsPath: string; readonly providerStatusCacheDir: string; readonly worktreesDir: string; + readonly chatsDir: string; readonly attachmentsDir: string; readonly logsDir: string; readonly serverLogPath: string; @@ -114,6 +115,7 @@ export const deriveServerPaths = Effect.fn(function* ( settingsPath: join(stateDir, "settings.json"), providerStatusCacheDir, worktreesDir: join(baseDir, "worktrees"), + chatsDir: join(baseDir, "chats"), attachmentsDir, logsDir, serverLogPath: join(logsDir, "server.log"), @@ -140,6 +142,7 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server fs.makeDirectory(derivedPaths.terminalLogsDir, { recursive: true }), fs.makeDirectory(derivedPaths.attachmentsDir, { recursive: true }), fs.makeDirectory(derivedPaths.worktreesDir, { recursive: true }), + fs.makeDirectory(derivedPaths.chatsDir, { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }), fs.makeDirectory(derivedPaths.providerStatusCacheDir, { recursive: true }), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index d4a24a209ad..1e06072c3ce 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -13,6 +13,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as ServerConfigModule from "../../config.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; @@ -33,6 +34,79 @@ const projectionSnapshotLayer = it.layer( ), ); +const projectionSnapshotWithServerConfigLayer = it.layer( + OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provideMerge(RepositoryIdentityResolver.layer), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge( + Layer.fresh( + ServerConfigModule.layerTest(process.cwd(), { prefix: "t3code-project-kind-test-" }), + ), + ), + Layer.provideMerge(NodeServices.layer), + ), +); + +projectionSnapshotWithServerConfigLayer("ProjectionSnapshotQuery project kind", (it) => { + it.effect("derives the chats kind for the project rooted at the chats dir", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const serverConfig = yield* ServerConfigModule.ServerConfig; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES + ( + 'project-chats', + 'Chats', + ${serverConfig.chatsDir}, + NULL, + '[]', + '2026-02-24T00:00:00.000Z', + '2026-02-24T00:00:01.000Z', + NULL + ), + ( + 'project-standard', + 'Standard', + '/tmp/project-standard', + NULL, + '[]', + '2026-02-24T00:00:00.000Z', + '2026-02-24T00:00:01.000Z', + NULL + ) + `; + + const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); + const kindsById = new Map( + shellSnapshot.projects.map((project) => [project.id, project.kind]), + ); + assert.equal(kindsById.get(asProjectId("project-chats")), "chats"); + assert.equal(kindsById.get(asProjectId("project-standard")), "standard"); + + const chatsProject = yield* snapshotQuery.getActiveProjectByWorkspaceRoot( + serverConfig.chatsDir, + ); + assert.equal(chatsProject._tag, "Some"); + if (chatsProject._tag === "Some") { + assert.equal(chatsProject.value.kind, "chats"); + } + }), + ); +}); + projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { it.effect("hydrates read model from projection tables and computes snapshot sequence", () => Effect.gen(function* () { @@ -261,6 +335,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: asProjectId("project-1"), title: "Project 1", workspaceRoot: "/tmp/project-1", + kind: "standard", repositoryIdentity: null, defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), @@ -376,6 +451,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: asProjectId("project-1"), title: "Project 1", workspaceRoot: "/tmp/project-1", + kind: "standard", repositoryIdentity: null, defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3d05bef4bdf..295ae288fc4 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -21,6 +21,7 @@ import { type OrchestrationSession, type OrchestrationThreadActivity, type OrchestrationThreadShell, + type ProjectKind, ModelSelection, ProjectId, ThreadId, @@ -50,6 +51,8 @@ import { ProjectionThreadProposedPlan } from "../../persistence/Services/Project import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; +import { resolveProjectKind } from "../../project/ProjectKind.ts"; +import { ServerConfig } from "../../config.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { ProjectionSnapshotQuery, @@ -227,11 +230,13 @@ function mapSessionRow( function mapProjectShellRow( row: Schema.Schema.Type, repositoryIdentity: OrchestrationProject["repositoryIdentity"], + kind: ProjectKind, ): OrchestrationProjectShell { return { id: row.projectId, title: row.title, workspaceRoot: row.workspaceRoot, + kind, repositoryIdentity, defaultModelSelection: row.defaultModelSelection, scripts: row.scripts, @@ -264,6 +269,14 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st const makeProjectionSnapshotQuery = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + // Optional so test layer stacks without ServerConfig keep working; without a + // configured chats dir every project resolves to "standard". + const chatsDir = Option.map( + yield* Effect.serviceOption(ServerConfig), + (config) => config.chatsDir, + ).pipe(Option.getOrUndefined); + const projectKindForWorkspaceRoot = (workspaceRoot: string) => + resolveProjectKind(workspaceRoot, chatsDir); const repositoryIdentityResolutionConcurrency = 4; const resolveRepositoryIdentitiesForProjects = Effect.fn( "ProjectionSnapshotQuery.resolveRepositoryIdentitiesForProjects", @@ -1181,6 +1194,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: row.projectId, title: row.title, workspaceRoot: row.workspaceRoot, + kind: projectKindForWorkspaceRoot(row.workspaceRoot), repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, defaultModelSelection: row.defaultModelSelection, scripts: row.scripts, @@ -1518,7 +1532,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { projects: Arr.filterMap(projectRows, (row) => row.deletedAt === null ? Result.succeed( - mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), + mapProjectShellRow( + row, + repositoryIdentities.get(row.projectId) ?? null, + projectKindForWorkspaceRoot(row.workspaceRoot), + ), ) : Result.failVoid, ), @@ -1657,7 +1675,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { projects: Arr.filterMap(projectRows, (row) => row.deletedAt === null && activeProjectIds.has(row.projectId) ? Result.succeed( - mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), + mapProjectShellRow( + row, + repositoryIdentities.get(row.projectId) ?? null, + projectKindForWorkspaceRoot(row.workspaceRoot), + ), ) : Result.failVoid, ), @@ -1755,6 +1777,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: option.value.projectId, title: option.value.title, workspaceRoot: option.value.workspaceRoot, + kind: projectKindForWorkspaceRoot(option.value.workspaceRoot), repositoryIdentity, defaultModelSelection: option.value.defaultModelSelection, scripts: option.value.scripts, @@ -1782,7 +1805,13 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { .resolve(option.value.workspaceRoot) .pipe( Effect.map((repositoryIdentity) => - Option.some(mapProjectShellRow(option.value, repositoryIdentity)), + Option.some( + mapProjectShellRow( + option.value, + repositoryIdentity, + projectKindForWorkspaceRoot(option.value.workspaceRoot), + ), + ), ), ), ), diff --git a/apps/server/src/project/ProjectKind.test.ts b/apps/server/src/project/ProjectKind.test.ts new file mode 100644 index 00000000000..a2df9d609c8 --- /dev/null +++ b/apps/server/src/project/ProjectKind.test.ts @@ -0,0 +1,16 @@ +import { assert, it } from "@effect/vitest"; + +import { resolveProjectKind } from "./ProjectKind.ts"; + +it("marks projects rooted at the chats dir as chats", () => { + assert.equal(resolveProjectKind("/home/user/.t3/chats", "/home/user/.t3/chats"), "chats"); +}); + +it("keeps other projects standard", () => { + assert.equal(resolveProjectKind("/home/user/dev/app", "/home/user/.t3/chats"), "standard"); + assert.equal(resolveProjectKind("/home/user/dev/chats", "/home/user/.t3/chats"), "standard"); +}); + +it("treats a missing chats dir as standard", () => { + assert.equal(resolveProjectKind("/home/user/.t3/chats", undefined), "standard"); +}); diff --git a/apps/server/src/project/ProjectKind.ts b/apps/server/src/project/ProjectKind.ts new file mode 100644 index 00000000000..e182131adde --- /dev/null +++ b/apps/server/src/project/ProjectKind.ts @@ -0,0 +1,16 @@ +/** + * ProjectKind - Derives the project kind from its workspace root. + * + * The reserved "Chats" pseudo-project lives at `/chats` and hosts + * one-off conversations that are not tied to a real codebase. The kind is + * derived at read time (never persisted) so it stays correct across base-dir + * moves and pre-existing rows. + * + * @module ProjectKind + */ +import type { ProjectKind } from "@t3tools/contracts"; + +export const resolveProjectKind = ( + workspaceRoot: string, + chatsDir: string | undefined, +): ProjectKind => (chatsDir !== undefined && workspaceRoot === chatsDir ? "chats" : "standard"); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index b8102bda9ad..eff262b11d3 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -107,6 +107,102 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa ), ); +it.effect("ensureChatsProject creates the Chats project when missing", () => + Effect.gen(function* () { + const dispatchedCommands = yield* Ref.make>>([]); + yield* ServerRuntimeStartup.ensureChatsProject.pipe( + Effect.provideService(ServerConfig.ServerConfig, { + chatsDir: "/tmp/t3-home/chats", + } as never), + Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + }), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { + readEvents: () => Stream.empty, + dispatch: (command) => + Ref.update(dispatchedCommands, (calls) => [...calls, command]).pipe( + Effect.as({ sequence: 1 }), + ), + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), + Effect.provide(NodeServices.layer), + ); + + const commands = yield* Ref.get(dispatchedCommands); + assert.equal(commands.length, 1); + assert.equal(commands[0]?.type, "project.create"); + assert.equal(commands[0]?.title, ServerRuntimeStartup.CHATS_PROJECT_TITLE); + assert.equal(commands[0]?.workspaceRoot, "/tmp/t3-home/chats"); + assert.equal(commands[0]?.createWorkspaceRootIfMissing, true); + }), +); + +it.effect("ensureChatsProject leaves an existing Chats project untouched", () => + Effect.gen(function* () { + const dispatchedCommands = yield* Ref.make>([]); + yield* ServerRuntimeStartup.ensureChatsProject.pipe( + Effect.provideService(ServerConfig.ServerConfig, { + chatsDir: "/tmp/t3-home/chats", + } as never), + Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => + Effect.succeed( + Option.some({ + id: ProjectId.make("project-chats"), + title: ServerRuntimeStartup.CHATS_PROJECT_TITLE, + workspaceRoot: "/tmp/t3-home/chats", + kind: "chats" as const, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + }), + ), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + }), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { + readEvents: () => Stream.empty, + dispatch: (command) => + Ref.update(dispatchedCommands, (calls) => [...calls, command.type]).pipe( + Effect.as({ sequence: 1 }), + ), + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), + Effect.provide(NodeServices.layer), + ); + + assert.deepStrictEqual(yield* Ref.get(dispatchedCommands), []); + }), +); + it.effect("resolveWelcomeBase derives cwd and project name from server config", () => Effect.gen(function* () { const welcome = yield* ServerRuntimeStartup.resolveWelcomeBase.pipe( diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index b52b577c5b5..764b80f3264 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -166,6 +166,37 @@ export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({ model: DEFAULT_MODEL, }); +export const CHATS_PROJECT_TITLE = "Chat"; + +/** + * Ensures the reserved Chat pseudo-project exists. It points at the + * `/chats` directory and hosts one-off conversations that are not + * tied to a real codebase. The directory is created when missing. + */ +export const ensureChatsProject = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const serverConfig = yield* ServerConfig.ServerConfig; + const projectionReadModelQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; + + const existingProject = yield* projectionReadModelQuery.getActiveProjectByWorkspaceRoot( + serverConfig.chatsDir, + ); + if (Option.isSome(existingProject)) { + return; + } + + yield* orchestrationEngine.dispatch({ + type: "project.create", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + projectId: ProjectId.make(yield* crypto.randomUUIDv4), + title: CHATS_PROJECT_TITLE, + workspaceRoot: serverConfig.chatsDir, + createWorkspaceRootIfMissing: true, + createdAt: DateTime.formatIso(yield* DateTime.now), + }); +}); + export const resolveWelcomeBase = Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const segments = serverConfig.cwd.split(/[/\\]/).filter(Boolean); @@ -346,6 +377,18 @@ export const make = Effect.gen(function* () { }), ); + yield* Effect.forkScoped( + runStartupPhase( + "chats.ensure", + ensureChatsProject.pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.catch((cause) => + Effect.logWarning("startup chats project ensure failed", { cause }), + ), + ), + ), + ); + const welcomeBase = yield* resolveWelcomeBase; const environment = yield* serverEnvironment.getDescriptor; yield* Effect.logDebug("startup phase: preparing welcome payload"); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ab1256cddb3..5a6bd2e347e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -26,6 +26,7 @@ import { type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { isChatsProject, projectDisplayTitle } from "@t3tools/client-runtime/state/models"; import { parseScopedThreadKey, scopedThreadKey, @@ -1557,6 +1558,10 @@ function ChatViewContent(props: ChatViewProps) { ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null; const activeProject = useProject(activeProjectRef); + // The reserved Chats pseudo-project has no real codebase, so project-scoped + // surfaces (diff, files, terminal, branches/worktrees, git actions) are + // hidden for it. + const activeProjectIsChats = isChatsProject(activeProject); const activeEnvironmentShell = useEnvironmentQuery( activeThread ? environmentShell.stateAtom(activeThread.environmentId) : null, ); @@ -1595,8 +1600,13 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { if (!activeThreadRef || !activeEnvironmentBootstrapComplete) return; - useRightPanelStore.getState().reconcileFileSurfaces(activeThreadRef, activeProject !== null); - }, [activeEnvironmentBootstrapComplete, activeProject, activeThreadRef]); + useRightPanelStore + .getState() + .reconcileFileSurfaces(activeThreadRef, activeProject !== null && !activeProjectIsChats); + useRightPanelStore + .getState() + .reconcileWorkspaceSurfaces(activeThreadRef, !activeProjectIsChats); + }, [activeEnvironmentBootstrapComplete, activeProject, activeProjectIsChats, activeThreadRef]); // Compute the list of environments this logical project spans, used to // drive the environment picker in BranchToolbar. @@ -2376,7 +2386,7 @@ function ChatViewContent(props: ChatViewProps) { const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. - const isGitRepo = gitStatusQuery.data?.isRepo ?? true; + const isGitRepo = (gitStatusQuery.data?.isRepo ?? true) && !activeProjectIsChats; const showComposerContextStrip = isGitRepo && activeProject !== null; const initialDiffPanelGitScope = gitStatusQuery.data?.hasWorkingTreeChanges === true ? "unstaged" : "branch"; @@ -2513,7 +2523,7 @@ function ChatViewContent(props: ChatViewProps) { if (!activeThreadRef) return; const nextOpen = !terminalUiState.terminalOpen; if (nextOpen && terminalUiState.terminalIds.length === 0) { - if (!activeThreadId || !activeProject) { + if (!activeThreadId || !activeProject || activeProjectIsChats) { return; } const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; @@ -2541,6 +2551,7 @@ function ChatViewContent(props: ChatViewProps) { }, [ activeKnownTerminalIds, activeProject, + activeProjectIsChats, activeThreadId, activeThreadRef, activeThreadWorktreePath, @@ -3004,15 +3015,15 @@ function ChatViewContent(props: ChatViewProps) { planSidebarOpen, ]); const addFilesSurface = useCallback(() => { - if (!activeThreadRef || !activeProject) return; + if (!activeThreadRef || !activeProject || activeProjectIsChats) return; useRightPanelStore.getState().open(activeThreadRef, "files"); - }, [activeProject, activeThreadRef]); + }, [activeProject, activeProjectIsChats, activeThreadRef]); const openFileSurface = useCallback( (relativePath: string) => { - if (!activeThreadRef || !activeProject) return; + if (!activeThreadRef || !activeProject || activeProjectIsChats) return; useRightPanelStore.getState().openFile(activeThreadRef, relativePath); }, - [activeProject, activeThreadRef], + [activeProject, activeProjectIsChats, activeThreadRef], ); const togglePreviewPanel = useCallback(() => { if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; @@ -3034,7 +3045,7 @@ function ChatViewContent(props: ChatViewProps) { } }, [activeThreadRef]); const addTerminalSurface = useCallback(() => { - if (!activeThreadRef || !activeThreadId || !activeProject) return; + if (!activeThreadRef || !activeThreadId || !activeProject || activeProjectIsChats) return; const cwd = gitCwd ?? activeProject.workspaceRoot; const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId); @@ -3055,6 +3066,7 @@ function ChatViewContent(props: ChatViewProps) { }, [ activeKnownTerminalIds, activeProject, + activeProjectIsChats, activeThreadId, activeThreadRef, activeThreadWorktreePath, @@ -4356,6 +4368,7 @@ function ChatViewContent(props: ChatViewProps) { return () => window.removeEventListener("keydown", handler, true); }, [ activeProject, + activeProjectIsChats, activeRightPanelSurface, addTerminalSurface, terminalUiState.terminalOpen, @@ -5509,7 +5522,7 @@ function ChatViewContent(props: ChatViewProps) { const panelToggleControls = ( @@ -5999,7 +6016,8 @@ function ChatViewContent(props: ChatViewProps) { onAddFiles={addFilesSurface} browserAvailable={isPreviewSupportedInRuntime()} diffAvailable={isServerThread && isGitRepo} - filesAvailable={activeProject !== null} + filesAvailable={activeProject !== null && !activeProjectIsChats} + terminalAvailable={activeProject !== null && !activeProjectIsChats} > {rightPanelContent} @@ -6026,7 +6044,8 @@ function ChatViewContent(props: ChatViewProps) { onAddFiles={addFilesSurface} browserAvailable={isPreviewSupportedInRuntime()} diffAvailable={isServerThread && isGitRepo} - filesAvailable={activeProject !== null} + filesAvailable={activeProject !== null && !activeProjectIsChats} + terminalAvailable={activeProject !== null && !activeProjectIsChats} > {rightPanelContent} diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 902b7e87773..9bfffa0f495 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import type { Thread } from "../types"; +import type { Project, Thread } from "../types"; import { + buildProjectActionItems, buildThreadActionItems, enumerateCommandPaletteItems, filterCommandPaletteGroups, @@ -193,3 +194,44 @@ describe("buildThreadActionItems", () => { expect(items.map((item) => item.value)).toEqual(["thread:thread-active"]); }); }); + +describe("buildProjectActionItems", () => { + const makeProject = (overrides: Partial = {}): Project => + ({ + id: ProjectId.make("project-1"), + environmentId: EnvironmentId.make("env-1"), + title: "Chats", + workspaceRoot: "/home/user/.t3/chats", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }) as Project; + + it("labels the chats pseudo-project and hides its workspace path", () => { + const [item] = buildProjectActionItems({ + projects: [makeProject({ kind: "chats" })], + valuePrefix: "project", + icon: () => null, + runProject: async () => undefined, + }); + + expect(item?.title).toBe("Chat"); + expect(item?.description).toBeUndefined(); + // The directory stays searchable even though it is not displayed. + expect(item?.searchTerms).toContain("/home/user/.t3/chats"); + }); + + it("keeps the title and path for standard projects", () => { + const [item] = buildProjectActionItems({ + projects: [makeProject({ title: "t3code", workspaceRoot: "/dev/t3code" })], + valuePrefix: "project", + icon: () => null, + runProject: async () => undefined, + }); + + expect(item?.title).toBe("t3code"); + expect(item?.description).toBe("/dev/t3code"); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index f69c38e1a0f..175850e40ee 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -10,6 +10,7 @@ import { type ReactNode } from "react"; import { sortThreads } from "../lib/threadSort"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type SidebarThreadSummary, type Thread } from "../types"; +import { isChatsProject, projectDisplayTitle } from "@t3tools/client-runtime/state/models"; export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; @@ -46,7 +47,8 @@ export interface CommandPaletteSubmenuItem extends CommandPaletteItem { export interface CommandPaletteGroup { readonly value: string; - readonly label: string; + /** Omitted for single-item groups that read as standalone actions. */ + readonly label?: string; readonly items: ReadonlyArray; } @@ -118,8 +120,10 @@ export function buildProjectActionItems(input: { kind: "action", value: `${input.valuePrefix}:${project.environmentId}:${project.id}`, searchTerms: [project.title, project.workspaceRoot, ...(input.searchTerms?.(project) ?? [])], - title: project.title, - description: project.workspaceRoot, + title: projectDisplayTitle(project), + // The Chat pseudo-project's directory is an implementation detail, so it + // stays hidden even though it is still searchable above. + ...(isChatsProject(project) ? {} : { description: project.workspaceRoot }), icon: input.icon(project), ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), run: async () => { @@ -292,7 +296,9 @@ export function filterCommandPaletteGroups(input: { return []; } - return [{ value: group.value, label: group.label, items }]; + return [ + { value: group.value, ...(group.label === undefined ? {} : { label: group.label }), items }, + ]; }); } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index aa7547c8ba6..04edbcd2167 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,6 +1,7 @@ "use client"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { isChatsProject, projectDisplayTitle } from "@t3tools/client-runtime/state/models"; import { isAtomCommandInterrupted, settlePromise, @@ -29,6 +30,7 @@ import { MessageSquareIcon, SettingsIcon, SquarePenIcon, + MessageCircleIcon, } from "lucide-react"; import { useCallback, @@ -586,6 +588,31 @@ function OpenCommandPaletteDialog(props: { })), [projectPickerEntries], ); + // The Chat pseudo-project is not a codebase: it never appears in project + // lists, and is reachable only through its own "Start a new chat" action. + const chatProject = useMemo( + () => pickerProjects.find((project) => isChatsProject(project)) ?? null, + [pickerProjects], + ); + const codebasePickerProjects = useMemo( + () => pickerProjects.filter((project) => !isChatsProject(project)), + [pickerProjects], + ); + const startNewChat = useCallback(async () => { + if (!chatProject) return; + await handleNewThread(scopeProjectRef(chatProject.environmentId, chatProject.id)); + }, [chatProject, handleNewThread]); + const startNewChatItem = useMemo( + (): CommandPaletteActionItem => ({ + kind: "action", + value: "action:new-chat", + searchTerms: ["start a new chat", "chat", "new chat", "conversation", "ask"], + title: "Start a new chat", + icon: , + run: startNewChat, + }), + [startNewChat], + ); const projectGroupByTargetKey = useMemo( () => new Map( @@ -695,7 +722,10 @@ function OpenCommandPaletteDialog(props: { [projects], ); const projectTitleById = useMemo( - () => new Map(projects.map((project) => [project.id, project.title])), + () => + new Map( + projects.map((project) => [project.id, projectDisplayTitle(project)]), + ), [projects], ); @@ -785,7 +815,7 @@ function OpenCommandPaletteDialog(props: { const projectSearchItems = useMemo( () => buildProjectActionItems({ - projects: pickerProjects, + projects: codebasePickerProjects, valuePrefix: "project", searchTerms: (project) => { const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); @@ -797,19 +827,20 @@ function OpenCommandPaletteDialog(props: { ), runProject: openProjectFromSearch, }), - [openProjectFromSearch, pickerProjects, projectGroupByTargetKey], + [codebasePickerProjects, openProjectFromSearch, projectGroupByTargetKey], ); const projectThreadItems = useMemo( () => enumerateCommandPaletteItems( buildProjectActionItems({ - projects: pickerProjects, + projects: codebasePickerProjects, valuePrefix: "new-thread-in", searchTerms: (project) => { const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); @@ -821,6 +852,7 @@ function OpenCommandPaletteDialog(props: { ), @@ -841,7 +873,7 @@ function OpenCommandPaletteDialog(props: { }, }), ), - [contextualProjectRef, handleNewThread, pickerProjects, projectGroupByTargetKey], + [codebasePickerProjects, contextualProjectRef, handleNewThread, projectGroupByTargetKey], ); const allThreadItems = useMemo( @@ -1130,6 +1162,7 @@ function OpenCommandPaletteDialog(props: { pushPaletteView({ addonIcon: , groups: [ + ...(chatProject ? [{ value: "chat", items: [startNewChatItem] }] : []), { value: "projects", label: "Projects", @@ -1138,19 +1171,27 @@ function OpenCommandPaletteDialog(props: { ], }); }, [ + chatProject, clearOpenIntent, currentProjectEnvironmentId, currentProjectId, openIntent, projectThreadItems, + startNewChatItem, ]); const actionItems: Array = []; - if (projects.length > 0) { + if (codebasePickerProjects.length > 0) { + const codebasePickerEntries = projectPickerEntries.filter( + (entry) => !isChatsProject(entry.targetProject), + ); + // "New thread in X" always names a real codebase, even while a chat + // thread is the active one. const activeProjectTitle = - projectPickerEntries.find((entry) => entry.isPreferred)?.group.displayName ?? - (currentProjectId ? (projectTitleById.get(currentProjectId) ?? null) : null); + codebasePickerEntries.find((entry) => entry.isPreferred)?.group.displayName ?? + codebasePickerEntries[0]?.group.displayName ?? + null; if (activeProjectTitle) { actionItems.push({ @@ -1182,10 +1223,17 @@ function OpenCommandPaletteDialog(props: { title: "New thread in...", icon: , addonIcon: , - groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], + groups: [ + ...(chatProject ? [{ value: "chat", items: [startNewChatItem] }] : []), + { value: "projects", label: "Projects", items: projectThreadItems }, + ], }); } + if (chatProject) { + actionItems.push(startNewChatItem); + } + actionItems.push({ kind: "action", value: "action:add-project", diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index d4d31af3682..4d9104b4337 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -41,7 +41,7 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { {props.groups.map((group) => ( - {group.label} + {group.label ? {group.label} : null} {(item) => item.disabled ? ( diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index bc3e8ee832f..666bd8594f8 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,6 +1,6 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectKind } from "@t3tools/contracts"; import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; -import { FolderIcon } from "lucide-react"; +import { FolderIcon, MessageCircleIcon } from "lucide-react"; import type { ComponentType } from "react"; import { useState } from "react"; import { useAssetUrl } from "../assets/assetUrls"; @@ -12,14 +12,18 @@ export function ProjectFavicon(input: { cwd: string; className?: string | undefined; fallbackIcon?: ComponentType<{ className?: string }>; + kind?: ProjectKind | undefined; }) { const src = useAssetUrl(input.environmentId, { _tag: "project-favicon", cwd: input.cwd, }); - const FallbackIcon = input.fallbackIcon ?? FolderIcon; + // The Chat pseudo-project has no repository to source a favicon from, and + // its directory is an implementation detail — always show the chat bubble. + const FallbackIcon = + input.kind === "chats" ? MessageCircleIcon : (input.fallbackIcon ?? FolderIcon); - if (!src || isProjectFaviconFallbackUrl(src)) { + if (input.kind === "chats" || !src || isProjectFaviconFallbackUrl(src)) { return ; } diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 83524009c19..fd7726f4f67 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -47,6 +47,7 @@ interface RightPanelTabsProps { browserAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; + terminalAvailable: boolean; children: ReactNode; } @@ -54,6 +55,7 @@ const SURFACE_DISABLED_REASONS = { browser: "Browser previews are only available in the T3 Code desktop app.", files: "Files are only available when a project is open.", diff: "Diff is only available for server threads in Git repositories.", + terminal: "Terminals are only available when a project workspace is open.", } as const; type TabContextMenuAction = "copy-path" | "close" | "close-others" | "close-to-right" | "close-all"; @@ -94,6 +96,7 @@ function RightPanelEmptyState(props: { browserAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; + terminalAvailable: boolean; }) { const actions = [ { @@ -108,8 +111,8 @@ function RightPanelEmptyState(props: { label: "Terminal", description: "Start a shell in this workspace.", icon: TerminalSquare, - available: true, - disabledReason: null, + available: props.terminalAvailable, + disabledReason: SURFACE_DISABLED_REASONS.terminal, onClick: props.onAddTerminal, }, { @@ -450,7 +453,11 @@ export function RightPanelTabs(props: RightPanelTabsProps) { Browser - + Terminal @@ -487,6 +494,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { browserAvailable={props.browserAvailable} diffAvailable={props.diffAvailable} filesAvailable={props.filesAvailable} + terminalAvailable={props.terminalAvailable} /> ) : ( props.children diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a1d95eaa734..c99747e0df9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2258,7 +2258,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }`} /> )} - + {project.displayName} diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 8c5891ebe7e..b568438e5ca 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -12,7 +12,7 @@ import { scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import type { ProjectKind, ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; import { AlarmClockIcon, AlarmClockOffIcon, @@ -216,6 +216,7 @@ function SidebarV2ThreadTooltip({ thread, projectTitle, projectCwd, + projectKind, environmentLabel, driverKind, modelInstanceId, @@ -225,6 +226,7 @@ function SidebarV2ThreadTooltip({ thread: SidebarThreadSummary; projectTitle: string | null; projectCwd: string | null; + projectKind: ProjectKind | undefined; environmentLabel: string | null; driverKind: ProviderInstanceEntry["driverKind"] | null; modelInstanceId: string; @@ -253,6 +255,7 @@ function SidebarV2ThreadTooltip({
{projectTitle}
@@ -374,6 +377,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { environmentLabel: string | null; projectCwd: string | null; projectTitle: string | null; + projectKind: ProjectKind | undefined; providerEntryByInstanceId: ReadonlyMap; onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; onThreadActivate: (threadRef: ScopedThreadRef) => void; @@ -531,6 +535,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { thread={thread} projectTitle={props.projectTitle} projectCwd={props.projectCwd} + projectKind={props.projectKind} environmentLabel={props.environmentLabel} driverKind={driverKind} modelInstanceId={modelInstanceId} @@ -763,6 +768,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { @@ -839,6 +845,28 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { } const diff = latestTurnDiff(thread); + const hasThreadMetaRow = Boolean(thread.branch) || prBadge !== null || diff !== null; + const threadMetaIcons = ( + + {isRemote ? ( + + + + ) : null} + {driverKind ? ( + + + + ) : null} + + ); return (
  • } > -
    +
    {props.projectTitle ? ( @@ -943,40 +980,29 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null}
    -
    {title}
    -
    - {thread.branch ? ( - {thread.branch} - ) : ( - - )} - {prBadge} - {diff ? ( - - +{diff.insertions}{" "} - −{diff.deletions} - - ) : null} - - {isRemote ? ( - - - - ) : null} - {driverKind ? ( - - +
    + {title} + {hasThreadMetaRow ? null : threadMetaIcons} +
    + {hasThreadMetaRow ? ( +
    + {thread.branch ? ( + {thread.branch} + ) : ( + + )} + {prBadge} + {diff ? ( + + + +{diff.insertions} + {" "} + −{diff.deletions} ) : null} - -
    + {threadMetaIcons} +
    + ) : null}
    {props.jumpLabel ? : null} @@ -1127,6 +1153,11 @@ export default function SidebarV2() { ), [projects], ); + const projectKindByKey = useMemo( + () => + new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project.kind])), + [projects], + ); const projectDisplayNameByKey = useMemo( () => new Map( @@ -2277,6 +2308,7 @@ export default function SidebarV2() { ) : ( @@ -2314,6 +2346,7 @@ export default function SidebarV2() { {project.displayName} @@ -2431,6 +2464,9 @@ export default function SidebarV2() { `${thread.environmentId}:${thread.projectId}`, ) ?? null } + projectKind={projectKindByKey.get( + `${thread.environmentId}:${thread.projectId}`, + )} providerEntryByInstanceId={providerEntryByInstanceId} onThreadClick={handleThreadClick} onThreadActivate={navigateToThread} @@ -2580,6 +2616,7 @@ export default function SidebarV2() {
    diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 3a7d57859a8..97f64f9e96a 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -34,6 +34,11 @@ interface ChatHeaderProps { availableEditors: ReadonlyArray; rightPanelOpen: boolean; gitCwd: string | null; + /** + * True for the reserved Chats pseudo-project: scripts, open-in and git + * actions are hidden because there is no real codebase behind the thread. + */ + isChatsProject: boolean; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; onUpdateProjectScript: ( @@ -69,6 +74,7 @@ export const ChatHeader = memo(function ChatHeader({ availableEditors, rightPanelOpen, gitCwd, + isChatsProject, onRunProjectScript, onAddProjectScript, onUpdateProjectScript, @@ -77,13 +83,15 @@ export const ChatHeader = memo(function ChatHeader({ const primaryEnvironmentId = usePrimaryEnvironmentId(); const fileScripts = useT3ProjectFileScripts( activeThreadEnvironmentId, - activeProjectScripts ? activeProjectCwd : null, + activeProjectScripts && !isChatsProject ? activeProjectCwd : null, ); - const showOpenInPicker = shouldShowOpenInPicker({ - activeProjectName, - activeThreadEnvironmentId, - primaryEnvironmentId, - }); + const showOpenInPicker = + !isChatsProject && + shouldShowOpenInPicker({ + activeProjectName, + activeThreadEnvironmentId, + primaryEnvironmentId, + }); return (
    @@ -96,6 +104,7 @@ export const ChatHeader = memo(function ChatHeader({ @@ -128,7 +137,7 @@ export const ChatHeader = memo(function ChatHeader({ rightPanelOpen ? "pr-0" : "pr-16", )} > - {activeProjectScripts && ( + {activeProjectScripts && !isChatsProject && ( )} - {activeProjectName && ( + {activeProjectName && !isChatsProject && ( - {hasResolvedProject ? ( + {hasResolvedProject && isChatsProject ? ( + <>What’s on your mind? + ) : hasResolvedProject ? ( <>What should we build in {projectSelector}? ) : canChooseProject ? ( <>{projectSelector} to start diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index fa7c7299667..9dcfd690909 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -97,6 +97,7 @@ import { useRelativeTimeTick, } from "./settingsLayout"; import { ProjectFavicon } from "../ProjectFavicon"; +import { projectDisplayTitle } from "@t3tools/client-runtime/state/models"; import { useAtomCommand } from "../../state/use-atom-command"; const THEME_OPTIONS = [ @@ -1565,8 +1566,9 @@ export function ArchivedThreadsPanel() { { id: project.id, environmentId, - name: project.title, + name: projectDisplayTitle(project), cwd: project.workspaceRoot, + kind: project.kind, }, ] as const, ), @@ -1684,7 +1686,13 @@ export function ArchivedThreadsPanel() { } + icon={ + + } > {projectThreads.map((thread) => ( void; reconcileBrowserSurfaces: (ref: ScopedThreadRef, tabIds: readonly string[]) => void; reconcileFileSurfaces: (ref: ScopedThreadRef, workspaceAvailable: boolean) => void; + /** + * Drops persisted diff and terminal tabs when the thread's project cannot + * host them (e.g. the reserved Chats pseudo-project). + */ + reconcileWorkspaceSurfaces: (ref: ScopedThreadRef, workspaceSurfacesAvailable: boolean) => void; show: (ref: ScopedThreadRef) => void; close: (ref: ScopedThreadRef) => void; toggleVisibility: (ref: ScopedThreadRef) => void; @@ -478,6 +483,27 @@ export const useRightPanelStore = create()( }; }), })), + reconcileWorkspaceSurfaces: (ref, workspaceSurfacesAvailable) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + if (workspaceSurfacesAvailable) return current; + const surfaces = current.surfaces.filter( + (surface) => surface.kind !== "diff" && surface.kind !== "terminal", + ); + if (surfaces.length === current.surfaces.length) return current; + const activeStillExists = surfaces.some( + (surface) => surface.id === current.activeSurfaceId, + ); + return { + ...current, + isOpen: surfaces.length > 0 ? current.isOpen : false, + surfaces, + activeSurfaceId: activeStillExists + ? current.activeSurfaceId + : (surfaces.at(-1)?.id ?? null), + }; + }), + })), show: (ref) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 32299b565f4..b7e272fe7aa 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -1,4 +1,5 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { projectDisplayTitle } from "@t3tools/client-runtime/state/models"; import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts"; import { deriveLogicalProjectKeyFromSettings, @@ -205,7 +206,7 @@ export function buildSidebarProjectSnapshots(input: { representative, members, }) - : representative.title, + : projectDisplayTitle(representative), groupedProjectCount: members.length, environmentPresence: hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index b601b59bfad..4d33ca06ef7 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -22,6 +22,30 @@ export interface EnvironmentThread extends OrchestrationThread { readonly environmentId: EnvironmentId; } +/** + * The reserved Chat pseudo-project hosts one-off conversations with no + * real codebase behind them, so project-scoped surfaces (diff, files, + * branches, worktrees, terminal, git actions) are hidden for it. + */ +export function isChatsProject( + project: Pick | null | undefined, +): boolean { + return project?.kind === "chats"; +} + +/** + * Label shown wherever the Chat pseudo-project is named. Overriding the + * stored title keeps already-created projects consistent with the current + * wording without a data migration. + */ +export const CHATS_PROJECT_LABEL = "Chat"; + +export function projectDisplayTitle( + project: Pick, +): string { + return isChatsProject(project) ? CHATS_PROJECT_LABEL : project.title; +} + export function scopeProject( environmentId: EnvironmentId, project: OrchestrationProjectShell, diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 84b7a8fa07f..dc7123093e5 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -209,10 +209,20 @@ export const ProjectScript = Schema.Struct({ }); export type ProjectScript = typeof ProjectScript.Type; +/** + * Discriminates the reserved "Chats" pseudo-project (workspace root at + * `/chats`) from normal projects. Derived server-side from the + * workspace root — never persisted — so it stays correct across base-dir + * changes and older snapshots. + */ +export const ProjectKind = Schema.Literals(["standard", "chats"]); +export type ProjectKind = typeof ProjectKind.Type; + export const OrchestrationProject = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + kind: Schema.optional(ProjectKind), repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), @@ -390,6 +400,7 @@ export const OrchestrationProjectShell = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + kind: Schema.optional(ProjectKind), repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), From c782f922dd05ceaa49f53b561e4879ae94668c31 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 25 Jul 2026 09:27:16 -0400 Subject: [PATCH 2/3] fix(mobile): hide Android header actions and close inspector pane for Chat Addresses review feedback on the Chat pseudo-project: - androidHeaderActions built its list independently of showActionControls, guarding on selectedThreadCwd / workspaceRoot. Both are non-null for Chat, so Android still showed Files, Terminal, Git and Inspector. Return early after the navigation action when the project is Chat. - The auxiliary-pane close effect only fired when selectedThreadCwd was null. Chat has a real workspace root, so switching from a Files/Git inspector into a Chat thread left the pane open with no registered content. Close it when the project is Chat as well. Co-Authored-By: Claude Opus 5 (1M context) --- .../mobile/src/features/threads/ThreadRouteScreen.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index c4dbcb6ff8a..6e46b84d043 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -228,7 +228,10 @@ function ThreadRouteContent( useEffect(() => { if ( fileInspector.supported && - selectedThreadCwd === null && + // Chat threads suppress the files/git inspectors while keeping a real + // workspace root, so the pane has to close on the kind too — otherwise + // switching into one leaves an empty trailing column. + (selectedThreadCwd === null || selectedProjectIsChats) && inspectorMode === null && panes.auxiliaryPaneVisible ) { @@ -238,6 +241,7 @@ function ThreadRouteContent( fileInspector.supported, inspectorMode, panes.auxiliaryPaneVisible, + selectedProjectIsChats, selectedThreadCwd, toggleAuxiliaryPane, ]); @@ -688,6 +692,10 @@ function ThreadRouteContent( onPress: props.onReturnToThread, }); } + // The Chat pseudo-project has no codebase behind it, so the Android + // header keeps only the navigation action above. Its workspace root is a + // real directory, so the cwd/workspaceRoot guards below never catch it. + if (selectedProjectIsChats) return actions; if (selectedThreadCwd !== null) { actions.push({ accessibilityLabel: "Open files", @@ -722,6 +730,7 @@ function ThreadRouteContent( handleOpenGitInspector, handleToggleInspector, props.onReturnToThread, + selectedProjectIsChats, selectedThreadCwd, selectedThreadProject?.workspaceRoot, ]); From 085447124e5a0342c6c6d4fe256064db933d8ff5 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 25 Jul 2026 09:37:23 -0400 Subject: [PATCH 3/3] fix(mobile): keep repository label for singleton project scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildHomeProjectScopes special-cased single-member groups to use the project title, which dropped the repository label that deriveProjectGroupLabel prefers — a lone project with a repositoryIdentity rendered its directory name instead of the repo name. Key the override on the Chat pseudo-project instead, so every real project keeps the derived group label regardless of group size. Co-Authored-By: Claude Opus 5 (1M context) --- apps/mobile/src/features/home/homeThreadList.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 1f364f73bfe..ee0875a1901 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -3,7 +3,7 @@ import { derivePhysicalProjectKey, deriveProjectGroupLabel, } from "@t3tools/client-runtime/state/project-grouping"; -import { projectDisplayTitle } from "@t3tools/client-runtime/state/models"; +import { isChatsProject, projectDisplayTitle } from "@t3tools/client-runtime/state/models"; import type { EnvironmentProject, EnvironmentThreadShell, @@ -114,10 +114,12 @@ export function buildHomeProjectScopes(input: { const representative = projects[0]!; return { key, - title: - projects.length === 1 - ? projectDisplayTitle(representative) - : deriveProjectGroupLabel({ representative, members: projects }), + // Only the Chat pseudo-project overrides its label. Real projects keep + // the derived group label, which prefers the repository name over the + // directory name even when the group has a single member. + title: isChatsProject(representative) + ? projectDisplayTitle(representative) + : deriveProjectGroupLabel({ representative, members: projects }), representative, projects, projectRefs: projectRefsByGroup.get(key) ?? [],