diff --git a/bun.lock b/bun.lock index a7ad8cd8..66acfda2 100644 --- a/bun.lock +++ b/bun.lock @@ -69,6 +69,7 @@ "@types/react-resizable": "^3.0.8", "date-fns": "^4.4.0", "embla-carousel-react": "^8.6.0", + "linkedom": "0.18.12", "oxfmt": "^0.54.0", "oxlint": "^1.82.0", "react-day-picker": "^10.0.1", diff --git a/client/features/chat/ChatPanel.tsx b/client/features/chat/ChatPanel.tsx index 7a2f90b8..19c33278 100644 --- a/client/features/chat/ChatPanel.tsx +++ b/client/features/chat/ChatPanel.tsx @@ -3,7 +3,7 @@ import { Link } from 'wouter' import { IconArrowBackUp, IconChevronDown, IconChevronsRight, IconX } from '@tabler/icons-react' import { draftSessionId } from '@/lib/session-drafts' -import { workspaceTabPath } from '@/lib/navigation' +import { addressPath } from '@/lib/navigation' import { useCurrentTabId } from './sessions/useSelectedSession' import { canSubmitComposerAction, focusComposer } from '@/client/components/shared/Composer' @@ -205,7 +205,7 @@ export function ChatPanel({
{forkedFromSessionId && (
diff --git a/client/features/chat/messages/MarkdownContent.test.tsx b/client/features/chat/messages/MarkdownContent.test.tsx index 9a33cd67..fdd5d411 100644 --- a/client/features/chat/messages/MarkdownContent.test.tsx +++ b/client/features/chat/messages/MarkdownContent.test.tsx @@ -8,12 +8,13 @@ test('chat renders portable links as native workspace hrefs with the deployment const html = renderToStaticMarkup( - + ) expect(html).toContain('href="/prefix/workspace/abc/views/events?eventId=123"') expect(html).toContain('href="https://example.com/"') + expect(html).toContain('href="/prefix/workspace/abc/chats/chat-1"') }) test('invalid moi links and executable URLs stay sanitized, including images', () => { diff --git a/client/features/chat/sessions/api.ts b/client/features/chat/sessions/api.ts index 7b030efb..7c8a0fa2 100644 --- a/client/features/chat/sessions/api.ts +++ b/client/features/chat/sessions/api.ts @@ -14,12 +14,16 @@ import type { WorkspaceSessionSelection } from '@/lib/types' -export function useWorkspaceSessions(workspaceId: string) { - return useQuery({ +export function workspaceSessionsOptions(workspaceId: string) { + return { queryKey: workspaceKeys.sessions(workspaceId), - queryFn: () => requestJson(`/api/workspaces/${workspaceId}/sessions`), + queryFn: () => requestJson(`/api/workspaces/${workspaceId}/sessions`), ...WORKSPACE_RESOURCE_OPTIONS - }) + } +} + +export function useWorkspaceSessions(workspaceId: string) { + return useQuery(workspaceSessionsOptions(workspaceId)) } export function removeArchivedSession( diff --git a/client/features/chat/sessions/chat-link.ts b/client/features/chat/sessions/chat-link.ts new file mode 100644 index 00000000..4256c539 --- /dev/null +++ b/client/features/chat/sessions/chat-link.ts @@ -0,0 +1,37 @@ +import type { QueryClient } from '@tanstack/react-query' +import { workspaceKeys } from '@/client/api/workspace-keys' +import { workspaceSessionsOptions } from './api' +import { tabAvailable } from '@/client/features/workspace/tab-resolution' +import { isWorkspaceTabId } from '@/lib/workspace-tabs' +import type { SessionInfo, ViewInfo, WorkspaceTabId } from '@/lib/types' + +// Metadata alone does not establish existence: the sessions API combines the +// harness's live chat list with moi's attribution records. +export async function resolveChatLink( + queryClient: QueryClient, + workspaceId: string, + sessionId: string, + views: ViewInfo[], + signal: AbortSignal +): Promise { + signal.throwIfAborted() + let session = queryClient + .getQueryData(workspaceKeys.sessions(workspaceId)) + ?.find(session => session.sessionId === sessionId) + if (!session) { + const sessions = await queryClient.fetchQuery(workspaceSessionsOptions(workspaceId)) + session = sessions.find(session => session.sessionId === sessionId) + } + signal.throwIfAborted() + if (!session) return null + const tab = isWorkspaceTabId(session.tabId) ? session.tabId : 'overview' + if (!tabAvailable(tab, views)) { + await queryClient.refetchQueries( + { queryKey: workspaceKeys.views(workspaceId), exact: true }, + { throwOnError: true } + ) + signal.throwIfAborted() + views = queryClient.getQueryData(workspaceKeys.views(workspaceId)) ?? views + } + return tabAvailable(tab, views) ? tab : null +} diff --git a/client/features/chat/sessions/useSelectedSession.ts b/client/features/chat/sessions/useSelectedSession.ts index 68412ce9..2560cdff 100644 --- a/client/features/chat/sessions/useSelectedSession.ts +++ b/client/features/chat/sessions/useSelectedSession.ts @@ -31,7 +31,8 @@ type SetSelectedSession = (sessionId: string | null) => void type SelectedSessionResult = readonly [ sessionId: string | null | undefined, - setSessionId: SetSelectedSession + setSessionId: SetSelectedSession, + selectSessionForTab: (sessionId: string, tabId: WorkspaceTabId) => Promise ] export type SelectedSessionSaveResult = 'applied' | 'conflict' | 'ignored' @@ -41,6 +42,23 @@ export function selectedSessionKey(workspaceId: string, scope: SelectedSessionSc return scope === 'browser-tab' ? ([...key, 'browser-tab'] as const) : key } +export function selectedSessionOptions( + workspaceId: string, + scope: SelectedSessionScope = 'shared' +) { + return { + queryKey: selectedSessionKey(workspaceId, scope), + queryFn: () => + scope === 'browser-tab' + ? Promise.resolve(readSelectedSession(workspaceId)) + : requestJson(`/api/workspaces/${workspaceId}/selected-session`), + staleTime: Infinity, + gcTime: 0, + refetchOnMount: false, + refetchOnWindowFocus: false + } +} + function selectedSessionMutationKey(workspaceId: string, scope: SelectedSessionScope) { return [...selectedSessionKey(workspaceId, scope), 'save'] as const } @@ -179,21 +197,11 @@ export function useSelectedSession(explicitTabId?: WorkspaceTabId): SelectedSess // WorkspaceContent remains an observer for the active workspace. Nested hook // users reuse its result without refetching; once the route unmounts, dropping // the cache makes the next visit load the server-owned selection again. - const query = useQuery({ - queryKey, - queryFn: () => - scope === 'browser-tab' - ? Promise.resolve(readSelectedSession(workspaceId)) - : requestJson(`/api/workspaces/${workspaceId}/selected-session`), - staleTime: Infinity, - gcTime: 0, - refetchOnMount: false, - refetchOnWindowFocus: false - }) + const query = useQuery(selectedSessionOptions(workspaceId, scope)) // A pin is shared with CLI creation and applies to every workspace tab. const sharedSelection = useWorkspaceSessionSelection().data - const { mutate: saveSelectedSession } = useMutation< + const { mutate: saveSelectedSession, mutateAsync: saveSelectedSessionAsync } = useMutation< SelectedSessionState, Error, SaveSelectedSessionInput @@ -253,6 +261,27 @@ export function useSelectedSession(explicitTabId?: WorkspaceTabId): SelectedSess [queryClient, saveSelectedSession, workspaceId, tabId, scope] ) + const selectSessionForTab = useCallback( + async (sessionId: string, destination: WorkspaceTabId) => { + const input = optimisticallySetSelectedSession( + queryClient, + workspaceId, + sessionId, + destination, + scope + ) + if (!input) return + const saved = await saveSelectedSessionAsync(input) + if ( + saved.sessionId !== sessionId || + queryClient.getQueryData(queryKey)?.selected[destination] !== + sessionId + ) + throw new Error('Couldn’t save selected chat') + }, + [queryClient, queryKey, saveSelectedSessionAsync, scope, workspaceId] + ) + useWorkspaceEvent(event => { if (event.type !== 'selected-session:updated' || event.workspaceId !== workspaceId) return applySelectedSessionEvent(queryClient, workspaceId, scope === 'shared' && pendingSaves > 0) @@ -266,19 +295,12 @@ export function useSelectedSession(explicitTabId?: WorkspaceTabId): SelectedSess tabId, scope === 'browser-tab' ? query.data : undefined ) - return [selectedSessionId, setSelectedSessionId] + return [selectedSessionId, setSelectedSessionId, selectSessionForTab] } function useWorkspaceSessionSelection() { const workspaceId = useWorkspaceId() - return useQuery({ - queryKey: appUiKeys.sessionSelection(workspaceId), - queryFn: () => requestJson(`/api/workspaces/${workspaceId}/selected-session`), - staleTime: Infinity, - gcTime: 0, - refetchOnMount: false, - refetchOnWindowFocus: false - }) + return useQuery(selectedSessionOptions(workspaceId)) } export function usePinnedSession() { @@ -322,6 +344,7 @@ export function usePinnedSession() { return { pinnedSessionId: data?.pinned ?? null, loaded: data !== undefined, - pin: mutation.mutate + pin: mutation.mutate, + pinAsync: mutation.mutateAsync } } diff --git a/client/features/workspace/WorkspaceScreen.tsx b/client/features/workspace/WorkspaceScreen.tsx index bf328bf4..c8621820 100644 --- a/client/features/workspace/WorkspaceScreen.tsx +++ b/client/features/workspace/WorkspaceScreen.tsx @@ -157,6 +157,10 @@ export function WorkspaceScreen({ widgets, views: allViews }: WorkspaceScreenPro // Split needs enough row width; the saved mode remains the user's intent. const wantsSplit = layout.layoutMode === 'split' const mode: LayoutMode = wantsSplit && canUseSplit ? 'split' : 'fullscreen' + const revealLinkedChat = useCallback(() => { + setFloatingChatOpen(true) + setChatFocusRequest(request => request + 1) + }, []) // The tab address: URL in, active tab + applet params out, plus the persisted // tab state it keeps in sync. See useWorkspaceNavigation for the invariants. @@ -169,7 +173,8 @@ export function WorkspaceScreen({ widgets, views: allViews }: WorkspaceScreenPro isUnavailable, onNavigationClick } = useWorkspaceNavigation({ - views: allViews + views: allViews, + onOpenChat: revealLinkedChat }) // Chat comes after navigation, and takes the address: every message carries diff --git a/client/features/workspace/use-workspace-navigation.dom.test.tsx b/client/features/workspace/use-workspace-navigation.dom.test.tsx new file mode 100644 index 00000000..e09d818b --- /dev/null +++ b/client/features/workspace/use-workspace-navigation.dom.test.tsx @@ -0,0 +1,227 @@ +import { afterEach, describe, expect, spyOn, test } from 'bun:test' +import { act, useState } from 'react' +import { createRoot } from 'react-dom/client' +import { parseHTML } from 'linkedom' +import { notifyManager, QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { Link, Route, Router } from 'wouter' + +import { workspaceKeys } from '@/client/api/workspace-keys' +import { selectedSessionKey } from '@/client/features/chat/sessions/useSelectedSession' +import { CollabContext } from '@/client/features/collab/provider' +import { NO_ENGINE } from '@/client/features/collab/client' +import { createFakeEngine } from '@/client/features/collab/testing/fake-engine' +import * as workspaceEvents from '@/client/runtime/useWorkspaceEvents' +import { addressPath } from '@/lib/navigation' +import { createDefaultWorkspaceLayout } from '@/lib/workspace-layout' +import type { WorkspaceSessionSelection } from '@/lib/types' +import { Workspace } from './WorkspaceContext' +import { WorkspaceLayoutContext } from './WorkspaceLayoutContext' +import { useWorkspaceNavigation } from './useWorkspaceNavigation' + +let cleanup: () => Promise = async () => {} +afterEach(() => cleanup()) + +function mountNavigation(path: string, collab: boolean) { + const { window, document } = parseHTML('
') + const location = new URL(`http://localhost/prefix/workspace/abc/${path}`) + Object.defineProperty(window, 'location', { configurable: true, value: location }) + const storage = new Map() + const entries: { kind: 'push' | 'replace'; href: string }[] = [] + const globals = { + window, + document, + location, + Element: window.Element, + HTMLElement: window.HTMLElement, + HTMLAnchorElement: window.HTMLAnchorElement, + addEventListener: window.addEventListener.bind(window), + removeEventListener: window.removeEventListener.bind(window), + history: { + pushState: (_state: unknown, _title: string, href: string) => { + entries.push({ kind: 'push', href }) + move(href) + }, + replaceState: (_state: unknown, _title: string, href: string) => { + entries.push({ kind: 'replace', href }) + move(href) + } + }, + sessionStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value) + }, + IS_REACT_ACT_ENVIRONMENT: true + } + function move(href: string) { + location.href = new URL(href, location).href + window.dispatchEvent(new window.Event('popstate')) + } + const descriptors = new Map( + Object.keys(globals).map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]) + ) + for (const [key, value] of Object.entries(globals)) + Object.defineProperty(globalThis, key, { configurable: true, value }) + notifyManager.setNotifyFunction(callback => { + act(callback) + }) + + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + let selection: WorkspaceSessionSelection = { selected: { overview: 'previous' }, pinned: 'other' } + queryClient.setQueryData(workspaceKeys.sessions('abc'), [ + { sessionId: 'source', summary: 'Source', lastModified: 1, tabId: 'scratchpad' } + ]) + queryClient.setQueryData(selectedSessionKey('abc'), selection) + const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation( + Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.endsWith('/selected-session') && !init?.method) return Response.json(selection) + const body = JSON.parse(String(init?.body)) as { sessionId: string; tabId: string } + if (url.endsWith('/pinned-session')) { + selection = { ...selection, pinned: null } + return Response.json({ pinnedSessionId: null }) + } + if (url.endsWith('/selected-session')) { + selection = { + ...selection, + selected: { ...selection.selected, [body.tabId]: body.sessionId } + } + return Response.json({ sessionId: body.sessionId }) + } + throw new Error(`Unexpected request: ${url}`) + }, + { preconnect: fetch.preconnect } + ) + ) + const navigationSpy = spyOn(workspaceEvents, 'useNavigationClient').mockImplementation(() => {}) + const eventsSpy = spyOn(workspaceEvents, 'useWorkspaceEvent').mockImplementation(() => {}) + + function Probe() { + const [revealed, setRevealed] = useState(false) + const { activeTab, onNavigationClick } = useWorkspaceNavigation({ + views: [], + onOpenChat: () => setRevealed(true) + }) + return ( +
+ + Started from another chat + + + Chat link + + {activeTab} + {revealed ? 'yes' : 'no'} +
+ ) + } + const root = createRoot(document.getElementById('root')!) + cleanup = async () => { + await act(async () => { + root.unmount() + queryClient.clear() + await Bun.sleep(0) + }) + notifyManager.setNotifyFunction(callback => callback()) + fetchSpy.mockRestore() + navigationSpy.mockRestore() + eventsSpy.mockRestore() + for (const [key, descriptor] of descriptors) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor) + else Reflect.deleteProperty(globalThis, key) + } + } + const render = () => + act(async () => { + root.render( + + + + + + {}, + name: null, + cwd: null, + provider: null, + workspaceId: 'abc', + isLoading: false + }} + > + + + + + + + + ) + }) + const waitForNavigation = async () => { + for (let i = 0; i < 50 && document.getElementById('revealed')?.textContent !== 'yes'; i++) + await act(async () => { + await Bun.sleep(10) + }) + expect(location.pathname).toBe('/prefix/workspace/abc/scratchpad') + expect(document.getElementById('active-tab')?.textContent).toBe('scratchpad') + expect(document.getElementById('revealed')?.textContent).toBe('yes') + expect( + queryClient.getQueryData(selectedSessionKey('abc'))?.pinned + ).toBeNull() + expect( + queryClient.getQueryData( + selectedSessionKey('abc', collab ? 'browser-tab' : 'shared') + )?.selected.scratchpad + ).toBe('source') + } + return { render, document, window, waitForNavigation, history: entries } +} + +for (const collab of [false, true]) { + describe(`mounted chat links with Collab ${collab ? 'enabled' : 'disabled'}`, () => { + test('a direct chat URL selects and reveals its chat through the route effect', async () => { + const fixture = mountNavigation('chats/source', collab) + await fixture.render() + await fixture.waitForNavigation() + // A direct load has no origin page, so the entry route is replaced in place. + expect(fixture.history).toEqual([ + { kind: 'replace', href: '/prefix/workspace/abc/scratchpad' } + ]) + }) + + test.each(['source', 'native'])( + 'clicking a %s chat link selects and reveals its chat', + async kind => { + const fixture = mountNavigation('overview', collab) + await fixture.render() + const click = new fixture.window.Event('click', { bubbles: true, cancelable: true }) + Object.assign(click, { button: 0 }) + await act(async () => { + fixture.document.querySelector(`a[data-link="${kind}"]`)!.dispatchEvent(click) + }) + expect(click.defaultPrevented).toBe(true) + await fixture.waitForNavigation() + // One net history entry: the pushed chat route is replaced by its home + // tab, so Back returns to the page the link was clicked on. + expect(fixture.history).toEqual([ + { kind: 'push', href: '/prefix/workspace/abc/chats/source' }, + { kind: 'replace', href: '/prefix/workspace/abc/scratchpad' } + ]) + } + ) + }) +} diff --git a/client/features/workspace/useWorkspaceNavigation.test.tsx b/client/features/workspace/useWorkspaceNavigation.test.tsx index b166ed19..252c4491 100644 --- a/client/features/workspace/useWorkspaceNavigation.test.tsx +++ b/client/features/workspace/useWorkspaceNavigation.test.tsx @@ -1,22 +1,39 @@ -import { expect, spyOn, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test' import { renderToStaticMarkup } from 'react-dom/server' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { Route, Router } from 'wouter' import { workspaceKeys } from '@/client/api/workspace-keys' -import type { ViewInfo } from '@/lib/types' +import { Workspace } from './WorkspaceContext' +import { CollabContext } from '@/client/features/collab/provider' +import { NO_ENGINE } from '@/client/features/collab/client' +import { createFakeEngine } from '@/client/features/collab/testing/fake-engine' +import { selectedSessionKey } from '@/client/features/chat/sessions/useSelectedSession' +import type { SessionInfo, ViewInfo, WorkspaceSessionSelection, WorkspaceTabId } from '@/lib/types' import { createDefaultWorkspaceLayout } from '@/lib/workspace-layout' import * as workspaceEvents from '@/client/runtime/useWorkspaceEvents' import { WorkspaceLayoutContext } from './WorkspaceLayoutContext' import { useWorkspaceNavigation } from './useWorkspaceNavigation' +type ReadNavigationOptions = { + base?: string + navigate?: (path: string, options?: { replace?: boolean }) => void + queryClient?: QueryClient + onOpenChat?: () => void + collab?: boolean +} + function readNavigation( path: string, search: string, views: ViewInfo[], - base = '', - navigate?: (path: string) => void, - queryClient = new QueryClient() + { + base = '', + navigate, + queryClient = new QueryClient(), + onOpenChat = () => {}, + collab = false + }: ReadNavigationOptions = {} ) { const queryKey = workspaceKeys.views('abc') if (!queryClient.getQueryDefaults(queryKey).queryFn) @@ -25,7 +42,8 @@ function readNavigation( const prefix = base === '/' ? '' : base function Probe() { const { activeTab, appletParams, isUnavailable } = useWorkspaceNavigation({ - views + views, + onOpenChat }) return (