feat: associate threads with projects (#8745)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Toohey
2026-04-22 19:08:55 +12:00
committed by GitHub
parent a7ccdd780d
commit c8b339e559
14 changed files with 160 additions and 30 deletions
@@ -5,8 +5,8 @@ const LEGACY_SESSION_CACHE_STORAGE_KEY = "goose:chat-sessions";
export interface SessionMetadataOverlayRecord {
sessionId: string;
projectId?: string | null;
userSetTitle?: string | null;
projectId?: string | null;
providerId?: string | null;
personaId?: string | null;
modelId?: string | null;
@@ -74,8 +74,8 @@ function recordFromLegacySession(
): SessionMetadataOverlayRecord {
return {
sessionId: session.acpSessionId ?? session.id,
projectId: session.projectId,
userSetTitle: session.userSetName ? session.title : null,
projectId: session.projectId ?? null,
providerId: session.providerId,
personaId: session.personaId,
modelId: session.modelId,
@@ -142,6 +142,7 @@ describe("chatSessionStore", () => {
title: null,
updatedAt: "2026-04-02",
messageCount: 7,
projectId: "project-123",
},
]);
@@ -12,6 +12,7 @@ import {
upsertSessionMetadataOverlayRecord,
type SessionMetadataOverlayRecord,
} from "@/features/chat/lib/sessionMetadataOverlay";
import { updateSessionProject } from "@/shared/api/acpApi";
export interface ChatSession {
id: string;
@@ -114,8 +115,8 @@ function buildOverlayRecord(
): SessionMetadataOverlayRecord {
return {
sessionId: overlayKeyForSession(session),
projectId: session.projectId ?? null,
userSetTitle: session.userSetName ? session.title : null,
projectId: session.projectId ?? null,
providerId: session.providerId ?? null,
personaId: session.personaId ?? null,
modelId: session.modelId ?? null,
@@ -139,7 +140,7 @@ function overlayToFallbackSession(
id: overlay.sessionId,
acpSessionId: overlay.sessionId,
title: overlay.userSetTitle ?? overlay.lastKnownTitle ?? "Untitled",
projectId: overlay.projectId,
projectId: overlay.projectId ?? undefined,
agentId: overlay.agentId ?? undefined,
providerId: overlay.providerId ?? undefined,
personaId: overlay.personaId ?? undefined,
@@ -166,7 +167,7 @@ function mergeAcpSessionWithOverlay(
session.title ??
overlay?.lastKnownTitle ??
"Untitled",
projectId: overlay?.projectId,
projectId: session.projectId ?? undefined,
agentId: overlay?.agentId ?? undefined,
providerId: overlay?.providerId ?? undefined,
personaId: overlay?.personaId ?? undefined,
@@ -239,6 +240,7 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
const { sessionId } = await acpCreateSession(providerId, opts.workingDir, {
personaId: opts.personaId,
modelId: opts.modelId,
projectId: opts.projectId,
});
const chatSession: ChatSession = {
id: sessionId,
@@ -318,7 +320,15 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
buildOverlayRecord(updatedSession, existing),
);
}
// TODO: wire session updates to ACP when supported
// Persist projectId change to ACP backend
const acpSessionId = updatedSession?.acpSessionId;
if ("projectId" in patch && acpSessionId) {
updateSessionProject(acpSessionId, patch.projectId ?? null).catch(
(err: unknown) =>
console.error("Failed to update session project in backend:", err),
);
}
},
addSession: (session) => {
+4 -7
View File
@@ -1,5 +1,6 @@
import type { ContentBlock } from "@agentclientprotocol/sdk";
import * as directAcp from "./acpApi";
import type { AcpSessionInfo } from "./acpApi";
import * as sessionTracker from "./acpSessionTracker";
import {
getCatalogEntry,
@@ -27,6 +28,7 @@ export interface AcpSendMessageOptions {
export interface AcpPrepareSessionOptions {
personaId?: string;
projectId?: string;
}
export interface AcpCreateSessionOptions extends AcpPrepareSessionOptions {
@@ -116,6 +118,7 @@ export async function acpPrepareSession(
providerId,
workingDir,
options.personaId,
options.projectId,
);
perfLog(
`[perf:prepare] ${sid} acpPrepareSession done in ${(performance.now() - t0).toFixed(1)}ms`,
@@ -155,13 +158,7 @@ export async function acpSetModel(
return directAcp.setModel(gooseSessionId ?? sessionId, modelId);
}
/** Session info returned by the goose binary's list_sessions. */
export interface AcpSessionInfo {
sessionId: string;
title: string | null;
updatedAt: string | null;
messageCount: number;
}
export type { AcpSessionInfo };
export interface AcpSessionSearchResult {
sessionId: string;
+18 -3
View File
@@ -18,6 +18,7 @@ export interface AcpSessionInfo {
title: string | null;
updatedAt: string | null;
messageCount: number;
projectId?: string | null;
}
const DEPRECATED_PROVIDER_IDS = new Set(["claude-code", "codex", "gemini-cli"]);
@@ -50,6 +51,7 @@ export async function listSessions(): Promise<AcpSessionInfo[]> {
title: info.title ?? null,
updatedAt: info.updatedAt ?? null,
messageCount: (info._meta?.messageCount as number) ?? 0,
projectId: (info._meta?.projectId as string) ?? null,
}));
}
@@ -124,6 +126,17 @@ export async function updateWorkingDir(
await client.extMethod("goose/working_dir/update", { sessionId, workingDir });
}
export async function updateSessionProject(
sessionId: string,
projectId: string | null,
): Promise<void> {
const client = await getClient();
await client.extMethod("_goose/session/update_project", {
sessionId,
projectId,
});
}
export async function cancelSession(sessionId: string): Promise<void> {
const client = await getClient();
await client.cancel({ sessionId });
@@ -132,6 +145,7 @@ export async function cancelSession(sessionId: string): Promise<void> {
export async function newSession(
workingDir: string,
providerId?: string,
projectId?: string,
): Promise<NewSessionResponse> {
const tClient = performance.now();
const client = await getClient();
@@ -142,9 +156,10 @@ export async function newSession(
mcpServers: [],
};
if (providerId) {
request.meta = { provider: providerId };
}
const meta: Record<string, string> = {};
if (providerId) meta.provider = providerId;
if (projectId) meta.projectId = projectId;
if (Object.keys(meta).length > 0) request.meta = meta;
const tCall = performance.now();
const response = await client.newSession(request);
@@ -56,6 +56,7 @@ export async function prepareSession(
providerId: string,
workingDir: string,
personaId?: string,
projectId?: string,
): Promise<string> {
const sid = sessionId.slice(0, 8);
const key = makeKey(sessionId, personaId);
@@ -101,7 +102,7 @@ export async function prepareSession(
if (!gooseSessionId) {
const tNew = performance.now();
const response = await acpApi.newSession(workingDir, providerId);
const response = await acpApi.newSession(workingDir, providerId, projectId);
gooseSessionId = response.sessionId;
perfLog(
`[perf:prepare] ${sid} tracker newSession done in ${(performance.now() - tNew).toFixed(1)}ms (goose_sid=${gooseSessionId.slice(0, 8)})`,
+7
View File
@@ -56,6 +56,7 @@ import type {
RemoveExtensionRequest,
RemoveSecretRequest,
UnarchiveSessionRequest,
UpdateSessionProjectRequest,
UpdateSourceRequest,
UpdateSourceResponse,
UpdateWorkingDirRequest,
@@ -194,6 +195,12 @@ export class GooseExtClient {
return zImportSessionResponse.parse(raw) as ImportSessionResponse;
}
async GooseSessionUpdateProject(
params: UpdateSessionProjectRequest,
): Promise<void> {
await this.conn.extMethod("_goose/session/update_project", params);
}
async GooseSessionArchive(params: ArchiveSessionRequest): Promise<void> {
await this.conn.extMethod("_goose/session/archive", params);
}
+6 -1
View File
@@ -1,6 +1,6 @@
// This file is auto-generated by @hey-api/openapi-ts
export type { AddExtensionRequest, ArchiveSessionRequest, CheckSecretRequest, CheckSecretResponse, CreateSourceRequest, CreateSourceResponse, DeleteSessionRequest, DeleteSourceRequest, DictationConfigRequest, DictationConfigResponse, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest, DictationModelDeleteRequest, DictationModelDownloadProgressRequest, DictationModelDownloadProgressResponse, DictationModelDownloadRequest, DictationModelOption, DictationModelSelectRequest, DictationModelsListRequest, DictationModelsListResponse, DictationProviderStatusEntry, DictationTranscribeRequest, DictationTranscribeResponse, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExportSourceRequest, ExportSourceResponse, ExtRequest, ExtResponse, GetExtensionsRequest, GetExtensionsResponse, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ImportSourcesRequest, ImportSourcesResponse, ListProvidersRequest, ListProvidersResponse, ListSourcesRequest, ListSourcesResponse, ProviderConfigKey, ProviderInventoryEntryDto, ProviderInventoryModelDto, ReadConfigRequest, ReadConfigResponse, ReadResourceRequest, ReadResourceResponse, RefreshProviderInventoryRequest, RefreshProviderInventoryResponse, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, SourceEntry, SourceType, UnarchiveSessionRequest, UpdateSourceRequest, UpdateSourceResponse, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js';
export type { AddExtensionRequest, ArchiveSessionRequest, CheckSecretRequest, CheckSecretResponse, CreateSourceRequest, CreateSourceResponse, DeleteSessionRequest, DeleteSourceRequest, DictationConfigRequest, DictationConfigResponse, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest, DictationModelDeleteRequest, DictationModelDownloadProgressRequest, DictationModelDownloadProgressResponse, DictationModelDownloadRequest, DictationModelOption, DictationModelSelectRequest, DictationModelsListRequest, DictationModelsListResponse, DictationProviderStatusEntry, DictationTranscribeRequest, DictationTranscribeResponse, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExportSourceRequest, ExportSourceResponse, ExtRequest, ExtResponse, GetExtensionsRequest, GetExtensionsResponse, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ImportSourcesRequest, ImportSourcesResponse, ListProvidersRequest, ListProvidersResponse, ListSourcesRequest, ListSourcesResponse, ProviderConfigKey, ProviderInventoryEntryDto, ProviderInventoryModelDto, ReadConfigRequest, ReadConfigResponse, ReadResourceRequest, ReadResourceResponse, RefreshProviderInventoryRequest, RefreshProviderInventoryResponse, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, SourceEntry, SourceType, UnarchiveSessionRequest, UpdateSessionProjectRequest, UpdateSourceRequest, UpdateSourceResponse, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js';
export const GOOSE_EXT_METHODS = [
{
@@ -93,6 +93,11 @@ export const GOOSE_EXT_METHODS = [
requestType: "ImportSessionRequest",
responseType: "ImportSessionResponse",
},
{
method: "_goose/session/update_project",
requestType: "UpdateSessionProjectRequest",
responseType: "EmptyResponse",
},
{
method: "_goose/session/archive",
requestType: "ArchiveSessionRequest",
+9 -1
View File
@@ -351,6 +351,14 @@ export type ImportSessionResponse = {
messageCount: number;
};
/**
* Update the project association for a session.
*/
export type UpdateSessionProjectRequest = {
sessionId: string;
projectId?: string | null;
};
/**
* Archive a session (soft delete).
*/
@@ -618,7 +626,7 @@ export type DictationModelSelectRequest = {
export type ExtRequest = {
id: string;
method: string;
params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | GetSessionExtensionsRequest | ListProvidersRequest | RefreshProviderInventoryRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | CreateSourceRequest | ListSourcesRequest | UpdateSourceRequest | DeleteSourceRequest | ExportSourceRequest | ImportSourcesRequest | DictationTranscribeRequest | DictationConfigRequest | DictationModelsListRequest | DictationModelDownloadRequest | DictationModelDownloadProgressRequest | DictationModelCancelRequest | DictationModelDeleteRequest | DictationModelSelectRequest | {
params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | GetSessionExtensionsRequest | ListProvidersRequest | RefreshProviderInventoryRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | UpdateSessionProjectRequest | ArchiveSessionRequest | UnarchiveSessionRequest | CreateSourceRequest | ListSourcesRequest | UpdateSourceRequest | DeleteSourceRequest | ExportSourceRequest | ImportSourcesRequest | DictationTranscribeRequest | DictationConfigRequest | DictationModelsListRequest | DictationModelDownloadRequest | DictationModelDownloadProgressRequest | DictationModelCancelRequest | DictationModelDeleteRequest | DictationModelSelectRequest | {
[key: string]: unknown;
} | null;
};
+12
View File
@@ -293,6 +293,17 @@ export const zImportSessionResponse = z.object({
messageCount: z.number().int().gte(0)
});
/**
* Update the project association for a session.
*/
export const zUpdateSessionProjectRequest = z.object({
sessionId: z.string(),
projectId: z.union([
z.string(),
z.null()
]).optional()
});
/**
* Archive a session (soft delete).
*/
@@ -594,6 +605,7 @@ export const zExtRequest = z.object({
zRemoveSecretRequest,
zExportSessionRequest,
zImportSessionRequest,
zUpdateSessionProjectRequest,
zArchiveSessionRequest,
zUnarchiveSessionRequest,
zCreateSourceRequest,