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
9 changes: 5 additions & 4 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ProviderInteractionMode,
RuntimeMode,
ServerConfig as T3ServerConfig,
ServerProviderSkill,
} from "@t3tools/contracts";
import {
detectComposerTrigger,
Expand Down Expand Up @@ -99,6 +100,7 @@ export interface ThreadComposerProps {
readonly threadSyncPhase?: "loading" | "syncing" | null;
readonly selectedThread: OrchestrationThreadShell;
readonly serverConfig: T3ServerConfig | null;
readonly providerSkills: ReadonlyArray<ServerProviderSkill>;
readonly queueCount: number;
readonly activeThreadBusy: boolean;
readonly environmentId: EnvironmentId;
Expand Down Expand Up @@ -365,7 +367,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
cwd: composerTrigger?.kind === "path" ? props.projectCwd : null,
query: composerTrigger?.kind === "path" ? composerTrigger.query : null,
});

const composerMenuItems: ComposerCommandItem[] = useMemo(() => {
if (!composerTrigger) return [];

Expand Down Expand Up @@ -412,7 +413,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}

if (composerTrigger.kind === "skill") {
const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled);
const enabledSkills = props.providerSkills.filter((s) => s.enabled);
const normalizedQuery = normalizeSearchQuery(composerTrigger.query, {
trimLeadingPattern: /^\$+/,
});
Expand Down Expand Up @@ -509,7 +510,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}

return [];
}, [composerTrigger, pathSearch.entries, selectedProviderStatus]);
}, [composerTrigger, pathSearch.entries, props.providerSkills, selectedProviderStatus]);

