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() { ...@@ -297,6 +297,7 @@ export default function App() {
const copiedTokenResetRef = useRef<number | null>(null); const copiedTokenResetRef = useRef<number | null>(null);
const lastLoadedWorkspacePathRef = useRef<string | null>(null); const lastLoadedWorkspacePathRef = useRef<string | null>(null);
const categoryPopupRef = useRef<HTMLDivElement | null>(null); const categoryPopupRef = useRef<HTMLDivElement | null>(null);
const stickySessionScopeRef = useRef<string | undefined>(undefined);
const isConversationView = viewMode === "chat" || viewMode === "experts"; const isConversationView = viewMode === "chat" || viewMode === "experts";
const { const {
config, config,
...@@ -673,12 +674,29 @@ export default function App() { ...@@ -673,12 +674,29 @@ export default function App() {
viewMode, viewMode,
defaultChatName: ui.defaultChat 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(() => { const sessionScopeProjectId = useMemo(() => {
if (bindingRequired) { if (bindingRequired) {
return undefined; return undefined;
} }
return viewMode === "chat" ? HOME_CHAT_PROJECT_ID : activeProject?.id; if (viewMode === "chat") {
}, [activeProject?.id, bindingRequired, viewMode]); 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( const scopedSessions = useMemo(
() => sessionScopeProjectId () => sessionScopeProjectId
? sessions.filter((session) => session.projectId === sessionScopeProjectId) ? sessions.filter((session) => session.projectId === sessionScopeProjectId)
...@@ -925,6 +943,7 @@ export default function App() { ...@@ -925,6 +943,7 @@ export default function App() {
setSessions, setSessions,
clearSessions, clearSessions,
clearAllSessionMessages, clearAllSessionMessages,
setViewMode,
setActiveProjectSession: setActiveSessionId, setActiveProjectSession: setActiveSessionId,
upsertSession, upsertSession,
setWorkspace, setWorkspace,
...@@ -1281,7 +1300,10 @@ export default function App() { ...@@ -1281,7 +1300,10 @@ export default function App() {
if (workspace?.currentProjectId === projectId && viewMode === "experts") { if (workspace?.currentProjectId === projectId && viewMode === "experts") {
return; return;
} }
await switchProject(projectId); const switched = await switchProject(projectId);
if (!switched) {
return;
}
setViewMode("experts"); setViewMode("experts");
} }
......
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react"
import type { DesktopApi, WorkspaceSummary } from "@qjclaw/shared-types" import type { DesktopApi, ProjectSessionSummary, WorkspaceSummary } from "@qjclaw/shared-types"
import { EMPTY_SESSION_ID, HOME_CHAT_PROJECT_ID } from "../../lib/constants" import { EMPTY_SESSION_ID, HOME_CHAT_PROJECT_ID } from "../../lib/constants"
import { resolvePreferredSessionId } from "../../lib/chat-utils" import { resolvePreferredSessionId } from "../../lib/chat-utils"
...@@ -17,9 +17,10 @@ interface UseChatSessionsControllerDeps { ...@@ -17,9 +17,10 @@ interface UseChatSessionsControllerDeps {
activeStreamSessionId?: string activeStreamSessionId?: string
workspace: WorkspaceSummary | null workspace: WorkspaceSummary | null
homeSessionTitle: string homeSessionTitle: string
setSessions: (sessions: WorkspaceSummary["sessions"]) => void setSessions: Dispatch<SetStateAction<WorkspaceSummary["sessions"]>>
clearSessions: () => void clearSessions: () => void
clearAllSessionMessages: () => void clearAllSessionMessages: () => void
setViewMode: (viewMode: ViewMode | ((current: ViewMode) => ViewMode)) => void
setActiveProjectSession: (sessionId: string) => void setActiveProjectSession: (sessionId: string) => void
upsertSession: (session: WorkspaceSummary["sessions"][number]) => void upsertSession: (session: WorkspaceSummary["sessions"][number]) => void
setWorkspace: (workspace: WorkspaceSummary | null) => void setWorkspace: (workspace: WorkspaceSummary | null) => void
...@@ -42,6 +43,7 @@ export function useChatSessionsController(deps: UseChatSessionsControllerDeps) { ...@@ -42,6 +43,7 @@ export function useChatSessionsController(deps: UseChatSessionsControllerDeps) {
setSessions, setSessions,
clearSessions, clearSessions,
clearAllSessionMessages, clearAllSessionMessages,
setViewMode,
setActiveProjectSession, setActiveProjectSession,
upsertSession, upsertSession,
setWorkspace, setWorkspace,
...@@ -171,53 +173,35 @@ export function useChatSessionsController(deps: UseChatSessionsControllerDeps) { ...@@ -171,53 +173,35 @@ export function useChatSessionsController(deps: UseChatSessionsControllerDeps) {
upsertSession upsertSession
]) ])
const switchProject = useCallback(async (projectId: string) => { const switchProject = useCallback(async (projectId: string, preferredProjectSessionId = activeSessionId) => {
if (projectActionPending) { if (projectActionPending) {
return return false
} }
const preserveVisibleConversation = sendPhase !== "idle"
setProjectActionPending(true) setProjectActionPending(true)
setErrorText("") setErrorText("")
try { try {
const nextWorkspace = await desktopApi.projects.setActive(projectId) 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) setWorkspace(nextWorkspace)
if (nextWorkspace.currentProjectId === projectId) {
const nextSessions = nextWorkspace.sessions.filter((session) => session.projectId === projectId)
if (nextSessions.length) {
setSessions(nextSessions) setSessions(nextSessions)
const nextSessionId = resolvePreferredSessionId(nextSessions, activeSessionId)
if (nextSessionId) {
setActiveProjectSession(nextSessionId) setActiveProjectSession(nextSessionId)
} if (!nextSessionId) {
} else if (!preserveVisibleConversation) {
clearSessions()
clearAllSessionMessages() clearAllSessionMessages()
setActiveProjectSession(EMPTY_SESSION_ID)
}
} else if (!preserveVisibleConversation) {
clearSessions()
clearAllSessionMessages()
setActiveProjectSession(EMPTY_SESSION_ID)
} }
return true
} catch (error) { } catch (error) {
if (!preserveVisibleConversation) {
clearSessions()
clearAllSessionMessages()
setActiveProjectSession(EMPTY_SESSION_ID)
}
setErrorText(error instanceof Error ? error.message : String(error)) setErrorText(error instanceof Error ? error.message : String(error))
return false
} finally { } finally {
setProjectActionPending(false) setProjectActionPending(false)
} }
}, [ }, [
activeSessionId, activeSessionId,
clearAllSessionMessages, clearAllSessionMessages,
clearSessions,
desktopApi.projects, desktopApi.projects,
projectActionPending, projectActionPending,
sendPhase,
setActiveProjectSession, setActiveProjectSession,
setErrorText, setErrorText,
setSessions, setSessions,
...@@ -311,9 +295,17 @@ export function useChatSessionsController(deps: UseChatSessionsControllerDeps) { ...@@ -311,9 +295,17 @@ export function useChatSessionsController(deps: UseChatSessionsControllerDeps) {
viewMode viewMode
]) ])
const openSession = useCallback((sessionId: string) => { const openSession = useCallback(async (session: ProjectSessionSummary) => {
setActiveProjectSession(sessionId) if (sendPhase !== "idle") {
}, [setActiveProjectSession]) 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) => { const isCloseProjectSessionDisabled = useCallback((sessionId: string) => {
return projectActionPending || (sendPhase !== "idle" && activeStreamSessionId === sessionId) return projectActionPending || (sendPhase !== "idle" && activeStreamSessionId === sessionId)
......
import type { ReactNode } from "react" import type { ReactNode } from "react"
import type { SessionSummary } from "@qjclaw/shared-types" import type { ProjectSessionSummary } from "@qjclaw/shared-types"
import { Sidebar } from "./Sidebar" import { Sidebar } from "./Sidebar"
import { ExpertTree, type ExpertCategoryId, type ExpertVisualKey, type SidebarExpertEntry } from "./ExpertTree" import { ExpertTree, type ExpertCategoryId, type ExpertVisualKey, type SidebarExpertEntry } from "./ExpertTree"
import { SessionList } from "./SessionList" import { SessionList } from "./SessionList"
...@@ -19,7 +19,7 @@ interface AppSidebarProps { ...@@ -19,7 +19,7 @@ interface AppSidebarProps {
} }
showBindEntry: boolean showBindEntry: boolean
projectActionPending: boolean projectActionPending: boolean
sessions: SessionSummary[] sessions: ProjectSessionSummary[]
activeSessionId: string activeSessionId: string
sidebarSessionTitles: Record<string, string> sidebarSessionTitles: Record<string, string>
sendPhase: SendPhase sendPhase: SendPhase
...@@ -37,7 +37,7 @@ interface AppSidebarProps { ...@@ -37,7 +37,7 @@ interface AppSidebarProps {
onExpertSelect(entry: SidebarExpertEntry): void onExpertSelect(entry: SidebarExpertEntry): void
renderCategoryIcon(categoryId: ExpertCategoryId): ReactNode renderCategoryIcon(categoryId: ExpertCategoryId): ReactNode
renderExpertIcon(expertKey: ExpertVisualKey): ReactNode renderExpertIcon(expertKey: ExpertVisualKey): ReactNode
onOpenSession(sessionId: string): void onOpenSession(session: ProjectSessionSummary): void
onCloseSession(sessionId: string): 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" type SendPhase = "idle" | "preparing" | "streaming" | "finalizing"
interface SessionListProps { interface SessionListProps {
visible: boolean visible: boolean
label: string label: string
sessions: SessionSummary[] sessions: ProjectSessionSummary[]
activeSessionId: string activeSessionId: string
sessionTitles: Record<string, string> sessionTitles: Record<string, string>
projectActionPending: boolean projectActionPending: boolean
...@@ -13,7 +13,7 @@ interface SessionListProps { ...@@ -13,7 +13,7 @@ interface SessionListProps {
activeStreamSessionId?: string activeStreamSessionId?: string
closeLabel: string closeLabel: string
formatSessionTitle(title: string, index: number): string formatSessionTitle(title: string, index: number): string
onOpenSession(sessionId: string): void onOpenSession(session: ProjectSessionSummary): void
onCloseSession(sessionId: string): void onCloseSession(sessionId: string): void
} }
...@@ -50,7 +50,7 @@ export function SessionList({ ...@@ -50,7 +50,7 @@ export function SessionList({
className="sidebar-session-main app-no-drag" className="sidebar-session-main app-no-drag"
aria-current={activeSessionId === session.id ? "true" : undefined} aria-current={activeSessionId === session.id ? "true" : undefined}
disabled={projectActionPending} disabled={projectActionPending}
onClick={() => onOpenSession(session.id)} onClick={() => onOpenSession(session)}
> >
<strong>{sessionTitles[session.id] ?? formatSessionTitle(session.title, index)}</strong> <strong>{sessionTitles[session.id] ?? formatSessionTitle(session.title, index)}</strong>
</button> </button>
......
...@@ -15,7 +15,7 @@ interface UseHomeNavigationDeps { ...@@ -15,7 +15,7 @@ interface UseHomeNavigationDeps {
setPrompt: (value: string | ((current: string) => string)) => void setPrompt: (value: string | ((current: string) => string)) => void
clearSessions: () => void clearSessions: () => void
clearAllSessionMessages: () => void clearAllSessionMessages: () => void
switchProject: (projectId: string) => Promise<void> switchProject: (projectId: string, preferredProjectSessionId?: string) => Promise<boolean>
switchProjectPreservingMessages?: (projectId: string) => Promise<void> switchProjectPreservingMessages?: (projectId: string) => Promise<void>
buildShortcutPrompt: (definition: ExpertDefinition) => string 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