feat: migrate session metadata storage from frontend overlay to backend (#8769)
Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Bradley Axen <baxen@squareup.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -276,7 +276,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
||||
perfLog(
|
||||
`[perf:newtab] createNewTab start (project=${project?.id ?? "none"})`,
|
||||
);
|
||||
const agentId = agentStore.activeAgentId ?? undefined;
|
||||
const providerId =
|
||||
project?.preferredProvider ?? agentStore.selectedProvider ?? "goose";
|
||||
const sessionModelPreference =
|
||||
@@ -312,7 +311,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
||||
const session = await sessionStore.createSession({
|
||||
title,
|
||||
projectId: project?.id,
|
||||
agentId,
|
||||
providerId: sessionModelPreference.providerId,
|
||||
workingDir,
|
||||
modelId: sessionModelPreference.modelId,
|
||||
@@ -327,7 +325,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
||||
return session;
|
||||
},
|
||||
[
|
||||
agentStore.activeAgentId,
|
||||
agentStore.selectedProvider,
|
||||
chatStore,
|
||||
providerInventoryEntries,
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import type { ModelOption } from "../types";
|
||||
|
||||
const ACP_SESSION_METADATA_STORAGE_KEY = "goose:acp-session-metadata";
|
||||
const LEGACY_SESSION_CACHE_STORAGE_KEY = "goose:chat-sessions";
|
||||
|
||||
export interface SessionMetadataOverlayRecord {
|
||||
sessionId: string;
|
||||
userSetTitle?: string | null;
|
||||
projectId?: string | null;
|
||||
providerId?: string | null;
|
||||
personaId?: string | null;
|
||||
modelId?: string | null;
|
||||
modelName?: string | null;
|
||||
archivedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
agentId?: string | null;
|
||||
lastKnownTitle?: string | null;
|
||||
lastKnownUpdatedAt?: string | null;
|
||||
lastKnownMessageCount?: number | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface LegacySessionRecord {
|
||||
id: string;
|
||||
acpSessionId?: string;
|
||||
title: string;
|
||||
projectId?: string | null;
|
||||
agentId?: string;
|
||||
providerId?: string;
|
||||
personaId?: string;
|
||||
modelId?: string;
|
||||
modelName?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archivedAt?: string;
|
||||
messageCount: number;
|
||||
draft?: boolean;
|
||||
userSetName?: boolean;
|
||||
}
|
||||
|
||||
function parseStorageArray<T>(storageKey: string): T[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const stored = window.localStorage.getItem(storageKey);
|
||||
if (!stored) return [];
|
||||
const parsed = JSON.parse(stored);
|
||||
return Array.isArray(parsed) ? (parsed as T[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function persistStorageArray<T>(storageKey: string, records: T[]): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
if (records.length === 0) {
|
||||
window.localStorage.removeItem(storageKey);
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(records));
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function loadLegacySessions(): LegacySessionRecord[] {
|
||||
return parseStorageArray<LegacySessionRecord>(
|
||||
LEGACY_SESSION_CACHE_STORAGE_KEY,
|
||||
);
|
||||
}
|
||||
|
||||
function recordFromLegacySession(
|
||||
session: LegacySessionRecord,
|
||||
): SessionMetadataOverlayRecord {
|
||||
return {
|
||||
sessionId: session.acpSessionId ?? session.id,
|
||||
userSetTitle: session.userSetName ? session.title : null,
|
||||
projectId: session.projectId ?? null,
|
||||
providerId: session.providerId,
|
||||
personaId: session.personaId,
|
||||
modelId: session.modelId,
|
||||
modelName: session.modelName,
|
||||
archivedAt: session.archivedAt ?? null,
|
||||
createdAt: session.createdAt,
|
||||
agentId: session.agentId,
|
||||
lastKnownTitle: session.title,
|
||||
lastKnownUpdatedAt: session.updatedAt,
|
||||
lastKnownMessageCount: session.messageCount,
|
||||
updatedAt: session.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadSessionMetadataOverlay(): Map<
|
||||
string,
|
||||
SessionMetadataOverlayRecord
|
||||
> {
|
||||
const records = parseStorageArray<SessionMetadataOverlayRecord>(
|
||||
ACP_SESSION_METADATA_STORAGE_KEY,
|
||||
);
|
||||
const overlays = new Map(records.map((record) => [record.sessionId, record]));
|
||||
|
||||
for (const session of loadLegacySessions()) {
|
||||
if (session.draft) continue;
|
||||
const key = session.acpSessionId ?? session.id;
|
||||
if (overlays.has(key)) continue;
|
||||
overlays.set(key, recordFromLegacySession(session));
|
||||
}
|
||||
|
||||
return overlays;
|
||||
}
|
||||
|
||||
export function persistSessionMetadataOverlay(
|
||||
records: Iterable<SessionMetadataOverlayRecord>,
|
||||
): void {
|
||||
persistStorageArray(
|
||||
ACP_SESSION_METADATA_STORAGE_KEY,
|
||||
[...records].sort((a, b) => a.sessionId.localeCompare(b.sessionId)),
|
||||
);
|
||||
}
|
||||
|
||||
export function upsertSessionMetadataOverlayRecord(
|
||||
record: SessionMetadataOverlayRecord,
|
||||
): void {
|
||||
const overlays = loadSessionMetadataOverlay();
|
||||
overlays.set(record.sessionId, record);
|
||||
persistSessionMetadataOverlay(overlays.values());
|
||||
}
|
||||
|
||||
export function removeSessionMetadataOverlayRecord(sessionId: string): void {
|
||||
const overlays = loadSessionMetadataOverlay();
|
||||
if (!overlays.delete(sessionId)) return;
|
||||
persistSessionMetadataOverlay(overlays.values());
|
||||
}
|
||||
|
||||
export function modelIdsMatch(
|
||||
cached: ModelOption[] | undefined,
|
||||
next: ModelOption[],
|
||||
): boolean {
|
||||
return Boolean(
|
||||
cached &&
|
||||
cached.length === next.length &&
|
||||
cached.every((model, index) => model.id === next[index]?.id),
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AcpSessionInfo } from "@/shared/api/acp";
|
||||
import { useChatSessionStore, type ChatSession } from "../chatSessionStore";
|
||||
|
||||
@@ -10,8 +10,12 @@ vi.mock("@/shared/api/acp", () => ({
|
||||
acpListSessions: (...args: unknown[]) => mockAcpListSessions(...args),
|
||||
}));
|
||||
|
||||
const LEGACY_SESSION_CACHE_KEY = "goose:chat-sessions";
|
||||
const OVERLAY_CACHE_KEY = "goose:acp-session-metadata";
|
||||
vi.mock("@/shared/api/acpApi", () => ({
|
||||
archiveSession: vi.fn().mockResolvedValue(undefined),
|
||||
unarchiveSession: vi.fn().mockResolvedValue(undefined),
|
||||
renameSession: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionProject: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
function resetStore() {
|
||||
useChatSessionStore.setState({
|
||||
@@ -45,16 +49,9 @@ function seedSession(overrides: Partial<ChatSession> = {}): ChatSession {
|
||||
describe("chatSessionStore", () => {
|
||||
beforeEach(() => {
|
||||
resetStore();
|
||||
window.localStorage.removeItem(LEGACY_SESSION_CACHE_KEY);
|
||||
window.localStorage.removeItem(OVERLAY_CACHE_KEY);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.removeItem(LEGACY_SESSION_CACHE_KEY);
|
||||
window.localStorage.removeItem(OVERLAY_CACHE_KEY);
|
||||
});
|
||||
|
||||
describe("createSession", () => {
|
||||
it("creates a real ACP-backed session", async () => {
|
||||
mockAcpCreateSession.mockResolvedValue({ sessionId: "acp-1" });
|
||||
@@ -96,13 +93,23 @@ describe("chatSessionStore", () => {
|
||||
sessionId: "acp-1",
|
||||
title: "ACP Session 1",
|
||||
updatedAt: "2026-04-01",
|
||||
createdAt: "2026-03-31",
|
||||
archivedAt: null,
|
||||
userSetName: false,
|
||||
messageCount: 4,
|
||||
providerId: "openai",
|
||||
modelId: "gpt-4.1",
|
||||
},
|
||||
{
|
||||
sessionId: "acp-2",
|
||||
title: null,
|
||||
updatedAt: "2026-04-02",
|
||||
createdAt: "2026-04-02",
|
||||
archivedAt: null,
|
||||
userSetName: false,
|
||||
messageCount: 7,
|
||||
providerId: null,
|
||||
modelId: null,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -116,69 +123,39 @@ describe("chatSessionStore", () => {
|
||||
expect(sessions[1].id).toBe("acp-1");
|
||||
expect(sessions[1].title).toBe("ACP Session 1");
|
||||
expect(sessions[1].messageCount).toBe(4);
|
||||
expect(sessions[1].providerId).toBe("openai");
|
||||
expect(sessions[1].modelId).toBe("gpt-4.1");
|
||||
});
|
||||
|
||||
it("rehydrates cached project metadata for ACP sessions", async () => {
|
||||
window.localStorage.setItem(
|
||||
LEGACY_SESSION_CACHE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "acp-1",
|
||||
title: "Renamed Project Chat",
|
||||
projectId: "project-123",
|
||||
providerId: "openai",
|
||||
personaId: "persona-1",
|
||||
createdAt: "2026-03-31",
|
||||
updatedAt: "2026-04-01",
|
||||
messageCount: 4,
|
||||
userSetName: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
it("reads all metadata fields from backend response", async () => {
|
||||
mockAcpListSessions.mockResolvedValue([
|
||||
{
|
||||
sessionId: "acp-1",
|
||||
title: null,
|
||||
title: "Renamed Chat",
|
||||
updatedAt: "2026-04-02",
|
||||
createdAt: "2026-03-31",
|
||||
archivedAt: null,
|
||||
userSetName: true,
|
||||
messageCount: 7,
|
||||
projectId: "project-123",
|
||||
providerId: "anthropic",
|
||||
personaId: "persona-1",
|
||||
modelId: "claude-sonnet-4",
|
||||
},
|
||||
]);
|
||||
|
||||
await useChatSessionStore.getState().loadSessions();
|
||||
|
||||
const session = useChatSessionStore.getState().sessions[0];
|
||||
expect(session.title).toBe("Renamed Project Chat");
|
||||
expect(session.title).toBe("Renamed Chat");
|
||||
expect(session.projectId).toBe("project-123");
|
||||
expect(session.providerId).toBe("openai");
|
||||
expect(session.providerId).toBe("anthropic");
|
||||
expect(session.personaId).toBe("persona-1");
|
||||
expect(session.createdAt).toBe("2026-03-31");
|
||||
expect(session.updatedAt).toBe("2026-04-02");
|
||||
expect(session.messageCount).toBe(7);
|
||||
expect(session.userSetName).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores legacy draft records while hydrating overlays", async () => {
|
||||
window.localStorage.setItem(
|
||||
LEGACY_SESSION_CACHE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "cached-draft",
|
||||
title: "Cached Draft",
|
||||
draft: true,
|
||||
createdAt: "2026-04-01",
|
||||
updatedAt: "2026-04-01",
|
||||
messageCount: 0,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
mockAcpListSessions.mockResolvedValue([]);
|
||||
|
||||
await useChatSessionStore.getState().loadSessions();
|
||||
|
||||
expect(useChatSessionStore.getState().sessions).toEqual([]);
|
||||
expect(session.modelId).toBe("claude-sonnet-4");
|
||||
});
|
||||
|
||||
it("drops stale sessions that are no longer in ACP", async () => {
|
||||
@@ -194,7 +171,12 @@ describe("chatSessionStore", () => {
|
||||
sessionId: "acp-1",
|
||||
title: "ACP Session",
|
||||
updatedAt: "2026-04-02",
|
||||
createdAt: "2026-04-02",
|
||||
archivedAt: null,
|
||||
userSetName: false,
|
||||
messageCount: 1,
|
||||
providerId: null,
|
||||
modelId: null,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -225,39 +207,12 @@ describe("chatSessionStore", () => {
|
||||
expect(useChatSessionStore.getState().hasHydratedSessions).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to cached sessions on error", async () => {
|
||||
window.localStorage.setItem(
|
||||
LEGACY_SESSION_CACHE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "cached-session",
|
||||
title: "Cached Session",
|
||||
projectId: "project-123",
|
||||
createdAt: "2026-04-01T00:00:00.000Z",
|
||||
updatedAt: "2026-04-01T00:00:00.000Z",
|
||||
messageCount: 8,
|
||||
},
|
||||
{
|
||||
id: "cached-draft",
|
||||
title: "Cached Draft",
|
||||
draft: true,
|
||||
createdAt: "2026-04-01T00:00:00.000Z",
|
||||
updatedAt: "2026-04-01T00:00:00.000Z",
|
||||
messageCount: 0,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
it("keeps empty sessions list on error", async () => {
|
||||
mockAcpListSessions.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
await useChatSessionStore.getState().loadSessions();
|
||||
|
||||
const sessions = useChatSessionStore.getState().sessions;
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0]).toMatchObject({
|
||||
id: "cached-session",
|
||||
projectId: "project-123",
|
||||
});
|
||||
expect(useChatSessionStore.getState().sessions).toEqual([]);
|
||||
expect(useChatSessionStore.getState().hasHydratedSessions).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,19 +7,17 @@ import {
|
||||
import type { Session } from "@/shared/types/chat";
|
||||
import { DEFAULT_CHAT_TITLE } from "@/features/chat/lib/sessionTitle";
|
||||
import {
|
||||
loadSessionMetadataOverlay,
|
||||
persistSessionMetadataOverlay,
|
||||
upsertSessionMetadataOverlayRecord,
|
||||
type SessionMetadataOverlayRecord,
|
||||
} from "@/features/chat/lib/sessionMetadataOverlay";
|
||||
import { updateSessionProject } from "@/shared/api/acpApi";
|
||||
archiveSession as acpArchiveSession,
|
||||
unarchiveSession as acpUnarchiveSession,
|
||||
renameSession as acpRenameSession,
|
||||
updateSessionProject,
|
||||
} from "@/shared/api/acpApi";
|
||||
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
acpSessionId?: string;
|
||||
title: string;
|
||||
projectId?: string | null;
|
||||
agentId?: string;
|
||||
providerId?: string;
|
||||
personaId?: string;
|
||||
modelId?: string;
|
||||
@@ -66,7 +64,6 @@ interface ChatSessionStoreState {
|
||||
interface CreateSessionOpts {
|
||||
title?: string;
|
||||
projectId?: string;
|
||||
agentId?: string;
|
||||
providerId?: string;
|
||||
personaId?: string;
|
||||
workingDir?: string;
|
||||
@@ -74,18 +71,10 @@ interface CreateSessionOpts {
|
||||
modelName?: string;
|
||||
}
|
||||
|
||||
interface UpdateSessionOptions {
|
||||
persistOverlay?: boolean;
|
||||
}
|
||||
|
||||
interface ChatSessionStoreActions {
|
||||
createSession: (opts?: CreateSessionOpts) => Promise<ChatSession>;
|
||||
loadSessions: () => Promise<void>;
|
||||
updateSession: (
|
||||
id: string,
|
||||
patch: Partial<ChatSession>,
|
||||
opts?: UpdateSessionOptions,
|
||||
) => void;
|
||||
updateSession: (id: string, patch: Partial<ChatSession>) => void;
|
||||
addSession: (session: ChatSession) => void;
|
||||
archiveSession: (id: string) => Promise<void>;
|
||||
unarchiveSession: (id: string) => Promise<void>;
|
||||
@@ -103,101 +92,24 @@ interface ChatSessionStoreActions {
|
||||
|
||||
export type ChatSessionStore = ChatSessionStoreState & ChatSessionStoreActions;
|
||||
|
||||
function overlayKeyForSession(
|
||||
session: Pick<ChatSession, "id" | "acpSessionId">,
|
||||
) {
|
||||
return session.acpSessionId ?? session.id;
|
||||
}
|
||||
|
||||
function buildOverlayRecord(
|
||||
session: ChatSession,
|
||||
existing?: SessionMetadataOverlayRecord,
|
||||
): SessionMetadataOverlayRecord {
|
||||
return {
|
||||
sessionId: overlayKeyForSession(session),
|
||||
userSetTitle: session.userSetName ? session.title : null,
|
||||
projectId: session.projectId ?? null,
|
||||
providerId: session.providerId ?? null,
|
||||
personaId: session.personaId ?? null,
|
||||
modelId: session.modelId ?? null,
|
||||
modelName: session.modelName ?? null,
|
||||
archivedAt: session.archivedAt ?? null,
|
||||
createdAt: session.createdAt ?? existing?.createdAt ?? null,
|
||||
agentId: session.agentId ?? existing?.agentId ?? null,
|
||||
lastKnownTitle: session.title,
|
||||
lastKnownUpdatedAt: session.updatedAt,
|
||||
lastKnownMessageCount: session.messageCount,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function overlayToFallbackSession(
|
||||
overlay: SessionMetadataOverlayRecord,
|
||||
): ChatSession {
|
||||
const updatedAt =
|
||||
overlay.lastKnownUpdatedAt ?? overlay.createdAt ?? overlay.updatedAt;
|
||||
return {
|
||||
id: overlay.sessionId,
|
||||
acpSessionId: overlay.sessionId,
|
||||
title: overlay.userSetTitle ?? overlay.lastKnownTitle ?? "Untitled",
|
||||
projectId: overlay.projectId ?? undefined,
|
||||
agentId: overlay.agentId ?? undefined,
|
||||
providerId: overlay.providerId ?? undefined,
|
||||
personaId: overlay.personaId ?? undefined,
|
||||
modelId: overlay.modelId ?? undefined,
|
||||
modelName: overlay.modelName ?? undefined,
|
||||
createdAt: overlay.createdAt ?? updatedAt,
|
||||
updatedAt,
|
||||
archivedAt: overlay.archivedAt ?? undefined,
|
||||
messageCount: overlay.lastKnownMessageCount ?? 0,
|
||||
userSetName: Boolean(overlay.userSetTitle),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeAcpSessionWithOverlay(
|
||||
session: AcpSessionInfo,
|
||||
overlay?: SessionMetadataOverlayRecord,
|
||||
): ChatSession {
|
||||
const updatedAt = session.updatedAt ?? overlay?.lastKnownUpdatedAt;
|
||||
function acpSessionToChatSession(session: AcpSessionInfo): ChatSession {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: session.sessionId,
|
||||
acpSessionId: session.sessionId,
|
||||
title:
|
||||
overlay?.userSetTitle ??
|
||||
session.title ??
|
||||
overlay?.lastKnownTitle ??
|
||||
"Untitled",
|
||||
title: session.title ?? "Untitled",
|
||||
projectId: session.projectId ?? undefined,
|
||||
agentId: overlay?.agentId ?? undefined,
|
||||
providerId: overlay?.providerId ?? undefined,
|
||||
personaId: overlay?.personaId ?? undefined,
|
||||
modelId: overlay?.modelId ?? undefined,
|
||||
modelName: overlay?.modelName ?? undefined,
|
||||
createdAt: overlay?.createdAt ?? updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: updatedAt ?? new Date().toISOString(),
|
||||
archivedAt: overlay?.archivedAt ?? undefined,
|
||||
providerId: session.providerId ?? undefined,
|
||||
personaId: session.personaId ?? undefined,
|
||||
modelId: session.modelId ?? undefined,
|
||||
createdAt: session.createdAt ?? session.updatedAt ?? now,
|
||||
updatedAt: session.updatedAt ?? now,
|
||||
archivedAt: session.archivedAt ?? undefined,
|
||||
messageCount: session.messageCount,
|
||||
userSetName: Boolean(overlay?.userSetTitle),
|
||||
userSetName: session.userSetName,
|
||||
};
|
||||
}
|
||||
|
||||
function syncOverlaySnapshots(
|
||||
sessions: ChatSession[],
|
||||
existingOverlays = loadSessionMetadataOverlay(),
|
||||
): void {
|
||||
const overlays = new Map(existingOverlays);
|
||||
for (const session of sessions) {
|
||||
overlays.set(
|
||||
overlayKeyForSession(session),
|
||||
buildOverlayRecord(
|
||||
session,
|
||||
existingOverlays.get(overlayKeyForSession(session)),
|
||||
),
|
||||
);
|
||||
}
|
||||
persistSessionMetadataOverlay(overlays.values());
|
||||
}
|
||||
|
||||
function sortByUpdatedAtDesc(sessions: ChatSession[]): ChatSession[] {
|
||||
return [...sessions].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
@@ -209,7 +121,6 @@ export function sessionToChatSession(session: Session): ChatSession {
|
||||
id: session.id,
|
||||
acpSessionId: session.id,
|
||||
title: session.title,
|
||||
agentId: session.agentId,
|
||||
projectId: session.projectId,
|
||||
providerId: session.providerId,
|
||||
personaId: session.personaId,
|
||||
@@ -247,7 +158,6 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
|
||||
acpSessionId: sessionId,
|
||||
title: opts.title ?? DEFAULT_CHAT_TITLE,
|
||||
projectId: opts.projectId,
|
||||
agentId: opts.agentId,
|
||||
providerId,
|
||||
personaId: opts.personaId,
|
||||
modelId: opts.modelId,
|
||||
@@ -257,48 +167,32 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
|
||||
messageCount: 0,
|
||||
};
|
||||
set((state) => ({ sessions: [chatSession, ...state.sessions] }));
|
||||
const existing = loadSessionMetadataOverlay().get(
|
||||
overlayKeyForSession(chatSession),
|
||||
);
|
||||
upsertSessionMetadataOverlayRecord(
|
||||
buildOverlayRecord(chatSession, existing),
|
||||
);
|
||||
return chatSession;
|
||||
},
|
||||
|
||||
loadSessions: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const overlays = loadSessionMetadataOverlay();
|
||||
const acpSessions = await acpListSessions();
|
||||
const mergedAcpSessions = sortByUpdatedAtDesc(
|
||||
acpSessions.map((session) =>
|
||||
mergeAcpSessionWithOverlay(session, overlays.get(session.sessionId)),
|
||||
),
|
||||
const sessions = sortByUpdatedAtDesc(
|
||||
acpSessions.map(acpSessionToChatSession),
|
||||
);
|
||||
const merged = mergedAcpSessions;
|
||||
const activeSessionId = get().activeSessionId;
|
||||
const activeSessionStillExists =
|
||||
activeSessionId == null ||
|
||||
merged.some((session) => session.id === activeSessionId);
|
||||
sessions.some((session) => session.id === activeSessionId);
|
||||
set({
|
||||
sessions: merged,
|
||||
sessions,
|
||||
activeSessionId: activeSessionStillExists ? activeSessionId : null,
|
||||
});
|
||||
syncOverlaySnapshots(mergedAcpSessions, overlays);
|
||||
} catch (error) {
|
||||
console.error("Failed to load sessions from ACP:", error);
|
||||
const overlays = loadSessionMetadataOverlay();
|
||||
const fallbackSessions = sortByUpdatedAtDesc(
|
||||
[...overlays.values()].map(overlayToFallbackSession),
|
||||
);
|
||||
set({ sessions: fallbackSessions });
|
||||
} finally {
|
||||
set({ isLoading: false, hasHydratedSessions: true });
|
||||
}
|
||||
},
|
||||
|
||||
updateSession: (id, patch, opts) => {
|
||||
updateSession: (id, patch) => {
|
||||
set((state) => ({
|
||||
sessions: state.sessions.map((session) =>
|
||||
session.id === id
|
||||
@@ -312,17 +206,22 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
|
||||
}));
|
||||
|
||||
const updatedSession = get().sessions.find((session) => session.id === id);
|
||||
const acpSessionId = updatedSession?.acpSessionId;
|
||||
|
||||
if (updatedSession && opts?.persistOverlay !== false) {
|
||||
const key = overlayKeyForSession(updatedSession);
|
||||
const existing = loadSessionMetadataOverlay().get(key);
|
||||
upsertSessionMetadataOverlayRecord(
|
||||
buildOverlayRecord(updatedSession, existing),
|
||||
// Persist title rename to backend
|
||||
if (
|
||||
"title" in patch &&
|
||||
"userSetName" in patch &&
|
||||
patch.userSetName &&
|
||||
acpSessionId &&
|
||||
patch.title
|
||||
) {
|
||||
acpRenameSession(acpSessionId, patch.title).catch((err: unknown) =>
|
||||
console.error("Failed to rename session in backend:", err),
|
||||
);
|
||||
}
|
||||
|
||||
// Persist projectId change to ACP backend
|
||||
const acpSessionId = updatedSession?.acpSessionId;
|
||||
// Persist projectId change to backend
|
||||
if ("projectId" in patch && acpSessionId) {
|
||||
updateSessionProject(acpSessionId, patch.projectId ?? null).catch(
|
||||
(err: unknown) =>
|
||||
@@ -347,12 +246,6 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
|
||||
}
|
||||
return { sessions: [normalizedSession, ...state.sessions] };
|
||||
});
|
||||
const existing = loadSessionMetadataOverlay().get(
|
||||
overlayKeyForSession(normalizedSession),
|
||||
);
|
||||
upsertSessionMetadataOverlayRecord(
|
||||
buildOverlayRecord(normalizedSession, existing),
|
||||
);
|
||||
},
|
||||
|
||||
archiveSession: async (id) => {
|
||||
@@ -366,11 +259,10 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
|
||||
state.activeSessionId === id ? null : state.activeSessionId,
|
||||
}));
|
||||
const session = get().sessions.find((candidate) => candidate.id === id);
|
||||
if (session) {
|
||||
const existing = loadSessionMetadataOverlay().get(
|
||||
overlayKeyForSession(session),
|
||||
if (session?.acpSessionId) {
|
||||
acpArchiveSession(session.acpSessionId).catch((err: unknown) =>
|
||||
console.error("Failed to archive session in backend:", err),
|
||||
);
|
||||
upsertSessionMetadataOverlayRecord(buildOverlayRecord(session, existing));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -381,11 +273,10 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
|
||||
),
|
||||
}));
|
||||
const session = get().sessions.find((candidate) => candidate.id === id);
|
||||
if (session) {
|
||||
const existing = loadSessionMetadataOverlay().get(
|
||||
overlayKeyForSession(session),
|
||||
if (session?.acpSessionId) {
|
||||
acpUnarchiveSession(session.acpSessionId).catch((err: unknown) =>
|
||||
console.error("Failed to unarchive session in backend:", err),
|
||||
);
|
||||
upsertSessionMetadataOverlayRecord(buildOverlayRecord(session, existing));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -433,15 +324,6 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
|
||||
: session,
|
||||
),
|
||||
}));
|
||||
const session = get().sessions.find(
|
||||
(candidate) => candidate.id === sessionId,
|
||||
);
|
||||
if (session) {
|
||||
const existing = loadSessionMetadataOverlay().get(
|
||||
overlayKeyForSession(session),
|
||||
);
|
||||
upsertSessionMetadataOverlayRecord(buildOverlayRecord(session, existing));
|
||||
}
|
||||
},
|
||||
|
||||
getSession: (id) => get().sessions.find((session) => session.id === id),
|
||||
|
||||
@@ -95,7 +95,13 @@ export async function acpSendMessage(
|
||||
`[perf:send] ${sid} acpSendMessage → prompt(len=${prompt.length}, imgs=${images?.length ?? 0})`,
|
||||
);
|
||||
const tPrompt = performance.now();
|
||||
await directAcp.prompt(gooseSessionId, content);
|
||||
const meta: Record<string, unknown> = {};
|
||||
if (personaId) meta.personaId = personaId;
|
||||
await directAcp.prompt(
|
||||
gooseSessionId,
|
||||
content,
|
||||
Object.keys(meta).length > 0 ? meta : undefined,
|
||||
);
|
||||
const tDone = performance.now();
|
||||
perfLog(
|
||||
`[perf:send] ${sid} prompt() resolved in ${(tDone - tPrompt).toFixed(1)}ms (total acpSendMessage ${(tDone - tStart).toFixed(1)}ms)`,
|
||||
|
||||
@@ -17,8 +17,14 @@ export interface AcpSessionInfo {
|
||||
sessionId: string;
|
||||
title: string | null;
|
||||
updatedAt: string | null;
|
||||
createdAt: string | null;
|
||||
archivedAt: string | null;
|
||||
userSetName: boolean;
|
||||
messageCount: number;
|
||||
projectId?: string | null;
|
||||
providerId: string | null;
|
||||
modelId: string | null;
|
||||
personaId: string | null;
|
||||
}
|
||||
|
||||
const DEPRECATED_PROVIDER_IDS = new Set(["claude-code", "codex", "gemini-cli"]);
|
||||
@@ -50,8 +56,14 @@ export async function listSessions(): Promise<AcpSessionInfo[]> {
|
||||
sessionId: info.sessionId,
|
||||
title: info.title ?? null,
|
||||
updatedAt: info.updatedAt ?? null,
|
||||
createdAt: (info._meta?.createdAt as string) ?? null,
|
||||
archivedAt: (info._meta?.archivedAt as string) ?? null,
|
||||
userSetName: info._meta?.userSetName === true,
|
||||
messageCount: (info._meta?.messageCount as number) ?? 0,
|
||||
projectId: (info._meta?.projectId as string) ?? null,
|
||||
providerId: (info._meta?.providerId as string) ?? null,
|
||||
modelId: (info._meta?.modelId as string) ?? null,
|
||||
personaId: (info._meta?.personaId as string) ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -78,7 +90,14 @@ export async function forkSession(sessionId: string): Promise<AcpSessionInfo> {
|
||||
sessionId: response.sessionId,
|
||||
title: (response._meta?.title as string) ?? null,
|
||||
updatedAt: null,
|
||||
createdAt: (response._meta?.createdAt as string) ?? null,
|
||||
archivedAt: (response._meta?.archivedAt as string) ?? null,
|
||||
userSetName: response._meta?.userSetName === true,
|
||||
messageCount: (response._meta?.messageCount as number) ?? 0,
|
||||
projectId: (response._meta?.projectId as string) ?? null,
|
||||
providerId: (response._meta?.providerId as string) ?? null,
|
||||
modelId: (response._meta?.modelId as string) ?? null,
|
||||
personaId: (response._meta?.personaId as string) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,6 +156,24 @@ export async function updateSessionProject(
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveSession(sessionId: string): Promise<void> {
|
||||
const client = await getClient();
|
||||
await client.extMethod("_goose/session/archive", { sessionId });
|
||||
}
|
||||
|
||||
export async function unarchiveSession(sessionId: string): Promise<void> {
|
||||
const client = await getClient();
|
||||
await client.extMethod("_goose/session/unarchive", { sessionId });
|
||||
}
|
||||
|
||||
export async function renameSession(
|
||||
sessionId: string,
|
||||
title: string,
|
||||
): Promise<void> {
|
||||
const client = await getClient();
|
||||
await client.extMethod("_goose/session/rename", { sessionId, title });
|
||||
}
|
||||
|
||||
export async function cancelSession(sessionId: string): Promise<void> {
|
||||
const client = await getClient();
|
||||
await client.cancel({ sessionId });
|
||||
@@ -146,6 +183,7 @@ export async function newSession(
|
||||
workingDir: string,
|
||||
providerId?: string,
|
||||
projectId?: string,
|
||||
personaId?: string,
|
||||
): Promise<NewSessionResponse> {
|
||||
const tClient = performance.now();
|
||||
const client = await getClient();
|
||||
@@ -159,6 +197,7 @@ export async function newSession(
|
||||
const meta: Record<string, string> = {};
|
||||
if (providerId) meta.provider = providerId;
|
||||
if (projectId) meta.projectId = projectId;
|
||||
if (personaId) meta.personaId = personaId;
|
||||
if (Object.keys(meta).length > 0) request.meta = meta;
|
||||
|
||||
const tCall = performance.now();
|
||||
@@ -192,7 +231,8 @@ export async function loadSession(
|
||||
export async function prompt(
|
||||
sessionId: string,
|
||||
content: ContentBlock[],
|
||||
meta?: Record<string, unknown>,
|
||||
): Promise<PromptResponse> {
|
||||
const client = await getClient();
|
||||
return client.prompt({ sessionId, prompt: content });
|
||||
return client.prompt({ sessionId, prompt: content, _meta: meta });
|
||||
}
|
||||
|
||||
@@ -415,11 +415,7 @@ function handleShared(sessionId: string, update: SessionUpdate): void {
|
||||
if (session && !session.userSetName) {
|
||||
useChatSessionStore
|
||||
.getState()
|
||||
.updateSession(
|
||||
sessionId,
|
||||
{ title: info.title as string },
|
||||
{ persistOverlay: false },
|
||||
);
|
||||
.updateSession(sessionId, { title: info.title as string });
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -456,11 +452,10 @@ function handleShared(sessionId: string, update: SessionUpdate): void {
|
||||
currentModelId;
|
||||
|
||||
const sessionStore = useChatSessionStore.getState();
|
||||
sessionStore.updateSession(
|
||||
sessionId,
|
||||
{ modelId: currentModelId, modelName: currentModelName },
|
||||
{ persistOverlay: false },
|
||||
);
|
||||
sessionStore.updateSession(sessionId, {
|
||||
modelId: currentModelId,
|
||||
modelName: currentModelName,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -102,7 +102,12 @@ export async function prepareSession(
|
||||
|
||||
if (!gooseSessionId) {
|
||||
const tNew = performance.now();
|
||||
const response = await acpApi.newSession(workingDir, providerId, projectId);
|
||||
const response = await acpApi.newSession(
|
||||
workingDir,
|
||||
providerId,
|
||||
projectId,
|
||||
personaId,
|
||||
);
|
||||
gooseSessionId = response.sessionId;
|
||||
perfLog(
|
||||
`[perf:prepare] ${sid} tracker newSession done in ${(performance.now() - tNew).toFixed(1)}ms (goose_sid=${gooseSessionId.slice(0, 8)})`,
|
||||
|
||||
@@ -55,7 +55,6 @@ export const INITIAL_SESSION_CHAT_RUNTIME: SessionChatRuntime = {
|
||||
export interface Session {
|
||||
id: string;
|
||||
title: string;
|
||||
agentId?: string;
|
||||
projectId?: string | null;
|
||||
providerId?: string;
|
||||
personaId?: string;
|
||||
|
||||
@@ -57,6 +57,7 @@ import type {
|
||||
RemoveConfigRequest,
|
||||
RemoveExtensionRequest,
|
||||
RemoveSecretRequest,
|
||||
RenameSessionRequest,
|
||||
ToggleConfigExtensionRequest,
|
||||
UnarchiveSessionRequest,
|
||||
UpdateSessionProjectRequest,
|
||||
@@ -222,6 +223,10 @@ export class GooseExtClient {
|
||||
await this.conn.extMethod("_goose/session/update_project", params);
|
||||
}
|
||||
|
||||
async GooseSessionRename(params: RenameSessionRequest): Promise<void> {
|
||||
await this.conn.extMethod("_goose/session/rename", params);
|
||||
}
|
||||
|
||||
async GooseSessionArchive(params: ArchiveSessionRequest): Promise<void> {
|
||||
await this.conn.extMethod("_goose/session/archive", params);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type { AddConfigExtensionRequest, 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, RemoveConfigExtensionRequest, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, SourceEntry, SourceType, ToggleConfigExtensionRequest, UnarchiveSessionRequest, UpdateSessionProjectRequest, UpdateSourceRequest, UpdateSourceResponse, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js';
|
||||
export type { AddConfigExtensionRequest, 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, RemoveConfigExtensionRequest, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, RenameSessionRequest, SourceEntry, SourceType, ToggleConfigExtensionRequest, UnarchiveSessionRequest, UpdateSessionProjectRequest, UpdateSourceRequest, UpdateSourceResponse, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js';
|
||||
|
||||
export const GOOSE_EXT_METHODS = [
|
||||
{
|
||||
@@ -113,6 +113,11 @@ export const GOOSE_EXT_METHODS = [
|
||||
requestType: "UpdateSessionProjectRequest",
|
||||
responseType: "EmptyResponse",
|
||||
},
|
||||
{
|
||||
method: "_goose/session/rename",
|
||||
requestType: "RenameSessionRequest",
|
||||
responseType: "EmptyResponse",
|
||||
},
|
||||
{
|
||||
method: "_goose/session/archive",
|
||||
requestType: "ArchiveSessionRequest",
|
||||
|
||||
@@ -388,6 +388,14 @@ export type UpdateSessionProjectRequest = {
|
||||
projectId?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Rename a session.
|
||||
*/
|
||||
export type RenameSessionRequest = {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Archive a session (soft delete).
|
||||
*/
|
||||
@@ -659,7 +667,7 @@ export type DictationModelSelectRequest = {
|
||||
export type ExtRequest = {
|
||||
id: string;
|
||||
method: string;
|
||||
params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | AddConfigExtensionRequest | RemoveConfigExtensionRequest | ToggleConfigExtensionRequest | 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 | {
|
||||
params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | AddConfigExtensionRequest | RemoveConfigExtensionRequest | ToggleConfigExtensionRequest | GetSessionExtensionsRequest | ListProvidersRequest | RefreshProviderInventoryRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | UpdateSessionProjectRequest | RenameSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | CreateSourceRequest | ListSourcesRequest | UpdateSourceRequest | DeleteSourceRequest | ExportSourceRequest | ImportSourcesRequest | DictationTranscribeRequest | DictationConfigRequest | DictationModelsListRequest | DictationModelDownloadRequest | DictationModelDownloadProgressRequest | DictationModelCancelRequest | DictationModelDeleteRequest | DictationModelSelectRequest | {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
};
|
||||
|
||||
@@ -328,6 +328,14 @@ export const zUpdateSessionProjectRequest = z.object({
|
||||
]).optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Rename a session.
|
||||
*/
|
||||
export const zRenameSessionRequest = z.object({
|
||||
sessionId: z.string(),
|
||||
title: z.string()
|
||||
});
|
||||
|
||||
/**
|
||||
* Archive a session (soft delete).
|
||||
*/
|
||||
@@ -629,6 +637,7 @@ export const zExtRequest = z.object({
|
||||
zExportSessionRequest,
|
||||
zImportSessionRequest,
|
||||
zUpdateSessionProjectRequest,
|
||||
zRenameSessionRequest,
|
||||
zArchiveSessionRequest,
|
||||
zUnarchiveSessionRequest,
|
||||
zCreateSourceRequest,
|
||||
|
||||
Reference in New Issue
Block a user