// ── Handle command selection ──────────────────────────────
const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;
Expand Down Expand Up @@ -785,7 +786,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
ref={inputRef}
multiline
value={props.draftMessage}
skills={selectedProviderStatus?.skills ?? []}
skills={props.providerSkills}
selection={composerSelection}
onChangeText={props.onChangeDraftMessage}
onSelectionChange={handleSelectionChange}
Expand Down
16 changes: 13 additions & 3 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type { StatusTone } from "../../components/StatusPill";
import type { DraftComposerImageAttachment } from "../../lib/composerImages";
import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { useProviderSkills } from "../../state/queries";
import type {
PendingApproval,
PendingUserInput,
Expand Down Expand Up @@ -225,12 +226,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined;
const selectedInstanceId = props.selectedThread.modelSelection.instanceId;
useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
const selectedProviderSkills = useMemo(
const selectedProviderStatus = useMemo(
() =>
props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId)
?.skills ?? [],
props.serverConfig?.providers.find(
(provider) => provider.instanceId === selectedInstanceId,
) ?? null,
[props.serverConfig, selectedInstanceId],
);
const selectedProviderSkills = useProviderSkills({
activeEnvironmentId: props.environmentId,
provider: selectedProviderStatus,
isServerThread: true,
threadId: props.selectedThread.id,
projectId: props.selectedThread.projectId,
});

useLayoutEffect(() => {
selectedThreadKeyRef.current = selectedThreadKey;
Expand Down Expand Up @@ -428,6 +437,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
threadSyncPhase={threadSyncPhase}
selectedThread={props.selectedThread}
serverConfig={props.serverConfig}
providerSkills={selectedProviderSkills}
queueCount={props.selectedThreadQueueCount}
activeThreadBusy={props.activeThreadBusy}
environmentId={props.environmentId}
Expand Down
13 changes: 10 additions & 3 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {
updateComposerDraftSettings,
useComposerDraft,
} from "../../state/use-composer-drafts";
import { useBranches } from "../../state/queries";
import { useBranches, useProviderSkills } from "../../state/queries";
import {
flattenQueuedThreadMessages,
threadOutboxManager,
Expand Down Expand Up @@ -406,13 +406,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
option.selection.instanceId === selectedModel.instanceId &&
option.selection.model === selectedModel.model,
) ?? null;
const selectedProviderSkills = useMemo(
const selectedProviderStatus = useMemo(
() =>
selectedEnvironmentServerConfig?.providers.find(
(provider) => provider.instanceId === selectedModel?.instanceId,
)?.skills ?? [],
) ?? null,
[selectedEnvironmentServerConfig, selectedModel?.instanceId],
);
const selectedProviderSkills = useProviderSkills({
activeEnvironmentId: selectedEnvironmentId,
provider: selectedProviderStatus,
isServerThread: false,
threadId: null,
projectId: selectedProject?.id ?? null,
});
const setSelectedModelKey = useCallback(
(key: string | null) => {
if (!key || !selectedProjectDraftKey) {
Expand Down
32 changes: 31 additions & 1 deletion apps/mobile/src/state/queries.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts";
import type {
EnvironmentId,
OrchestrationThread,
ServerProviderSkill,
ThreadId,
} from "@t3tools/contracts";
import {
type ProviderSkillInventoryContext,
resolveProviderSkillInventoryRequest,
resolveProviderSkillInventoryTarget,
selectProviderSkills,
} from "@t3tools/client-runtime/state/provider-skill-inventory";
import {
createThreadSearchResultsAtomFamily,
makeThreadSearchKey,
Expand All @@ -12,6 +23,7 @@ import { useEffect, useMemo, useState } from "react";
import { orchestrationEnvironment } from "./orchestration";
import { projectEnvironment } from "./projects";
import { useEnvironmentQuery } from "./query";
import { serverEnvironment } from "./server";
import { useEnvironmentThread } from "./threads";
import { vcsEnvironment } from "./vcs";
import {
Expand Down Expand Up @@ -125,6 +137,24 @@ export function useBranches(input: {
);
}

/**
* Skills for a composer or message feed. Snapshot-mode providers never hit the
* network; project-mode providers issue one request per
* (environment, scope, instance).
*/
export function useProviderSkills(
context: ProviderSkillInventoryContext,
): ReadonlyArray<ServerProviderSkill> {
const target = resolveProviderSkillInventoryTarget(context);
const request = resolveProviderSkillInventoryRequest(target);
const result = useEnvironmentQuery(
request === null ? null : serverEnvironment.skillInventory(request),
);
const provider = target.provider;
const inventory = result.data;
return useMemo(() => selectProviderSkills({ provider, inventory }), [provider, inventory]);
}

export function useComposerPathSearch(target: ComposerPathSearchTarget) {
const normalizedTarget = useMemo(
() => ({
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope,
[WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope,
[WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope,
[WS_METHODS.providersSkillInventory]: AuthOrchestrationReadScope,
[WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope,
[WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope,
[WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope,
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/checkpointing/CheckpointDiffQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ describe("CheckpointDiffQuery.layer", () => {
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
getThreadWorkspaceContextById: () => Effect.die("unused"),
getThreadCheckpointContext: () =>
Effect.sync(() => {
getThreadCheckpointContextCalls += 1;
Expand Down Expand Up @@ -197,6 +198,7 @@ describe("CheckpointDiffQuery.layer", () => {
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
getThreadWorkspaceContextById: () => Effect.die("unused"),
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
Expand Down Expand Up @@ -281,6 +283,7 @@ describe("CheckpointDiffQuery.layer", () => {
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
getThreadWorkspaceContextById: () => Effect.die("unused"),
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
Expand Down Expand Up @@ -350,6 +353,7 @@ describe("CheckpointDiffQuery.layer", () => {
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
getThreadWorkspaceContextById: () => Effect.die("unused"),
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
Expand Down Expand Up @@ -404,6 +408,7 @@ describe("CheckpointDiffQuery.layer", () => {
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
getThreadWorkspaceContextById: () => Effect.die("unused"),
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ describe("OrchestrationEngine", () => {
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
getThreadWorkspaceContextById: () => Effect.die("unused"),
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
'full-access',
'default',
NULL,
NULL,
'/tmp/archive-worktree',
NULL,
NULL,
0,
Expand Down Expand Up @@ -569,6 +569,31 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
[ThreadId.make("thread-archived")],
);
assert.equal(archivedShellSnapshot.threads[0]?.archivedAt, "2026-04-06T00:00:06.000Z");

const archivedThreadId = ThreadId.make("thread-archived");
const archivedThread = yield* snapshotQuery.getThreadShellById(archivedThreadId);
assert.equal(archivedThread._tag, "None");

const archivedWorkspaceContext =
yield* snapshotQuery.getThreadWorkspaceContextById(archivedThreadId);
assert.equal(archivedWorkspaceContext._tag, "Some");
if (archivedWorkspaceContext._tag === "Some") {
assert.equal(archivedWorkspaceContext.value.workspaceRoot, "/tmp/archive-test");
assert.equal(archivedWorkspaceContext.value.worktreePath, "/tmp/archive-worktree");
}

yield* sql`
UPDATE projection_projects
SET deleted_at = '2026-04-06T00:00:08.000Z'
WHERE project_id = 'project-archive-test'
`;
const contextWithoutActiveProject =
yield* snapshotQuery.getThreadWorkspaceContextById(archivedThreadId);
assert.equal(contextWithoutActiveProject._tag, "Some");
if (contextWithoutActiveProject._tag === "Some") {
assert.equal(contextWithoutActiveProject.value.workspaceRoot, null);
assert.equal(contextWithoutActiveProject.value.worktreePath, "/tmp/archive-worktree");
}
}),
);

Expand Down
51 changes: 50 additions & 1 deletion apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,9 @@ import {
ProjectionSnapshotQuery,
type ProjectionFullThreadDiffContext,
type ProjectionSnapshotCounts,
type ProjectionThreadCheckpointContext,
type ProjectionSnapshotQueryShape,
type ProjectionThreadCheckpointContext,
type ProjectionThreadWorkspaceContext,
} from "../Services/ProjectionSnapshotQuery.ts";

const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel);
Expand Down Expand Up @@ -133,6 +134,12 @@ const ProjectionProjectLookupRowSchema = ProjectionProjectDbRowSchema;
const ProjectionThreadIdLookupRowSchema = Schema.Struct({
threadId: ThreadId,
});
const ProjectionThreadWorkspaceContextRowSchema = Schema.Struct({
threadId: ThreadId,
projectId: ProjectId,
workspaceRoot: Schema.NullOr(Schema.String),
worktreePath: Schema.NullOr(Schema.String),
});
const ProjectionThreadCheckpointContextThreadRowSchema = Schema.Struct({
threadId: ThreadId,
projectId: ProjectId,
Expand Down Expand Up @@ -849,6 +856,26 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
`,
});

const getThreadWorkspaceContextRow = SqlSchema.findOneOption({
Request: ThreadIdLookupInput,
Result: ProjectionThreadWorkspaceContextRowSchema,
execute: ({ threadId }) =>
sql`
SELECT
threads.thread_id AS "threadId",
threads.project_id AS "projectId",
projects.workspace_root AS "workspaceRoot",
threads.worktree_path AS "worktreePath"
FROM projection_threads AS threads
LEFT JOIN projection_projects AS projects
ON projects.project_id = threads.project_id
AND projects.deleted_at IS NULL
WHERE threads.thread_id = ${threadId}
AND threads.deleted_at IS NULL
LIMIT 1
`,
});

const getThreadCheckpointContextThreadRow = SqlSchema.findOneOption({
Request: ThreadIdLookupInput,
Result: ProjectionThreadCheckpointContextThreadRowSchema,
Expand Down Expand Up @@ -1952,6 +1979,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
Effect.map(Option.map((row) => row.threadId)),
);

const getThreadWorkspaceContextById: ProjectionSnapshotQueryShape["getThreadWorkspaceContextById"] =
(threadId) =>
getThreadWorkspaceContextRow({ threadId }).pipe(
Effect.mapError(
toPersistenceSqlOrDecodeError(
"ProjectionSnapshotQuery.getThreadWorkspaceContextById:query",
"ProjectionSnapshotQuery.getThreadWorkspaceContextById:decodeRow",
),
),
Effect.map(
Option.map(
(row): ProjectionThreadWorkspaceContext => ({
threadId: row.threadId,
projectId: row.projectId,
workspaceRoot: row.workspaceRoot,
worktreePath: row.worktreePath,
}),
),
),
);

const getThreadCheckpointContext: ProjectionSnapshotQueryShape["getThreadCheckpointContext"] = (
threadId,
) =>
Expand Down Expand Up @@ -2268,6 +2316,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
getActiveProjectByWorkspaceRoot,
getProjectShellById,
getFirstActiveThreadIdByProjectId,
getThreadWorkspaceContextById,
getThreadCheckpointContext,
getFullThreadDiffContext,
getThreadShellById,
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ export interface ProjectionSnapshotSequence {
readonly snapshotSequence: number;
}

export interface ProjectionThreadWorkspaceContext {
readonly threadId: ThreadId;
readonly projectId: ProjectId;
readonly workspaceRoot: string | null;
readonly worktreePath: string | null;
}

export interface ProjectionThreadCheckpointContext {
readonly threadId: ThreadId;
readonly projectId: ProjectId;
Expand Down Expand Up @@ -139,6 +146,14 @@ export interface ProjectionSnapshotQueryShape {
projectId: ProjectId,
) => Effect.Effect<Option.Option<ThreadId>, ProjectionRepositoryError>;

/**
* Read the workspace context for a non-deleted thread, including archived
* threads. The project root is absent when the owning project is unavailable.
*/
readonly getThreadWorkspaceContextById: (
threadId: ThreadId,
) => Effect.Effect<Option.Option<ProjectionThreadWorkspaceContext>, ProjectionRepositoryError>;

/**
* Read the checkpoint context needed to resolve a single thread diff.
*/
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/project/ProjectSetupScriptRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
getProjectShellById: (projectId) =>
Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
getThreadWorkspaceContextById: () => Effect.die("unused"),
getThreadCheckpointContext: () => Effect.die("unused"),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.die("unused"),
Expand Down
Loading
Loading