From 2c762c9b3ebdc6ce8116c97809ad85c68d04a33a Mon Sep 17 00:00:00 2001 From: Anton Frehser Date: Tue, 6 Oct 2026 19:20:00 +0200 Subject: [PATCH 1/3] Open exact chats through workspace links --- client/features/chat/ChatPanel.tsx | 4 +- .../chat/messages/MarkdownContent.test.tsx | 3 +- client/features/chat/sessions/api.ts | 12 +- client/features/chat/sessions/chat-link.ts | 37 ++ .../chat/sessions/useSelectedSession.ts | 69 ++-- client/features/workspace/WorkspaceScreen.tsx | 7 +- .../workspace/useWorkspaceNavigation.test.tsx | 350 ++++++++++++++++-- .../workspace/useWorkspaceNavigation.ts | 134 ++++++- docs/navigation.md | 23 +- lib/navigation.test.ts | 32 +- lib/navigation.ts | 34 +- server/control.ts | 7 +- server/test/navigation-cli.test.ts | 59 +-- 13 files changed, 651 insertions(+), 120 deletions(-) create mode 100644 client/features/chat/sessions/chat-link.ts 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/useWorkspaceNavigation.test.tsx b/client/features/workspace/useWorkspaceNavigation.test.tsx index b166ed19..c7d39173 100644 --- a/client/features/workspace/useWorkspaceNavigation.test.tsx +++ b/client/features/workspace/useWorkspaceNavigation.test.tsx @@ -1,10 +1,15 @@ -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' @@ -16,7 +21,9 @@ function readNavigation( views: ViewInfo[], base = '', navigate?: (path: string) => void, - queryClient = new QueryClient() + queryClient = new QueryClient(), + onOpenChat: (tab: WorkspaceTabId) => void = () => {}, + collab = false ) { const queryKey = workspaceKeys.views('abc') if (!queryClient.getQueryDefaults(queryKey).queryFn) @@ -25,7 +32,8 @@ function readNavigation( const prefix = base === '/' ? '' : base function Probe() { const { activeTab, appletParams, isUnavailable } = useWorkspaceNavigation({ - views + views, + onOpenChat }) return (