Skip to content

Commit 4ad99bb

Browse files
committed
fix(extension): refresh permissions and retain completed answers
1 parent 074689c commit 4ad99bb

14 files changed

Lines changed: 628 additions & 139 deletions

File tree

‎apps/extension/DESIGN.md‎

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,20 @@ Implementation scope and research, 2026-10-29.
66

77
The extension is another surface of Sim. A native Chrome side panel hosts the actual
88
Sim composer and transcript, using existing authenticated Mothership requests,
9-
conversation persistence, streaming recovery, model controls, tool permissions,
10-
and workspace authorization. A conversation can continue in the full app.
9+
conversation persistence, streaming recovery, tool permissions, and organization
10+
or workspace authorization. The panel keeps Fast mode and omits the model selector.
11+
A conversation can continue in the full app.
1112

12-
The user signs in in a normal Sim tab, chooses a workspace, opens or creates a
13-
conversation, and explicitly attaches the current page or highlighted text.
13+
The user signs in in a normal Sim tab and lands in their organization chat. Choosing
14+
a workspace is optional; members without Build access use Assistant. They can open
15+
or create a conversation and explicitly attach the current page or highlighted text.
1416
The composer’s + menu offers Current page and Selected text. Pending snapshots sit
1517
inside the composer as compact removable chips alongside file attachments. Opening one
1618
shows the captured text, source, capture time, and conversation-retention disclosure.
1719
Partial captures remain labeled in the attachment row. Site access lives in the
1820
composer’s Site access action, which opens an extension-owned dialog; successful delivery adds no persistent status banner.
19-
They are immutable: navigating to another page cannot silently change an attached
20-
source. Removing an attachment, changing workspace, or starting a new conversation
21+
Snapshots are immutable: navigating to another page cannot silently change an attached
22+
source. Removing an attachment, changing organization or workspace, or starting a new conversation
2123
clears pending page context. Several explicitly attached pages can support comparison.
2224
Assistant accepts page snapshots while retaining its existing restriction on
2325
workspace-resource contexts.
@@ -35,7 +37,7 @@ Existing Sim server tools remain subject to their existing authorization and app
3537
extraction, per-site permission controls, build artifacts, browser verification.
3638
2. `packages/browser-extension-protocol`: versioned, strictly validated messages and
3739
immutable page snapshots shared by the extension, app, and server.
38-
3. `apps/sim/app/extension`: session gate, workspace picker, minimal app providers,
40+
3. `apps/sim/app/extension`: session gate, organization landing, optional workspace picker, app providers,
3941
real chat components, history and a narrow parent-window bridge.
4042
4. Existing chat context pipeline: `browser_page` input validation, durable message
4143
projection, restored context chips, untrusted-content formatting, Assistant admission.
@@ -102,10 +104,11 @@ report and screenshot/trace artifacts. Security-boundary tests may cover header
102104
isolation and durable-context semantics where the running app needs unavailable
103105
services. Do not replace real Chrome APIs with mocks and describe that as E2E.
104106

105-
The authenticated live-app acceptance gate is: sign in, choose a permitted workspace,
106-
capture a real page, receive a grounded Mothership answer, reopen the conversation,
107-
stop/reconnect streaming, switch workspaces, and verify access denial. An absent
108-
backend or account is an unverified gate, not a pass.
107+
The authenticated live-app acceptance gate covers organization chat, Assistant, and
108+
permitted workspaces: capture a real page, receive a grounded Mothership answer,
109+
reopen the conversation, stop/reconnect streaming, and switch scopes. Verify revoked
110+
access, live policy changes, retained drafts, and late history reads after completion.
111+
An absent backend or account is an unverified gate, not a pass.
109112

110113
## Research and decisions
111114

@@ -147,7 +150,7 @@ autonomous browser actions need a separate design and estimate.
147150

148151
The first implementation targets Chrome MV3 and a configured Sim deployment. It
149152
includes the shared chat UI and engine, explicit page/selection context, Assistant
150-
context support, workspace/history navigation, permission controls and verification.
153+
context support, organization/workspace/history navigation, permission controls and verification.
151154
It does not constitute store publication or proof of every third-party site's DOM.
152155

153156
Follow-on work: screenshot/image attachment through existing authorized upload paths;

