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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions client/features/chat/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -205,7 +205,7 @@ export function ChatPanel({
<div className="mx-auto flex w-full max-w-(--chat-max-container) flex-1 flex-col gap-6">
{forkedFromSessionId && (
<Link
href={workspaceTabPath(workspaceId, 'overview')}
href={addressPath(workspaceId, { sessionId: forkedFromSessionId, search: '' })}
className="block text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
<div className="flex items-center gap-3">
Expand Down
3 changes: 2 additions & 1 deletion client/features/chat/messages/MarkdownContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ test('chat renders portable links as native workspace hrefs with the deployment
const html = renderToStaticMarkup(
<Router base="/prefix">
<Workspace id="abc">
<MarkdownContent content="[Event](moi:/views/events?eventId=123) [Web](https://example.com/)" />
<MarkdownContent content="[Event](moi:/views/events?eventId=123) [Chat](moi:/chats/chat-1) [Web](https://example.com/)" />
</Workspace>
</Router>
)
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', () => {
Expand Down
12 changes: 8 additions & 4 deletions client/features/chat/sessions/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,16 @@ import type {
WorkspaceSessionSelection
} from '@/lib/types'

export function useWorkspaceSessions(workspaceId: string) {
return useQuery<SessionInfo[]>({
export function workspaceSessionsOptions(workspaceId: string) {
return {
queryKey: workspaceKeys.sessions(workspaceId),
queryFn: () => requestJson(`/api/workspaces/${workspaceId}/sessions`),
queryFn: () => requestJson<SessionInfo[]>(`/api/workspaces/${workspaceId}/sessions`),
...WORKSPACE_RESOURCE_OPTIONS
})
}
}

export function useWorkspaceSessions(workspaceId: string) {
return useQuery(workspaceSessionsOptions(workspaceId))
}

export function removeArchivedSession(
Expand Down
37 changes: 37 additions & 0 deletions client/features/chat/sessions/chat-link.ts
Original file line number Diff line number Diff line change
@@ -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<WorkspaceTabId | null> {
signal.throwIfAborted()
let session = queryClient
.getQueryData<SessionInfo[]>(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<ViewInfo[]>(workspaceKeys.views(workspaceId)) ?? views
}
return tabAvailable(tab, views) ? tab : null
}
69 changes: 46 additions & 23 deletions client/features/chat/sessions/useSelectedSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
]

export type SelectedSessionSaveResult = 'applied' | 'conflict' | 'ignored'
Expand All @@ -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<WorkspaceSessionSelection>(`/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
}
Expand Down Expand Up @@ -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<WorkspaceSessionSelection>({
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
Expand Down Expand Up @@ -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<WorkspaceSessionSelection>(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)
Expand All @@ -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<WorkspaceSessionSelection>({
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() {
Expand Down Expand Up @@ -322,6 +344,7 @@ export function usePinnedSession() {
return {
pinnedSessionId: data?.pinned ?? null,
loaded: data !== undefined,
pin: mutation.mutate
pin: mutation.mutate,
pinAsync: mutation.mutateAsync
}
}
7 changes: 6 additions & 1 deletion client/features/workspace/WorkspaceScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@
// 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.
Expand All @@ -169,7 +173,8 @@
isUnavailable,
onNavigationClick
} = useWorkspaceNavigation({
views: allViews
views: allViews,
onOpenChat: revealLinkedChat
})

// Chat comes after navigation, and takes the address: every message carries
Expand Down Expand Up @@ -264,13 +269,13 @@

useEffect(() => {
if (mode !== 'fullscreen') {
setFloatingChatOpen(false)

Check warning on line 272 in client/features/workspace/WorkspaceScreen.tsx

View workflow job for this annotation

GitHub Actions / check

react(set-state-in-effect)

client/features/workspace/WorkspaceScreen.tsx:272:7: Calling setState synchronously within an effect can trigger cascading renders
}
}, [mode])

useEffect(() => {
if (!activeDraftViewId) return
if (mode === 'fullscreen') setFloatingChatOpen(true)

Check warning on line 278 in client/features/workspace/WorkspaceScreen.tsx

View workflow job for this annotation

GitHub Actions / check

react(set-state-in-effect)

client/features/workspace/WorkspaceScreen.tsx:278:32: Calling setState synchronously within an effect can trigger cascading renders
setChatFocusRequest(request => request + 1)
}, [activeDraftViewId, mode])

Expand Down
Loading
Loading