Skip to content

Commit 65ee6ad

Browse files
andresdjassoclaude
andcommitted
feat(workflow): docked chat — chat is the constant, the editor owns the stage
Workflows are environments, not documents: they never render as resource tabs. The workflow route gains a WorkflowWithChat shell — picking a chat from the canvas switcher docks the full Mothership chat beside the editor (?chat= in the URL for refresh/deep links), the chat title bar swaps chats in place (new switcher navigateOnSelect=false plumbing through MothershipChat), and × returns the editor to full width. On chat surfaces, every workflow open — agent-touched, +-menu, or chip — swaps the stage to /w/{id}?chat={chatId} instead of opening a tab; the workspace tab strip persists untouched behind it, and non-workflow chips in the docked pane swap back to the tabbed chat view with that tab focused. Legacy persisted workflow tabs are filtered on read and the tabs store refuses new ones. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5305670 commit 65ee6ad

10 files changed

Lines changed: 311 additions & 14 deletions

File tree

apps/sim/app/workspace/[workspaceId]/components/chat-switcher/chat-switcher.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ function derivePageResource(pathname: string, workspaceId: string): MothershipRe
3333
const prefix = `/workspace/${workspaceId}/`
3434
if (!pathname.startsWith(prefix)) return null
3535
const [segment, detail] = pathname.slice(prefix.length).split('/')
36-
if (segment === 'w' && detail) return { type: 'workflow', id: detail, title: 'Workflow' }
3736
if (segment === 'tables' && detail) return { type: 'table', id: detail, title: 'Table' }
3837
if (segment === 'knowledge' && detail) {
3938
return { type: 'knowledgebase', id: detail, title: 'Knowledge Base' }
@@ -65,6 +64,12 @@ interface ChatSwitcherProps {
6564
* to reopen a hidden chat pane (including re-picking the current chat).
6665
*/
6766
onSelectChat?: (chatId: string) => void
67+
/**
68+
* When false, selecting a chat only fires {@link onSelectChat} — the host
69+
* owns what happens next. The workflow editor uses this to dock the chat
70+
* beside the canvas instead of leaving the page.
71+
*/
72+
navigateOnSelect?: boolean
6873
/**
6974
* The chat is generating a response — the recents icon becomes a spinner so
7075
* the title bar signals work in progress even when the messages are off
@@ -83,6 +88,7 @@ export function ChatSwitcher({
8388
isNewChat = false,
8489
iconOnly = false,
8590
onSelectChat,
91+
navigateOnSelect = true,
8692
isWorking = false,
8793
}: ChatSwitcherProps) {
8894
const isHidden = useSidebarToggleHidden()
@@ -113,6 +119,7 @@ export function ChatSwitcher({
113119
const handleSelect = (selectedChatId: string) => {
114120
setOpen(false)
115121
onSelectChat?.(selectedChatId)
122+
if (!navigateOnSelect) return
116123
if (selectedChatId === chatId) return
117124
// Opening a chat never takes away what you're looking at: the current
118125
// page becomes the focused panel tab, and the chat slides in beside it.

apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/components/chat-title-bar/chat-title-bar.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ interface ChatTitleBarProps {
1313
* to reopen a hidden chat pane (including re-picking the current chat).
1414
*/
1515
onSelectChat?: (chatId: string) => void
16+
/**
17+
* Forwarded to the switcher: when false, selecting a chat only fires
18+
* {@link onSelectChat} — used by the workflow editor's docked chat, where
19+
* switching swaps the pane in place instead of navigating.
20+
*/
21+
navigateOnSelect?: boolean
1622
/** Renders a close (×) control at the bar's right edge that hides the chat pane. */
1723
onClose?: () => void
1824
/** The chat is generating a response — the switcher's recents icon becomes a spinner. */
@@ -25,7 +31,13 @@ interface ChatTitleBarProps {
2531
* straight between chats without returning to the new-chat view. Selecting a
2632
* chat navigates to it.
2733
*/
28-
export function ChatTitleBar({ chatId, onSelectChat, onClose, isWorking }: ChatTitleBarProps) {
34+
export function ChatTitleBar({
35+
chatId,
36+
onSelectChat,
37+
navigateOnSelect,
38+
onClose,
39+
isWorking,
40+
}: ChatTitleBarProps) {
2941
return (
3042
<div className='flex h-[44px] flex-shrink-0 items-center gap-1 border-[var(--border)] border-b px-4'>
3143
{/* Edge controls pull out by 9px so their 30px hover pills sit 7px from
@@ -37,6 +49,7 @@ export function ChatTitleBar({ chatId, onSelectChat, onClose, isWorking }: ChatT
3749
chatId={chatId}
3850
isNewChat={!chatId}
3951
onSelectChat={onSelectChat}
52+
navigateOnSelect={navigateOnSelect}
4053
isWorking={isWorking}
4154
/>
4255
{onClose && (

apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ interface MothershipChatProps {
6262
onInputAnimationEnd?: () => void
6363
/** Shows the title bar's close (×) control that hides the chat pane. */
6464
onCloseChat?: () => void
65+
/** Forwarded to the title bar's chat switcher. */
66+
onSelectChat?: (chatId: string) => void
67+
/**
68+
* Forwarded to the title bar's chat switcher: when false, selecting a chat
69+
* only fires {@link onSelectChat} (docked-chat hosts swap in place).
70+
*/
71+
switcherNavigates?: boolean
6572
className?: string
6673
}
6774

@@ -213,6 +220,8 @@ export function MothershipChat({
213220
animateInput = false,
214221
onInputAnimationEnd,
215222
onCloseChat,
223+
onSelectChat,
224+
switcherNavigates,
216225
className,
217226
}: MothershipChatProps) {
218227
const styles = LAYOUT_STYLES[layout]
@@ -309,7 +318,13 @@ export function MothershipChat({
309318
>
310319
<div className={cn('flex h-full min-h-0 flex-col', className)}>
311320
{layout === 'mothership-view' && (
312-
<ChatTitleBar chatId={chatId} onClose={onCloseChat} isWorking={isStreamActive} />
321+
<ChatTitleBar
322+
chatId={chatId}
323+
onClose={onCloseChat}
324+
onSelectChat={onSelectChat}
325+
navigateOnSelect={switcherNavigates}
326+
isWorking={isStreamActive}
327+
/>
313328
)}
314329
<div className='relative flex min-h-0 flex-1 flex-col'>
315330
<div ref={setScrollContainer} className={cn(styles.scrollContainer, SCROLLBAR_AUTOHIDE)}>

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

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,12 +162,17 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom
162162

163163
// The tab strip is user-owned per workspace (browser-tab semantics): chats
164164
// merge their artifacts in additively; only the user closes/reorders tabs.
165+
// Workflows never tab — they own the stage as the full editor, so any
166+
// legacy persisted workflow tabs are filtered out on read.
165167
const workspaceTabs = useMothershipTabsStore((s) => s.byWorkspace[workspaceId])
166168
const openTabs = useMothershipTabsStore((s) => s.openTabs)
167169
const closeTab = useMothershipTabsStore((s) => s.closeTab)
168170
const reorderTabs = useMothershipTabsStore((s) => s.reorderTabs)
169171
const setActiveTab = useMothershipTabsStore((s) => s.setActiveTab)
170-
const storeTabs = workspaceTabs?.tabs
172+
const storeTabs = useMemo(
173+
() => workspaceTabs?.tabs.filter((tab) => tab.type !== 'workflow'),
174+
[workspaceTabs]
175+
)
171176
const storeActiveTabId = workspaceTabs?.activeTabId ?? null
172177

173178
function handleResourceEvent() {
@@ -176,6 +181,22 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom
176181
}
177182
}
178183

184+
/**
185+
* The chat the panel is following, readable from stream callbacks without
186+
* re-creating the options object (resolvedChatId lands a render later).
187+
*/
188+
const activeChatIdRef = useRef<string | undefined>(chatId)
189+
190+
/** Swaps the stage to a workflow's full editor, carrying the chat along. */
191+
const openWorkflowStage = useCallback(
192+
(workflowId: string) => {
193+
const chatKey = activeChatIdRef.current
194+
const chatParam = chatKey ? `?chat=${chatKey}` : ''
195+
router.push(`/workspace/${workspaceId}/w/${workflowId}${chatParam}`)
196+
},
197+
[router, workspaceId]
198+
)
199+
179200
const {
180201
messages,
181202
isSending,
@@ -204,8 +225,13 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom
204225
onResourceEvent: handleResourceEvent,
205226
// The panel follows the conversation: any resource the agent touches —
206227
// even one that's already an open tab — surfaces and takes focus, so
207-
// "switch to X" in chat actually switches the strip.
228+
// "switch to X" in chat actually switches the strip. Workflows are the
229+
// exception: they never tab, the stage swaps to their full editor.
208230
onResourceTouched: (resource) => {
231+
if (resource.type === 'workflow') {
232+
openWorkflowStage(resource.id)
233+
return
234+
}
209235
openTabs(workspaceId, [resource], { focusId: resource.id })
210236
},
211237
initialActiveResourceId: initialResourceId,
@@ -247,7 +273,10 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom
247273
mergedKeysRef.current = new Set()
248274
}
249275
const fresh = resources.filter(
250-
(r) => !isEphemeralResource(r) && !mergedKeysRef.current.has(`${r.type}:${r.id}`)
276+
(r) =>
277+
!isEphemeralResource(r) &&
278+
r.type !== 'workflow' &&
279+
!mergedKeysRef.current.has(`${r.type}:${r.id}`)
251280
)
252281
if (fresh.length === 0) return
253282
for (const r of fresh) mergedKeysRef.current.add(`${r.type}:${r.id}`)
@@ -411,9 +440,15 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom
411440
/**
412441
* Manually attaching a resource opens its tab (session) AND records it on
413442
* the chat (provenance + agent context) via {@link addResource}, which keeps
414-
* the existing persistence machinery.
443+
* the existing persistence machinery. Workflows never tab — they swap the
444+
* stage to their full editor (still recorded on the chat first).
415445
*/
416446
function openResourceTab(resource: MothershipResource) {
447+
if (resource.type === 'workflow') {
448+
addResource(resource)
449+
openWorkflowStage(resource.id)
450+
return
451+
}
417452
openTabs(workspaceId, [resource], { focusId: resource.id })
418453
addResource(resource)
419454
handleResourceEvent()
@@ -494,6 +529,7 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom
494529
// it (not just the prop) lets an inline-opened chat render its skeleton + view
495530
// before its history finishes loading.
496531
const activeChatId = resolvedChatId ?? chatId
532+
activeChatIdRef.current = activeChatId
497533
const { isPending: isActiveChatHistoryPending } = useMothershipChatHistory(activeChatId)
498534
const hasMessages = messages.length > 0
499535
const showChatSkeleton = Boolean(activeChatId) && !hasMessages && isActiveChatHistoryPending
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
'use client'
2+
3+
import { useEffect } from 'react'
4+
import { useRouter } from 'next/navigation'
5+
import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components'
6+
import { getMothershipUseChatOptions, useChat } from '@/app/workspace/[workspaceId]/home/hooks'
7+
import type {
8+
FileAttachmentForApi,
9+
MothershipResource,
10+
} from '@/app/workspace/[workspaceId]/home/types'
11+
import { useMothershipChatHistory } from '@/hooks/queries/mothership-chats'
12+
import type { ChatContext } from '@/stores/panel'
13+
14+
interface DockedChatProps {
15+
workspaceId: string
16+
/** The workflow occupying the stage — its own chips never navigate. */
17+
workflowId: string
18+
/** Undefined renders the new-chat empty state; first send creates the chat. */
19+
chatId?: string
20+
onClose: () => void
21+
/** Swap the docked pane to another chat (host remounts with the new id). */
22+
onSelectChat: (chatId: string) => void
23+
/** A new chat resolved its server id — host reflects it into the URL. */
24+
onChatResolved: (chatId: string) => void
25+
}
26+
27+
/**
28+
* The Mothership chat docked beside the workflow editor. The editor owns the
29+
* stage: workflow chips for other workflows swap the stage to their editor,
30+
* and any other resource chip swaps back to the tabbed chat view with that
31+
* tab focused. The conversation itself is the same chat that the chat view
32+
* hosts — same history, same drafts.
33+
*/
34+
export function DockedChat({
35+
workspaceId,
36+
workflowId,
37+
chatId,
38+
onClose,
39+
onSelectChat,
40+
onChatResolved,
41+
}: DockedChatProps) {
42+
const router = useRouter()
43+
const {
44+
messages,
45+
isSending,
46+
isReconnecting,
47+
sendMessage,
48+
stopGeneration,
49+
resolvedChatId,
50+
messageQueue,
51+
removeFromQueue,
52+
sendNow,
53+
editQueuedMessage,
54+
cancelQueueEdit,
55+
editingQueuedId,
56+
dispatchingHeadId,
57+
} = useChat(workspaceId, chatId, getMothershipUseChatOptions({}))
58+
59+
useEffect(() => {
60+
if (resolvedChatId && resolvedChatId !== chatId) onChatResolved(resolvedChatId)
61+
}, [resolvedChatId, chatId, onChatResolved])
62+
63+
const activeChatId = resolvedChatId ?? chatId
64+
const { isPending: isHistoryPending } = useMothershipChatHistory(activeChatId)
65+
const showSkeleton = Boolean(activeChatId) && messages.length === 0 && isHistoryPending
66+
67+
const handleSubmit = (
68+
text: string,
69+
fileAttachments?: FileAttachmentForApi[],
70+
contexts?: ChatContext[]
71+
) => {
72+
const trimmed = text.trim()
73+
if (!trimmed && !(fileAttachments && fileAttachments.length > 0)) return
74+
sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, contexts)
75+
}
76+
77+
const handleWorkspaceResourceSelect = (resource: MothershipResource) => {
78+
if (resource.type === 'workflow') {
79+
if (resource.id === workflowId) return
80+
const chatParam = activeChatId ? `?chat=${activeChatId}` : ''
81+
router.push(`/workspace/${workspaceId}/w/${resource.id}${chatParam}`)
82+
return
83+
}
84+
if (!activeChatId) return
85+
router.push(`/workspace/${workspaceId}/chat/${activeChatId}?resource=${resource.id}`)
86+
}
87+
88+
return (
89+
<MothershipChat
90+
messages={messages}
91+
isSending={isSending}
92+
isReconnecting={isReconnecting}
93+
isLoading={showSkeleton}
94+
onSubmit={handleSubmit}
95+
onStopGeneration={() => void stopGeneration().catch(() => {})}
96+
messageQueue={messageQueue}
97+
editingQueuedId={editingQueuedId}
98+
dispatchingHeadId={dispatchingHeadId}
99+
onRemoveQueuedMessage={removeFromQueue}
100+
onSendQueuedMessage={sendNow}
101+
onEditQueuedMessage={editQueuedMessage}
102+
onCancelQueueEdit={cancelQueueEdit}
103+
chatId={activeChatId}
104+
onWorkspaceResourceSelect={handleWorkspaceResourceSelect}
105+
draftScopeKey={`${workspaceId}:${activeChatId ?? 'new'}`}
106+
onCloseChat={onClose}
107+
onSelectChat={onSelectChat}
108+
switcherNavigates={false}
109+
/>
110+
)
111+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { DockedChat } from './docked-chat'
2+
export { WorkflowWithChat } from './workflow-with-chat'

0 commit comments

Comments
 (0)