diff --git a/app/desktop/src/platform/useDesktopWorkbenchModel.test.tsx b/app/desktop/src/platform/useDesktopWorkbenchModel.test.tsx index 4ab8badd4..70aa729d7 100644 --- a/app/desktop/src/platform/useDesktopWorkbenchModel.test.tsx +++ b/app/desktop/src/platform/useDesktopWorkbenchModel.test.tsx @@ -3,10 +3,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import React from 'react'; import { WORKBENCH_DATA_MODE_STORAGE_KEY, resolveDemoWorkbenchTranscript } from '@shared/demo'; +import { HUB_EVENTS } from '@shared/hubEvents'; +import { hubQueryKeys } from '@shared/stores/queryKeys'; import { useThreadMessages, useThreadPins, useThreads } from '@/api/threadQueries'; import { useHubSessions, useHubMessages, useHubPinnedMessages } from '@/api/sessionQueries'; import { getAccessToken } from '@/hooks/useAuth'; import { useHubStore } from '@/stores/hubStore'; +import { createDesktopHubEventBridge, type DesktopHubWSLike } from '@/stores/hubEventBridge'; import { useDesktopWorkbenchModel } from './useDesktopWorkbenchModel'; import { useDesktopEdgeEvents } from './useDesktopEdgeEvents'; import { fetchHealth } from '@/api/edgeClient'; @@ -308,6 +311,173 @@ describe('useDesktopWorkbenchModel', () => { ); }); + it('activates the Hub transcript for a session that only carries snake_case session_id (#1972)', () => { + window.localStorage.setItem(WORKBENCH_DATA_MODE_STORAGE_KEY, 'approved-real'); + mockedUseHubStore.mockReturnValue(true as never); + mockedGetAccessToken.mockReturnValue('token'); + + // Real REST /client/sessions payloads carry session_id only (no id). + mockedUseHubSessions.mockReturnValue({ + data: [{ session_id: 'hub-session-1', title: 'Hub DM', type: 'private' }], + isLoading: false, + error: null, + } as unknown as ReturnType); + mockedUseHubMessages.mockReturnValue({ + data: [ + { id: 'm1', session_id: 'hub-session-1', seq_id: 1, sender_type: 'user', sender_id: 'me', content_type: 'text', content: '{"text":"hi"}' }, + { + id: 'm2', + session_id: 'hub-session-1', + seq_id: 2, + sender_type: 'user', + sender_id: 'me', + content_type: 'image', + content: '{"text":"img","attachment_id":"att-1"}', + attachments: [{ id: 'att-1', size: 10, mime_type: 'image/png' }], + }, + { + id: 'm3', + session_id: 'hub-session-1', + seq_id: 3, + sender_type: 'user', + sender_id: 'me', + content_type: 'image', + content: '{"text":"img","attachment_id":"att-gone"}', + }, + ], + isLoading: false, + error: null, + } as unknown as ReturnType); + const translate = vi.fn((key: string) => ( + key === 'message.attachmentMissingImage' ? 'Image attachment missing' : key + )); + + const { result } = renderHook( + () => useDesktopWorkbenchModel('hub-session-1', translate), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + expect(mockedUseHubMessages).toHaveBeenCalledWith( + 'hub-session-1', + expect.objectContaining({ enabled: true }), + ); + expect(result.current.activeConversationId).toBe('hub-session-1'); + expect(result.current.transcript.map((block) => block.id)).toEqual([ + 'hub-message-m1', + 'hub-message-m2', + 'hub-message-m3', + ]); + expect(result.current.transcript[1]).toMatchObject({ + kind: 'attachment', + contentType: 'image', + attachmentRef: expect.objectContaining({ id: 'att-1' }), + }); + expect(result.current.transcript[2]).toMatchObject({ + kind: 'attachment', + contentType: 'image', + attachmentRef: { id: '', name: 'Image attachment missing', size: 0, mime_type: '' }, + }); + expect(translate).toHaveBeenCalledWith('message.attachmentMissingImage'); + }); + + it('invalidates the REST history query when a live image message frame arrives (#1972)', () => { + const handlers = new Map void>(); + const hubWS: DesktopHubWSLike = { + on: (type, handler) => { + handlers.set(type, handler); + return () => handlers.delete(type); + }, + }; + const liveQueryClient = new QueryClient(); + const invalidateQueries = vi.spyOn(liveQueryClient, 'invalidateQueries'); + const bridge = createDesktopHubEventBridge(hubWS, liveQueryClient); + + // The server's message.new frame is the stored model.Message and does not + // include joined attachments. Desktop must invalidate the history query; + // the refetched REST MessageResponse then supplies attachments to the + // same normalizer exercised above. + handlers.get(HUB_EVENTS.MESSAGE_NEW)?.({ + id: 'msg-live-image', + session_id: 'hub-session-1', + content_type: 'image', + content: '{"attachment_id":"att-live"}', + }); + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: hubQueryKeys.threads.messages('hub-session-1'), + }); + bridge.destroy(); + }); + + it('keeps legacy id-only Hub sessions queryable on Desktop (#1972)', () => { + window.localStorage.setItem(WORKBENCH_DATA_MODE_STORAGE_KEY, 'approved-real'); + mockedUseHubStore.mockReturnValue(true as never); + mockedGetAccessToken.mockReturnValue('token'); + mockedUseHubSessions.mockReturnValue({ + data: [{ id: 'legacy-hub-session', title: 'Legacy Hub DM', type: 'private' }], + isLoading: false, + error: null, + } as unknown as ReturnType); + mockedUseHubMessages.mockReturnValue({ + data: [], + isLoading: false, + error: null, + } as unknown as ReturnType); + + const { result } = renderHook( + () => useDesktopWorkbenchModel('legacy-hub-session'), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + expect(mockedUseHubMessages).toHaveBeenCalledWith( + 'legacy-hub-session', + expect.objectContaining({ enabled: true }), + ); + expect(result.current.activeConversationId).toBe('legacy-hub-session'); + }); + + it('does not enable Hub message queries for a selection that matches no Hub session (#1972)', () => { + window.localStorage.setItem(WORKBENCH_DATA_MODE_STORAGE_KEY, 'approved-real'); + mockedUseHubStore.mockReturnValue(true as never); + mockedGetAccessToken.mockReturnValue('token'); + + mockedUseHubSessions.mockReturnValue({ + data: [{ session_id: 'hub-session-1', title: 'Hub DM', type: 'private' }], + isLoading: false, + error: null, + } as unknown as ReturnType); + + const { result } = renderHook( + () => useDesktopWorkbenchModel('edge-thread-x'), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + expect(mockedUseHubMessages).toHaveBeenCalledWith( + '', + expect.objectContaining({ enabled: false }), + ); + expect(result.current.activeConversationId).toBe('edge-thread-x'); + expect(result.current.transcript).toEqual([]); + }); + it('exposes the IM transcript unread marker from the Hub session watermark (T8)', () => { window.localStorage.setItem(WORKBENCH_DATA_MODE_STORAGE_KEY, 'approved-real'); mockedUseHubStore.mockReturnValue(true as never); diff --git a/app/desktop/src/platform/useDesktopWorkbenchModel.ts b/app/desktop/src/platform/useDesktopWorkbenchModel.ts index 5eb1b5fba..65d82d869 100644 --- a/app/desktop/src/platform/useDesktopWorkbenchModel.ts +++ b/app/desktop/src/platform/useDesktopWorkbenchModel.ts @@ -149,6 +149,17 @@ function useEdgeAvailableForDemo(enabled: boolean): boolean { return enabled && available; } +/** + * Stable conversation id for one Hub session (#1972). Real REST + * `/client/sessions` payloads carry snake_case `session_id`; compatibility + * fixtures and older clients may still carry `id`. Selection matching and + * query activation must use `(id ?? session_id)`, otherwise the real REST + * shape leaves Hub message/pin queries disabled. + */ +function hubSessionMatchId(session: { id?: string; session_id?: string }): string | undefined { + return session.id ?? session.session_id; +} + export function useDesktopWorkbenchModel( selectedConversationId?: string, t?: (key: string) => string, @@ -241,7 +252,7 @@ export function useDesktopWorkbenchModel( // Never fall back to hubSessions[0] when the user is on an Edge thread (or any // non-Hub id) — that steals Edge selection whenever any Hub session exists (#1010). const matchedHubSession = useHubConversations - ? hubSessions.find((s) => s.id === selectedConversationId) + ? hubSessions.find((s) => hubSessionMatchId(s) === selectedConversationId) : undefined; const matchedThread = edgeEnabled ? threads.find((thread) => thread.threadId === selectedConversationId) @@ -251,7 +262,10 @@ export function useDesktopWorkbenchModel( ?? (!selectedConversationId && useHubConversations ? hubSessions[0] : undefined); const activeThread = matchedThread ?? (!selectedConversationId && !activeHubSession && edgeEnabled ? threads[0] : undefined); - const activeConversationId = activeHubSession?.id ?? activeThread?.threadId ?? selectedConversationId ?? ''; + // Conversation id derivation aligned with hubSessionToConversation (#1972): + // real REST sessions only carry snake_case session_id. + const activeHubSessionId = activeHubSession ? hubSessionMatchId(activeHubSession) : undefined; + const activeConversationId = activeHubSessionId ?? activeThread?.threadId ?? selectedConversationId ?? ''; // Edge thread messages (execution path). const threadItemsQuery = useThreadMessages(edgeEnabled ? activeThread?.threadId ?? null : null); @@ -262,26 +276,26 @@ export function useDesktopWorkbenchModel( const liveTranscript = useDesktopEdgeEvents(edgeEnabled ? activeThread?.threadId : undefined, persistedUntilMs); // Hub session messages (IM path) — only when a Hub session is active. - const hubMessagesQuery = useHubMessages(activeHubSession?.id ?? '', { enabled: hubReady && !!activeHubSession?.id }); + const hubMessagesQuery = useHubMessages(activeHubSessionId ?? '', { enabled: hubReady && !!activeHubSessionId }); const hubMessages = useMemo(() => hubMessagesQuery.data ?? [], [hubMessagesQuery.data]); // Hub session pins — seed the pinMap store from GET /client/sessions/{id}/pins. // Keyed per session (query key matches hubQueryKeys.threads.pins, which // hubEventBridge invalidates on MESSAGE_PIN/MESSAGE_UNPIN); each arrival // re-seeds the session bucket (server list is authoritative). - const hubPinsQuery = useHubPinnedMessages(activeHubSession?.id ?? '', { enabled: hubReady && !!activeHubSession?.id }); + const hubPinsQuery = useHubPinnedMessages(activeHubSessionId ?? '', { enabled: hubReady && !!activeHubSessionId }); useEffect(() => { - if (activeHubSession?.id && hubPinsQuery.data) { + if (activeHubSessionId && hubPinsQuery.data) { getPinMapStore().loadPinnedForSession( - activeHubSession.id, + activeHubSessionId, hubPinsQuery.data.map((message) => message.id), ); - } else if (!activeHubSession?.id) { + } else if (!activeHubSessionId) { // Signed out / no Hub session: drop the session pointer so stale frames // can never leak into a later session. getPinMapStore().setActiveSession(null); } - }, [activeHubSession?.id, hubPinsQuery.data]); + }, [activeHubSessionId, hubPinsQuery.data]); const demoModel = useMemo(() => { // When auto mode can use Local Edge fallback, use Edge API data for diff --git a/app/shared/src/chatview/i18n/resources.ts b/app/shared/src/chatview/i18n/resources.ts index ce8ab0ded..531a707ab 100644 --- a/app/shared/src/chatview/i18n/resources.ts +++ b/app/shared/src/chatview/i18n/resources.ts @@ -362,6 +362,8 @@ export const chatviewResources = { // ═══ Message state ═══ 'message.recalled': '消息已撤回', + 'message.attachmentMissingImage': '图片附件缺失', + 'message.attachmentMissingFile': '文件附件缺失', // ═══ Chat kind ═══ 'chat.kind.group': '群聊', @@ -1114,6 +1116,8 @@ export const chatviewResources = { // ═══ Message state ═══ 'message.recalled': 'Message recalled', + 'message.attachmentMissingImage': 'Image attachment missing', + 'message.attachmentMissingFile': 'File attachment missing', // ═══ Chat kind ═══ 'chat.kind.group': 'Group', diff --git a/app/shared/src/transcript/normalizeHubMessages.test.ts b/app/shared/src/transcript/normalizeHubMessages.test.ts index beb9c089e..4c17db184 100644 --- a/app/shared/src/transcript/normalizeHubMessages.test.ts +++ b/app/shared/src/transcript/normalizeHubMessages.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { normalizeHubMessagesToTranscript } from './normalizeHubMessages'; describe('normalizeHubMessagesToTranscript', () => { @@ -347,3 +347,151 @@ describe('normalizeHubMessagesToTranscript', () => { ]); }); }); + +describe('normalizeHubMessagesToTranscript attachment pass-through (#1972)', () => { + // Mirrors the real REST /client/sessions/{id}/messages payload shape: the + // Hub joins message_attachments into each message and the client carries + // them untouched into the normalizer. + const imageMessageWithAttachment = { + id: 'msg-img-1', + session_id: 'hub-session-1', + seq_id: 20, + sender_type: 'user', + sender_id: 'user-1', + sender: { nickname: 'ImageSender' }, + content_type: 'image', + content: '{"text": "user image caption", "attachment_id": "att-1"}', + created_at: '2026-08-25T00:10:08Z', + attachments: [{ + id: 'att-1', + hash: 'd9209d6f6fe12fe1', + size: 62798, + mime_type: 'image/png', + uploader_user_id: 'user-1', + metadata: '{"width": 320, "height": 200}', + created_at: '2026-08-25T00:10:08Z', + }], + }; + + it('projects a REST image message with its joined attachment into an attachment block', () => { + const blocks = normalizeHubMessagesToTranscript([imageMessageWithAttachment]); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + id: 'hub-message-msg-img-1', + kind: 'attachment', + contentType: 'image', + attachmentRef: { + id: 'att-1', + size: 62798, + mime_type: 'image/png', + hash: 'd9209d6f6fe12fe1', + created_at: '2026-08-25T00:10:08Z', + }, + }); + }); + + it('projects a REST file message with its joined attachment into a file attachment block', () => { + const blocks = normalizeHubMessagesToTranscript([{ + ...imageMessageWithAttachment, + id: 'msg-file-1', + content_type: 'file', + attachments: [{ + id: 'att-2', + size: 1024, + mime_type: 'application/pdf', + original_name: 'report.pdf', + }], + }]); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + id: 'hub-message-msg-file-1', + kind: 'attachment', + contentType: 'file', + attachmentRef: { + id: 'att-2', + name: 'report.pdf', + original_name: 'report.pdf', + size: 1024, + mime_type: 'application/pdf', + }, + }); + }); + + it('keeps an image message whose attachment data is missing as an honest degraded entry', () => { + const { attachments: _attachments, ...withoutAttachment } = imageMessageWithAttachment; + + const blocks = normalizeHubMessagesToTranscript([withoutAttachment]); + + // The message must not be silently dropped (#1972 acceptance 2). + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + id: 'hub-message-msg-img-1', + kind: 'attachment', + contentType: 'image', + // Empty id is the degradation marker: the renderer resolves it to the + // #1938 chip + explicit status notice instead of a broken image. + attachmentRef: { id: '', name: '图片附件缺失', size: 0, mime_type: '' }, + }); + }); + + it('keeps a file message with an empty attachments array as a degraded entry', () => { + const blocks = normalizeHubMessagesToTranscript([{ + ...imageMessageWithAttachment, + id: 'msg-file-empty', + content_type: 'file', + attachments: [], + }]); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + id: 'hub-message-msg-file-empty', + kind: 'attachment', + contentType: 'file', + attachmentRef: { id: '', name: '文件附件缺失', size: 0, mime_type: '' }, + }); + }); + + it('routes degraded attachment labels through the injected translator', () => { + const translate = vi.fn((key: string) => { + if (key === 'message.attachmentMissingImage') return 'Image attachment missing'; + if (key === 'message.attachmentMissingFile') return 'File attachment missing'; + return key; + }); + const { attachments: _attachments, ...withoutAttachment } = imageMessageWithAttachment; + + const imageBlocks = normalizeHubMessagesToTranscript([withoutAttachment], translate); + const fileBlocks = normalizeHubMessagesToTranscript([{ + ...withoutAttachment, + id: 'msg-file-missing', + content_type: 'file', + }], translate); + + expect(imageBlocks[0]).toMatchObject({ attachmentRef: { name: 'Image attachment missing' } }); + expect(fileBlocks[0]).toMatchObject({ attachmentRef: { name: 'File attachment missing' } }); + expect(translate).toHaveBeenCalledWith('message.attachmentMissingImage'); + expect(translate).toHaveBeenCalledWith('message.attachmentMissingFile'); + }); + + it('still drops nothing but the runtime diagnostics when attachment messages mix in', () => { + const blocks = normalizeHubMessagesToTranscript([ + imageMessageWithAttachment, + { + id: 'msg-text-1', + session_id: 'hub-session-1', + seq_id: 19, + sender_type: 'user', + sender_id: 'user-1', + content_type: 'text', + content: '{"text":"before the image"}', + created_at: '2026-08-25T00:09:00Z', + }, + ]); + + expect(blocks.map((block) => block.id)).toEqual([ + 'hub-message-msg-text-1', + 'hub-message-msg-img-1', + ]); + }); +}); diff --git a/app/shared/src/transcript/normalizeHubMessages.ts b/app/shared/src/transcript/normalizeHubMessages.ts index d098110da..fa534e5af 100644 --- a/app/shared/src/transcript/normalizeHubMessages.ts +++ b/app/shared/src/transcript/normalizeHubMessages.ts @@ -63,6 +63,13 @@ export interface HubMessageTranscriptInput { /** zh fallback for `message.recalled` when no translator is injected. */ const RECALLED_TEXT_FALLBACK = '消息已撤回'; +/** + * zh fallbacks for the missing-attachment degraded labels (#1972) when no + * translator is injected. Keys live in the shared 'chatview' namespace. + */ +const ATTACHMENT_MISSING_IMAGE_FALLBACK = '图片附件缺失'; +const ATTACHMENT_MISSING_FILE_FALLBACK = '文件附件缺失'; + /** * Plain-text translator callback for i18n of normalizer-owned labels. * Key space: the shared 'chatview' namespace (see chatview/i18n/resources.ts). @@ -160,7 +167,28 @@ function normalizeHubMessage( contentType: contentType === 'image' ? 'image' : 'file', }; } - // Fallback: treat as text if attachment data is missing + // #1972 honest degradation: the Hub delivered an image/file message but + // the attachment record is missing. Emit an attachment block with an + // unresolvable ref (empty id) so the renderer degrades to the #1938 + // chip + explicit status notice. The text fallback below cannot render + // image/file content, so dropping the message there would silently lose + // it — forbidden by the #1972 acceptance contract. + return { + id, + author: normalizeAuthor(message), + ...(message.created_at ? { createdAt: message.created_at } : {}), + ...(pinned ? { pinned: true } : {}), + kind: 'attachment', + attachmentRef: { + id: '', + name: contentType === 'image' + ? (t?.('message.attachmentMissingImage') ?? ATTACHMENT_MISSING_IMAGE_FALLBACK) + : (t?.('message.attachmentMissingFile') ?? ATTACHMENT_MISSING_FILE_FALLBACK), + size: 0, + mime_type: '', + }, + contentType: contentType === 'image' ? 'image' : 'file', + }; } const metadata = recalled ? null : hubContentMetadata(message.content); diff --git a/app/shared/src/transcript/types.ts b/app/shared/src/transcript/types.ts index 60b1908ca..04d9e9717 100644 --- a/app/shared/src/transcript/types.ts +++ b/app/shared/src/transcript/types.ts @@ -299,7 +299,12 @@ export interface ReplayGapTranscriptBlock extends TranscriptBlockBase { export interface AttachmentTranscriptBlock extends TranscriptBlockBase { kind: 'attachment'; - /** The attachment reference stored on the Hub server. */ + /** + * The attachment reference stored on the Hub server. A degraded entry + * (attachment data missing on an image/file message, #1972) uses an empty + * `id`, which the renderer resolves to the #1938 honest fallback: file + * chip plus explicit status notice instead of a silently dropped message. + */ attachmentRef: import('../composer').AttachmentRef; /** Whether this is an image or a generic file attachment. */ contentType: 'image' | 'file'; diff --git a/app/web/src/platform/useWebWorkbenchModel.test.ts b/app/web/src/platform/useWebWorkbenchModel.test.ts index fc4b2d3db..62451d4b7 100644 --- a/app/web/src/platform/useWebWorkbenchModel.test.ts +++ b/app/web/src/platform/useWebWorkbenchModel.test.ts @@ -20,6 +20,7 @@ import { workspaceProjectToProjectInfo, } from './webWorkbenchProjects'; import { + resolveWebActiveHubSessionId, resolveWebSessionLastReadSeq, resolveWebWorkbenchContacts, useWebSessionAutoMarkRead, @@ -1097,3 +1098,107 @@ describe('web session auto mark-read (#1352)', () => { expect(firstMarkRead).toHaveBeenCalledTimes(1); }); }); + +describe('resolveWebActiveHubSessionId (#1972 gate wiring)', () => { + it('activates the Hub transcript for a session that only carries snake_case session_id', () => { + // Real REST /client/sessions payloads have no `id` field; the gate must + // use the same derivation as webPlatformMapping (id ?? session_id). + expect(resolveWebActiveHubSessionId( + true, + [{ session_id: 'hub-session-1', type: 'private' }], + 'hub-session-1', + )).toBe('hub-session-1'); + }); + + it('activates for legacy payloads that still carry id', () => { + expect(resolveWebActiveHubSessionId( + true, + [{ id: 'hub-session-2', type: 'group' }], + 'hub-session-2', + )).toBe('hub-session-2'); + }); + + it('does not activate for conversation ids that match no Hub session', () => { + const sessions = [{ session_id: 'hub-session-1', type: 'private' }]; + + expect(resolveWebActiveHubSessionId(true, sessions, 'edge-thread-1')).toBeNull(); + expect(resolveWebActiveHubSessionId(true, [], 'hub-session-1')).toBeNull(); + expect(resolveWebActiveHubSessionId(true, undefined, 'hub-session-1')).toBeNull(); + expect(resolveWebActiveHubSessionId(true, sessions, undefined)).toBeNull(); + }); + + it('does not activate before the Hub is ready', () => { + expect(resolveWebActiveHubSessionId( + false, + [{ session_id: 'hub-session-1', type: 'private' }], + 'hub-session-1', + )).toBeNull(); + }); + + it('renders the real-shape REST image payload (with and without attachments) through the web transcript path', () => { + const translate = vi.fn((key: string) => ( + key === 'message.attachmentMissingImage' ? 'Image attachment missing' : key + )); + // Receive-path contract (#1972 acceptance 3): REST payload -> messages + // query result -> normalizeHubMessages. The live WS path converges here + // too — message.new frames only invalidate ['web-v4','hub-messages', + // session_id] (webHubRealtime), so the refetched payload is the only + // transcript source for live messages as well. + const transcript = resolveWebWorkbenchTranscript( + true, + 'hub-session-1', + [ + { + id: 'msg-img-with', + session_id: 'hub-session-1', + seq_id: 20, + sender_type: 'user', + sender_id: 'user-1', + content_type: 'image', + content: '{"text": "user image", "attachment_id": "att-1"}', + created_at: '2026-08-25T00:10:08Z', + attachments: [{ + id: 'att-1', + size: 62798, + mime_type: 'image/png', + created_at: '2026-08-25T00:10:08Z', + }], + }, + { + id: 'msg-img-missing', + session_id: 'hub-session-1', + seq_id: 21, + sender_type: 'user', + sender_id: 'user-1', + content_type: 'image', + content: '{"text": "user image", "attachment_id": "att-gone"}', + created_at: '2026-08-25T00:11:08Z', + }, + ], + [], + undefined, + undefined, + translate, + ); + + expect(transcript).toEqual([ + expect.objectContaining({ + id: 'hub-message-msg-img-with', + kind: 'attachment', + contentType: 'image', + attachmentRef: expect.objectContaining({ id: 'att-1', mime_type: 'image/png' }), + }), + expect.objectContaining({ + id: 'hub-message-msg-img-missing', + kind: 'attachment', + contentType: 'image', + attachmentRef: expect.objectContaining({ + id: '', + name: 'Image attachment missing', + size: 0, + }), + }), + ]); + expect(translate).toHaveBeenCalledWith('message.attachmentMissingImage'); + }); +}); diff --git a/app/web/src/platform/useWebWorkbenchModel.ts b/app/web/src/platform/useWebWorkbenchModel.ts index ea103366a..83899786f 100644 --- a/app/web/src/platform/useWebWorkbenchModel.ts +++ b/app/web/src/platform/useWebWorkbenchModel.ts @@ -21,7 +21,7 @@ import { type HubRuntimeEventTranscriptInput, } from '@shared/transcript'; import { useToastStore } from '@shared/ui/toast'; -import { createHubClient } from '@/api/hubClient'; +import { createHubClient, type Session } from '@/api/hubClient'; import { useHubExecutionTargets, usePingHubExecutionTarget, @@ -85,6 +85,28 @@ import { errorMessage } from './webWorkbenchError'; const hubClient = createHubClient({ getToken: getAccessToken }); +/** + * Resolve the active Hub session id for transcript queries (#1972). + * + * REST `/client/sessions` payloads only carry snake_case `session_id`, + * while conversation ids are derived as `session.id ?? session.session_id` + * in webPlatformMapping. The activation gate must use the same derivation — + * matching on `session.id` alone is always false against real payloads, + * which leaves the hub-messages/pins/agent-task queries permanently + * disabled and the transcript stuck on the preview/empty fallback. + */ +export function resolveWebActiveHubSessionId( + hubReady: boolean, + sessions: Session[] | undefined, + activeConversationId: string | undefined, +): string | null { + if (!hubReady || !activeConversationId) return null; + const matched = sessions?.some( + (session) => (session.id ?? session.session_id) === activeConversationId, + ); + return matched ? activeConversationId : null; +} + export function useWebWorkbenchModel(selectedConversationId?: string, selectedProjectId?: string) { const { t } = useTranslation(CHATVIEW_I18N_NAMESPACE); const dataModeOverride = useSyncExternalStore( @@ -133,11 +155,9 @@ export function useWebWorkbenchModel(selectedConversationId?: string, selectedPr const activeConversationId = selectedConversationId ?? conversations[0]?.id ?? 'agent-collab'; - // Only treat as Hub session when the resolved id is actually a Hub session. - const activeHubSessionId = hubReady - && sessions.data?.some((session) => session.id === activeConversationId) - ? activeConversationId - : null; + // Only treat as Hub session when the resolved id is actually a Hub session + // (id derivation aligned with webPlatformMapping; see resolveWebActiveHubSessionId, #1972). + const activeHubSessionId = resolveWebActiveHubSessionId(hubReady, sessions.data, activeConversationId); useEffect(() => { setLiveRuntimeEvents([]);