chore: goose2 UI state refactor (part 1) (#9049)

This commit is contained in:
Lifei Zhou
2026-05-07 15:52:49 +10:00
committed by GitHub
parent f98eef61f7
commit d7df3fb431
39 changed files with 2208 additions and 339 deletions
+100 -75
View File
@@ -1,4 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Sidebar } from "@/features/sidebar/ui/Sidebar";
import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog";
import { archiveProject } from "@/features/projects/api/projects";
@@ -8,12 +10,21 @@ import type { SectionId } from "@/features/settings/ui/SettingsModal";
import { OPEN_SETTINGS_EVENT } from "@/features/settings/lib/settingsEvents";
import { TopBar } from "./ui/TopBar";
import { useChatStore } from "@/features/chat/stores/chatStore";
import { selectMessagesBySession } from "@/features/chat/stores/chatSelectors";
import {
type ChatSession,
useChatSessionStore,
} from "@/features/chat/stores/chatSessionStore";
import {
selectActiveSessionId,
selectHasHydratedSessions,
selectSessions,
selectSessionsLoading,
} from "@/features/chat/stores/chatSessionSelectors";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import { selectSelectedProvider } from "@/features/agents/stores/agentSelectors";
import { useProjectStore } from "@/features/projects/stores/projectStore";
import { selectProjects } from "@/features/projects/stores/projectSelectors";
import { findExistingDraft } from "@/features/chat/lib/newChat";
import { DEFAULT_CHAT_TITLE } from "@/features/chat/lib/sessionTitle";
import { useAppStartup } from "./hooks/useAppStartup";
@@ -23,6 +34,10 @@ import { resolveSupportedSessionModelPreference } from "./lib/resolveSupportedSe
import { useCreatePersonaNavigation } from "./hooks/useCreatePersonaNavigation";
import { AppShellContent } from "./ui/AppShellContent";
import { acpPrepareSession, acpSetModel } from "@/shared/api/acp";
import {
updateSessionProject,
updateSessionTitle,
} from "@/features/chat/stores/chatSessionOperations";
import {
clearReplayBuffer,
getAndDeleteReplayBuffer,
@@ -103,6 +118,7 @@ async function syncWindowMinimumSize() {
}
export function AppShell({ children }: { children?: React.ReactNode }) {
const { t } = useTranslation("chat");
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(SIDEBAR_DEFAULT_WIDTH);
const [isResizing, setIsResizing] = useState(false);
@@ -120,10 +136,21 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
loadStoredHomeSessionId(),
);
const chatStore = useChatStore();
const sessionStore = useChatSessionStore();
const agentStore = useAgentStore();
const projectStore = useProjectStore();
const messagesBySession = useChatStore(selectMessagesBySession);
const setChatActiveSession = useChatStore((s) => s.setActiveSession);
const cleanupChatSession = useChatStore((s) => s.cleanupSession);
const sessions = useChatSessionStore(selectSessions);
const activeSessionId = useChatSessionStore(selectActiveSessionId);
const hasHydratedSessions = useChatSessionStore(selectHasHydratedSessions);
const sessionsLoading = useChatSessionStore(selectSessionsLoading);
const createSession = useChatSessionStore((s) => s.createSession);
const patchSession = useChatSessionStore((s) => s.patchSession);
const setActiveSession = useChatSessionStore((s) => s.setActiveSession);
const archiveSession = useChatSessionStore((s) => s.archiveSession);
const selectedProvider = useAgentStore(selectSelectedProvider);
const projects = useProjectStore(selectProjects);
const fetchProjects = useProjectStore((s) => s.fetchProjects);
const reorderProjects = useProjectStore((s) => s.reorderProjects);
const providerInventoryEntries = useProviderInventoryStore((s) => s.entries);
const startup = useAppStartup();
const onboardingGate = useOnboardingGate(startup.ready);
@@ -182,10 +209,8 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
}, []);
useEffect(() => {
projectStore.fetchProjects();
}, [projectStore.fetchProjects]);
const { activeSessionId } = sessionStore;
fetchProjects();
}, [fetchProjects]);
useEffect(() => {
if (activeView === "chat" && activeSessionId) {
@@ -194,23 +219,23 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
}, [activeSessionId, activeView]);
const activeSession = activeSessionId
? sessionStore.getSession(activeSessionId)
? sessions.find((session) => session.id === activeSessionId)
: undefined;
const homeSession = homeSessionId
? sessionStore.getSession(homeSessionId)
? sessions.find((session) => session.id === homeSessionId)
: undefined;
useHomeSessionStateSync({
homeSessionId,
homeSession,
messagesBySession: chatStore.messagesBySession,
hasHydratedSessions: sessionStore.hasHydratedSessions,
isLoading: sessionStore.isLoading,
messagesBySession,
hasHydratedSessions,
isLoading: sessionsLoading,
setHomeSessionId,
});
const ensureHomeSession = useCallback(async () => {
if (!sessionStore.hasHydratedSessions || sessionStore.isLoading) {
if (!hasHydratedSessions || sessionsLoading) {
return null;
}
@@ -226,11 +251,11 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
) {
const sessionModelPreference =
await resolveSupportedSessionModelPreference(
agentStore.selectedProvider ?? "goose",
selectedProvider ?? "goose",
providerInventoryEntries,
);
const project = homeSession.projectId
? (projectStore.projects.find(
? (projects.find(
(candidate) => candidate.id === homeSession.projectId,
) ?? null)
: null;
@@ -243,14 +268,14 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
const shouldClearHomeModel =
sessionModelPreference.providerId !== homeSession.providerId ||
!sessionModelPreference.modelId;
sessionStore.updateSession(homeSession.id, {
patchSession(homeSession.id, {
providerId: sessionModelPreference.providerId,
modelId: shouldClearHomeModel ? undefined : homeSession.modelId,
modelName: shouldClearHomeModel ? undefined : homeSession.modelName,
});
if (sessionModelPreference.modelId) {
await acpSetModel(homeSession.id, sessionModelPreference.modelId);
sessionStore.updateSession(homeSession.id, {
patchSession(homeSession.id, {
modelId: sessionModelPreference.modelId,
modelName: sessionModelPreference.modelName,
});
@@ -261,10 +286,10 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
const workingDir = await resolveSessionCwd(null);
const sessionModelPreference =
await resolveSupportedSessionModelPreference(
agentStore.selectedProvider ?? "goose",
selectedProvider ?? "goose",
providerInventoryEntries,
);
const session = await sessionStore.createSession({
const session = await createSession({
title: DEFAULT_CHAT_TITLE,
providerId: sessionModelPreference.providerId,
workingDir,
@@ -284,13 +309,14 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
}
}
}, [
agentStore.selectedProvider,
selectedProvider,
createSession,
hasHydratedSessions,
homeSession,
providerInventoryEntries,
projectStore.projects,
sessionStore.hasHydratedSessions,
sessionStore,
sessionStore.isLoading,
projects,
sessionsLoading,
patchSession,
]);
useEffect(() => {
@@ -309,7 +335,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
`[perf:newtab] createNewTab start (project=${project?.id ?? "none"})`,
);
const providerId =
project?.preferredProvider ?? agentStore.selectedProvider ?? "goose";
project?.preferredProvider ?? selectedProvider ?? "goose";
const sessionModelPreference =
await resolveSupportedSessionModelPreference(
providerId,
@@ -330,9 +356,9 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
});
if (existingDraft) {
sessionStore.setActiveSession(existingDraft.id);
setActiveSession(existingDraft.id);
setActiveView("chat");
chatStore.setActiveSession(existingDraft.id);
setChatActiveSession(existingDraft.id);
perfLog(
`[perf:newtab] ${existingDraft.id.slice(0, 8)} reused draft in ${(performance.now() - tStart).toFixed(1)}ms`,
);
@@ -340,7 +366,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
}
const workingDir = await resolveSessionCwd(project);
const session = await sessionStore.createSession({
const session = await createSession({
title,
projectId: project?.id,
providerId: sessionModelPreference.providerId,
@@ -348,19 +374,20 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
modelId: sessionModelPreference.modelId,
modelName: sessionModelPreference.modelName,
});
sessionStore.setActiveSession(session.id);
setActiveSession(session.id);
setActiveView("chat");
chatStore.setActiveSession(session.id);
setChatActiveSession(session.id);
perfLog(
`[perf:newtab] ${session.id.slice(0, 8)} created session in ${(performance.now() - tStart).toFixed(1)}ms`,
);
return session;
},
[
agentStore.selectedProvider,
chatStore,
selectedProvider,
createSession,
providerInventoryEntries,
sessionStore,
setActiveSession,
setChatActiveSession,
],
);
@@ -374,7 +401,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
const handleStartChatWithSkill = useCallback(
(skill: SkillInfo, projectId?: string | null) => {
const project = projectId
? projectStore.projects.find((candidate) => candidate.id === projectId)
? projects.find((candidate) => candidate.id === projectId)
: undefined;
void createNewTab(DEFAULT_CHAT_TITLE, project)
@@ -387,38 +414,38 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
console.error("Failed to start chat with skill:", error);
});
},
[createNewTab, projectStore.projects],
[createNewTab, projects],
);
const handleNewChatInProject = useCallback(
(projectId: string) => {
const project = projectStore.projects.find((p) => p.id === projectId);
const project = projects.find((p) => p.id === projectId);
if (project) {
void createNewTab(DEFAULT_CHAT_TITLE, project);
}
},
[createNewTab, projectStore.projects],
[createNewTab, projects],
);
const handleArchiveProject = useCallback(
async (projectId: string) => {
try {
await archiveProject(projectId);
projectStore.fetchProjects();
fetchProjects();
} catch {
// best-effort
}
},
[projectStore.fetchProjects],
[fetchProjects],
);
const clearActiveSession = useCallback(
(sessionId: string) => {
chatStore.cleanupSession(sessionId);
sessionStore.setActiveSession(null);
cleanupChatSession(sessionId);
setActiveSession(null);
setActiveView("home");
},
[chatStore, sessionStore],
[cleanupChatSession, setActiveSession],
);
const openSettings = useCallback((section: SectionId = "appearance") => {
setSettingsInitialSection(section);
@@ -456,43 +483,43 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
const wasActiveSession = currentActiveSessionId === sessionId;
try {
await sessionStore.archiveSession(sessionId);
chatStore.cleanupSession(sessionId);
await archiveSession(sessionId);
cleanupChatSession(sessionId);
if (!wasActiveSession) {
return;
}
sessionStore.setActiveSession(null);
setActiveSession(null);
setActiveView("home");
} catch {
// best-effort
}
},
[chatStore, sessionStore],
[archiveSession, cleanupChatSession, setActiveSession],
);
const handleEditProject = useCallback(
(projectId: string) => {
const project = projectStore.projects.find((p) => p.id === projectId);
const project = projects.find((p) => p.id === projectId);
if (project) {
setEditingProject(project);
setCreateProjectOpen(true);
}
},
[projectStore.projects],
[projects],
);
const handleMoveToProject = useCallback(
(sessionId: string, projectId: string | null) => {
sessionStore.updateSession(sessionId, { projectId });
const session = useChatSessionStore.getState().getSession(sessionId);
if (!session) {
return;
}
void (async () => {
await updateSessionProject(sessionId, projectId);
const nextProject =
projectId == null
? null
@@ -505,27 +532,25 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
}
await acpPrepareSession(
sessionId,
session.providerId ?? agentStore.selectedProvider ?? "goose",
session.providerId ?? selectedProvider ?? "goose",
workingDir,
);
})().catch((error) => {
console.error(
"Failed to update ACP session project working directory:",
error,
);
console.error("Failed to move chat to project:", error);
toast.error(t("notifications.moveError"));
});
},
[agentStore.selectedProvider, sessionStore],
[selectedProvider, t],
);
const handleRenameChat = useCallback(
(sessionId: string, nextTitle: string) => {
sessionStore.updateSession(sessionId, {
title: nextTitle,
userSetName: true,
void updateSessionTitle(sessionId, nextTitle).catch((error) => {
console.error("Failed to rename session:", error);
toast.error(t("notifications.renameError"));
});
},
[sessionStore],
[t],
);
const openCreateProjectDialog = useCallback(
@@ -546,23 +571,23 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
if (homeSessionId === sessionId) {
setHomeSessionId(null);
}
sessionStore.setActiveSession(sessionId);
setActiveSession(sessionId);
setActiveView("chat");
chatStore.setActiveSession(sessionId);
setChatActiveSession(sessionId);
useChatStore.getState().markSessionRead(sessionId);
},
[chatStore, homeSessionId, sessionStore],
[homeSessionId, setActiveSession, setChatActiveSession],
);
const handleSelectSession = useCallback(
(id: string) => {
sessionStore.setActiveSession(id);
setActiveSession(id);
setActiveView("chat");
chatStore.setActiveSession(id);
setChatActiveSession(id);
useChatStore.getState().markSessionRead(id);
loadSessionMessages(id);
},
[sessionStore, chatStore, loadSessionMessages],
[setActiveSession, setChatActiveSession, loadSessionMessages],
);
const handleSelectSearchResult = useCallback(
@@ -580,11 +605,11 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
const handleNavigate = useCallback(
(view: AppView) => {
if (view !== "chat") {
sessionStore.setActiveSession(null);
setActiveSession(null);
}
setActiveView(view);
},
[sessionStore],
[setActiveSession],
);
const handleCreatePersona = useCreatePersonaNavigation(() =>
@@ -717,13 +742,13 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
// Cmd+N opens new conversation screen
if (e.key === "n" && e.metaKey) {
e.preventDefault();
sessionStore.setActiveSession(null);
setActiveSession(null);
setActiveView("home");
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [clearActiveSession, sessionStore, toggleSidebar]);
}, [clearActiveSession, setActiveSession, toggleSidebar]);
if (!startup.ready) {
return (
@@ -768,7 +793,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
onNavigate={handleNavigate}
onNewChatInProject={handleNewChatInProject}
onNewChat={() => {
sessionStore.setActiveSession(null);
setActiveSession(null);
setActiveView("home");
}}
onCreateProject={() => openCreateProjectDialog()}
@@ -777,12 +802,12 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
onArchiveChat={handleArchiveChat}
onRenameChat={handleRenameChat}
onMoveToProject={handleMoveToProject}
onReorderProject={projectStore.reorderProjects}
onReorderProject={reorderProjects}
onSelectSession={handleSelectSession}
onSelectSearchResult={handleSelectSearchResult}
activeView={activeView}
activeSessionId={activeSessionId}
projects={projectStore.projects}
projects={projects}
className="h-full rounded-xl"
/>
</div>
@@ -832,7 +857,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
pendingProjectCreatedRef.current = null;
}}
onCreated={(project) => {
projectStore.fetchProjects();
fetchProjects();
pendingProjectCreatedRef.current?.(project.id);
pendingProjectCreatedRef.current = null;
setCreateProjectInitialWorkingDir(null);
@@ -1,5 +1,9 @@
import { useEffect, useCallback, useRef } from "react";
import { useAgentStore } from "../stores/agentStore";
import {
selectPersonas,
selectPersonasLoading,
} from "../stores/agentSelectors";
import type {
CreatePersonaRequest,
UpdatePersonaRequest,
@@ -9,32 +13,36 @@ import * as api from "@/shared/api/agents";
const REFRESH_INTERVAL_MS = 60_000;
export function usePersonas() {
const store = useAgentStore();
const personas = useAgentStore(selectPersonas);
const personasLoading = useAgentStore(selectPersonasLoading);
const setPersonas = useAgentStore((s) => s.setPersonas);
const addPersona = useAgentStore((s) => s.addPersona);
const updatePersonaInStore = useAgentStore((s) => s.updatePersona);
const removePersona = useAgentStore((s) => s.removePersona);
const setPersonasLoading = useAgentStore((s) => s.setPersonasLoading);
const refreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: store is stable and should not trigger re-creation
const loadPersonas = useCallback(async () => {
store.setPersonasLoading(true);
setPersonasLoading(true);
try {
const personas = await api.listPersonas();
store.setPersonas(personas);
setPersonas(personas);
} catch (error) {
console.error("Failed to load personas:", error);
// Fall back to empty list - builtins will come from backend
} finally {
store.setPersonasLoading(false);
setPersonasLoading(false);
}
}, []);
}, [setPersonas, setPersonasLoading]);
// biome-ignore lint/correctness/useExhaustiveDependencies: store is stable and should not trigger re-creation
const refreshFromDisk = useCallback(async () => {
try {
const personas = await api.refreshPersonas();
store.setPersonas(personas);
setPersonas(personas);
} catch (error) {
console.error("Failed to refresh personas from disk:", error);
}
}, []);
}, [setPersonas]);
useEffect(() => {
loadPersonas();
@@ -57,32 +65,35 @@ export function usePersonas() {
};
}, [refreshFromDisk]);
// biome-ignore lint/correctness/useExhaustiveDependencies: store is stable and should not trigger re-creation
const createPersona = useCallback(async (req: CreatePersonaRequest) => {
const persona = await api.createPersona(req);
store.addPersona(persona);
return persona;
}, []);
const createPersona = useCallback(
async (req: CreatePersonaRequest) => {
const persona = await api.createPersona(req);
addPersona(persona);
return persona;
},
[addPersona],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: store is stable and should not trigger re-creation
const updatePersona = useCallback(
async (id: string, req: UpdatePersonaRequest) => {
const persona = await api.updatePersona(id, req);
store.updatePersona(id, persona);
updatePersonaInStore(id, persona);
return persona;
},
[],
[updatePersonaInStore],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: store is stable and should not trigger re-creation
const deletePersona = useCallback(async (id: string) => {
await api.deletePersona(id);
store.removePersona(id);
}, []);
const deletePersona = useCallback(
async (id: string) => {
await api.deletePersona(id);
removePersona(id);
},
[removePersona],
);
return {
personas: store.personas,
isLoading: store.personasLoading,
personas,
isLoading: personasLoading,
createPersona,
updatePersona,
deletePersona,
@@ -1,10 +1,11 @@
import { useCallback } from "react";
import { useAgentStore } from "../stores/agentStore";
import { selectSelectedProvider } from "../stores/agentSelectors";
export function useProviderSelection() {
const providers = useAgentStore((s) => s.providers);
const providersLoading = useAgentStore((s) => s.providersLoading);
const selectedProvider = useAgentStore((s) => s.selectedProvider);
const selectedProvider = useAgentStore(selectSelectedProvider);
const storeSetSelectedProvider = useAgentStore((s) => s.setSelectedProvider);
const setSelectedProvider = useCallback(
@@ -0,0 +1,9 @@
import type { AgentStore } from "./agentStore";
export const selectPersonas = (state: AgentStore) => state.personas;
export const selectPersonasLoading = (state: AgentStore) =>
state.personasLoading;
export const selectSelectedProvider = (state: AgentStore) =>
state.selectedProvider;
@@ -16,6 +16,10 @@ import {
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import {
selectPersonas,
selectPersonasLoading,
} from "@/features/agents/stores/agentSelectors";
import { PersonaGallery } from "@/features/agents/ui/PersonaGallery";
import { PersonaEditor } from "@/features/agents/ui/PersonaEditor";
import {
@@ -41,8 +45,8 @@ export function AgentsView() {
const [search, setSearch] = useState("");
const [deletingPersona, setDeletingPersona] = useState<Persona | null>(null);
const personas = useAgentStore((s) => s.personas);
const personasLoading = useAgentStore((s) => s.personasLoading);
const personas = useAgentStore(selectPersonas);
const personasLoading = useAgentStore(selectPersonasLoading);
const personaEditorOpen = useAgentStore((s) => s.personaEditorOpen);
const editingPersona = useAgentStore((s) => s.editingPersona);
const personaEditorMode = useAgentStore((s) => s.personaEditorMode);
@@ -246,7 +246,7 @@ describe("useChatSessionController compaction behavior", () => {
useChatStore
.getState()
.replaceTokenState("session-1", mockTokenState, true);
useChatSessionStore.getState().updateSession("session-1", {
useChatSessionStore.getState().patchSession("session-1", {
providerId: "goose",
});
@@ -300,7 +300,7 @@ describe("useChatSessionController compaction behavior", () => {
useChatStore
.getState()
.replaceTokenState("session-1", mockTokenState, true);
useChatSessionStore.getState().updateSession("session-1", {
useChatSessionStore.getState().patchSession("session-1", {
providerId: "goose",
personaId: "persona-b",
});
+98 -87
View File
@@ -4,10 +4,12 @@ import { useChatSessionStore } from "../stores/chatSessionStore";
import { clearReplayBuffer, getAndDeleteReplayBuffer } from "./replayBuffer";
import {
type ChatAttachmentDraft,
type Message,
createSystemNotificationMessage,
createUserMessage,
} from "@/shared/types/messages";
import type { ChatState, TokenState } from "@/shared/types/chat";
import { INITIAL_SESSION_CHAT_RUNTIME } from "@/shared/types/chat";
import {
acpSendMessage,
acpCancelSession,
@@ -18,7 +20,6 @@ import {
getSessionTitleFromDraft,
isDefaultChatTitle,
} from "../lib/sessionTitle";
import { findLastIndex } from "@/shared/lib/arrays";
import { perfLog } from "@/shared/lib/perfLog";
import {
appendAttachmentPaths,
@@ -28,10 +29,10 @@ import {
import { sanitizeReplayMessages } from "../lib/replaySanitizer";
import { i18n } from "@/shared/i18n";
import type { ChatSendOptions } from "../types";
import { buildSkillRetryOptions } from "../lib/skillSendPayload";
// TODO: Remove this fallback once goose2 has first-class /-commands.
const MANUAL_COMPACT_TRIGGER = "/compact";
const EMPTY_MESSAGES: Message[] = [];
type CompactConversationResult = "completed" | "failed" | "skipped";
function createCompactionConfirmationMessage() {
@@ -104,12 +105,28 @@ export function useChat(
ensurePrepared?: (personaId?: string) => Promise<void>;
},
) {
const store = useChatStore();
const abortRef = useRef<AbortController | null>(null);
const messages = store.messagesBySession[sessionId] ?? [];
const { chatState, tokenState, error, streamingMessageId } =
store.getSessionRuntime(sessionId);
const messages = useChatStore(
(s) => s.messagesBySession[sessionId] ?? EMPTY_MESSAGES,
);
const runtime = useChatStore(
(s) => s.sessionStateById[sessionId] ?? INITIAL_SESSION_CHAT_RUNTIME,
);
const setActiveSession = useChatStore((s) => s.setActiveSession);
const addMessage = useChatStore((s) => s.addMessage);
const setMessages = useChatStore((s) => s.setMessages);
const clearMessages = useChatStore((s) => s.clearMessages);
const setChatState = useChatStore((s) => s.setChatState);
const setError = useChatStore((s) => s.setError);
const setStreamingMessageId = useChatStore((s) => s.setStreamingMessageId);
const setPendingAssistantProvider = useChatStore(
(s) => s.setPendingAssistantProvider,
);
const clearDraft = useChatStore((s) => s.clearDraft);
const setSessionLoading = useChatStore((s) => s.setSessionLoading);
const { chatState, tokenState, error, streamingMessageId } = runtime;
const isStreaming = chatState === "streaming" || streamingMessageId !== null;
const resolvePersonaInfo = useCallback(
@@ -166,8 +183,8 @@ export function useChat(
systemPromptOverride ?? agent?.systemPrompt ?? undefined;
// Ensure active session
store.setActiveSession(sessionId);
store.setPendingAssistantProvider(sessionId, providerId);
setActiveSession(sessionId);
setPendingAssistantProvider(sessionId, providerId);
// Create and add user message
const userMessage = createUserMessage(
@@ -195,9 +212,9 @@ export function useChat(
});
}
}
store.addMessage(sessionId, userMessage);
store.setChatState(sessionId, "thinking");
store.setError(sessionId, null);
addMessage(sessionId, userMessage);
setChatState(sessionId, "thinking");
setError(sessionId, null);
const sessionStore = useChatSessionStore.getState();
const session = sessionStore.getSession(sessionId);
@@ -208,19 +225,19 @@ export function useChat(
// A better backend-generated title will overwrite this if it arrives
// via the acp:session_info event.
if (session && isDefaultChatTitle(session.title)) {
sessionStore.updateSession(sessionId, {
sessionStore.patchSession(sessionId, {
title: getSessionTitleFromDraft(text, attachments),
updatedAt: new Date().toISOString(),
});
} else {
sessionStore.updateSession(sessionId, {
sessionStore.patchSession(sessionId, {
updatedAt: new Date().toISOString(),
});
}
options?.onMessageAccepted?.(sessionId);
store.clearDraft(sessionId);
clearDraft(sessionId);
const abort = new AbortController();
abortRef.current = abort;
@@ -228,7 +245,7 @@ export function useChat(
try {
await options?.ensurePrepared?.(effectivePersonaInfo?.id);
store.setChatState(sessionId, "streaming");
setChatState(sessionId, "streaming");
const promptWithPaths = appendAttachmentPaths(text.trim(), attachments);
const acpPrompt =
promptWithPaths || (images?.length ? " " : promptWithPaths);
@@ -251,11 +268,11 @@ export function useChat(
`[perf:send] ${sid} acpSendMessage returned after ${(performance.now() - tAcp).toFixed(1)}ms (total sendMessage ${(performance.now() - tSendStart).toFixed(1)}ms)`,
);
store.setChatState(sessionId, "idle");
store.setStreamingMessageId(sessionId, null);
setChatState(sessionId, "idle");
setStreamingMessageId(sessionId, null);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
store.setChatState(sessionId, "idle");
setChatState(sessionId, "idle");
} else {
const errorMessage = getErrorMessage(err);
const liveStore = useChatStore.getState();
@@ -278,18 +295,24 @@ export function useChat(
sessionId,
createSystemNotificationMessage(errorMessage, "error"),
);
store.setError(sessionId, errorMessage);
store.setChatState(sessionId, "idle");
store.setStreamingMessageId(sessionId, null);
setError(sessionId, errorMessage);
setChatState(sessionId, "idle");
setStreamingMessageId(sessionId, null);
}
store.setPendingAssistantProvider(sessionId, null);
setPendingAssistantProvider(sessionId, null);
} finally {
abortRef.current = null;
}
},
[
sessionId,
store,
setActiveSession,
setPendingAssistantProvider,
addMessage,
setChatState,
setError,
clearDraft,
setStreamingMessageId,
providerOverride,
systemPromptOverride,
resolvePersonaInfo,
@@ -303,9 +326,9 @@ export function useChat(
.getState()
.getSessionRuntime(sessionId).streamingMessageId;
store.setChatState(sessionId, "idle");
store.setStreamingMessageId(sessionId, null);
store.setPendingAssistantProvider(sessionId, null);
setChatState(sessionId, "idle");
setStreamingMessageId(sessionId, null);
setPendingAssistantProvider(sessionId, null);
// Cancel the backend ACP session to stop orphaned streaming events
acpCancelSession(sessionId)
.then((wasCancelled) => {
@@ -316,50 +339,26 @@ export function useChat(
.catch(() => {
// Best-effort cancellation — ignore errors
});
}, [store, sessionId]);
const retryLastMessage = useCallback(async () => {
const sessionMessages = store.messagesBySession[sessionId] ?? [];
// Find the last user message
const lastUserIndex = findLastIndex(
sessionMessages,
(m) => m.role === "user",
);
if (lastUserIndex === -1) return;
const lastUserMessage = sessionMessages[lastUserIndex];
// Remove all messages after (and including) the last assistant response
const messagesToKeep = sessionMessages.slice(0, lastUserIndex);
store.setMessages(sessionId, messagesToKeep);
// Extract the text and resend
const textContent = lastUserMessage.content.find((c) => c.type === "text");
if (textContent && "text" in textContent) {
const targetPersonaId = lastUserMessage.metadata?.targetPersonaId;
const targetPersonaName = lastUserMessage.metadata?.targetPersonaName;
const retryOptions = buildSkillRetryOptions(
textContent.text,
lastUserMessage.metadata?.chips,
);
await sendMessage(
textContent.text || (retryOptions ? " " : ""),
targetPersonaId
? { id: targetPersonaId, name: targetPersonaName }
: undefined,
undefined,
retryOptions,
);
}
}, [sessionId, store, sendMessage]);
}, [
setChatState,
setPendingAssistantProvider,
setStreamingMessageId,
sessionId,
]);
const clearChat = useCallback(() => {
abortRef.current?.abort();
store.clearMessages(sessionId);
store.setChatState(sessionId, "idle");
store.setStreamingMessageId(sessionId, null);
store.setPendingAssistantProvider(sessionId, null);
}, [sessionId, store]);
clearMessages(sessionId);
setChatState(sessionId, "idle");
setStreamingMessageId(sessionId, null);
setPendingAssistantProvider(sessionId, null);
}, [
sessionId,
clearMessages,
setChatState,
setStreamingMessageId,
setPendingAssistantProvider,
]);
const getWorkingDir = useCallback(
() =>
@@ -381,25 +380,25 @@ export function useChat(
overridePersona?.name,
);
store.setActiveSession(sessionId);
store.setChatState(sessionId, "compacting");
store.setStreamingMessageId(sessionId, null);
store.setError(sessionId, null);
setActiveSession(sessionId);
setChatState(sessionId, "compacting");
setStreamingMessageId(sessionId, null);
setError(sessionId, null);
try {
await options?.ensurePrepared?.(effectivePersonaInfo?.id);
} catch (err) {
const errorMessage = getErrorMessage(err);
store.addMessage(
addMessage(
sessionId,
createSystemNotificationMessage(errorMessage, "error"),
);
store.setError(sessionId, errorMessage);
store.setChatState(sessionId, "idle");
setError(sessionId, errorMessage);
setChatState(sessionId, "idle");
return "failed" as CompactConversationResult;
}
store.setSessionLoading(sessionId, true);
setSessionLoading(sessionId, true);
clearReplayBuffer(sessionId);
try {
@@ -415,37 +414,50 @@ export function useChat(
const workingDir = getWorkingDir();
await acpLoadSession(sessionId, workingDir);
store.setSessionLoading(sessionId, false);
setSessionLoading(sessionId, false);
const buffer = getAndDeleteReplayBuffer(sessionId);
if (buffer) {
store.setMessages(sessionId, [
setMessages(sessionId, [
...sanitizeReplayMessages(buffer),
createCompactionConfirmationMessage(),
]);
} else {
store.addMessage(sessionId, createCompactionConfirmationMessage());
addMessage(sessionId, createCompactionConfirmationMessage());
}
return "completed" as CompactConversationResult;
} catch (err) {
clearReplayBuffer(sessionId);
store.setSessionLoading(sessionId, false);
setSessionLoading(sessionId, false);
const errorMessage = getErrorMessage(err);
store.addMessage(
addMessage(
sessionId,
createSystemNotificationMessage(errorMessage, "error"),
);
store.setError(sessionId, errorMessage);
setError(sessionId, errorMessage);
return "failed" as CompactConversationResult;
} finally {
store.setChatState(sessionId, "idle");
store.setStreamingMessageId(sessionId, null);
store.setPendingAssistantProvider(sessionId, null);
store.setSessionLoading(sessionId, false);
setChatState(sessionId, "idle");
setStreamingMessageId(sessionId, null);
setPendingAssistantProvider(sessionId, null);
setSessionLoading(sessionId, false);
}
},
[getWorkingDir, options, resolvePersonaInfo, sessionId, store],
[
getWorkingDir,
options,
resolvePersonaInfo,
sessionId,
setActiveSession,
setChatState,
setStreamingMessageId,
setError,
addMessage,
setSessionLoading,
setMessages,
setPendingAssistantProvider,
],
);
const stopStreaming = stopGeneration;
@@ -459,7 +471,6 @@ export function useChat(
sendMessage,
stopGeneration,
stopStreaming,
retryLastMessage,
clearChat,
compactConversation,
isStreaming,
@@ -8,8 +8,10 @@ import { useMessageQueue } from "./useMessageQueue";
import { useChatStore } from "../stores/chatStore";
import { useChatSessionStore } from "../stores/chatSessionStore";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import { selectPersonas } from "@/features/agents/stores/agentSelectors";
import { useProviderSelection } from "@/features/agents/hooks/useProviderSelection";
import { useProjectStore } from "@/features/projects/stores/projectStore";
import { selectProjects } from "@/features/projects/stores/projectSelectors";
import { resolveAgentProviderCatalogIdStrictFromEntries } from "@/features/providers/providerCatalog";
import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore";
import {
@@ -25,6 +27,7 @@ import {
} from "../lib/autoCompact";
import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection";
import { acpPrepareSession, acpSetModel } from "@/shared/api/acp";
import { updateSessionProject } from "../stores/chatSessionOperations";
import {
useResolvedAgentModelPicker,
type PreferredModelSelection,
@@ -51,7 +54,7 @@ export function useChatSessionController({
selectedProvider: globalSelectedProvider,
setSelectedProvider: setGlobalSelectedProvider,
} = useProviderSelection();
const personas = useAgentStore((s) => s.personas);
const personas = useAgentStore(selectPersonas);
const session = useChatSessionStore((s) =>
sessionId
? s.sessions.find((candidate) => candidate.id === sessionId)
@@ -63,7 +66,7 @@ export function useChatSessionController({
const clearActiveWorkspace = useChatSessionStore(
(s) => s.clearActiveWorkspace,
);
const projects = useProjectStore((s) => s.projects);
const projects = useProjectStore(selectProjects);
const projectsLoading = useProjectStore((s) => s.loading);
const catalogEntries = useProviderCatalogStore((s) => s.entries);
const [pendingPersonaId, setPendingPersonaId] = useState<string | null>();
@@ -184,7 +187,7 @@ export function useChatSessionController({
}
await acpSetModel(sessionId, modelSelection.id);
sessionStore.updateSession(sessionId, {
sessionStore.patchSession(sessionId, {
modelId: modelSelection.id,
modelName: modelSelection.name,
});
@@ -315,16 +318,18 @@ export function useChatSessionController({
.projects.find((candidate) => candidate.id === projectId) ??
null);
useChatSessionStore.getState().updateSession(sessionId, { projectId });
if (!selectedProvider) {
return;
}
void prepareCurrentSession(
selectedProvider,
nextProject,
activeWorkspace?.path,
effectiveModelSelection,
).catch((error) => {
void (async () => {
await updateSessionProject(sessionId, projectId);
if (!selectedProvider) {
return;
}
await prepareCurrentSession(
selectedProvider,
nextProject,
activeWorkspace?.path,
effectiveModelSelection,
);
})().catch((error) => {
console.error("Failed to update ACP session working directory:", error);
});
},
@@ -374,7 +379,7 @@ export function useChatSessionController({
}
useChatSessionStore
.getState()
.updateSession(sessionId, { personaId: personaId ?? undefined });
.patchSession(sessionId, { personaId: personaId ?? undefined });
},
[
handleProviderChange,
@@ -395,7 +400,7 @@ export function useChatSessionController({
if (sessionId) {
useChatSessionStore
.getState()
.updateSession(sessionId, { personaId: undefined });
.patchSession(sessionId, { personaId: undefined });
} else {
setPendingPersonaId(undefined);
}
@@ -703,7 +708,6 @@ export function useChatSessionController({
const patch: {
providerId?: string;
personaId?: string | undefined;
projectId?: string | null;
modelId?: string | undefined;
modelName?: string | undefined;
} = {};
@@ -716,13 +720,14 @@ export function useChatSessionController({
if (hasPendingPersona) {
patch.personaId = nextPersonaId;
}
if (hasPendingProject) {
patch.projectId = nextProjectId ?? null;
if (Object.keys(patch).length > 0) {
useChatSessionStore.getState().patchSession(sessionId, patch);
}
useChatSessionStore.getState().updateSession(sessionId, patch);
try {
if (hasPendingProject) {
await updateSessionProject(sessionId, nextProjectId ?? null);
}
await prepareCurrentSession(
nextProviderId,
nextProject,
@@ -310,7 +310,7 @@ export function useResolvedAgentModelPicker({
setGlobalSelectedProvider(nextProviderId);
}
useChatSessionStore.getState().updateSession(sessionId, {
useChatSessionStore.getState().patchSession(sessionId, {
modelId,
modelName,
});
@@ -335,7 +335,7 @@ export function useResolvedAgentModelPicker({
} else {
clearStoredModelPreference(selectedAgentId);
}
useChatSessionStore.getState().updateSession(sessionId, {
useChatSessionStore.getState().patchSession(sessionId, {
providerId: previousProviderId,
modelId: previousModelId,
modelName: previousModelName,
@@ -2,7 +2,6 @@ import {
formatSkillInstructionPrompt,
type SkillCommandMatch,
} from "@/features/skills/lib/skillChatPrompt";
import type { MessageChip } from "@/shared/types/messages";
import type { ChatSendOptions, ChatSkillDraft } from "../types";
interface SkillSendPayload {
@@ -50,19 +49,3 @@ export function buildSkillSendPayload(
},
};
}
export function buildSkillRetryOptions(
text: string,
chips?: MessageChip[],
): ChatSendOptions | undefined {
const skillChips = chips?.filter((chip) => chip.type === "skill") ?? [];
if (skillChips.length === 0) return undefined;
return {
displayText: text,
assistantPrompt: formatSkillInstructionPrompt(
skillChips.map((chip) => ({ name: chip.label })),
),
chips: skillChips,
};
}
@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useChatSessionStore, type ChatSession } from "../chatSessionStore";
import {
updateSessionProject,
updateSessionTitle,
} from "../chatSessionOperations";
const mockRenameSession = vi.fn();
const mockUpdateSessionProject = vi.fn();
vi.mock("@/shared/api/acpApi", () => ({
renameSession: (...args: unknown[]) => mockRenameSession(...args),
updateSessionProject: (...args: unknown[]) =>
mockUpdateSessionProject(...args),
}));
function resetStore() {
useChatSessionStore.setState({
sessions: [],
activeSessionId: null,
isLoading: false,
hasHydratedSessions: false,
contextPanelOpenBySession: {},
activeWorkspaceBySession: {},
});
}
function seedSession(overrides: Partial<ChatSession> = {}) {
useChatSessionStore.setState({
sessions: [
{
id: "session-1",
title: "Original Title",
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
messageCount: 0,
...overrides,
},
],
});
}
describe("chatSessionOperations", () => {
beforeEach(() => {
resetStore();
vi.clearAllMocks();
});
describe("updateSessionTitle", () => {
it("renames in backend before patching local state", async () => {
seedSession({ userSetName: false });
mockRenameSession.mockResolvedValue(undefined);
await updateSessionTitle("session-1", "Manual Title");
expect(mockRenameSession).toHaveBeenCalledWith(
"session-1",
"Manual Title",
);
expect(
useChatSessionStore.getState().getSession("session-1"),
).toMatchObject({
title: "Manual Title",
userSetName: true,
});
});
it("does not patch local state when backend rename fails", async () => {
seedSession({ userSetName: false });
mockRenameSession.mockRejectedValue(new Error("rename failed"));
await expect(
updateSessionTitle("session-1", "Manual Title"),
).rejects.toThrow("rename failed");
expect(
useChatSessionStore.getState().getSession("session-1"),
).toMatchObject({
title: "Original Title",
userSetName: false,
});
});
});
describe("updateSessionProject", () => {
it("updates project in backend before patching local state", async () => {
seedSession({ projectId: "project-old" });
mockUpdateSessionProject.mockResolvedValue(undefined);
await updateSessionProject("session-1", "project-new");
expect(mockUpdateSessionProject).toHaveBeenCalledWith(
"session-1",
"project-new",
);
expect(
useChatSessionStore.getState().getSession("session-1")?.projectId,
).toBe("project-new");
});
it("does not patch local state when backend project update fails", async () => {
seedSession({ projectId: "project-old" });
mockUpdateSessionProject.mockRejectedValue(new Error("project failed"));
await expect(
updateSessionProject("session-1", "project-new"),
).rejects.toThrow("project failed");
expect(
useChatSessionStore.getState().getSession("session-1")?.projectId,
).toBe("project-old");
});
});
});
@@ -225,54 +225,32 @@ describe("chatSessionStore", () => {
});
});
describe("updateSession", () => {
it("updates session properties", () => {
describe("patchSession", () => {
it("patches session properties while preserving updatedAt when omitted", () => {
const session = seedSession();
const originalUpdatedAt = session.updatedAt;
useChatSessionStore.getState().updateSession(session.id, {
useChatSessionStore.getState().patchSession(session.id, {
title: "Updated Title",
projectId: "new-project",
});
const updated = useChatSessionStore.getState().getSession(session.id);
expect(updated?.title).toBe("Updated Title");
expect(updated?.projectId).toBe("new-project");
});
it("preserves updatedAt when not explicitly provided in patch", () => {
const session = seedSession();
const originalUpdatedAt = session.updatedAt;
vi.useFakeTimers();
vi.advanceTimersByTime(1000);
useChatSessionStore.getState().updateSession(session.id, {
title: "New Title",
expect(updated).toMatchObject({
title: "Updated Title",
projectId: "new-project",
updatedAt: originalUpdatedAt,
});
vi.useRealTimers();
const updated = useChatSessionStore.getState().getSession(session.id);
expect(updated?.updatedAt).toBe(originalUpdatedAt);
});
it("updates updatedAt when explicitly provided in patch", () => {
const session = seedSession();
const originalUpdatedAt = session.updatedAt;
vi.useFakeTimers();
vi.advanceTimersByTime(1000);
const newTimestamp = new Date().toISOString();
useChatSessionStore.getState().updateSession(session.id, {
title: "New Title",
const newTimestamp = "2026-04-01T00:01:00.000Z";
useChatSessionStore.getState().patchSession(session.id, {
updatedAt: newTimestamp,
});
vi.useRealTimers();
const updated = useChatSessionStore.getState().getSession(session.id);
expect(updated?.updatedAt).not.toBe(originalUpdatedAt);
expect(updated?.updatedAt).toBe(newTimestamp);
});
});
@@ -0,0 +1,7 @@
import type { ChatStore } from "./chatStore";
export const selectMessagesBySession = (state: ChatStore) =>
state.messagesBySession;
export const selectSessionStateById = (state: ChatStore) =>
state.sessionStateById;
@@ -0,0 +1,28 @@
import {
renameSession,
updateSessionProject as updateSessionProjectApi,
} from "@/shared/api/acpApi";
import { useChatSessionStore } from "./chatSessionStore";
export async function updateSessionTitle(
sessionId: string,
title: string,
): Promise<void> {
await renameSession(sessionId, title);
useChatSessionStore.getState().patchSession(sessionId, {
title,
userSetName: true,
});
}
export async function updateSessionProject(
sessionId: string,
projectId: string | null,
): Promise<void> {
await updateSessionProjectApi(sessionId, projectId);
useChatSessionStore.getState().patchSession(sessionId, {
projectId,
});
}
@@ -0,0 +1,12 @@
import type { ChatSessionStore } from "./chatSessionStore";
export const selectSessions = (state: ChatSessionStore) => state.sessions;
export const selectActiveSessionId = (state: ChatSessionStore) =>
state.activeSessionId;
export const selectHasHydratedSessions = (state: ChatSessionStore) =>
state.hasHydratedSessions;
export const selectSessionsLoading = (state: ChatSessionStore) =>
state.isLoading;
@@ -12,8 +12,6 @@ import {
import {
archiveSession as acpArchiveSession,
unarchiveSession as acpUnarchiveSession,
renameSession as acpRenameSession,
updateSessionProject,
} from "@/shared/api/acpApi";
export interface ChatSession {
@@ -77,7 +75,7 @@ interface CreateSessionOpts {
interface ChatSessionStoreActions {
createSession: (opts?: CreateSessionOpts) => Promise<ChatSession>;
loadSessions: () => Promise<void>;
updateSession: (id: string, patch: Partial<ChatSession>) => void;
patchSession: (id: string, patch: Partial<ChatSession>) => void;
addSession: (session: ChatSession) => void;
archiveSession: (id: string) => Promise<void>;
unarchiveSession: (id: string) => Promise<void>;
@@ -195,7 +193,7 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
}
},
updateSession: (id, patch) => {
patchSession: (id, patch) => {
set((state) => ({
sessions: state.sessions.map((session) =>
session.id === id
@@ -207,29 +205,6 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
: session,
),
}));
const updatedSession = get().sessions.find((session) => session.id === id);
// Persist title rename to backend
if (
"title" in patch &&
"userSetName" in patch &&
patch.userSetName &&
updatedSession &&
patch.title
) {
acpRenameSession(updatedSession.id, patch.title).catch((err: unknown) =>
console.error("Failed to rename session in backend:", err),
);
}
// Persist projectId change to backend
if ("projectId" in patch && updatedSession) {
updateSessionProject(updatedSession.id, patch.projectId ?? null).catch(
(err: unknown) =>
console.error("Failed to update session project in backend:", err),
);
}
},
addSession: (session) => {
@@ -0,0 +1,3 @@
import type { ProjectStore } from "./projectStore";
export const selectProjects = (state: ProjectStore) => state.projects;
@@ -34,7 +34,7 @@ function persistProjects(projects: ProjectInfo[]): void {
}
}
interface ProjectState {
export interface ProjectStore {
projects: ProjectInfo[];
loading: boolean;
activeProjectId: string | null;
@@ -70,7 +70,7 @@ interface ProjectState {
getActiveProject: () => ProjectInfo | null;
}
export const useProjectStore = create<ProjectState>((set, get) => ({
export const useProjectStore = create<ProjectStore>((set, get) => ({
projects: loadCachedProjects(),
loading: false,
activeProjectId: null,
@@ -12,8 +12,11 @@ import {
getVisibleSessions,
useChatSessionStore,
} from "@/features/chat/stores/chatSessionStore";
import { selectSessions } from "@/features/chat/stores/chatSessionSelectors";
import { useChatStore } from "@/features/chat/stores/chatStore";
import { selectMessagesBySession } from "@/features/chat/stores/chatSelectors";
import { useProjectStore } from "@/features/projects/stores/projectStore";
import { selectProjects } from "@/features/projects/stores/projectSelectors";
import {
acpDuplicateSession,
acpExportSession,
@@ -41,8 +44,8 @@ export function SessionHistoryView({
onArchiveChat,
}: SessionHistoryViewProps) {
const { t, i18n } = useTranslation(["sessions", "common"]);
const sessions = useChatSessionStore((s) => s.sessions);
const messagesBySession = useChatStore((s) => s.messagesBySession);
const sessions = useChatSessionStore(selectSessions);
const messagesBySession = useChatStore(selectMessagesBySession);
const loadSessions = useChatSessionStore((s) => s.loadSessions);
const activeSessions = useMemo(
() =>
@@ -59,7 +62,7 @@ export function SessionHistoryView({
[],
);
const projects = useProjectStore((s) => s.projects);
const projects = useProjectStore(selectProjects);
const getProjectName = useCallback(
(projectId: string) => projects.find((p) => p.id === projectId)?.name,
[projects],
+17 -13
View File
@@ -17,13 +17,20 @@ import { cn } from "@/shared/lib/cn";
import type { AppView } from "@/app/AppShell";
import type { ProjectInfo } from "@/features/projects/api/projects";
import { useChatStore } from "@/features/chat/stores/chatStore";
import {
selectMessagesBySession,
selectSessionStateById,
} from "@/features/chat/stores/chatSelectors";
import { INITIAL_SESSION_CHAT_RUNTIME } from "@/shared/types/chat";
import {
getVisibleSessions,
useChatSessionStore,
} from "@/features/chat/stores/chatSessionStore";
import { selectSessions } from "@/features/chat/stores/chatSessionSelectors";
import { isSessionRunning } from "@/features/chat/lib/sessionActivity";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import { useProjectStore } from "@/features/projects/stores/projectStore";
import { selectProjects } from "@/features/projects/stores/projectSelectors";
import { Button } from "@/shared/ui/button";
import { useSessionSearch } from "@/features/sessions/hooks/useSessionSearch";
import { SidebarProjectsSection } from "./SidebarProjectsSection";
@@ -101,12 +108,12 @@ export function Sidebar({
}
});
const chatStore = useChatStore();
const { sessions } = useChatSessionStore();
const visibleSessions = getVisibleSessions(
sessions,
chatStore.messagesBySession,
);
const messagesBySession = useChatStore(selectMessagesBySession);
const sessionStateById = useChatStore(selectSessionStateById);
const sessions = useChatSessionStore(selectSessions);
const getPersonaById = useAgentStore((s) => s.getPersonaById);
const projectStoreProjects = useProjectStore(selectProjects);
const visibleSessions = getVisibleSessions(sessions, messagesBySession);
const activeSessions = visibleSessions.filter(
(session) => !session.archivedAt,
);
@@ -162,7 +169,8 @@ export function Sidebar({
const standalone: SessionItem[] = [];
for (const session of visibleSessions) {
if (session.archivedAt) continue;
const runtime = chatStore.getSessionRuntime(session.id);
const runtime =
sessionStateById[session.id] ?? INITIAL_SESSION_CHAT_RUNTIME;
const item: SessionItem = {
id: session.id,
title: session.title,
@@ -194,15 +202,11 @@ export function Sidebar({
return { byProject, standalone: limitedStandalone };
})();
const agentStoreState = useAgentStore();
const projectStoreState = useProjectStore();
const sidebarResolvers = {
getPersonaName: (personaId: string) =>
agentStoreState.getPersonaById(personaId)?.displayName,
getPersonaById(personaId)?.displayName,
getProjectName: (projectId: string) =>
projectStoreState.projects.find((p: { id: string }) => p.id === projectId)
?.name,
projectStoreProjects.find((p) => p.id === projectId)?.name,
};
const sidebarSearch = useSessionSearch({
sessions: activeSessions,
@@ -13,33 +13,34 @@ const mockSessions: Array<{
}> = [];
vi.mock("@/features/chat/stores/chatStore", () => ({
useChatStore: () => ({
messagesBySession: {},
getSessionRuntime: () => ({
chatState: "idle",
hasUnread: false,
useChatStore: (selector: (state: unknown) => unknown) =>
selector({
messagesBySession: {},
sessionStateById: {},
}),
}),
}));
vi.mock("@/features/chat/stores/chatSessionStore", () => ({
getVisibleSessions: (sessions: typeof mockSessions) =>
sessions.filter((session) => session.messageCount > 0),
useChatSessionStore: () => ({
sessions: mockSessions,
}),
useChatSessionStore: (selector: (state: unknown) => unknown) =>
selector({
sessions: mockSessions,
}),
}));
vi.mock("@/features/agents/stores/agentStore", () => ({
useAgentStore: () => ({
getPersonaById: () => undefined,
}),
useAgentStore: (selector: (state: unknown) => unknown) =>
selector({
getPersonaById: () => undefined,
}),
}));
vi.mock("@/features/projects/stores/projectStore", () => ({
useProjectStore: () => ({
projects: [],
}),
useProjectStore: (selector: (state: unknown) => unknown) =>
selector({
projects: [],
}),
}));
describe("Sidebar", () => {
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Plus, Upload } from "lucide-react";
import { toast } from "sonner";
import { useProjectStore } from "@/features/projects/stores/projectStore";
import { selectProjects } from "@/features/projects/stores/projectSelectors";
import { Button } from "@/shared/ui/button";
import { PageHeader, PageShell } from "@/shared/ui/page-shell";
import { revealInFileManager } from "@/shared/lib/fileManager";
@@ -32,7 +33,7 @@ interface SkillsViewProps {
export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
const { t } = useTranslation(["skills", "common"]);
const projects = useProjectStore((state) => state.projects);
const projects = useProjectStore(selectProjects);
const [search, setSearch] = useState("");
const [activeFilter, setActiveFilter] = useState<SkillsFilter>("all");
const [dialogOpen, setDialogOpen] = useState(false);
@@ -506,7 +506,7 @@ function handleShared(sessionId: string, update: SessionUpdate): void {
currentModelId;
const sessionStore = useChatSessionStore.getState();
sessionStore.updateSession(sessionId, {
sessionStore.patchSession(sessionId, {
modelId: currentModelId,
modelName: currentModelName,
});
@@ -24,7 +24,7 @@ export function handleSessionInfoUpdate(
}
const meta = isRecord(info._meta) ? info._meta : {};
const patch: Parameters<typeof sessionStore.updateSession>[1] = {};
const patch: Parameters<typeof sessionStore.patchSession>[1] = {};
if (typeof info.title === "string" && info.title && !session.userSetName) {
patch.title = info.title;
@@ -40,6 +40,6 @@ export function handleSessionInfoUpdate(
}
if (Object.keys(patch).length > 0) {
sessionStore.updateSession(sessionId, patch);
sessionStore.patchSession(sessionId, patch);
}
}
@@ -119,7 +119,9 @@
"filesTitle": "Files"
},
"notifications": {
"compactionComplete": "Conversation compacted. Older context was summarized."
"compactionComplete": "Conversation compacted. Older context was summarized.",
"moveError": "Failed to move chat",
"renameError": "Failed to rename chat"
},
"message": {
"copied": "Copied",
@@ -119,7 +119,9 @@
"filesTitle": "Archivos"
},
"notifications": {
"compactionComplete": "Conversacion compactada. El contexto anterior se resumio."
"compactionComplete": "Conversacion compactada. El contexto anterior se resumio.",
"moveError": "No se pudo mover el chat",
"renameError": "No se pudo cambiar el nombre del chat"
},
"message": {
"copied": "Copiado",
-10
View File
@@ -1,10 +0,0 @@
/** Find the last index in an array matching a predicate. */
export function findLastIndex<T>(
arr: readonly T[],
predicate: (item: T) => boolean,
): number {
for (let i = arr.length - 1; i >= 0; i--) {
if (predicate(arr[i])) return i;
}
return -1;
}