Skip to content

Commit 2f5d127

Browse files
committed
feat(mothership): add dev-gated Plan mode across chat surfaces
1 parent 0db78fd commit 2f5d127

68 files changed

Lines changed: 423 additions & 190 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci.yml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,9 @@ jobs:
221221
file: ${{ matrix.dockerfile }}
222222
platforms: linux/amd64
223223
tags: ${{ steps.login-ecr.outputs.registry }}/${{ steps.ecr-repo.outputs.name }}:${{ github.sha }}-dev
224-
build-args: SIM_SEARCH_LIVE_DEFAULT=true
224+
build-args: |
225+
SIM_SEARCH_LIVE_DEFAULT=true
226+
MSHIP_PLAN_MODE_DEFAULT=true
225227
max-cache-size-mb: ${{ matrix.cache_mb }}
226228

227229
# Build and upload tasks alongside tests and images. The unpromoted version

‎apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useMothershipEffortStore } from '@/stores/mothership-effort/store'
99

1010
const mocks = vi.hoisted(() => ({
1111
live: false,
12+
plan: false,
1213
speech: vi.fn<typeof useSpeechToText>(),
1314
toggleListening: vi.fn(),
1415
resetTranscript: vi.fn(),
@@ -35,6 +36,9 @@ const mocks = vi.hoisted(() => ({
3536
}))
3637

3738
vi.mock('@/lib/core/config/deployment-shape', () => ({
39+
useDeploymentShape: () => ({
40+
features: { liveEnterpriseSearch: mocks.live, planMode: mocks.plan },
41+
}),
3842
getDeploymentShape: () => ({ features: { liveEnterpriseSearch: mocks.live } }),
3943
}))
4044
vi.mock('@/hooks/queries/workspace', () => ({
@@ -87,6 +91,7 @@ let container: HTMLDivElement
8791
let queryClient: QueryClient
8892

8993
beforeEach(() => {
94+
mocks.plan = false
9095
mocks.live = false
9196
vi.clearAllMocks()
9297
mocks.workspaces = [
@@ -852,3 +857,25 @@ it('offers only Auto and Max for live Search, with Auto sending the Fast preset'
852857
await act(async () => options[0].click())
853858
expect(onChange).toHaveBeenCalledWith('fast')
854859
})
860+
861+
it.each([false, true])('exposes Plan only when enabled (%s)', async (enabled) => {
862+
mocks.plan = enabled
863+
const onModeChange = vi.fn()
864+
await render(true, 'Understand triage', 'agent', {
865+
isSending: false,
866+
showModeSelector: true,
867+
onModeChange,
868+
})
869+
const mode = container.querySelector<HTMLButtonElement>('[aria-label="Conversation mode"]')!
870+
await act(async () =>
871+
mode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
872+
)
873+
const plan = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')].find(
874+
(item) => item.textContent === 'Plan'
875+
)
876+
expect(Boolean(plan)).toBe(enabled)
877+
if (plan) {
878+
await act(async () => plan.click())
879+
expect(onModeChange).toHaveBeenCalledExactlyOnceWith('plan')
880+
}
881+
})

‎apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Chip, ChipDropdown, ComposerActionButton, cn, Tooltip, toast } from '@s
55
import { ArrowUp, Paperclip, Plus, Search, Slash, StopFilled } from '@sim/emcn/icons'
66
import { escapeRegExp } from '@sim/utils/string'
77
import { useQueries } from '@tanstack/react-query'
8+
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
89
import {
910
ASSISTANT_IMAGE_ACCEPT_ATTRIBUTE,
1011
isAssistantImageType,
@@ -40,6 +41,7 @@ const CONVERSATION_MODES = [
4041
] as const
4142

4243
interface ComposerProps {
44+
searchEnabled?: boolean
4345
requestMode?: ChatRequestMode
4446
assistantSearchLevel?: SearchLevel
4547
onAssistantSearchLevelChange?: (level: SearchLevel) => void
@@ -62,6 +64,7 @@ interface ComposerProps {
6264
* carries only the controls that are wired for the organization.
6365
*/
6466
export function Composer({
67+
searchEnabled = true,
6568
requestMode = 'assistant',
6669
onModeChange,
6770
assistantSearchLevel = 'adaptive',
@@ -76,6 +79,8 @@ export function Composer({
7679
restoredContexts,
7780
onStop,
7881
}: ComposerProps) {
82+
const planEnabled = useDeploymentShape().features.planMode === true
83+
const modes = CONVERSATION_MODES.filter((mode) => searchEnabled || mode.value !== 'assistant')
7984
const imagesOnly = requestMode === 'assistant'
8085
const [modeSelectorOpen, setModeSelectorOpen] = useState(false)
8186
const { organization } = useOrganizationContext()
@@ -200,15 +205,21 @@ export function Composer({
200205
iconOnly={imagesOnly}
201206
leftIcon={imagesOnly ? Search : undefined}
202207
aria-label='Conversation mode'
203-
options={CONVERSATION_MODES}
208+
options={planEnabled ? [...modes, { value: 'plan', label: 'Plan' }] : modes}
204209
value={requestMode}
205210
disabled={!onModeChange}
206211
align='start'
207212
matchTriggerWidth={false}
208213
showSelectedCheck={false}
209214
onOpenChange={setModeSelectorOpen}
210215
onChange={(mode) => {
211-
if ((mode !== 'assistant' && mode !== 'agent') || mode === requestMode) return
216+
if (
217+
(mode !== 'assistant' &&
218+
mode !== 'agent' &&
219+
!(planEnabled && mode === 'plan')) ||
220+
mode === requestMode
221+
)
222+
return
212223
if (
213224
mode === 'assistant' &&
214225
(editor.getActiveContexts().length > 0 ||

‎apps/sim/app/o/[organizationId]/home/organization-home.test.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { useOrganizationChatModeStore } from '@/stores/organization-chat-mode/st
1010

1111
const mocks = vi.hoisted(() => ({
1212
live: false,
13+
plan: false,
1314
context: vi.fn(),
1415
session: vi.fn(),
1516
push: vi.fn(),
@@ -30,6 +31,9 @@ const mocks = vi.hoisted(() => ({
3031
activeResource: null as string | null,
3132
}))
3233
vi.mock('@/lib/core/config/deployment-shape', () => ({
34+
useDeploymentShape: () => ({
35+
features: { liveEnterpriseSearch: mocks.live, planMode: mocks.plan },
36+
}),
3337
getDeploymentShape: () => ({ features: { liveEnterpriseSearch: mocks.live } }),
3438
}))
3539
vi.mock('@/blocks/integration-matcher', () => ({ mentionifyIntegrations: (text: string) => text }))
@@ -86,6 +90,7 @@ import { OrganizationHome } from '@/app/o/[organizationId]/home/organization-hom
8690
let root: Root
8791
let container: HTMLDivElement
8892
beforeEach(() => {
93+
mocks.plan = false
8994
mocks.live = false
9095
vi.clearAllMocks()
9196
mocks.activeResource = null

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

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { requestJson } from '@/lib/api/client/request'
99
import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge'
1010
import { getWorkspaceHostContextContract } from '@/lib/api/contracts/workspaces'
1111
import { useSession } from '@/lib/auth/auth-client'
12-
import { getDeploymentShape } from '@/lib/core/config/deployment-shape'
12+
import { getDeploymentShape, useDeploymentShape } from '@/lib/core/config/deployment-shape'
1313
import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
1414
import { getMothershipAttachmentPreviewUrl } from '@/lib/mothership/chat/attachment-preview'
1515
import { createSearchResource } from '@/lib/mothership/resources/search'
@@ -90,27 +90,31 @@ function OrganizationHomeContent({
9090
)
9191
const rememberMode = useOrganizationChatModeStore((state) => state.setMode)
9292
const [selectedMode, setSelectedMode] = useState<ChatRequestMode | null>(null)
93+
const planEnabled = useDeploymentShape().features.planMode === true
9394
const requestMode =
9495
selectedMode ??
9596
(urlSearchLevel && searchAccess.memberScoped && !chatId ? 'assistant' : undefined) ??
9697
savedMode ??
9798
(!canBuild || !mothershipAvailable
9899
? 'assistant'
99-
: rememberedMode === 'assistant' && searchAccess.memberScoped
100-
? 'assistant'
101-
: 'agent')
100+
: rememberedMode === 'plan' && planEnabled
101+
? 'plan'
102+
: rememberedMode === 'assistant' && searchAccess.memberScoped
103+
? 'assistant'
104+
: 'agent')
102105
const [draft, setDraft] = useState('')
103106
const [restoredContexts, setRestoredContexts] = useState<ChatContext[]>([])
104107
const controller = useResourcePanelController()
105108
const queryClient = useQueryClient()
106109
const chat = useChat({ organizationId: organization.id }, chatId, {
107110
requestMode,
108-
projectsDesktopTabs: requestMode === 'agent',
111+
projectsDesktopTabs: requestMode !== 'assistant',
109112
onResourceEvent: controller.onResourceEvent,
110113
activeResourceState: controller.activeResourceState,
111114
})
112115
const hasChat = Boolean(chatId || chat.messages.length)
113-
const canSelectMode = !hasChat && mothershipAvailable && canBuild && searchAccess.memberScoped
116+
const canSelectMode =
117+
!hasChat && mothershipAvailable && canBuild && (searchAccess.memberScoped || planEnabled)
114118
const liveSearch = getDeploymentShape().features.liveEnterpriseSearch === true
115119
const assistantSearchLevel = resolveSearchLevel(
116120
urlSearchLevel ?? rememberedSearchLevel,
@@ -229,9 +233,9 @@ function OrganizationHomeContent({
229233
contexts?: ChatContext[],
230234
assistantSearch?: WorkspaceSearchFilters
231235
) => {
232-
if (requestMode === 'agent' && !canBuild) return
236+
if (requestMode !== 'assistant' && !canBuild) return
233237
setSelectedMode(requestMode)
234-
if (requestMode === 'agent') panel.prepareResourceViewForAgentTurn()
238+
if (requestMode !== 'assistant') panel.prepareResourceViewForAgentTurn()
235239
void sendMessage(message, fileAttachments, contexts, {
236240
requestMode,
237241
...(requestMode === 'assistant' ? { assistantSearchLevel } : {}),
@@ -266,7 +270,7 @@ function OrganizationHomeContent({
266270
}
267271

268272
const composer =
269-
requestMode === 'agent' && !canBuild ? (
273+
requestMode !== 'assistant' && !canBuild ? (
270274
<div className='px-4 py-3 text-[var(--text-muted)] text-sm'>
271275
Build requires permission to create workspaces.{' '}
272276
{searchAccess.memberScoped && (
@@ -278,6 +282,7 @@ function OrganizationHomeContent({
278282
) : (
279283
<Composer
280284
requestMode={requestMode}
285+
searchEnabled={searchAccess.memberScoped}
281286
assistantSearchLevel={assistantSearchLevel}
282287
onAssistantSearchLevelChange={changeAssistantSearchLevel}
283288
showModeSelector={canSelectMode}
@@ -341,9 +346,9 @@ function OrganizationHomeContent({
341346
userId={session?.user?.id}
342347
chatId={chat.resolvedChatId}
343348
composer={composer}
344-
onWorkspaceResourceSelect={requestMode === 'agent' ? selectResource : undefined}
349+
onWorkspaceResourceSelect={requestMode !== 'assistant' ? selectResource : undefined}
345350
initialScrollBlocked={
346-
(requestMode === 'agent' || liveSearch) &&
351+
(requestMode !== 'assistant' || liveSearch) &&
347352
chat.resources.length > 0 &&
348353
panel.isResourceCollapsed
349354
}
@@ -355,7 +360,9 @@ function OrganizationHomeContent({
355360
<h1 className='mb-7 max-w-chat text-balance font-season text-[26px] text-[var(--text-primary)] leading-[1.15] tracking-[-0.01em] sm:text-[28px]'>
356361
{requestMode === 'assistant'
357362
? `Search ${organization.name}`
358-
: `What should we get done${firstName ? `, ${firstName}` : ''}?`}
363+
: requestMode === 'plan'
364+
? `What should we understand and plan${firstName ? `, ${firstName}` : ''}?`
365+
: `What should we get done${firstName ? `, ${firstName}` : ''}?`}
359366
</h1>
360367
<div className='relative w-full max-w-chat'>
361368
{composer}
@@ -378,7 +385,7 @@ function OrganizationHomeContent({
378385
)
379386
return (
380387
<ChatResourcePanel
381-
allowBuildControls={requestMode === 'agent' && canBuild}
388+
allowBuildControls={requestMode !== 'assistant' && canBuild}
382389
organizationId={organization.id}
383390
chat={chat}
384391
panel={panel}

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,7 @@ interface ChatContentProps {
431431
content: string
432432
messageId?: string
433433
imageRequestId?: string
434-
requestMode?: 'agent' | 'assistant'
434+
requestMode?: 'agent' | 'assistant' | 'plan'
435435
isStreaming?: boolean
436436
/** Transcript-derived answers for this message's question card (renders the recap). */
437437
questionAnswers?: string[]

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1758,7 +1758,7 @@ function recoverTrailingBareOptions(segments: ContentSegment[]): void {
17581758

17591759
interface SpecialTagsProps {
17601760
segment: Exclude<ContentSegment, { type: 'text' }>
1761-
requestMode?: 'agent' | 'assistant'
1761+
requestMode?: 'agent' | 'assistant' | 'plan'
17621762
/** Stable identity for interaction state owned by this message/tag. */
17631763
interactionId?: string
17641764
/** Transcript-derived answers for this message's question card (renders the recap). */
@@ -2060,7 +2060,7 @@ function getCredentialProviderDisplayName(provider: string): string {
20602060
*/
20612061
interface CredentialControlProps {
20622062
data: CredentialItemData
2063-
requestMode?: 'agent' | 'assistant'
2063+
requestMode?: 'agent' | 'assistant' | 'plan'
20642064
controlId?: string
20652065
embedded?: boolean
20662066
divided?: boolean
@@ -2723,7 +2723,7 @@ const CREDENTIAL_CARD_TYPES: ReadonlySet<CredentialTagType> = new Set([
27232723
function isCredentialCardItemVisible(
27242724
item: CredentialItemData,
27252725
canEdit: boolean,
2726-
requestMode?: 'agent' | 'assistant'
2726+
requestMode?: 'agent' | 'assistant' | 'plan'
27272727
): boolean {
27282728
if (requestMode === 'assistant')
27292729
return (
@@ -2743,7 +2743,7 @@ function isCredentialCardItemVisible(
27432743
export function credentialTagHasVisibleCard(
27442744
data: CredentialTagData,
27452745
canEdit: boolean,
2746-
requestMode?: 'agent' | 'assistant'
2746+
requestMode?: 'agent' | 'assistant' | 'plan'
27472747
): boolean {
27482748
return (
27492749
data.length > 0 &&
@@ -2873,7 +2873,7 @@ function CredentialInputCard({
28732873
onContinue,
28742874
}: {
28752875
data: CredentialTagData
2876-
requestMode?: 'agent' | 'assistant'
2876+
requestMode?: 'agent' | 'assistant' | 'plan'
28772877
interactionId?: string
28782878
submitted?: CredentialSubmissionPayload
28792879
abandoned?: boolean
@@ -3194,7 +3194,7 @@ function CredentialDisplayContent({
31943194
onContinue,
31953195
}: {
31963196
data: CredentialTagData
3197-
requestMode?: 'agent' | 'assistant'
3197+
requestMode?: 'agent' | 'assistant' | 'plan'
31983198
interactionId?: string
31993199
submitted?: CredentialSubmissionPayload
32003200
abandoned?: boolean

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -957,7 +957,7 @@ interface MessageContentProps {
957957
fallbackContent: string
958958
messageId?: string
959959
imageRequestId?: string
960-
requestMode?: 'agent' | 'assistant'
960+
requestMode?: 'agent' | 'assistant' | 'plan'
961961
isStreaming: boolean
962962
/**
963963
* True for the last message in the transcript. The last turn keeps a

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ import {
1010
useRef,
1111
useState,
1212
} from 'react'
13-
import { Chip, cn, Tooltip, toast } from '@sim/emcn'
13+
import { Chip, ChipDropdown, cn, Tooltip, toast } from '@sim/emcn'
1414
import { Paperclip, Plus, Slash } from '@sim/emcn/icons'
1515
import { createLogger } from '@sim/logger'
1616
import { useParams } from 'next/navigation'
17+
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
1718
import { getMothershipAttachmentPreviewUrl } from '@/lib/mothership/chat/attachment-preview'
1819
import { MOTHERSHIP_ADD_CONTEXT_EVENT } from '@/lib/mothership/events'
1920
import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/mothership/resource-types'
@@ -50,6 +51,8 @@ export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/ty
5051
const logger = createLogger('UserInput')
5152

5253
interface UserInputProps {
54+
requestMode?: 'agent' | 'plan'
55+
onModeChange?: (mode: 'agent' | 'plan') => void
5356
defaultValue?: string
5457
draftScopeKey?: string
5558
onSubmit: (
@@ -81,6 +84,8 @@ export interface UserInputHandle {
8184
*/
8285
const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserInput(
8386
{
87+
requestMode = 'agent',
88+
onModeChange,
8489
defaultValue = '',
8590
draftScopeKey,
8691
onSubmit,
@@ -92,6 +97,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
9297
},
9398
ref
9499
) {
100+
const planEnabled = useDeploymentShape().features.planMode === true
95101
const { workspaceId } = useParams<{ workspaceId: string }>()
96102
const { navigateToSettings } = useSettingsNavigation()
97103
const { userId, onContextAdd, onContextRemove } = useChatSurface()
@@ -579,6 +585,19 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
579585
<InputToolbar
580586
leadingControls={
581587
<>
588+
{planEnabled && onModeChange && (
589+
<ChipDropdown
590+
aria-label='Conversation mode'
591+
value={requestMode}
592+
options={[
593+
{ value: 'agent', label: 'Build' },
594+
{ value: 'plan', label: 'Plan' },
595+
]}
596+
onChange={(mode) => {
597+
if (mode === 'agent' || mode === 'plan') onModeChange(mode)
598+
}}
599+
/>
600+
)}
582601
<Tooltip.Root>
583602
<Tooltip.Trigger asChild>
584603
<Chip

0 commit comments

Comments
 (0)