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
13 changes: 3 additions & 10 deletions client/features/applets/applet-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import { toast } from '@/client/components/ui/toast'
import { createRateLimiter, type RateLimiter } from '@/client/lib/rate-limit'
import { useLatestRef } from '@/client/lib/use-latest-ref'
import { isParamsRecord } from '@/lib/workspace-tabs'
import { resolveUrl, workspacePath } from '@/lib/navigation'
import { resolveUrl } from '@/lib/navigation'

type HostAppletBridge = SharedAppletBridge & {
readonly collab: AppletCollabApi | undefined
Expand Down Expand Up @@ -127,10 +127,6 @@ function createRuntime(workspaceId: string) {
connect(identity: AppletIdentity, base = '') {
let alive = true
const source = appletSource(identity)
const urlContext = {
apiBase: `/api/workspaces/${encodeURIComponent(workspaceId)}`,
workspacePath: workspacePath(workspaceId, base)
}
const bridge: HostAppletBridge = {
get collab() {
return alive ? appletCollabApi : undefined
Expand All @@ -149,17 +145,14 @@ function createRuntime(workspaceId: string) {
navigate(url) {
if (!alive) return
try {
if (typeof url !== 'string') throw new Error('Navigation needs a URL')
resolveUrl(url, urlContext)
emitter.emit('navigate', url)
emitter.emit('navigate', resolveUrl(url))
} catch (error) {
drop(identity, `navigate() was dropped: ${errorMessage(error)}`)
}
},
resolveUrl(url) {
if (!alive) return ''
if (typeof url !== 'string') throw new Error('A URL is required')
return resolveUrl(url, urlContext)
return resolveUrl(url, { workspaceId, base })
},
sendChatMessage(input, legacyContext) {
if (!alive) return
Expand Down
14 changes: 9 additions & 5 deletions client/features/chat/ChatPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { Link } from 'wouter'
import { useRouter } from 'wouter'

import { IconArrowBackUp, IconChevronDown, IconChevronsRight, IconX } from '@tabler/icons-react'
import { draftSessionId } from '@/lib/session-drafts'
import { addressPath } from '@/lib/navigation'
import { resolveUrl } from '@/lib/navigation'
import { useCurrentTabId } from './sessions/useSelectedSession'

import { canSubmitComposerAction, focusComposer } from '@/client/components/shared/Composer'
Expand Down Expand Up @@ -91,6 +91,7 @@ export function ChatPanel({
viewDraft
}: ChatPanelProps) {
const workspaceId = useWorkspaceId()
const { base } = useRouter()
const { data: sessions } = useWorkspaceSessions(workspaceId)
const sourceName = sessions?.find(session => session.sessionId === forkedFromSessionId)?.summary
const tabId = useCurrentTabId()
Expand Down Expand Up @@ -204,8 +205,11 @@ export function ChatPanel({
>
<div className="mx-auto flex w-full max-w-(--chat-max-container) flex-1 flex-col gap-6">
{forkedFromSessionId && (
<Link
href={addressPath(workspaceId, { sessionId: forkedFromSessionId, search: '' })}
<a
href={resolveUrl(`moi:/chats/${encodeURIComponent(forkedFromSessionId)}`, {
workspaceId,
base
})}
className="block text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
<div className="flex items-center gap-3">
Expand All @@ -222,7 +226,7 @@ export function ChatPanel({
</span>
</div>
</div>
</Link>
</a>
)}
{showEmptyState && (
<ChatEmptyState
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,13 +8,14 @@ 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) [Chat](moi:/chats/chat-1) [Web](https://example.com/)" />
<MarkdownContent content="[Event](moi:/views/events?eventId=123) [Chat](moi:/chats/chat-1) [Current chat](moi:/chat) [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"')
expect(html).toContain('href="/prefix/workspace/abc/chat"')
})

test('invalid moi links and executable URLs stay sanitized, including images', () => {
Expand Down
6 changes: 3 additions & 3 deletions client/features/chat/messages/MarkdownContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import rehypeHighlight from 'rehype-highlight'
import remarkGfm from 'remark-gfm'

import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext'
import { resolveUrl, workspacePath } from '@/lib/navigation'
import { resolveUrl } from '@/lib/navigation'
import { cn } from '@/client/lib/cn'

const remarkPlugins = [remarkGfm]
Expand Down Expand Up @@ -64,8 +64,8 @@ export function MarkdownContent({ size = 'sm', content }: MarkdownContentProps)
if (node.tagName === 'a' && key === 'href' && url.startsWith('moi:')) {
try {
return resolveUrl(url, {
apiBase: `/api/workspaces/${encodeURIComponent(workspaceId)}`,
workspacePath: workspacePath(workspaceId, base)
workspaceId,
base
})
} catch {
return ''
Expand Down
54 changes: 52 additions & 2 deletions client/features/chat/sessions/api.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { QueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'

import { jsonRequest, requestJson, requestVoid } from '@/client/api/http'
import { WORKSPACE_RESOURCE_OPTIONS } from '@/client/api/query-options'
import { workspaceKeys } from '@/client/api/workspace-keys'
import { sessionViewOptions } from '@/client/features/chat/sessions/session-view'
import { readSelectedSession, writeSelectedSession } from './browser-tab-state'
import { selectedSessionKey } from './useSelectedSession'
import {
selectedSessionKey,
selectedSessionOptions,
usePinnedSession,
useSelectedSession
} from './useSelectedSession'
import { useCollabEnabled } from '@/client/features/collab'
import { useLatestRef } from '@/client/lib/use-latest-ref'
import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext'
import type {
SessionConfig,
SessionInfo,
SessionRecord,
WorkspaceSessionSelection
WorkspaceSessionSelection,
WorkspaceTabId
} from '@/lib/types'

export function workspaceSessionsOptions(workspaceId: string) {
Expand All @@ -26,6 +36,46 @@ export function useWorkspaceSessions(workspaceId: string) {
return useQuery(workspaceSessionsOptions(workspaceId))
}

// Select the linked chat on the destination tab, unpinning a different chat first.
export function useSelectLinkedChat() {
const workspaceId = useWorkspaceId()
const queryClient = useQueryClient()
const collabEnabled = useCollabEnabled()
const [, , selectSessionForTab] = useSelectedSession()
const { pinAsync } = usePinnedSession()
const stateRef = useLatestRef({ collabEnabled, selectSessionForTab, pinAsync })
// Stable so selection updates do not restart a pending chat link.
return useCallback(
async (sessionId: string, tab: WorkspaceTabId, signal: AbortSignal): Promise<boolean> => {
const { collabEnabled, selectSessionForTab, pinAsync } = stateRef.current
signal.throwIfAborted()
let sessions =
queryClient.getQueryData<SessionInfo[]>(workspaceKeys.sessions(workspaceId)) ?? []
if (!sessions.some(session => session.sessionId === sessionId))
sessions = await queryClient.fetchQuery(workspaceSessionsOptions(workspaceId))
signal.throwIfAborted()
if (!sessions.some(session => session.sessionId === sessionId)) return false
await queryClient.ensureQueryData(selectedSessionOptions(workspaceId))
if (collabEnabled)
await queryClient.ensureQueryData(selectedSessionOptions(workspaceId, 'browser-tab'))
signal.throwIfAborted()
const pinned = queryClient.getQueryData<WorkspaceSessionSelection>(
selectedSessionKey(workspaceId)
)?.pinned
if (pinned && pinned !== sessionId) {
await pinAsync(null)
signal.throwIfAborted()
}
if (pinned !== sessionId) {
await selectSessionForTab(sessionId, tab)
signal.throwIfAborted()
}
return true
},
[queryClient, stateRef, workspaceId]
)
}

export function removeArchivedSession(
sessions: SessionInfo[] | undefined,
sessionId: string
Expand Down
37 changes: 0 additions & 37 deletions client/features/chat/sessions/chat-link.ts

This file was deleted.

5 changes: 3 additions & 2 deletions client/features/chat/sessions/useSelectedSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
} from '@/client/features/chat/sessions/browser-tab-state'
import { useCallback, useMemo } from 'react'
import { useParams } from 'wouter'
import { tabFromPath } from '@/lib/navigation'
import { isWorkspaceTabId } from '@/lib/workspace-tabs'
import type { WorkspaceSessionSelection, WorkspaceTabId } from '@/lib/types'
import { composerDraftKey } from '@/lib/session-drafts'
import { useUiStore } from '@/client/store/ui'
Expand Down Expand Up @@ -166,7 +166,8 @@ export function renameSelectedSessionInCache(
}

export function useCurrentTabId(): WorkspaceTabId {
return tabFromPath(useParams()['*'] ?? '') ?? 'overview'
const tab = useParams()['*']
return isWorkspaceTabId(tab) ? tab : 'overview'
}

export function selectedSessionForTab(
Expand Down
2 changes: 1 addition & 1 deletion client/features/collab/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ test('presence pages follow nested navigation paths and deployment bases, decodi
'views/board'
)
expect(pageFromPath('/workspace/test/views/%2562oard', 'test')).toBe('views/%2562oard')
expect(pageFromPath('/workspace/test/view:board', 'test')).toBe('views/board')
expect(pageFromPath('/workspace/test/view:board', 'test')).toBe('view:board')
expect(pageFromPath('/workspace/test', 'test')).toBe('overview')
})

Expand Down
6 changes: 3 additions & 3 deletions client/features/collab/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ReactNode } from 'react'
import { useRouter } from 'wouter'
import { usePathname } from 'wouter/use-browser-location'

import { legacyTabFromPath, tabFromPath, workspacePath } from '@/lib/navigation'
import { tabFromPath } from '@/lib/workspace-tabs'

import { CollabClient, NO_ENGINE } from './client'
import type { CollabEngineApi } from './client'
Expand All @@ -13,9 +13,9 @@ export const CollabContext = createContext<CollabEngineApi>(NO_ENGINE)
const AppletPresenceContext = createContext<AppletPresence | null>(null)

export function pageFromPath(path: string, workspaceId: string, base = ''): string {
const prefix = workspacePath(workspaceId, base) + '/'
const prefix = `${base.replace(/\/$/, '')}/workspace/${encodeURIComponent(workspaceId)}/`
const page = path.startsWith(prefix) ? path.slice(prefix.length) : ''
return tabFromPath(page) ?? legacyTabFromPath(page) ?? (page || 'overview')
return tabFromPath(page) ?? (page || 'overview')
}

export type CollabProviderProps = {
Expand Down
2 changes: 1 addition & 1 deletion client/features/collab/workspace-users-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
} from '@/client/components/ui/popover'
import { cn } from '@/client/lib/cn'
import type { WorkspaceTabId } from '@/lib/types'
import { tabFromPath } from '@/lib/navigation'
import { tabFromPath } from '@/lib/workspace-tabs'

import { User } from './components/user'
import { UserAvatarGroup } from './components/user-avatar-group'
Expand Down
4 changes: 2 additions & 2 deletions client/features/home/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
} from '@/client/features/home/workspace-presentation'
import { resolveWorkspaceTheme } from '@/lib/themes'
import type { DiscoveredWorkspace, WorkspaceEntry } from '@/lib/types'
import { workspaceTabPath } from '@/lib/navigation'
import { resolveUrl } from '@/lib/navigation'

import { WorkspacePreview } from './WorkspacePreview'

Expand Down Expand Up @@ -198,7 +198,7 @@ function WorkspaceCard({ workspace }: WorkspaceCardProps) {
<TooltipTrigger
render={
<Link
href={workspaceTabPath(workspace.id, 'overview')}
href={resolveUrl('moi:/chat', { workspaceId: workspace.id })}
aria-label={`Chat in ${name}`}
style={getWorkspaceThemeStyle(theme)}
className="absolute right-3 bottom-3 rounded-lg outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
Expand Down
4 changes: 2 additions & 2 deletions client/features/workspace/WorkspaceScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@
// 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(() => {
const revealChat = useCallback(() => {
setFloatingChatOpen(true)
setChatFocusRequest(request => request + 1)
}, [])
Expand All @@ -174,7 +174,7 @@
onNavigationClick
} = useWorkspaceNavigation({
views: allViews,
onOpenChat: revealLinkedChat
onOpenChat: revealChat
})

// Chat comes after navigation, and takes the address: every message carries
Expand Down Expand Up @@ -269,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
25 changes: 16 additions & 9 deletions client/features/workspace/browser-tab-state.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useCallback, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'

import type { WorkspaceTabsState } from '@/lib/types'
import { normalizeWorkspaceTabs } from '@/lib/workspace-layout'
import { useCollabEnabled } from '@/client/features/collab'
import { useWorkspaceLayoutCtx } from './WorkspaceLayoutContext'

const keyFor = (workspaceId: string) => `moi:collab:${workspaceId}:tabs`

Expand All @@ -25,20 +27,25 @@ export function writeWorkspaceTabs(workspaceId: string, tabs: WorkspaceTabsState
}
}

export function useWorkspaceTabs(
workspaceId: string,
enabled: boolean,
defaults: WorkspaceTabsState
) {
export function useWorkspaceTabs() {
const { workspaceId, layout, setLayout } = useWorkspaceLayoutCtx()
const enabled = useCollabEnabled()
const defaults = useMemo(() => normalizeWorkspaceTabs(layout.tabs), [layout.tabs])
const [local, setLocal] = useState<WorkspaceTabsState>(() =>
enabled ? readWorkspaceTabs(workspaceId, defaults) : defaults
)
const setTabs = useCallback(
(tabs: WorkspaceTabsState) => {
setLocal(tabs)
writeWorkspaceTabs(workspaceId, tabs)
const next = normalizeWorkspaceTabs(tabs)
if (enabled) {
setLocal(next)
writeWorkspaceTabs(workspaceId, next)
} else {
setLayout({ tabs: next })
}
return next
},
[workspaceId]
[enabled, setLayout, workspaceId]
)
return [enabled ? local : defaults, setTabs] as const
}
Loading
Loading