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
+
+
+
+
+ )
+ }
+ 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 (