fix(ui): stabilize workplace chat scrolling

This commit is contained in:
LZH-YS1998
2026-07-13 23:02:39 +08:00
parent 4e7aa75ba5
commit 4bc18dcd27
46 changed files with 5937 additions and 1258 deletions
@@ -1,6 +1,10 @@
import assert from 'node:assert/strict'
import type { Session } from '../types/kanban'
import { composerExecModeForSession } from './ContextPanel'
import {
composerExecModeForSession,
conversationHasOlderHistory,
sessionHasMoreForDetail,
} from './ContextPanel'
function makeSession(overrides: Partial<Session> = {}): Session {
return {
@@ -49,4 +53,44 @@ assert.equal(
'org',
)
const independentlyPaged = makeSession({
hasMore: false,
summaryHasMore: false,
fullHasMore: true,
messageCount: 400,
})
assert.equal(sessionHasMoreForDetail(independentlyPaged, 'summary'), false)
assert.equal(sessionHasMoreForDetail(independentlyPaged, 'full'), true)
assert.equal(
conversationHasOlderHistory([independentlyPaged], 200, 'summary'),
false,
'a full-detail ACK must not reopen summary history',
)
assert.equal(
conversationHasOlderHistory([independentlyPaged], 200, 'full'),
true,
'full history must retain its own cursor state',
)
assert.equal(
conversationHasOlderHistory([independentlyPaged], 200, 'full', false),
true,
'a known scoped cursor remains loadable while a company turn is running',
)
const summaryOnlyState = makeSession({
hasMore: false,
summaryHasMore: true,
messageCount: 400,
})
assert.equal(
sessionHasMoreForDetail(summaryOnlyState, 'full'),
undefined,
'generic hasMore is no longer authoritative after a scoped policy is known',
)
assert.equal(
conversationHasOlderHistory([makeSession({ messageCount: 400 })], 200, 'summary', false),
false,
'only the racy message-count fallback is suppressed during active generation',
)
console.log('ContextPanel composer identity checks passed')
@@ -193,6 +193,45 @@ function hasCompanyRuntimeIdentity(session: Session): boolean {
)
}
function isCompanyRuntimeSession(
session: Session | null | undefined,
relatedSessionCount = 0,
): boolean {
if (!session) return false
const mode = String(session.execMode ?? '').trim().toLowerCase()
return relatedSessionCount > 0
|| hasCompanyRuntimeIdentity(session)
|| mode === 'company'
|| mode === 'org'
|| mode === 'custom'
}
export function sessionHasMoreForDetail(
session: Session,
detailLevel: 'summary' | 'full',
): boolean | undefined {
const scoped = detailLevel === 'full' ? session.fullHasMore : session.summaryHasMore
if (scoped !== undefined) return scoped
// Old snapshots only expose the unscoped value. Once either scoped cursor
// has been observed, the generic field may describe the other policy.
if (session.summaryHasMore === undefined && session.fullHasMore === undefined) {
return session.hasMore
}
return undefined
}
export function conversationHasOlderHistory(
sessions: Session[],
displayedMessageCount: number,
detailLevel: 'summary' | 'full',
allowMessageCountFallback = true,
): boolean {
const pagination = sessions.map(session => sessionHasMoreForDetail(session, detailLevel))
if (pagination.some(hasMore => hasMore === true)) return true
if (sessions.length !== 1 || pagination[0] === false) return false
return allowMessageCountFallback && sessions[0].messageCount > displayedMessageCount
}
function hasCustomRuntimeIdentity(session: Session): boolean {
const rawMode = String(session.execMode ?? '').trim().toLowerCase()
const normalizedMode = normalizePanelExecMode(session.execMode)
@@ -498,7 +537,7 @@ export function ContextPanel({
const isChildDetail = activeView.kind === 'child-detail'
const isTaskDetail = activeView.kind === 'task-detail'
const isCompanyRuntime = !!(activeSession && (activeSession.isCompanyRuntime || childSessions.length > 0))
const isCompanyRuntime = isCompanyRuntimeSession(activeSession, childSessions.length)
const showTabs = activeView.kind === 'session' && activeSession
const canSend = isSecretary ? true : !!activeSession
const showSessionStrip = !isChildDetail && openSessions.length > 0
@@ -553,10 +592,14 @@ export function ContextPanel({
const matched = activeConversation.timelineSessions.find(
(session) => session.channelId === oldestMessage.channelId,
)
if (matched) return matched
if (matched && sessionHasMoreForDetail(matched, activeDetailMode) !== false) return matched
}
const knownTarget = activeConversation.timelineSessions.find(
session => sessionHasMoreForDetail(session, activeDetailMode) === true,
)
if (knownTarget) return knownTarget
return activeDisplaySession ?? activeSession
}, [activeConversation.timelineSessions, activeDisplaySession, activeSession])
}, [activeConversation.timelineSessions, activeDetailMode, activeDisplaySession, activeSession])
// Child detail: find the agent for this session
const childDetailAgent = useMemo(() => {
@@ -706,24 +749,20 @@ export function ContextPanel({
draftTurnId={childDetailSession.draftTurnId}
onMarkRead={onMarkRead}
hasOlderHistory={
// The `messageCount > loaded.length` race flashes the
// "Load older messages" hint every ~1s while the agent
// streams: backend bumps count, new message arrives
// at chatStore 1 tick later, hint appears then hides.
// The insertion/removal of the hint row also triggers
// auto-scroll, which pushes the user's own input off
// the top of the viewport. Suppress the hint while the
// session is actively working — any transient delta
// during active turns is almost always in-flight new
// messages, not an older-history gap.
!isSessionWorking(childDetailSession)
&& childDetailSession.messageCount > childDetailMessages.length
// A scoped backend cursor remains actionable during live
// work. Only the racy messageCount fallback is suppressed.
conversationHasOlderHistory(
[childDetailSession],
childDetailMessages.length,
'full',
!isSessionWorking(childDetailSession),
)
}
totalMessageCount={childDetailSession.messageCount}
onLoadOlderHistory={(oldestMessage) => onLoadSessionHistory?.(childDetailSession.taskId, oldestMessage, 'full')}
loadingOlderHistory={isSessionHistoryLoading?.(childDetailSession.taskId) ?? false}
autoScroll={false}
initialScrollToBottom
scrollPolicy="initial-bottom"
scrollScope={childDetailSession.channelId}
showRuntimeProgress
renderUserMarkdown
/>
@@ -911,7 +950,7 @@ export function ContextPanel({
.map(id => agents.find(agent => agent.agent_id === id)?.name ?? id)
.filter(Boolean)
const runtimeLabel = sessionRuntimeLabel(sessionConversationSession ?? session, activeChildCount)
const sessionIsCompanyRuntime = !!(session.isCompanyRuntime || sessionChildren.length > 0)
const sessionIsCompanyRuntime = isCompanyRuntimeSession(session, sessionChildren.length)
const sessionDisplaySession = sessionConversation.displaySession ?? session
const sessionProgressLog = mergeConversationProgressLog(sessionConversation.timelineSessions)
const sessionMessageCount = getConversationMessageCount(sessionConversation.timelineSessions)
@@ -970,37 +1009,52 @@ export function ContextPanel({
channelName={sessionDisplaySession?.title ?? session.title}
viewKind="session"
detailMode={sessionDetailLevel(sessionDisplaySession)}
agentStatus={sessionConversationSession?.agentStatus ?? sessionDisplaySession?.agentStatus}
currentTool={sessionConversationSession?.currentTool ?? sessionDisplaySession?.currentTool}
toolElapsedMs={sessionConversationSession?.toolElapsedMs ?? sessionDisplaySession?.toolElapsedMs}
lastToolSummary={sessionConversationSession?.lastToolSummary ?? sessionDisplaySession?.lastToolSummary}
progressLog={sessionProgressLog}
draftAssistantText={sessionConversationSession?.draftAssistantText ?? sessionDisplaySession?.draftAssistantText}
draftUpdatedAt={sessionConversationSession?.draftUpdatedAt ?? sessionDisplaySession?.draftUpdatedAt}
draftIteration={sessionConversationSession?.draftIteration ?? sessionDisplaySession?.draftIteration}
draftTurnId={sessionConversationSession?.draftTurnId ?? sessionDisplaySession?.draftTurnId}
isCompanyRuntime={sessionConversationSession?.isCompanyRuntime ?? sessionIsCompanyRuntime}
agentStatus={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.agentStatus ?? sessionDisplaySession?.agentStatus)}
currentTool={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.currentTool ?? sessionDisplaySession?.currentTool)}
toolElapsedMs={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.toolElapsedMs ?? sessionDisplaySession?.toolElapsedMs)}
lastToolSummary={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.lastToolSummary ?? sessionDisplaySession?.lastToolSummary)}
progressLog={sessionIsCompanyRuntime ? undefined : sessionProgressLog}
draftAssistantText={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.draftAssistantText ?? sessionDisplaySession?.draftAssistantText)}
draftUpdatedAt={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.draftUpdatedAt ?? sessionDisplaySession?.draftUpdatedAt)}
draftIteration={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.draftIteration ?? sessionDisplaySession?.draftIteration)}
draftTurnId={sessionIsCompanyRuntime ? undefined : (sessionConversationSession?.draftTurnId ?? sessionDisplaySession?.draftTurnId)}
isCompanyRuntime={sessionIsCompanyRuntime}
workItemLog={sessionConversationSession?.workItemLog ?? session.workItemLog}
childSessions={sessionWorkItemRoleSessions}
showWorkItemRuntimeCard={!sessionIsCompanyRuntime}
onSend={(content, _taskId, metadata) => onSessionSend?.(session.taskId, content, undefined, metadata)}
onWorkItemClick={onWorkItemClick}
onWorkItemOpenSession={onWorkItemOpenSession}
onMarkRead={() => onSessionMarkRead?.(session.taskId)}
scrollScope={session.channelId}
hasOlderHistory={
// Suppress during active work — see note
// on the childDetailSession case above.
!sessionConversation.timelineSessions.some(isSessionWorking)
&& sessionMessageCount > sessionMessages.length
// Keep known cursors available during live work;
// suppress only the count-based fallback.
conversationHasOlderHistory(
sessionConversation.timelineSessions,
sessionMessages.length,
sessionDetailLevel(sessionDisplaySession ?? session),
!sessionConversation.timelineSessions.some(isSessionWorking),
)
}
totalMessageCount={sessionMessageCount}
onLoadOlderHistory={(oldestMessage) => {
const targetSession = sessionConversation.timelineSessions.find(
const detailLevel = sessionDetailLevel(sessionDisplaySession ?? session)
const matchedSession = sessionConversation.timelineSessions.find(
(timelineSession) => timelineSession.channelId === oldestMessage?.channelId,
)
const targetSession = (
matchedSession
&& sessionHasMoreForDetail(matchedSession, detailLevel) !== false
? matchedSession
: undefined
) ?? sessionConversation.timelineSessions.find(
timelineSession => sessionHasMoreForDetail(timelineSession, detailLevel) === true,
) ?? sessionDisplaySession ?? session
return onLoadSessionHistory?.(
targetSession.taskId,
oldestMessage,
sessionDetailLevel(targetSession, { childDetail: targetSession.mode === 'child' }),
detailLevel,
)
}}
loadingOlderHistory={sessionHistoryLoading}
@@ -1062,6 +1116,7 @@ export function ContextPanel({
detailMode="summary"
onSend={onMessageSend}
onMarkRead={onMarkRead}
scrollScope={channelId}
/>
</>
)}
@@ -1077,6 +1132,7 @@ export function ContextPanel({
detailMode="summary"
onSend={onMessageSend}
onMarkRead={onMarkRead}
scrollScope={secretaryChannelId}
/>
<MessageComposer
disabled={false}
@@ -1099,7 +1155,7 @@ export function ContextPanel({
onComplete={(activeHeaderSession ?? activeSession).status !== 'done' && (activeHeaderSession ?? activeSession).status !== 'cancelled' ? onComplete : undefined}
onResume={onResume}
/>
{isCompanyRuntime && (hasRoleWorkItems || activeWorkItemLog.length > 0 || activeWorkItemRoleSessions.length > 0) && (
{isCompanyRuntime && (
<div className="ctx-work-item-progress">
<WorkItemProgressCard
workItemLog={activeWorkItemLog}
@@ -1117,16 +1173,16 @@ export function ContextPanel({
channelName={channelName}
viewKind="session"
detailMode={activeDetailMode}
agentStatus={activeConversationSession?.agentStatus ?? activeDisplaySession?.agentStatus}
currentTool={activeConversationSession?.currentTool ?? activeDisplaySession?.currentTool}
toolElapsedMs={activeConversationSession?.toolElapsedMs ?? activeDisplaySession?.toolElapsedMs}
lastToolSummary={activeConversationSession?.lastToolSummary ?? activeDisplaySession?.lastToolSummary}
progressLog={activeConversationProgress}
draftAssistantText={activeConversationSession?.draftAssistantText ?? activeDisplaySession?.draftAssistantText}
draftUpdatedAt={activeConversationSession?.draftUpdatedAt ?? activeDisplaySession?.draftUpdatedAt}
draftIteration={activeConversationSession?.draftIteration ?? activeDisplaySession?.draftIteration}
draftTurnId={activeConversationSession?.draftTurnId ?? activeDisplaySession?.draftTurnId}
isCompanyRuntime={activeConversationSession?.isCompanyRuntime ?? isCompanyRuntime}
agentStatus={isCompanyRuntime ? undefined : (activeConversationSession?.agentStatus ?? activeDisplaySession?.agentStatus)}
currentTool={isCompanyRuntime ? undefined : (activeConversationSession?.currentTool ?? activeDisplaySession?.currentTool)}
toolElapsedMs={isCompanyRuntime ? undefined : (activeConversationSession?.toolElapsedMs ?? activeDisplaySession?.toolElapsedMs)}
lastToolSummary={isCompanyRuntime ? undefined : (activeConversationSession?.lastToolSummary ?? activeDisplaySession?.lastToolSummary)}
progressLog={isCompanyRuntime ? undefined : activeConversationProgress}
draftAssistantText={isCompanyRuntime ? undefined : (activeConversationSession?.draftAssistantText ?? activeDisplaySession?.draftAssistantText)}
draftUpdatedAt={isCompanyRuntime ? undefined : (activeConversationSession?.draftUpdatedAt ?? activeDisplaySession?.draftUpdatedAt)}
draftIteration={isCompanyRuntime ? undefined : (activeConversationSession?.draftIteration ?? activeDisplaySession?.draftIteration)}
draftTurnId={isCompanyRuntime ? undefined : (activeConversationSession?.draftTurnId ?? activeDisplaySession?.draftTurnId)}
isCompanyRuntime={isCompanyRuntime}
workItemLog={activeWorkItemLog}
roleWorkItems={activeRoleWorkItems}
executorRoleWorkItems={activeExecutorRoleWorkItems}
@@ -1135,11 +1191,16 @@ export function ContextPanel({
onWorkItemClick={onWorkItemClick}
onWorkItemOpenSession={onWorkItemOpenSession}
onMarkRead={onMarkRead}
scrollScope={channelId}
hasOlderHistory={
// Suppress during active work — see note on the
// childDetailSession case above.
!activeConversation.timelineSessions.some(isSessionWorking)
&& activeConversationMessageCount > messages.length
// Keep known cursors available during live work;
// suppress only the count-based fallback.
conversationHasOlderHistory(
activeConversation.timelineSessions,
messages.length,
activeDetailMode,
!activeConversation.timelineSessions.some(isSessionWorking),
)
}
totalMessageCount={activeConversationMessageCount}
onLoadOlderHistory={(oldestMessage) => {
@@ -1148,7 +1209,7 @@ export function ContextPanel({
return onLoadSessionHistory?.(
targetSession.taskId,
oldestMessage,
sessionDetailLevel(targetSession, { childDetail: targetSession.mode === 'child' }),
activeDetailMode,
)
}}
loadingOlderHistory={activeConversationLoading}
@@ -297,9 +297,11 @@ export function TaskDetailView({
{linkedSession && linkedSessionMessages && linkedSessionMessages.length > 0 ? (
<div className="task-detail-linked-messages">
<MessageList
key={linkedSession.channelId}
messages={linkedSessionMessages}
channelName={linkedSession.title ?? 'Runtime Session'}
detailMode="summary"
scrollScope={linkedSession.channelId}
/>
</div>
) : linkedSession ? (
@@ -10,10 +10,74 @@ assert.match(src, /makeOptimisticUserMessageId/, 'ordinary composer sends must c
assert.match(src, /chatStore\.sendMessage/, 'ordinary composer sends must echo the user message locally before backend response')
assert.match(src, /ui_message_id: uiMessageId/, 'optimistic local message and websocket metadata must share ui_message_id')
assert.match(src, /checkpointReplyId/, 'checkpoint replies must be excluded from ordinary optimistic composer echo')
assert.match(src, /const \{ markRead \} = chatStore/, 'workspace must consume the stable markRead action directly')
assert.doesNotMatch(src, /chatStore\.markRead/, 'workspace mark-read callbacks must not depend on the aggregate chatStore object')
assert.equal(
[...src.matchAll(/\bmarkRead\(/g)].length,
2,
'markRead must only be invoked by the active viewport and per-session viewport callbacks',
)
assert.match(
src,
/const handleMarkRead = useCallback\(\(\) => \{\s*for \(const visibleChannelId of visibleChannelIds\) \{\s*markRead\(visibleChannelId\)\s*\}\s*\}, \[visibleChannelIds, markRead\]\)/,
'the active transcript viewport must mark every channel represented by the visible company timeline',
)
assert.match(
src,
/const handleMarkSessionRead = useCallback\(\(taskId: string\) => \{[\s\S]*?if \(session\) markRead\(session\.channelId\)[\s\S]*?\}, \[sessions, markRead\]\)/,
'each multi-session transcript viewport callback must own its channel markRead',
)
assert.match(src, /onMarkRead=\{handleMarkRead\}/, 'the active viewport must receive the markRead callback')
assert.match(src, /onSessionMarkRead=\{handleMarkSessionRead\}/, 'multi-session viewports must receive their scoped markRead callback')
assert.match(
src,
/const outgoing = metadata\?\.ui_message_id\s*\?\s*metadata\s*:\s*\{ \.\.\.\(metadata \?\? \{\}\), ui_message_id: makeOptimisticUserMessageId\(\) \}/,
'every session send must carry a client-generated ui_message_id so the backend can deduplicate re-deliveries',
)
console.log('WorkspacePage.test.ts: OK (optimistic composer echo wiring)')
const requestHistoryStart = src.indexOf('const requestSessionHistory = useCallback')
const requestHistoryEnd = src.indexOf('const isSessionHistoryLoading = useCallback', requestHistoryStart)
assert.ok(requestHistoryStart >= 0 && requestHistoryEnd > requestHistoryStart, 'history request implementation must be present')
const requestHistorySrc = src.slice(requestHistoryStart, requestHistoryEnd)
assert.doesNotMatch(
requestHistorySrc,
/setTimeout/,
'history single-flight completion must follow the transport Promise, not a fixed 800ms timer',
)
assert.match(
requestHistorySrc,
/Promise\.resolve\(request\)[\s\S]*?\.finally\(\(\) => \{[\s\S]*?historyRequestInFlightRef\.current\.delete\(requestKey\)/,
'history single-flight state must be released only when the transport request settles',
)
assert.match(
requestHistorySrc,
/const generation = historyRequestGenerationRef\.current[\s\S]*?const requestKey = \[\s*generation,/,
'history claims must be scoped to a project generation',
)
assert.match(
requestHistorySrc,
/historyRequestInFlightRef\.current\.delete\(requestKey\)\s*if \(historyRequestGenerationRef\.current !== generation\) return/,
'an old project Promise must not clear loading state for a newer project generation',
)
assert.match(
requestHistorySrc,
/oldestMessage && targetChannelId && oldestMessage\.channelId !== targetChannelId[\s\S]*?getChannelMessagesRef\.current\(targetChannelId\)\.find\([\s\S]*?isMessageVisibleAtDetailLevel\(message, detailLevel\)/,
'multi-channel history must use a selected target cursor visible to the requested detail policy',
)
assert.match(
src,
/if \(autoHistoryRequestRef\.current\.scope !== activeSessionId\) \{\s*autoHistoryRequestRef\.current\.scope = activeSessionId\s*autoHistoryRequestRef\.current\.active\.clear\(\)/,
'switching the active transcript scope must clear prior auto-history claims',
)
assert.match(
src,
/const historyTargets = activeConversation\.timelineSessions\.length > 0\s*\? activeConversation\.timelineSessions/,
'company history must enumerate the root and child timeline sessions',
)
assert.match(
src,
/const detailLevel = isCompanyConversation\(activeSession, childSessions\.length\)\s*\? 'summary'[\s\S]*?requestSessionHistory\(\s*session\.taskId,\s*undefined,\s*detailLevel/,
'company root and child history requests must use summary detail independently',
)
console.log('WorkspacePage.test.ts: OK (composer, mark-read, and history wiring)')
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { AgentInfo, OrgInfoPayload, SavedOrgSummary } from '../types/visual'
import { WorkItemRecoveryPanel } from './WorkItemRecoveryPanel'
import type { ChatMessage, CheckpointReplyMetadata, OutgoingAttachmentPayload } from '../types/chat'
@@ -14,8 +14,10 @@ import { BoardSelector } from '../kanban/BoardSelector'
import {
getConversationPeerSessions,
getWorkItemChildSessions,
isMessageVisibleAtDetailLevel,
mergeConversationMessages,
projectSessionConversation,
selectCompanySummaryMessages,
} from '../lib/workItemSessions'
import { getRuntimeOrgView } from '../lib/runtimeOrg'
import { getLinkedRuntimeTaskId } from '../lib/workItemRuntimeIds'
@@ -125,6 +127,18 @@ function sessionDetailLevel(
return session.execMode === 'company' || session.execMode === 'org' || session.execMode === 'custom' ? 'summary' : 'full'
}
function isCompanyConversation(session: Session | null | undefined, relatedSessionCount = 0): boolean {
if (!session) return false
const mode = String(session.execMode ?? '').trim().toLowerCase()
return relatedSessionCount > 0
|| !!session.isCompanyRuntime
|| !!session.roleWorkItems
|| !!session.executorRoleWorkItems
|| mode === 'company'
|| mode === 'org'
|| mode === 'custom'
}
function sessionBoardId(session: Session | null | undefined): string | null {
const boardId = String(session?.originTaskId ?? session?.taskId ?? '').trim()
return boardId || null
@@ -244,7 +258,7 @@ interface WorkspacePageProps {
onLoadSessionDetail?: (
taskId: string,
opts?: { beforeCreatedAt?: number; beforeMessageId?: string; limit?: number; detailLevel?: 'summary' | 'full'; include?: string[] },
) => void
) => Promise<void> | void
onOpenExecutionPanel?: (taskId: string) => void
onCollabSync?: () => void
orgInfoData?: OrgInfoPayload | null
@@ -304,6 +318,7 @@ export function WorkspacePage({
onSavedOrgLoad,
}: WorkspacePageProps) {
const { sessions, activeSessionId, activeSession } = sessionStore
const { markRead } = chatStore
// ── Panel state ──
const [panelState, setPanelState] = useState<'collapsed' | 'open' | 'maximized'>('collapsed')
@@ -320,10 +335,18 @@ export function WorkspacePage({
const [multiSessionView, setMultiSessionView] = useState(false)
const [sessionHistoryLoading, setSessionHistoryLoading] = useState<Record<string, boolean>>({})
const onLoadSessionDetailRef = useRef(onLoadSessionDetail)
const autoHistoryRequestRef = useRef<{ active: string | null; child: string | null }>({
active: null,
const sessionsRef = useRef(sessions)
const getChannelMessagesRef = useRef(chatStore.getChannelMessages)
const autoHistoryRequestRef = useRef<{ scope: string | null; active: Set<string>; child: string | null }>({
scope: null,
active: new Set(),
child: null,
})
const historyRequestInFlightRef = useRef<Set<string>>(new Set())
const historyRequestGenerationRef = useRef(0)
sessionsRef.current = sessions
getChannelMessagesRef.current = chatStore.getChannelMessages
const isCompanyMode = execMode === 'company' || execMode === 'org' || execMode === 'custom'
@@ -375,16 +398,58 @@ export function WorkspacePage({
) => {
const loadSessionDetail = onLoadSessionDetailRef.current
if (!loadSessionDetail || !taskId) return
setSessionHistoryLoading(prev => prev[taskId] ? prev : { ...prev, [taskId]: true })
loadSessionDetail(taskId, {
limit: SESSION_DETAIL_PAGE_SIZE,
beforeCreatedAt: oldestMessage?.timestamp,
beforeMessageId: oldestMessage?.id,
const generation = historyRequestGenerationRef.current
const targetSession = sessionsRef.current.find(session => session.taskId === taskId)
const targetChannelId = targetSession?.channelId
const cursorMessage = oldestMessage && targetChannelId && oldestMessage.channelId !== targetChannelId
? getChannelMessagesRef.current(targetChannelId).find(
message => isMessageVisibleAtDetailLevel(message, detailLevel),
)
: oldestMessage
const requestKey = [
generation,
taskId,
detailLevel,
cursorMessage?.timestamp ?? 'latest',
cursorMessage?.id ?? '',
].join('|')
if (historyRequestInFlightRef.current.has(requestKey)) return
// Claim the cursor synchronously before invoking the transport. Loading
// state is asynchronous and cannot serve as a single-flight guard.
historyRequestInFlightRef.current.add(requestKey)
setSessionHistoryLoading(prev => prev[taskId] ? prev : { ...prev, [taskId]: true })
let request: Promise<void> | void
try {
request = loadSessionDetail(taskId, {
limit: SESSION_DETAIL_PAGE_SIZE,
beforeCreatedAt: cursorMessage?.timestamp,
beforeMessageId: cursorMessage?.id,
detailLevel,
})
} catch (error) {
historyRequestInFlightRef.current.delete(requestKey)
if (historyRequestGenerationRef.current === generation) {
setSessionHistoryLoading(prev => prev[taskId] ? { ...prev, [taskId]: false } : prev)
autoHistoryRequestRef.current.active.delete(`${taskId}:${detailLevel}`)
if (autoHistoryRequestRef.current.child === taskId) autoHistoryRequestRef.current.child = null
}
return
}
return Promise.resolve(request).catch(() => {
if (historyRequestGenerationRef.current === generation) {
autoHistoryRequestRef.current.active.delete(`${taskId}:${detailLevel}`)
if (autoHistoryRequestRef.current.child === taskId) autoHistoryRequestRef.current.child = null
}
}).finally(() => {
historyRequestInFlightRef.current.delete(requestKey)
if (historyRequestGenerationRef.current !== generation) return
const taskPrefix = `${generation}|${taskId}|`
const taskStillLoading = [...historyRequestInFlightRef.current.keys()]
.some(key => key.startsWith(taskPrefix))
if (!taskStillLoading) {
setSessionHistoryLoading(prev => prev[taskId] ? { ...prev, [taskId]: false } : prev)
}
})
window.setTimeout(() => {
setSessionHistoryLoading(prev => prev[taskId] ? { ...prev, [taskId]: false } : prev)
}, 800)
}, [])
const isSessionHistoryLoading = useCallback((taskId: string) => {
@@ -392,10 +457,18 @@ export function WorkspacePage({
}, [sessionHistoryLoading])
// Auto-clear childDetailTaskId if session was deleted
useEffect(() => {
autoHistoryRequestRef.current = { active: null, child: null }
useLayoutEffect(() => {
historyRequestGenerationRef.current += 1
historyRequestInFlightRef.current.clear()
autoHistoryRequestRef.current = { scope: null, active: new Set(), child: null }
setSessionHistoryLoading({})
}, [projectId])
useEffect(() => () => {
historyRequestGenerationRef.current += 1
historyRequestInFlightRef.current.clear()
}, [])
useEffect(() => {
if (childDetailTaskId && !childDetailSession) {
setChildDetailTaskId(null)
@@ -432,31 +505,37 @@ export function WorkspacePage({
useEffect(() => {
if (!activeSessionId) {
autoHistoryRequestRef.current.active = null
autoHistoryRequestRef.current.scope = null
autoHistoryRequestRef.current.active.clear()
return
}
if (autoHistoryRequestRef.current.scope !== activeSessionId) {
autoHistoryRequestRef.current.scope = activeSessionId
autoHistoryRequestRef.current.active.clear()
}
const historyTargets = activeConversation.timelineSessions.length > 0
? activeConversation.timelineSessions
: (sessions.find(session => session.taskId === activeSessionId)
? [sessions.find(session => session.taskId === activeSessionId)!]
: [])
if (historyTargets.length === 0) {
autoHistoryRequestRef.current.active = null
autoHistoryRequestRef.current.active.clear()
return
}
const requestKey = historyTargets
.map((session) => `${session.taskId}:${sessionDetailLevel(session, { childDetail: session.mode === 'child' })}`)
.join('|')
if (autoHistoryRequestRef.current.active === requestKey) return
autoHistoryRequestRef.current.active = requestKey
for (const session of historyTargets) {
const detailLevel = isCompanyConversation(activeSession, childSessions.length)
? 'summary'
: sessionDetailLevel(session)
const requestKey = `${session.taskId}:${detailLevel}`
if (autoHistoryRequestRef.current.active.has(requestKey)) continue
autoHistoryRequestRef.current.active.add(requestKey)
requestSessionHistory(
session.taskId,
undefined,
sessionDetailLevel(session, { childDetail: session.mode === 'child' }),
detailLevel,
)
}
}, [activeConversation.timelineSessions, activeSessionId, requestSessionHistory, sessions])
}, [activeConversation.timelineSessions, activeSession, activeSessionId, childSessions.length, requestSessionHistory, sessions])
// Sync activeView when activeSessionId changes externally
const effectiveView: ActiveView = useMemo(() => {
@@ -517,9 +596,13 @@ export function WorkspacePage({
.reverse()
}
if (effectiveView.kind === 'session' && visibleChannelIds.length > 1) {
return mergeConversationMessages(
visibleChannelIds.map((visibleChannelId) => chatStore.getChannelMessages(visibleChannelId)),
const messageGroups = visibleChannelIds.map(
(visibleChannelId) => chatStore.getChannelMessages(visibleChannelId),
)
if (isCompanyConversation(activeSession, childSessions.length) && activeSession) {
return selectCompanySummaryMessages(messageGroups.flat(), activeSession.channelId)
}
return mergeConversationMessages(messageGroups)
}
return chatStore.getChannelMessages(channelId)
}, [
@@ -528,6 +611,8 @@ export function WorkspacePage({
channelId,
effectiveView.kind,
activeChannelIds,
activeSession,
childSessions.length,
visibleChannelIds,
])
const childDetailMessages = useMemo(() => {
@@ -607,11 +692,12 @@ export function WorkspacePage({
const sessionChildren = getWorkItemChildSessions(session, sessions)
const sessionPeers = getConversationPeerSessions(session, sessions)
const projection = projectSessionConversation(session, [...sessionPeers, ...sessionChildren])
result[session.taskId] = mergeConversationMessages(
projection.timelineSessions.map((timelineSession) => (
chatStore.getChannelMessages(timelineSession.channelId)
)),
)
const messageGroups = projection.timelineSessions.map((timelineSession) => (
chatStore.getChannelMessages(timelineSession.channelId)
))
result[session.taskId] = isCompanyConversation(session, sessionChildren.length)
? selectCompanySummaryMessages(messageGroups.flat(), session.channelId)
: mergeConversationMessages(messageGroups)
}
return result
}, [openSessions, sessions, chatStore.getChannelMessages])
@@ -670,24 +756,16 @@ export function WorkspacePage({
return () => document.removeEventListener('keydown', handleKeyDown)
}, [panelState])
// Auto-mark channel as read
useEffect(() => {
if (panelState === 'collapsed') return
for (const visibleChannelId of visibleChannelIds) {
chatStore.markRead(visibleChannelId)
}
}, [visibleChannelIds, chatStore, panelState])
const handleMarkRead = useCallback(() => {
for (const visibleChannelId of visibleChannelIds) {
chatStore.markRead(visibleChannelId)
markRead(visibleChannelId)
}
}, [visibleChannelIds, chatStore])
}, [visibleChannelIds, markRead])
const handleMarkSessionRead = useCallback((taskId: string) => {
const session = sessions.find(item => item.taskId === taskId)
if (session) chatStore.markRead(session.channelId)
}, [sessions, chatStore])
if (session) markRead(session.channelId)
}, [sessions, markRead])
const focusSession = useCallback((taskId: string) => {
const session = sessions.find(item => item.taskId === taskId)
@@ -698,8 +776,7 @@ export function WorkspacePage({
setPanelState('open')
setPanelTab('chat')
setChildDetailTaskId(null)
chatStore.markRead(session.channelId)
}, [sessions, ensureSessionOpen, sessionStore, chatStore])
}, [sessions, ensureSessionOpen, sessionStore])
const handleCloseSessionView = useCallback((taskId: string) => {
const remaining = openSessionIds.filter(id => id !== taskId)
@@ -711,14 +788,12 @@ export function WorkspacePage({
const nextActive = remaining[remaining.length - 1] ?? null
sessionStore.setActiveSession(nextActive)
if (nextActive) {
const nextSession = sessions.find(item => item.taskId === nextActive)
setActiveView({ kind: 'session', taskId: nextActive })
setPanelTab('chat')
if (nextSession) chatStore.markRead(nextSession.channelId)
return
}
setActiveView({ kind: 'activity' })
}, [openSessionIds, childDetailTaskId, activeSessionId, sessionStore, sessions, chatStore])
}, [openSessionIds, childDetailTaskId, activeSessionId, sessionStore])
// ── Session selection (sidebar click or board card click) ──
const handleSelectSession = useCallback((taskId: string | null) => {
@@ -746,8 +821,7 @@ export function WorkspacePage({
sessionStore.setActiveSession(null)
setChildDetailTaskId(null)
setPanelState('open')
chatStore.markRead(secretaryChannelId)
}, [sessionStore, chatStore, secretaryChannelId])
}, [sessionStore])
// ── Board interactions ──
const handleCardClick = useCallback((task: { id: string }) => {
@@ -999,9 +1073,8 @@ export function WorkspacePage({
setChildDetailTaskId(session.taskId)
setPanelState('open')
setPanelTab('chat')
chatStore.markRead(session.channelId)
}
}, [sessions, chatStore])
}, [sessions])
const handleWorkItemClick = useCallback((executionTurnId: string) => {
// Always forward to ExecutionPanel. The panel's lookup matches against
@@ -665,21 +665,53 @@
overflow: hidden;
}
.ctx-body > .msg-list {
.ctx-body > .msg-list,
.ctx-body > .msg-list-shell {
min-height: 0;
}
.ctx-work-item-progress {
flex-shrink: 0;
flex: 0 0 84px;
height: 84px;
min-height: 84px;
padding: 12px 12px 0;
overflow-y: auto;
overflow-x: hidden;
max-height: 50vh;
overflow: hidden;
position: relative;
z-index: 1;
background: var(--bg-elevated);
}
.ctx-work-item-progress .wi-progress-card {
box-sizing: border-box;
height: 72px;
padding: 10px 12px;
gap: 7px;
overflow: hidden;
}
.ctx-work-item-progress .wi-progress-pipeline {
min-width: 0;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.ctx-work-item-progress .wi-progress-pipeline::-webkit-scrollbar {
display: none;
}
.ctx-work-item-progress .wi-projection-group {
flex: 0 0 auto;
}
.ctx-work-item-progress .wi-progress-pipeline-empty {
color: var(--text-secondary);
font-size: 11px;
line-height: 22px;
white-space: nowrap;
}
.ctx-multi-grid {
flex: 1;
min-height: 0;
@@ -1863,13 +1895,21 @@
TaskDetailView — linked session messages
═══════════════════════════════════════════════════════ */
.task-detail-linked-messages {
max-height: 400px;
overflow-y: auto;
height: min(400px, 55vh);
min-height: 220px;
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 0;
background: var(--bg);
}
.task-detail-linked-messages > .msg-list-shell {
flex: 1;
min-height: 0;
}
.task-detail-empty-hint {
color: var(--text-secondary);
font-size: 12px;