‎apps/sim/app/api/organizations/[id]/route.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const GET = defineInternalJsonRoute({
2828
mapInput: ({ params, query }) => ({
2929
organizationId: params.id,
3030
includeSeats: query.include === 'seats',
31+
includeChatAccess: query.include === 'chat-access',
3132
}),
3233
useCase: getOrganization,
3334
present: (result) => ({
@@ -45,6 +46,7 @@ export const GET = defineInternalJsonRoute({
4546
},
4647
userRole: organizationRoleSchema.parse(result.role),
4748
hasAdminAccess: result.hasAdminAccess,
49+
...(result.chatAccess ? { chatAccess: result.chatAccess } : {}),
4850
}),
4951
})
5052

‎apps/sim/app/extension/o/[organizationId]/organization.tsx‎

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export function ExtensionOrganization({ context }: ExtensionOrganizationProps) {
3232
)
3333
if (isApiClientError(denied) && denied.status === 401) return <ExtensionSignIn />
3434
if (access.isPending) return <ExtensionLoading />
35-
if (denied || !access.data)
35+
if (denied || !access.data?.chatAccess)
3636
return (
3737
<ExtensionUnavailable
3838
scope='organization'
@@ -42,10 +42,11 @@ export function ExtensionOrganization({ context }: ExtensionOrganizationProps) {
4242
}}
4343
/>
4444
)
45+
const refreshedContext = { ...context, ...access.data.chatAccess }
4546
return (
46-
<OrganizationProvider context={context}>
47+
<OrganizationProvider context={refreshedContext}>
4748
<SettingsLoader />
48-
<ExtensionOrganizationChat context={context} history={history} />
49+
<ExtensionOrganizationChat context={refreshedContext} history={history} />
4950
</OrganizationProvider>
5051
)
5152
}
@@ -65,27 +66,52 @@ function ExtensionOrganizationChat({ context, history }: ExtensionOrganizationCh
6566
)
6667
const [captureChatId, setCaptureChatId] = useState(selectedChatId)
6768
const [initialChatId, setInitialChatId] = useState(selectedChatId)
69+
const [submittedMode, setSubmittedMode] = useState<ChatRequestMode | null>(null)
6870
const [composerKey, setComposerKey] = useState(generateId)
6971
if (captureChatId !== selectedChatId) {
7072
setCaptureChatId(selectedChatId)
7173
setInitialChatId(selectedChatId)
74+
setSubmittedMode(null)
7275
setComposerKey(generateId())
7376
}
77+
const selectChat = (chatId: string | null) => {
78+
if (chatId === selectedChatId) {
79+
setInitialChatId(chatId)
80+
setSubmittedMode(null)
81+
setComposerKey(generateId())
82+
}
83+
void setResource(null)
84+
void setSelectedChatId(chatId)
85+
}
7486
const selectedChat = initialChatId
7587
? history.data?.find((chat) => chat.id === initialChatId)
7688
: undefined
7789
if (initialChatId && !history.data && history.isPending) return <ExtensionLoading />
7890
if (initialChatId && !selectedChat)
7991
return <ExtensionUnavailable scope='chat' onRetry={() => void history.refetch()} />
80-
const requestMode = initialChatId ? selectedChat?.mode : context.canBuild ? 'agent' : 'assistant'
92+
const requestMode = initialChatId
93+
? selectedChat?.mode
94+
: (submittedMode ?? (context.canBuild ? 'agent' : 'assistant'))
8195
if (!requestMode)
8296
return history.isFetching ? (
8397
<ExtensionLoading />
8498
) : (
8599
<ExtensionUnavailable scope='chat' onRetry={() => void history.refetch()} />
86100
)
87-
if (requestMode === 'assistant' ? !context.searchAccess.memberScoped : !context.canBuild)
88-
return <ExtensionUnavailable scope='chat' />
101+
if (
102+
!context.mothershipAvailable ||
103+
(requestMode === 'assistant' ? !context.searchAccess.memberScoped : !context.canBuild)
104+
)
105+
return (
106+
<ExtensionUnavailable
107+
scope='chat'
108+
onBackToChat={
109+
context.mothershipAvailable && (context.canBuild || context.searchAccess.memberScoped)
110+
? () => selectChat(null)
111+
: undefined
112+
}
113+
/>
114+
)
89115
return (
90116
<ExtensionOrganizationConversation
91117
key={composerKey}
@@ -95,10 +121,8 @@ function ExtensionOrganizationChat({ context, history }: ExtensionOrganizationCh
95121
initialChatId={initialChatId ?? undefined}
96122
selectedChatId={selectedChatId ?? undefined}
97123
requestMode={requestMode}
98-
onSelectChat={(chatId) => {
99-
void setResource(null)
100-
void setSelectedChatId(chatId)
101-
}}
124+
onSelectChat={selectChat}
125+
onCommitMode={() => setSubmittedMode(requestMode)}
102126
onChatIdResolved={(chatId) => {
103127
setCaptureChatId(chatId)
104128
void setSelectedChatId(chatId)
@@ -113,6 +137,7 @@ interface ExtensionOrganizationConversationProps extends ExtensionOrganizationCh
113137
selectedChatId?: string
114138
requestMode: ChatRequestMode
115139
onSelectChat: (chatId: string | null) => void
140+
onCommitMode: () => void
116141
onChatIdResolved: (chatId: string) => void
117142
}
118143

@@ -124,6 +149,7 @@ function ExtensionOrganizationConversation({
124149
selectedChatId,
125150
requestMode,
126151
onSelectChat,
152+
onCommitMode,
127153
onChatIdResolved,
128154
}: ExtensionOrganizationConversationProps) {
129155
const composer = useExtensionComposer(scopeKey)
@@ -141,6 +167,11 @@ function ExtensionOrganizationConversation({
141167
requestMode={requestMode}
142168
panelOptions={{
143169
...composer,
170+
prepareSubmission: (message, contexts) => {
171+
const submission = composer.prepareSubmission(message, contexts)
172+
if (submission !== false) onCommitMode()
173+
return submission
174+
},
144175
draftNamespace: 'extension',
145176
onChatIdResolved,
146177
onNavigateToChat: onSelectChat,

‎apps/sim/app/extension/unavailable.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,19 @@ import { Chip, ChipLink } from '@sim/emcn'
33
interface ExtensionUnavailableProps {
44
scope: 'organization' | 'workspace' | 'chat'
55
onRetry?: () => void
6+
onBackToChat?: () => void
67
}
78

8-
export function ExtensionUnavailable({ scope, onRetry }: ExtensionUnavailableProps) {
9+
export function ExtensionUnavailable({ scope, onRetry, onBackToChat }: ExtensionUnavailableProps) {
910
return (
1011
<main className='flex h-dvh flex-col items-center justify-center gap-4 bg-[var(--bg)] p-6 text-[var(--text-body)] text-small'>
1112
<p>This {scope} is unavailable.</p>
1213
{onRetry && <Chip onClick={onRetry}>Retry</Chip>}
13-
<ChipLink href='/extension'>Back to chat</ChipLink>
14+
{onBackToChat ? (
15+
<Chip onClick={onBackToChat}>Back to chat</Chip>
16+
) : (
17+
<ChipLink href='/extension'>Back to chat</ChipLink>
18+
)}
1419
</main>
1520
)
1621
}
Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,53 @@
11
'use client'
22

3-
import { Chip, ChipLink } from '@sim/emcn'
3+
import { useState } from 'react'
4+
import {
5+
Chip,
6+
ChipLink,
7+
cn,
8+
scrollFadeAttributes,
9+
scrollFadeClass,
10+
useScrollEdges,
11+
} from '@sim/emcn'
412
import ExtensionLoading from '@/app/extension/loading'
513
import { useWorkspacesQuery } from '@/hooks/queries/workspace'
614

715
export function ExtensionWorkspaces() {
16+
const [scrollElement, setScrollElement] = useState<HTMLElement | null>(null)
17+
const scrollEdges = useScrollEdges(scrollElement)
818
const { data: workspaces, isPending, error, refetch } = useWorkspacesQuery()
919
if (isPending) return <ExtensionLoading />
1020
return (
11-
<main className='flex h-dvh flex-col gap-4 overflow-y-auto bg-[var(--bg)] p-4 text-[var(--text-body)] text-small'>
12-
<h1 className='text-lg'>Choose a workspace</h1>
13-
{error ? (
14-
<Chip onClick={() => void refetch()}>Retry loading workspaces</Chip>
15-
) : workspaces?.length ? (
16-
<nav className='flex flex-col gap-2' aria-label='Workspaces'>
17-
{workspaces.map((workspace) => (
18-
<ChipLink
19-
key={workspace.id}
20-
href={`/extension/${encodeURIComponent(workspace.id)}`}
21-
variant='border'
22-
>
23-
{workspace.name}
24-
</ChipLink>
25-
))}
26-
</nav>
27-
) : (
28-
<ChipLink href='/home' target='_blank' rel='noopener noreferrer'>
29-
Open Sim to create a workspace
30-
</ChipLink>
21+
<main
22+
ref={setScrollElement}
23+
{...scrollFadeAttributes(scrollEdges)}
24+
className={cn(
25+
'h-dvh overflow-y-auto bg-[var(--bg)] p-4 text-[var(--text-body)] text-small',
26+
scrollFadeClass
3127
)}
28+
>
29+
<div className='flex flex-col gap-4'>
30+
<h1 className='text-lg'>Choose a workspace</h1>
31+
{error ? (
32+
<Chip onClick={() => void refetch()}>Retry loading workspaces</Chip>
33+
) : workspaces?.length ? (
34+
<nav className='flex flex-col gap-2' aria-label='Workspaces'>
35+
{workspaces.map((workspace) => (
36+
<ChipLink
37+
key={workspace.id}
38+
href={`/extension/${encodeURIComponent(workspace.id)}`}
39+
variant='border'
40+
>
41+
{workspace.name}
42+
</ChipLink>
43+
))}
44+
</nav>
45+
) : (
46+
<ChipLink href='/home' target='_blank' rel='noopener noreferrer'>
47+
Open Sim to create a workspace
48+
</ChipLink>
49+
)}
50+
</div>
3251
</main>
3352
)
3453
}

‎apps/sim/app/o/[organizationId]/components/organization-conversation.tsx‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { type ReactNode, useCallback, useEffect, useState } from 'react'
44
import { ChipModal, ChipModalHeader, cn, toast } from '@sim/emcn'
5+
import { getErrorMessage } from '@sim/utils/errors'
56
import { toRecord } from '@sim/utils/object'
67
import { useQueryClient } from '@tanstack/react-query'
78
import { useQueryStates } from 'nuqs'
@@ -17,6 +18,13 @@ import {
1718
} from '@/lib/mothership/chat/attachment-preview'
1819
import { createSearchResource } from '@/lib/mothership/resources/search'
1920
import { isWorkspaceResourceKind, workspaceResourcePath } from '@/lib/resources'
21+
import { assertMultiFileUploadAdmission } from '@/lib/uploads/client/admission'
22+
import {
23+
ASSISTANT_IMAGE_MAX_BYTES,
24+
ASSISTANT_IMAGE_MAX_COUNT,
25+
ASSISTANT_IMAGE_MAX_TOTAL_BYTES,
26+
isAssistantImageType,
27+
} from '@/lib/uploads/shared/assistant-images'
2028
import { OrganizationLanding } from '@/app/o/[organizationId]/components/organization-landing'
2129
import { Composer } from '@/app/o/[organizationId]/home/components/composer'
2230
import { GetStarted } from '@/app/o/[organizationId]/home/components/get-started'
@@ -94,7 +102,7 @@ export function OrganizationConversation(props: OrganizationConversationProps) {
94102
if (isRestoredChatEntry || !isClient || !session?.user?.id) return <HomeFallback />
95103
return (
96104
<OrganizationConversationContent
97-
key={`${session.user.id}:${organization.id}:${props.chatId ?? 'new'}:${props.requestMode}`}
105+
key={`${session.user.id}:${organization.id}:${props.chatId ?? 'new'}:${props.panelOptions ? 'panel' : props.requestMode}`}
98106
{...props}
99107
/>
100108
)
@@ -130,7 +138,7 @@ function OrganizationConversationContent({
130138
? { projectsDesktopTabs: false, onChatIdResolved: panelOptions.onChatIdResolved }
131139
: {}),
132140
})
133-
const newDraftId = destinationMode === 'assistant' ? 'search-assistant' : 'home'
141+
const newDraftId = !compact && destinationMode === 'assistant' ? 'search-assistant' : 'home'
134142
const draftPrefix = panelOptions ? `${panelOptions.draftNamespace}:` : ''
135143
const initialDraftKey = `${draftPrefix}${userId}:organization:${organization.id}:${chatId ?? newDraftId}`
136144
const draftKey = `${draftPrefix}${userId}:organization:${organization.id}:${chat.resolvedChatId ?? chatId ?? newDraftId}`
@@ -327,6 +335,22 @@ function OrganizationConversationContent({
327335
}
328336
const submit = (text: string, contexts?: ChatContext[]) => {
329337
if (files.attachedFiles.some((file) => file.uploading)) return false
338+
if (requestMode === 'assistant') {
339+
if (files.attachedFiles.some((file) => !isAssistantImageType(file.type))) {
340+
toast.error('Remove unsupported files to use Assistant.')
341+
return false
342+
}
343+
try {
344+
assertMultiFileUploadAdmission(files.attachedFiles, {
345+
maxFiles: ASSISTANT_IMAGE_MAX_COUNT,
346+
maxFileBytes: ASSISTANT_IMAGE_MAX_BYTES,
347+
maxTotalBytes: ASSISTANT_IMAGE_MAX_TOTAL_BYTES,
348+
})
349+
} catch (error) {
350+
toast.error(getErrorMessage(error, 'Check the attached images before sending.'))
351+
return false
352+
}
353+
}
330354
const submission = panelOptions
331355
? panelOptions.prepareSubmission(text.trim(), contexts)
332356
: { message: text.trim(), contexts }

0 commit comments

Comments
 (0)