Skip to content

Commit f822a52

Browse files
committed
feat(copilot): persist desktop resources across chats
1 parent d7a4647 commit f822a52

8 files changed

Lines changed: 308 additions & 136 deletions

File tree

apps/sim/app/api/copilot/chat/resources/route.ts

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,12 @@ import {
1616
createNotFoundResponse,
1717
createUnauthorizedResponse,
1818
} from '@/lib/copilot/request/http'
19-
import type { ChatResource, ResourceType } from '@/lib/copilot/resources/persistence'
19+
import type { ChatResource } from '@/lib/copilot/resources/persistence'
2020
import { GENERIC_RESOURCE_TITLES } from '@/lib/copilot/resources/types'
2121
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2222

2323
const logger = createLogger('CopilotChatResourcesAPI')
2424

25-
const VALID_RESOURCE_TYPES = new Set<ResourceType>([
26-
'table',
27-
'file',
28-
'workflow',
29-
'knowledgebase',
30-
'folder',
31-
'scheduledtask',
32-
'log',
33-
'integration',
34-
'browser',
35-
])
36-
3725
export const POST = withRouteHandler(async (req: NextRequest) => {
3826
try {
3927
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
@@ -58,10 +46,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
5846
return NextResponse.json({ success: true })
5947
}
6048

61-
if (!VALID_RESOURCE_TYPES.has(resource.type)) {
62-
return createBadRequestResponse(`Invalid resource type: ${resource.type}`)
63-
}
64-
6549
const [chat] = await db
6650
.select({ resources: copilotChats.resources })
6751
.from(copilotChats)

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ const ResourceTabItem = memo(function ResourceTabItem({
189189
onMouseDown={(e) => {
190190
if (e.button === 1) {
191191
e.preventDefault()
192-
if (chatId) onRemove(e, resource)
192+
onRemove(e, resource)
193193
}
194194
}}
195195
onClick={(e) => onTabClick(e, idx)}
@@ -204,7 +204,10 @@ const ResourceTabItem = memo(function ResourceTabItem({
204204
>
205205
{config.renderTabIcon(resource, 'mr-1.5 size-[14px]')}
206206
{displayName}
207-
{(isHovered || isActive) && chatId && (
207+
{/* Closable without a chat, matching the add control: a resource opened
208+
while composing the first prompt has to be removable too, and
209+
removal already skips the server delete when nothing is persisted. */}
210+
{(isHovered || isActive) && (
208211
<span
209212
role='button'
210213
tabIndex={-1}
@@ -327,9 +330,11 @@ export function ResourceTabs({
327330

328331
const handleAdd = useCallback(
329332
(resource: MothershipResource) => {
330-
if (!chatId) return
331-
// Synthetic result/preview panels are in-memory only.
332-
if (!isEphemeralResource(resource)) {
333+
// Opening a resource before the first message is sent is allowed: there
334+
// is simply no chat to attach it to yet. `onAddResource` queues it and
335+
// persists once the chat exists, so only the server call is conditional.
336+
// Synthetic result/preview panels are in-memory only either way.
337+
if (chatId && !isEphemeralResource(resource)) {
333338
addResource.mutate({ chatId, resource })
334339
}
335340
onAddResource(resource)
@@ -618,15 +623,16 @@ export function ResourceTabs({
618623
)
619624
})}
620625
</div>
621-
{chatId && (
622-
<AddResourceDropdown
623-
workspaceId={workspaceId}
624-
existingKeys={existingKeys}
625-
onAdd={handleAdd}
626-
onSwitch={selectResource}
627-
excludeTypes={ADD_RESOURCE_EXCLUDED_TYPES}
628-
/>
629-
)}
626+
{/* Offered before the chat exists too: a resource opened while composing
627+
the first prompt is context for that prompt, and gating on a chat id
628+
meant the panel could be opened but not filled. */}
629+
<AddResourceDropdown
630+
workspaceId={workspaceId}
631+
existingKeys={existingKeys}
632+
onAdd={handleAdd}
633+
onSwitch={selectResource}
634+
excludeTypes={ADD_RESOURCE_EXCLUDED_TYPES}
635+
/>
630636
</div>
631637
{(actions || (previewMode && onCyclePreviewMode)) && (
632638
<div className={cn('ml-auto flex shrink-0 items-center', RESOURCE_TAB_GAP_CLASS)}>

apps/sim/app/workspace/[workspaceId]/home/home.tsx

Lines changed: 77 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
useRef,
1212
useState,
1313
} from 'react'
14-
import { Button } from '@sim/emcn'
14+
import { Button, cn } from '@sim/emcn'
1515
import { PanelLeft } from '@sim/emcn/icons'
1616
import { createLogger } from '@sim/logger'
1717
import { useParams, useRouter } from 'next/navigation'
@@ -411,70 +411,85 @@ export function Home({ chatId, userName, userId }: HomeProps) {
411411
const showChatSkeleton = Boolean(chatId) && !hasMessages && isChatHistoryPending
412412
const draftScopeKey = `${workspaceId}:${chatId ?? 'new'}`
413413

414-
if (!hasMessages && !showChatSkeleton) {
415-
return (
416-
<div className='relative h-full overflow-y-auto bg-[var(--bg)] [scrollbar-gutter:stable_both-edges]'>
417-
<div className='absolute top-[8.5px] right-[16px] z-10'>
418-
<CreditsChip />
419-
</div>
420-
{/* Asymmetric padding biases the group up so the full cluster (heading + input + suggestions) sits at the optical center */}
421-
<div className='flex min-h-full flex-col items-center justify-center px-6 pt-[2vh] pb-[22vh]'>
422-
<h1 className='mb-7 max-w-[48rem] text-balance font-season text-[30px] text-[var(--text-primary)]'>
423-
What should we get done{firstName ? `, ${firstName}` : ''}?
424-
</h1>
425-
<div ref={initialViewInputRef} className='relative w-full max-w-[48rem]'>
426-
<ChatSurfaceProvider
427-
userId={userId}
428-
onContextAdd={handleContextAdd}
429-
onContextRemove={handleInitialContextRemove}
430-
>
431-
<UserInput
432-
ref={initialViewUserInputRef}
433-
defaultValue={initialPrompt}
434-
draftScopeKey={draftScopeKey}
435-
onSubmit={handleSubmit}
436-
isSending={isSending}
437-
onStopGeneration={handleStopGeneration}
438-
/>
439-
</ChatSurfaceProvider>
440-
{/* Anchored out of flow so expanding/collapsing never shifts the centered input */}
441-
<div className='absolute inset-x-0 top-full'>
442-
<SuggestedActions
443-
onSelectPrompt={(prompt) => initialViewUserInputRef.current?.populatePrompt(prompt)}
444-
/>
445-
</div>
446-
</div>
447-
</div>
448-
</div>
449-
)
450-
}
414+
// The empty state is the chat pane's content, not a layout of its own. It
415+
// used to return early, which meant the resource panel and its toggle did
416+
// not exist until the first message — so there was no way to open a resource
417+
// while composing the very prompt that needed one.
418+
const showEmptyState = !hasMessages && !showChatSkeleton
451419

452420
return (
453421
<div className='relative flex h-full bg-[var(--bg)]'>
454-
<div className='flex h-full min-w-[320px] flex-1 flex-col'>
455-
<MothershipChat
456-
messages={messages}
457-
isSending={isSending}
458-
isReconnecting={isReconnecting}
459-
isLoading={showChatSkeleton}
460-
onSubmit={handleSubmit}
461-
onStopGeneration={handleStopGeneration}
462-
messageQueue={messageQueue}
463-
editingQueuedId={editingQueuedId}
464-
dispatchingHeadId={dispatchingHeadId}
465-
onRemoveQueuedMessage={removeFromQueue}
466-
onSendQueuedMessage={sendNow}
467-
onEditQueuedMessage={editQueuedMessage}
468-
onCancelQueueEdit={cancelQueueEdit}
469-
userId={userId}
470-
chatId={resolvedChatId}
471-
onContextAdd={handleContextAdd}
472-
onWorkspaceResourceSelect={handleWorkspaceResourceSelect}
473-
draftScopeKey={draftScopeKey}
474-
animateInput={isInputEntering}
475-
onInputAnimationEnd={isInputEntering ? () => setIsInputEntering(false) : undefined}
476-
initialScrollBlocked={resources.length > 0 && isResourceCollapsed}
477-
/>
422+
<div className='relative flex h-full min-w-[320px] flex-1 flex-col'>
423+
{/* Clears the expand button when the panel is closed and that button is
424+
occupying the same corner. */}
425+
{showEmptyState && (
426+
<div
427+
className={cn(
428+
'absolute top-[8.5px] z-10',
429+
isResourceCollapsed ? 'right-[54px]' : 'right-[16px]'
430+
)}
431+
>
432+
<CreditsChip />
433+
</div>
434+
)}
435+
{showEmptyState ? (
436+
<div className='h-full overflow-y-auto [scrollbar-gutter:stable_both-edges]'>
437+
{/* Asymmetric padding biases the group up so the full cluster (heading + input + suggestions) sits at the optical center */}
438+
<div className='flex min-h-full flex-col items-center justify-center px-6 pt-[2vh] pb-[22vh]'>
439+
<h1 className='mb-7 max-w-[48rem] text-balance font-season text-[30px] text-[var(--text-primary)]'>
440+
What should we get done{firstName ? `, ${firstName}` : ''}?
441+
</h1>
442+
<div ref={initialViewInputRef} className='relative w-full max-w-[48rem]'>
443+
<ChatSurfaceProvider
444+
userId={userId}
445+
onContextAdd={handleContextAdd}
446+
onContextRemove={handleInitialContextRemove}
447+
>
448+
<UserInput
449+
ref={initialViewUserInputRef}
450+
defaultValue={initialPrompt}
451+
draftScopeKey={draftScopeKey}
452+
onSubmit={handleSubmit}
453+
isSending={isSending}
454+
onStopGeneration={handleStopGeneration}
455+
/>
456+
</ChatSurfaceProvider>
457+
{/* Anchored out of flow so expanding/collapsing never shifts the centered input */}
458+
<div className='absolute inset-x-0 top-full'>
459+
<SuggestedActions
460+
onSelectPrompt={(prompt) =>
461+
initialViewUserInputRef.current?.populatePrompt(prompt)
462+
}
463+
/>
464+
</div>
465+
</div>
466+
</div>
467+
</div>
468+
) : (
469+
<MothershipChat
470+
messages={messages}
471+
isSending={isSending}
472+
isReconnecting={isReconnecting}
473+
isLoading={showChatSkeleton}
474+
onSubmit={handleSubmit}
475+
onStopGeneration={handleStopGeneration}
476+
messageQueue={messageQueue}
477+
editingQueuedId={editingQueuedId}
478+
dispatchingHeadId={dispatchingHeadId}
479+
onRemoveQueuedMessage={removeFromQueue}
480+
onSendQueuedMessage={sendNow}
481+
onEditQueuedMessage={editQueuedMessage}
482+
onCancelQueueEdit={cancelQueueEdit}
483+
userId={userId}
484+
chatId={resolvedChatId}
485+
onContextAdd={handleContextAdd}
486+
onWorkspaceResourceSelect={handleWorkspaceResourceSelect}
487+
draftScopeKey={draftScopeKey}
488+
animateInput={isInputEntering}
489+
onInputAnimationEnd={isInputEntering ? () => setIsInputEntering(false) : undefined}
490+
initialScrollBlocked={resources.length > 0 && isResourceCollapsed}
491+
/>
492+
)}
478493
</div>
479494

480495
{/* Resize handle — zero-width flex child whose absolute child straddles the border */}

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import {
5959
isFilePreviewSession,
6060
} from '@/lib/copilot/request/session/file-preview-session-contract'
6161
import type { StreamBatchEvent } from '@/lib/copilot/request/session/types'
62+
import { canDisplayResource } from '@/lib/copilot/resources/availability'
6263
import {
6364
BROWSER_SESSION_RESOURCE_ID,
6465
isEphemeralResource,
@@ -1150,6 +1151,14 @@ export function useChat(
11501151
}, [])
11511152
const resourcesRef = useRef(resources)
11521153
resourcesRef.current = resources
1154+
/**
1155+
* Stored resources this client cannot display — the desktop-only panels when
1156+
* there is no bridge. Held so they survive a session that never shows them:
1157+
* a reorder sends the full stored set, and the server rejects one that does
1158+
* not match what it has, so leaving them out would both break reordering and
1159+
* make the tabs disappear for the desktop app too.
1160+
*/
1161+
const undisplayableResourcesRef = useRef<MothershipResource[]>([])
11531162
const pendingPersistResourceKeysRef = useRef<Set<string>>(new Set())
11541163
const inFlightResourceAddsRef = useRef<Map<string, Promise<unknown>>>(new Map())
11551164
const reorderNeededAfterFlushRef = useRef(false)
@@ -1385,6 +1394,7 @@ export function useChat(
13851394
setTransportIdle()
13861395
setResources([])
13871396
setActiveResourceId(null)
1397+
undisplayableResourcesRef.current = []
13881398
pendingPersistResourceKeysRef.current.clear()
13891399
inFlightResourceAddsRef.current.clear()
13901400
reorderNeededAfterFlushRef.current = false
@@ -1430,10 +1440,14 @@ export function useChat(
14301440
await Promise.allSettled(flushPromises)
14311441
if (!reorderNeededAfterFlushRef.current) return
14321442
reorderNeededAfterFlushRef.current = false
1433-
const localOrder = resourcesRef.current.filter(
1434-
(r) =>
1435-
r.id !== 'streaming-file' && !pendingPersistResourceKeysRef.current.has(`${r.type}:${r.id}`)
1436-
)
1443+
const localOrder = [
1444+
...resourcesRef.current.filter(
1445+
(r) =>
1446+
r.id !== 'streaming-file' &&
1447+
!pendingPersistResourceKeysRef.current.has(`${r.type}:${r.id}`)
1448+
),
1449+
...undisplayableResourcesRef.current,
1450+
]
14371451
if (localOrder.length === 0) return
14381452
requestJson(reorderMothershipChatResourcesContract, {
14391453
body: { chatId, resources: localOrder },
@@ -1565,11 +1579,14 @@ export function useChat(
15651579
reorderNeededAfterFlushRef.current = false
15661580
const chatId = chatIdRef.current ?? selectedChatIdRef.current
15671581
if (!chatId) return
1568-
const order = resourcesRef.current.filter(
1569-
(r) =>
1570-
!isEphemeralResource(r) &&
1571-
!pendingPersistResourceKeysRef.current.has(`${r.type}:${r.id}`)
1572-
)
1582+
const order = [
1583+
...resourcesRef.current.filter(
1584+
(r) =>
1585+
!isEphemeralResource(r) &&
1586+
!pendingPersistResourceKeysRef.current.has(`${r.type}:${r.id}`)
1587+
),
1588+
...undisplayableResourcesRef.current,
1589+
]
15731590
if (order.length === 0) return
15741591
requestJson(reorderMothershipChatResourcesContract, {
15751592
body: { chatId, resources: order },
@@ -1580,7 +1597,10 @@ export function useChat(
15801597
}
15811598
return
15821599
}
1583-
const persistableResources = newOrder.filter((r) => !isEphemeralResource(r))
1600+
const persistableResources = [
1601+
...newOrder.filter((r) => !isEphemeralResource(r)),
1602+
...undisplayableResourcesRef.current,
1603+
]
15841604
if (persistableResources.length === 0) return
15851605
requestJson(reorderMothershipChatResourcesContract, {
15861606
body: { chatId: persistChatId, resources: persistableResources },
@@ -1874,6 +1894,13 @@ export function useChat(
18741894
flushPendingResources(chatHistory.id)
18751895

18761896
const persistedResources = chatHistory.resources.filter((r) => r.id !== 'streaming-file')
1897+
// A stored panel this client cannot open is kept out of the tab strip
1898+
// rather than restored onto an error, but stays in the stored set so the
1899+
// desktop app still gets it back.
1900+
const restorableResources = persistedResources.filter(canDisplayResource)
1901+
undisplayableResourcesRef.current = persistedResources.filter((r) => !canDisplayResource(r))
1902+
// Keyed on everything the server holds, not just what is restorable, so a
1903+
// resource being hidden cannot make it look local-only and get re-added.
18771904
const serverKeys = new Set(persistedResources.map((r) => `${r.type}:${r.id}`))
18781905
const localOnly = resourcesRef.current.filter(
18791906
(r) => r.id !== 'streaming-file' && !serverKeys.has(`${r.type}:${r.id}`)
@@ -1883,7 +1910,7 @@ export function useChat(
18831910
// keep their current on-screen position — hydration reruns on every send
18841911
// and stream completion, and appending them at the end made those tabs
18851912
// visibly jump/flash each time.
1886-
const mergedResources = [...persistedResources]
1913+
const mergedResources = [...restorableResources]
18871914
for (const resource of localOnly) {
18881915
const currentIndex = resourcesRef.current.findIndex(
18891916
(r) => r.type === resource.type && r.id === resource.id

apps/sim/lib/api/contracts/copilot.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
COPILOT_BILLING_PROTOCOL_HEADER,
1313
COPILOT_BILLING_PROTOCOL_VALUES,
1414
} from '@/lib/copilot/generated/billing-protocol-v1'
15+
import { PERSISTED_RESOURCE_TYPES } from '@/lib/copilot/resources/types'
1516

1617
export const copilotApiKeySchema = z.object({
1718
id: z.string(),
@@ -117,15 +118,7 @@ export const renameCopilotChatBodySchema = z.object({
117118
})
118119
export type RenameCopilotChatBody = z.input<typeof renameCopilotChatBodySchema>
119120

120-
const copilotResourceTypeSchema = z.enum([
121-
'table',
122-
'file',
123-
'workflow',
124-
'knowledgebase',
125-
'folder',
126-
'scheduledtask',
127-
'log',
128-
])
121+
const copilotResourceTypeSchema = z.enum(PERSISTED_RESOURCE_TYPES)
129122

130123
export const addCopilotChatResourceBodySchema = z.object({
131124
chatId: z.string(),

0 commit comments

Comments
 (0)