Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions app/desktop/src/platform/useDesktopWorkbenchModel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<typeof useHubSessions>);
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<typeof useHubMessages>);
const translate = vi.fn((key: string) => (
key === 'message.attachmentMissingImage' ? 'Image attachment missing' : key
));

const { result } = renderHook(
() => useDesktopWorkbenchModel('hub-session-1', translate),
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
},
);

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<string, (payload: unknown) => 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<typeof useHubSessions>);
mockedUseHubMessages.mockReturnValue({
data: [],
isLoading: false,
error: null,
} as unknown as ReturnType<typeof useHubMessages>);

const { result } = renderHook(
() => useDesktopWorkbenchModel('legacy-hub-session'),
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
},
);

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<typeof useHubSessions>);

const { result } = renderHook(
() => useDesktopWorkbenchModel('edge-thread-x'),
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
},
);

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);
Expand Down
30 changes: 22 additions & 8 deletions app/desktop/src/platform/useDesktopWorkbenchModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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);
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions app/shared/src/chatview/i18n/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,8 @@ export const chatviewResources = {

// ═══ Message state ═══
'message.recalled': '消息已撤回',
'message.attachmentMissingImage': '图片附件缺失',
'message.attachmentMissingFile': '文件附件缺失',

// ═══ Chat kind ═══
'chat.kind.group': '群聊',
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading