Skip to content

Commit 623c710

Browse files
committed
fix(mothership): unify scoped CLI and organization resource panels
1 parent 4e325b0 commit 623c710

32 files changed

Lines changed: 1775 additions & 141 deletions

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

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import { mothershipTaskKeys } from '@/hooks/queries/mothership-tasks'
66
import { TaskPill } from './task-pill'
77

88
const { request } = vi.hoisted(() => ({ request: vi.fn() }))
9+
vi.mock('@/lib/mothership/tools/client/resource-display', () => ({
10+
resolveResourceDisplayName: (_type: string, id: unknown) =>
11+
id === 'workflow' ? 'Alfred' : undefined,
12+
}))
913
vi.mock('@/lib/api/client/request', () => ({ requestJson: request }))
1014
vi.mock('@sim/emcn', () => ({
1115
Check: () => null,
@@ -39,7 +43,8 @@ it('updates a pill in an earlier message without needing an event in that turn',
3943
await waitFor(() => expect(request).toHaveBeenCalledTimes(1))
4044
request.mockResolvedValue({ taskId: task.taskId, status: 'completed', summary: 'Timer elapsed' })
4145
await act(() => client.invalidateQueries({ queryKey: mothershipTaskKeys.detail(task.taskId) }))
42-
expect(await screen.findByText(/completed timer.*Timer elapsed/)).toBeTruthy()
46+
expect(await screen.findByText(/Background watch.*Completed timer/)).toBeTruthy()
47+
expect(screen.getByRole('status').title).toContain('Timer elapsed')
4348
})
4449

4550
it('never replaces a live terminal event with a cached pending status', async () => {
@@ -56,5 +61,42 @@ it('never replaces a live terminal event with a cached pending status', async ()
5661
<TaskPill task={{ ...task, status: 'stopped' }} />
5762
</QueryClientProvider>
5863
)
59-
expect(screen.getByText(/stopped timer/)).toBeTruthy()
64+
expect(screen.getByText(/Stopped timer/)).toBeTruthy()
65+
})
66+
67+
it('shows completed workflow watches as status without duplicate run ids or action controls', () => {
68+
const executionId = '733e3428-f081-4504-bc5e-25be3b515304'
69+
const summary = `Workflow run ${executionId} of "Alfred" completed`
70+
render(
71+
<QueryClientProvider client={client}>
72+
<TaskPill
73+
task={{
74+
...task,
75+
kind: 'workflow_run',
76+
status: 'completed',
77+
target: { executionId, workflowId: 'workflow', workspaceId: 'workspace' },
78+
summary,
79+
}}
80+
/>
81+
</QueryClientProvider>
82+
)
83+
const status = screen.getByRole('status')
84+
expect(status.textContent).toBe('Background watch · Completed workflow run · Alfred')
85+
expect(status.title).toContain(summary)
86+
expect(status.textContent).not.toContain(executionId)
87+
expect(screen.queryByRole('button')).toBeNull()
88+
expect(request).not.toHaveBeenCalled()
6089
})
90+
91+
it.each(['failed', 'expired', 'stopped'] as const)(
92+
'retains a %s workflow watch outcome and detail',
93+
(status) => {
94+
render(
95+
<QueryClientProvider client={client}>
96+
<TaskPill task={{ ...task, kind: 'workflow_run', status, summary: 'Run did not finish' }} />
97+
</QueryClientProvider>
98+
)
99+
expect(screen.getByRole('status').textContent).not.toContain('Completed')
100+
expect(screen.getByRole('status').title).toContain('Run did not finish')
101+
}
102+
)

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

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Check, Clock, cn, X } from '@sim/emcn'
22
import type { TaskBlockInfo } from '@/lib/mothership/request/types'
3+
import { resolveResourceDisplayName } from '@/lib/mothership/tools/client/resource-display'
34
import { useMothershipTaskStatus } from '@/hooks/queries/mothership-tasks'
45

56
interface TaskPillProps {
@@ -8,8 +9,10 @@ interface TaskPillProps {
89

910
function describeTarget(task: TaskBlockInfo): string {
1011
if (task.kind === 'workflow_run') {
11-
const id = typeof task.target.executionId === 'string' ? task.target.executionId : ''
12-
return `workflow run ${id.slice(0, 8)}`
12+
const workspaceId =
13+
typeof task.target.workspaceId === 'string' ? task.target.workspaceId : undefined
14+
const name = resolveResourceDisplayName('workflow', task.target.workflowId, { workspaceId })
15+
return name ? `workflow run · ${name}` : 'workflow run'
1316
}
1417
const at = typeof task.target.firesAt === 'string' ? new Date(task.target.firesAt) : null
1518
return at
@@ -18,9 +21,9 @@ function describeTarget(task: TaskBlockInfo): string {
1821
}
1922

2023
/**
21-
* The "watching" pill under a turn that armed a background task (mothership
24+
* Read-only background-watch status under a turn that armed a background task (mothership
2225
* docs/revamp/21-background-tasks.md §6.4). Pending until the task's notification lands
23-
* in this chat, then it shows the outcome. Read-only.
26+
* in this chat, then it shows the outcome. The full notification remains available as detail.
2427
*/
2528
export function TaskPill({ task: recorded }: TaskPillProps) {
2629
const { data } = useMothershipTaskStatus(recorded)
@@ -31,13 +34,20 @@ export function TaskPill({ task: recorded }: TaskPillProps) {
3134
const pending = task.status === undefined || task.status === 'pending'
3235
const failed = task.status === 'failed' || task.status === 'expired'
3336
const Icon = pending ? Clock : failed ? X : Check
37+
const label = {
38+
pending: 'Watching',
39+
completed: 'Completed',
40+
failed: 'Failed',
41+
stopped: 'Stopped',
42+
expired: 'Expired',
43+
}[task.status ?? 'pending']
44+
const target = describeTarget(task)
3445
return (
3546
<div
36-
className={cn(
37-
'my-1 inline-flex max-w-full items-center gap-2 rounded-full border px-3 py-1 text-[12px]',
38-
'border-[var(--border)] bg-[var(--bg)] text-[var(--text-secondary)]'
39-
)}
40-
title={task.note}
47+
className='my-1 flex max-w-full items-center gap-2 text-[12px] text-[var(--text-secondary)]'
48+
title={[task.summary, task.note].filter(Boolean).join('\n')}
49+
role='status'
50+
aria-label={`Background watch: ${label} ${target}`}
4151
>
4252
<Icon
4353
className={cn(
@@ -47,8 +57,7 @@ export function TaskPill({ task: recorded }: TaskPillProps) {
4757
)}
4858
/>
4959
<span className='truncate'>
50-
{pending ? 'Watching' : task.status} {describeTarget(task)}
51-
{task.summary ? ` · ${task.summary}` : ` · ${task.note}`}
60+
Background watch · {label} {target}
5261
</span>
5362
</div>
5463
)

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ import {
102102
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
103103
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
104104
import { useTableViewPinStore } from '@/stores/table/view-pin/store'
105+
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
105106

106107
describe('ResourceContent handoff', () => {
107108
let container: HTMLDivElement
@@ -201,6 +202,31 @@ describe('ResourceContent handoff', () => {
201202
])
202203
})
203204

205+
it('leaves an existing workflow hydration error to the canvas retry UI instead of calling it missing', async () => {
206+
const previous = useWorkflowRegistry.getState().hydration
207+
useWorkflowRegistry.setState({
208+
hydration: {
209+
...previous,
210+
phase: 'error',
211+
workspaceId: 'workspace-1',
212+
workflowId: 'verified',
213+
error: 'Temporary network failure',
214+
},
215+
})
216+
client.setQueryData(workflowKeys.list('workspace-1'), [
217+
{ id: 'verified', name: 'Verified workflow' },
218+
])
219+
try {
220+
await act(async () =>
221+
render({ type: 'workflow', id: 'verified', title: 'Verified workflow' })
222+
)
223+
expect(container.textContent).toContain('Verified workflow canvas')
224+
expect(container.textContent).not.toContain('Workflow not found')
225+
} finally {
226+
useWorkflowRegistry.setState({ hydration: previous })
227+
}
228+
})
229+
204230
it('does not cancel canvas hydration when StrictMode replays panel effects', async () => {
205231
client.setQueryData(workflowKeys.list('workspace-1'), [{ id: 'verified' }])
206232
canvasHydration.enabled = true

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

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -700,28 +700,11 @@ interface EmbeddedWorkflowProps {
700700
function EmbeddedWorkflow({ workspaceId, workflowId }: EmbeddedWorkflowProps) {
701701
const { data: workflowList } = useWorkflows(workspaceId)
702702
const workflowExists = (workflowList ?? []).some((workflow) => workflow.id === workflowId)
703-
const hasLoadError = useWorkflowRegistry(
704-
(state) => state.hydration.phase === 'error' && state.hydration.workflowId === workflowId
705-
)
706703

707704
if (!workflowExists) {
708705
return <ResolveEmbeddedWorkflow workspaceId={workspaceId} workflowId={workflowId} />
709706
}
710707

711-
if (hasLoadError) {
712-
return (
713-
<div className='flex h-full flex-col items-center justify-center gap-3'>
714-
<WorkflowX className='size-[32px] text-[var(--text-icon)]' />
715-
<div className='flex flex-col items-center gap-1'>
716-
<h2 className='text-[20px] text-[var(--text-primary)]'>Workflow not found</h2>
717-
<p className='text-[var(--text-body)] text-small'>
718-
This workflow may have been deleted or moved
719-
</p>
720-
</div>
721-
</div>
722-
)
723-
}
724-
725708
return (
726709
<Suspense fallback={LOADING_SKELETON}>
727710
<Workflow workspaceId={workspaceId} workflowId={workflowId} embedded />

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

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,14 @@ const mocks = vi.hoisted(() => ({
1414
remove: vi.fn(),
1515
reorder: vi.fn(),
1616
add: vi.fn(),
17+
ownerQueries: vi.fn<
18+
(
19+
...args: unknown[]
20+
) => Array<{ data?: Array<{ id: string; workspaceId: string; name: string }> }>
21+
>(() => []),
1722
files: vi.fn(() => ({ data: [] })),
1823
}))
24+
vi.mock('@tanstack/react-query', () => ({ useQueries: mocks.ownerQueries }))
1925
vi.mock('@sim/emcn', () => ({
2026
TabStrip: mocks.strip,
2127
Button: ({ children }: { children: ReactNode }) => children,
@@ -47,7 +53,7 @@ vi.mock(
4753
)
4854
vi.mock(
4955
'@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry',
50-
() => ({ getResourceConfig: () => ({ renderTabIcon: () => null }) })
56+
() => ({ getResourceConfig: () => ({ renderTabIcon: () => null, label: 'Workflow' }) })
5157
)
5258
vi.mock(
5359
'@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/use-terminal-close-confirmation',
@@ -75,6 +81,7 @@ function props(): ComponentProps<typeof TabStrip> {
7581
}
7682
beforeEach(async () => {
7783
vi.clearAllMocks()
84+
mocks.ownerQueries.mockReturnValue([])
7885
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
7986
vi.stubGlobal('fetch', vi.fn())
8087
mocks.strip.mockReturnValue(null)
@@ -117,3 +124,48 @@ it('reorders the selected alias while retaining both owners', async () => {
117124
expect(mocks.reorder).toHaveBeenCalledExactlyOnceWith([second, first])
118125
expect(fetch).not.toHaveBeenCalled()
119126
})
127+
128+
it('restores named workflow tabs across owners and ignores empty or wrong-owner names', async () => {
129+
const workflows: MothershipResource[] = [
130+
{ type: 'workflow', id: 'flow-a', workspaceId: 'ws-a', title: 'Saved A' },
131+
{ type: 'workflow', id: 'flow-b', workspaceId: 'ws-b', title: 'Saved B' },
132+
]
133+
mocks.ownerQueries.mockReturnValue([
134+
{ data: [{ id: 'flow-a', workspaceId: 'ws-a', name: 'Current A' }] },
135+
{ data: [{ id: 'flow-b', workspaceId: 'wrong-owner', name: 'Wrong name' }] },
136+
])
137+
await act(async () =>
138+
root.render(
139+
<ResourceTabs
140+
resources={workflows}
141+
activeId='flow-a'
142+
desktopScopeId='org-chat'
143+
chatId='chat-a'
144+
/>
145+
)
146+
)
147+
expect(props().tabs.map((tab) => tab.title)).toEqual(['Current A', 'Saved B'])
148+
expect(
149+
mocks.ownerQueries.mock.lastCall![0].queries.map(
150+
(query: { queryKey: unknown }) => query.queryKey
151+
)
152+
).toEqual([
153+
['workflows', 'list', 'ws-a', 'active'],
154+
['workflows', 'list', 'ws-b', 'active'],
155+
])
156+
mocks.ownerQueries.mockReturnValue([
157+
{ data: [{ id: 'flow-a', workspaceId: 'ws-a', name: '' }] },
158+
{},
159+
])
160+
await act(async () =>
161+
root.render(
162+
<ResourceTabs
163+
resources={workflows}
164+
activeId='flow-b'
165+
desktopScopeId='org-chat'
166+
chatId='chat-a'
167+
/>
168+
)
169+
)
170+
expect(props().tabs.map((tab) => tab.title)).toEqual(['Saved A', 'Saved B'])
171+
})

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

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from '@sim/emcn'
2323
import { Columns3, Eye, Pencil } from '@sim/emcn/icons'
2424
import type { TerminalTabState } from '@sim/terminal-protocol'
25+
import { useQueries } from '@tanstack/react-query'
2526
import { browserTabTitle } from '@/lib/browser-agent/tab-label'
2627
import {
2728
openBrowserTab,
@@ -52,6 +53,7 @@ import type {
5253
import { useFolders } from '@/hooks/queries/folders'
5354
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
5455
import { useTablesList } from '@/hooks/queries/tables'
56+
import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query'
5557
import { useWorkflows } from '@/hooks/queries/workflows'
5658
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
5759
import { useSettledTerminalCommands } from '@/hooks/use-settled-terminal-commands'
@@ -157,8 +159,24 @@ const NO_RESOURCE_NAMES = new Map<string, string>()
157159
*/
158160
function useResourceNameLookup(
159161
workspaceId: string | undefined,
160-
enabled: boolean
162+
resources: MothershipResource[]
161163
): Map<string, string> {
164+
const enabled = resources.length > 0
165+
const owners = [
166+
...new Set(
167+
resources
168+
.filter(
169+
(resource) =>
170+
resource.type === 'workflow' &&
171+
resource.workspaceId &&
172+
resource.workspaceId !== workspaceId
173+
)
174+
.map((resource) => resource.workspaceId!)
175+
),
176+
]
177+
const ownedWorkflows = useQueries({
178+
queries: owners.map((owner) => getWorkflowListQueryOptions(owner)),
179+
})
162180
const { data: workflows } = useWorkflows(workspaceId ?? '', {
163181
enabled: enabled && Boolean(workspaceId),
164182
})
@@ -178,13 +196,18 @@ function useResourceNameLookup(
178196
return useMemo(() => {
179197
if (!enabled) return NO_RESOURCE_NAMES
180198
const map = new Map<string, string>()
181-
for (const w of workflows ?? []) map.set(`workflow:${w.id}`, w.name)
199+
for (const w of workflows ?? []) if (w.name.trim()) map.set(`workflow:${w.id}`, w.name)
200+
for (const [index, result] of ownedWorkflows.entries()) {
201+
for (const workflow of result.data ?? [])
202+
if (workflow.name.trim() && workflow.workspaceId === owners[index])
203+
map.set(`workflow:${workflow.id}`, workflow.name)
204+
}
182205
for (const t of tables ?? []) map.set(`table:${t.id}`, t.name)
183206
for (const f of files ?? []) map.set(`file:${f.id}`, f.name)
184207
for (const kb of knowledgeBases ?? []) map.set(`knowledgebase:${kb.id}`, kb.name)
185208
for (const folder of folders ?? []) map.set(`folder:${folder.id}`, folder.name)
186209
return map
187-
}, [enabled, workflows, tables, files, knowledgeBases, folders])
210+
}, [enabled, workflows, tables, files, knowledgeBases, folders, ownedWorkflows, owners])
188211
}
189212

190213
interface ResourceTabsProps {
@@ -224,7 +247,7 @@ export function ResourceTabs({
224247
onAddResourceClose,
225248
}: ResourceTabsProps) {
226249
const PreviewModeIcon = PREVIEW_MODE_ICONS[previewMode ?? 'split']
227-
const nameLookup = useResourceNameLookup(workspaceId, resources.length > 0)
250+
const nameLookup = useResourceNameLookup(workspaceId, resources)
228251
const {
229252
selectResource,
230253
addResource: onAddResource,
@@ -279,7 +302,10 @@ export function ResourceTabs({
279302
? browserTitles.get(resource.id)
280303
: terminal
281304
? terminalTabTitle(terminal, settledCommands)
282-
: nameLookup.get(`${resource.type}:${resource.id}`)) ?? resource.title,
305+
: nameLookup.get(`${resource.type}:${resource.id}`)
306+
)?.trim() ||
307+
resource.title.trim() ||
308+
getResourceConfig(resource.type).label,
283309
// A shell's label is a basename, and it may be running something it is
284310
// not naming yet, so hovering identifies the directory and program.
285311
...(terminal ? { tooltip: terminalTooltip(terminal) } : {}),

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-tool-resource-titles.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export function useToolResourceTitles(blocks: ContentBlock[]): ContentBlock[] {
2020
if (
2121
event.type === 'updated' &&
2222
event.action.type === 'success' &&
23-
isResourceNameQuery(event.query.queryKey, workspaceId)
23+
isResourceNameQuery(event.query.queryKey)
2424
)
2525
refresh()
2626
})

0 commit comments

Comments
 (0)