Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/mobile/src/features/home/homeThreadList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
derivePhysicalProjectKey,
deriveProjectGroupLabel,
} from "@t3tools/client-runtime/state/project-grouping";
import { isChatsProject, projectDisplayTitle } from "@t3tools/client-runtime/state/models";
import type {
EnvironmentProject,
EnvironmentThreadShell,
Expand Down Expand Up @@ -113,7 +114,12 @@ export function buildHomeProjectScopes(input: {
const representative = projects[0]!;
return {
key,
title: 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) ?? [],
Expand Down
12 changes: 8 additions & 4 deletions apps/mobile/src/features/threads/ThreadGitControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
);
}

Expand Down
45 changes: 37 additions & 8 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -206,19 +207,31 @@ function ThreadRouteContent(
const [inspectorSelection, setInspectorSelection] = useState<ThreadInspectorSelection | null>(
() => (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;
})();
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
) {
Expand All @@ -228,6 +241,7 @@ function ThreadRouteContent(
fileInspector.supported,
inspectorMode,
panes.auxiliaryPaneVisible,
selectedProjectIsChats,
selectedThreadCwd,
toggleAuxiliaryPane,
]);
Expand Down Expand Up @@ -594,22 +608,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,
Expand Down Expand Up @@ -671,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",
Expand Down Expand Up @@ -705,6 +730,7 @@ function ThreadRouteContent(
handleOpenGitInspector,
handleToggleInspector,
props.onReturnToThread,
selectedProjectIsChats,
selectedThreadCwd,
selectedThreadProject?.workspaceRoot,
]);
Expand Down Expand Up @@ -743,7 +769,10 @@ function ThreadRouteContent(
const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null;
const renderThreadRouteBody = (showActionControls: boolean) => (
<>
<ThreadGitControls {...threadGitControlProps} showActionControls={showActionControls} />
<ThreadGitControls
{...threadGitControlProps}
showActionControls={showActionControls && !selectedProjectIsChats}
/>

<GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} />

Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"),
Expand All @@ -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 }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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* () {
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down
35 changes: 32 additions & 3 deletions apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type OrchestrationSession,
type OrchestrationThreadActivity,
type OrchestrationThreadShell,
type ProjectKind,
ModelSelection,
ProjectId,
ThreadId,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -227,11 +230,13 @@ function mapSessionRow(
function mapProjectShellRow(
row: Schema.Schema.Type<typeof ProjectionProjectDbRowSchema>,
repositoryIdentity: OrchestrationProject["repositoryIdentity"],
kind: ProjectKind,
): OrchestrationProjectShell {
return {
id: row.projectId,
title: row.title,
workspaceRoot: row.workspaceRoot,
kind,
repositoryIdentity,
defaultModelSelection: row.defaultModelSelection,
scripts: row.scripts,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Comment on lines +1197 to 1198

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent Chat from inheriting a parent repository identity

When an explicit base directory is located inside a Git checkout, resolving identity for <baseDir>/chats assigns the parent repository's canonical identity to the Chat project. Under the default repository grouping mode, Chat is then merged with the real codebase; because the picker exposes one target per logical group, chatProject may disappear entirely or the real project may be filtered as Chat. Emit a null repository identity for kind === "chats" or otherwise force the pseudo-project to use a distinct logical key.

Useful? React with 👍 / 👎.

defaultModelSelection: row.defaultModelSelection,
scripts: row.scripts,
Expand Down Expand Up @@ -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,
),
Expand Down Expand Up @@ -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,
),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
),
),
),
),
),
Expand Down
Loading
Loading