fix(ui): stabilize workplace chat scrolling
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user