Commit 0e954cbb authored by edy's avatar edy

fix(ui): restore session card navigation and preserve session scope across tabs

- When clicking a session card, switch to the correct project and viewMode
  ('chat' for home sessions, 'experts' for expert sessions) instead of only
  setting the active session ID
- Persist session scope via sticky ref so switching to non-conversation tabs
  (knowledge, tasks, etc.) no longer jumps to the first expert's sessions
- Prevent project switch during active streaming to avoid disrupting AI output
- Keep useMemo pure by extracting ref writes into useEffect
- Remove redundant ternary in openSession
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent 84410c3f
......@@ -297,6 +297,7 @@ export default function App() {
const copiedTokenResetRef = useRef<number | null>(null);
const lastLoadedWorkspacePathRef = useRef<string | null>(null);
const categoryPopupRef = useRef<HTMLDivElement | null>(null);
const stickySessionScopeRef = useRef<string | undefined>(undefined);
const isConversationView = viewMode === "chat" || viewMode === "experts";
const {
config,
......@@ -673,12 +674,29 @@ export default function App() {
viewMode,
defaultChatName: ui.defaultChat
});
// Persist the session scope so non-conversation tabs donʼt jump to a different project
useEffect(() => {
if (viewMode === "chat") {
stickySessionScopeRef.current = HOME_CHAT_PROJECT_ID;
} else if (viewMode === "experts" && workspace?.currentProjectId) {
stickySessionScopeRef.current = workspace.currentProjectId;
}
}, [viewMode, workspace?.currentProjectId]);
const sessionScopeProjectId = useMemo(() => {
if (bindingRequired) {
return undefined;
}
return viewMode === "chat" ? HOME_CHAT_PROJECT_ID : activeProject?.id;
}, [activeProject?.id, bindingRequired, viewMode]);
if (viewMode === "chat") {
return HOME_CHAT_PROJECT_ID;
}
if (viewMode === "experts") {
return workspace?.currentProjectId;
}
// Preserve the last conversation session scope when on non-conversation tabs
return stickySessionScopeRef.current ?? HOME_CHAT_PROJECT_ID;
}, [bindingRequired, viewMode, workspace?.currentProjectId]);
const scopedSessions = useMemo(
() => sessionScopeProjectId
? sessions.filter((session) => session.projectId === sessionScopeProjectId)
......@@ -925,6 +943,7 @@ export default function App() {
setSessions,
clearSessions,
clearAllSessionMessages,
setViewMode,
setActiveProjectSession: setActiveSessionId,
upsertSession,
setWorkspace,
......@@ -1281,7 +1300,10 @@ export default function App() {
if (workspace?.currentProjectId === projectId && viewMode === "experts") {
return;
}
await switchProject(projectId);
const switched = await switchProject(projectId);
if (!switched) {
return;
}
setViewMode("experts");
}
......
import { useCallback, useEffect, useState } from "react"
import type { DesktopApi, WorkspaceSummary } from "@qjclaw/shared-types"
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react"
import type { DesktopApi, ProjectSessionSummary, WorkspaceSummary } from "@qjclaw/shared-types"
import { EMPTY_SESSION_ID, HOME_CHAT_PROJECT_ID } from "../../lib/constants"
import { resolvePreferredSessionId } from "../../lib/chat-utils"
......@@ -17,9 +17,10 @@ interface UseChatSessionsControllerDeps {
activeStreamSessionId?: string
workspace: WorkspaceSummary | null
homeSessionTitle: string
setSessions: (sessions: WorkspaceSummary["sessions"]) => void
setSessions: Dispatch<SetStateAction<WorkspaceSummary["sessions"]>>
clearSessions: () => void
clearAllSessionMessages: () => void
setViewMode: (viewMode: ViewMode | ((current: ViewMode) => ViewMode)) => void
setActiveProjectSession: (sessionId: string) => void
upsertSession: (session: WorkspaceSummary["sessions"][number]) => void
setWorkspace: (workspace: WorkspaceSummary | null) => void
......@@ -42,6 +43,7 @@ export function useChatSessionsController(deps: UseChatSessionsControllerDeps) {
setSessions,
clearSessions,
clearAllSessionMessages,
setViewMode,
setActiveProjectSession,
upsertSession,
setWorkspace,
......@@ -171,53 +173,35 @@ export function useChatSessionsController(deps: UseChatSessionsControllerDeps) {
upsertSession
])
const switchProject = useCallback(async (projectId: string) => {
const switchProject = useCallback(async (projectId: string, preferredProjectSessionId = activeSessionId) => {
if (projectActionPending) {
return
return false
}
const preserveVisibleConversation = sendPhase !== "idle"
setProjectActionPending(true)
setErrorText("")
try {
const nextWorkspace = await desktopApi.projects.setActive(projectId)
const nextSessions = (nextWorkspace.sessions ?? []).filter((s) => s.projectId === projectId)
const nextSessionId = resolvePreferredSessionId(nextSessions, preferredProjectSessionId) ?? EMPTY_SESSION_ID
setWorkspace(nextWorkspace)
if (nextWorkspace.currentProjectId === projectId) {
const nextSessions = nextWorkspace.sessions.filter((session) => session.projectId === projectId)
if (nextSessions.length) {
setSessions(nextSessions)
const nextSessionId = resolvePreferredSessionId(nextSessions, activeSessionId)
if (nextSessionId) {
setActiveProjectSession(nextSessionId)
}
} else if (!preserveVisibleConversation) {
clearSessions()
if (!nextSessionId) {
clearAllSessionMessages()
setActiveProjectSession(EMPTY_SESSION_ID)
}
} else if (!preserveVisibleConversation) {
clearSessions()
clearAllSessionMessages()
setActiveProjectSession(EMPTY_SESSION_ID)
}
return true
} catch (error) {
if (!preserveVisibleConversation) {
clearSessions()
clearAllSessionMessages()
setActiveProjectSession(EMPTY_SESSION_ID)
}
setErrorText(error instanceof Error ? error.message : String(error))
return false
} finally {
setProjectActionPending(false)
}
}, [
activeSessionId,
clearAllSessionMessages,
clearSessions,
desktopApi.projects,
projectActionPending,
sendPhase,
setActiveProjectSession,
setErrorText,
setSessions,
......@@ -311,9 +295,17 @@ export function useChatSessionsController(deps: UseChatSessionsControllerDeps) {
viewMode
])
const openSession = useCallback((sessionId: string) => {
setActiveProjectSession(sessionId)
}, [setActiveProjectSession])
const openSession = useCallback(async (session: ProjectSessionSummary) => {
if (sendPhase !== "idle") {
return
}
const nextViewMode = session.projectId === HOME_CHAT_PROJECT_ID ? "chat" : "experts"
const switched = await switchProject(session.projectId, session.id)
if (!switched) {
return
}
setViewMode(nextViewMode)
}, [sendPhase, setViewMode, switchProject])
const isCloseProjectSessionDisabled = useCallback((sessionId: string) => {
return projectActionPending || (sendPhase !== "idle" && activeStreamSessionId === sessionId)
......
import type { ReactNode } from "react"
import type { SessionSummary } from "@qjclaw/shared-types"
import type { ProjectSessionSummary } from "@qjclaw/shared-types"
import { Sidebar } from "./Sidebar"
import { ExpertTree, type ExpertCategoryId, type ExpertVisualKey, type SidebarExpertEntry } from "./ExpertTree"
import { SessionList } from "./SessionList"
......@@ -19,7 +19,7 @@ interface AppSidebarProps {
}
showBindEntry: boolean
projectActionPending: boolean
sessions: SessionSummary[]
sessions: ProjectSessionSummary[]
activeSessionId: string
sidebarSessionTitles: Record<string, string>
sendPhase: SendPhase
......@@ -37,7 +37,7 @@ interface AppSidebarProps {
onExpertSelect(entry: SidebarExpertEntry): void
renderCategoryIcon(categoryId: ExpertCategoryId): ReactNode
renderExpertIcon(expertKey: ExpertVisualKey): ReactNode
onOpenSession(sessionId: string): void
onOpenSession(session: ProjectSessionSummary): void
onCloseSession(sessionId: string): void
}
......
import type { SessionSummary } from "@qjclaw/shared-types"
import type { ProjectSessionSummary } from "@qjclaw/shared-types"
type SendPhase = "idle" | "preparing" | "streaming" | "finalizing"
interface SessionListProps {
visible: boolean
label: string
sessions: SessionSummary[]
sessions: ProjectSessionSummary[]
activeSessionId: string
sessionTitles: Record<string, string>
projectActionPending: boolean
......@@ -13,7 +13,7 @@ interface SessionListProps {
activeStreamSessionId?: string
closeLabel: string
formatSessionTitle(title: string, index: number): string
onOpenSession(sessionId: string): void
onOpenSession(session: ProjectSessionSummary): void
onCloseSession(sessionId: string): void
}
......@@ -50,7 +50,7 @@ export function SessionList({
className="sidebar-session-main app-no-drag"
aria-current={activeSessionId === session.id ? "true" : undefined}
disabled={projectActionPending}
onClick={() => onOpenSession(session.id)}
onClick={() => onOpenSession(session)}
>
<strong>{sessionTitles[session.id] ?? formatSessionTitle(session.title, index)}</strong>
</button>
......
......@@ -15,7 +15,7 @@ interface UseHomeNavigationDeps {
setPrompt: (value: string | ((current: string) => string)) => void
clearSessions: () => void
clearAllSessionMessages: () => void
switchProject: (projectId: string) => Promise<void>
switchProject: (projectId: string, preferredProjectSessionId?: string) => Promise<boolean>
switchProjectPreservingMessages?: (projectId: string) => Promise<void>
buildShortcutPrompt: (definition: ExpertDefinition) => string
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment