Visitar URL original
feat(mothership): let a question card pick from the workspace's resou… · simstudioai/sim@9d78690 · GitHub
Skip to content

Commit 9d78690

Browse files
committed
feat(mothership): let a question card pick from the workspace's resources
1 parent 1741ff9 commit 9d78690

10 files changed

Lines changed: 428 additions & 34 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import {
5959
import {
6060
type ContentSegment,
6161
type CredentialSubmissionPayload,
62+
type InteractionAnswerHandler,
6263
parseSpecialTags,
6364
type SourceTagData,
6465
SpecialTags,
@@ -520,7 +521,7 @@ interface ChatContentProps {
520521
credentialSubmission?: CredentialSubmissionPayload
521522
/** The user moved on without submitting this message's credential card. */
522523
credentialAbandoned?: boolean
523-
onOptionSelect?: (id: string) => void
524+
onOptionSelect?: InteractionAnswerHandler
524525
onQuestionDismiss?: () => void
525526
onWorkspaceResourceSelect?: (resource: WorkspaceResourceRef) => void
526527
onRevealStateChange?: (isRevealing: boolean) => void

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

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,14 @@ import {
1919
InteractionCardInputRow,
2020
InteractionCardRecap,
2121
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card'
22-
import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
22+
import { ResourceQuestionRows } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question/resource-question-rows'
23+
import type {
24+
InteractionAnswerHandler,
25+
QuestionItem,
26+
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
27+
import { mapResourceToContext } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants'
28+
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
29+
import type { ChatContext } from '@/stores/panel'
2330

2431
/**
2532
* Builds the single user message sent after the final question is answered:
@@ -88,8 +95,11 @@ interface QuestionDisplayProps {
8895
* — it IS the user turn; the paired message bubble is hidden by the chat.
8996
*/
9097
answers?: string[]
91-
/** Reports the combined answer; undefined renders the card inert. */
92-
onSelect?: (message: string) => void
98+
/**
99+
* Reports the combined answer, with any resources picked by
100+
* `resource_select` steps attached as contexts; undefined renders the card inert.
101+
*/
102+
onSelect?: InteractionAnswerHandler
93103
/** Reports that the active card was dismissed so its message actions can return. */
94104
onDismiss?: () => void
95105
/** Whether the active card can be dismissed without answering. */
@@ -102,7 +112,9 @@ interface QuestionDisplayProps {
102112
* (and a `‹ N of M ›` stepper for multi-step batches) at the top right, and
103113
* suggested-action option rows beneath, always followed by a custom-answer
104114
* text field whose placeholder reads "Something else". `single_select`
105-
* answers and advances on click (or on submitting typed text); `multi_select`
115+
* answers and advances on click (or on submitting typed text);
116+
* `resource_select` does the same over the workspace's live resources of one
117+
* family, with the text field filtering them; `multi_select`
106118
* rows toggle checkboxes and an option-styled Submit row confirms the step.
107119
* Answering the last question sends one combined user message and collapses
108120
* the div to a question/answer recap.
@@ -121,6 +133,10 @@ export function QuestionDisplay({
121133
const [step, setStep] = useState(0)
122134
const [selectedByStep, setSelectedByStep] = useState<string[][]>(() => data.map(() => []))
123135
const [customByStep, setCustomByStep] = useState<string[]>(() => data.map(() => ''))
136+
// resource_select only: the resource behind a step's picked row, sent as a
137+
// chat context so the agent gets its id, not just the displayed name.
138+
// Only read on submit, so it never needs to re-render the card.
139+
const resourceByStep = useRef<(MothershipResource | null)[]>(data.map(() => null))
124140
const [freeText, setFreeText] = useState('')
125141
// multi_select only: whether the typed "Something else" text is included in
126142
// the answer. Unchecking keeps the text; it just stops counting.
@@ -157,7 +173,8 @@ export function QuestionDisplay({
157173

158174
const question = data[step]
159175
const isLast = step === data.length - 1
160-
const options = question.options
176+
const isResource = question.type === 'resource_select'
177+
const options = isResource ? [] : question.options
161178
const selected = selectedByStep[step] ?? []
162179
const isMulti = question.type === 'multi_select'
163180

@@ -183,21 +200,26 @@ export function QuestionDisplay({
183200
return
184201
}
185202
setPhase('answered')
203+
const contexts = resourceByStep.current
204+
.map((resource) => (resource ? mapResourceToContext(resource) : null))
205+
.filter((context): context is ChatContext => context !== null)
186206
onSelect?.(
187207
formatQuestionAnswerMessage(
188208
data,
189209
data.map((q, i) => answerFor(q, selections[i] ?? [], customFor(i, customs)))
190-
)
210+
),
211+
contexts.length > 0 ? contexts : undefined
191212
)
192213
}
193214

194-
const handleSingleSelect = (label: string) => {
215+
const handleSingleSelect = (label: string, resource: MothershipResource | null = null) => {
195216
const selections = [...selectedByStep]
196217
selections[step] = [label]
197218
setSelectedByStep(selections)
198219
const customs = [...customByStep]
199220
customs[step] = ''
200221
setCustomByStep(customs)
222+
resourceByStep.current[step] = resource
201223
setFreeText('')
202224
finishStep(selections, customs)
203225
}
@@ -239,6 +261,7 @@ export function QuestionDisplay({
239261
const selections = [...selectedByStep]
240262
selections[step] = []
241263
setSelectedByStep(selections)
264+
resourceByStep.current[step] = null
242265
finishStep(selections, customs)
243266
}
244267

@@ -316,6 +339,14 @@ export function QuestionDisplay({
316339
}
317340
>
318341
<div className='flex flex-col'>
342+
{question.type === 'resource_select' && (
343+
<ResourceQuestionRows
344+
resourceType={question.resourceType}
345+
query={freeText}
346+
disabled={disabled}
347+
onPick={(resource) => handleSingleSelect(resource.title, resource)}
348+
/>
349+
)}
319350
{options.map((option, i) => {
320351
const isSelected = selected.includes(option.label)
321352
return (
@@ -343,7 +374,7 @@ export function QuestionDisplay({
343374
})}
344375
<InteractionCardInputRow
345376
ref={freeTextInputRef}
346-
divided={options.length > 0}
377+
divided={options.length > 0 || isResource}
347378
leading={
348379
isMulti ? (
349380
<div className='flex size-[16px] shrink-0 items-center justify-center'>
@@ -389,7 +420,7 @@ export function QuestionDisplay({
389420
}
390421
type='text'
391422
value={freeText}
392-
placeholder='Something else'
423+
placeholder={isResource ? 'Search or type something else' : 'Something else'}
393424
disabled={disabled}
394425
onFocus={() => {
395426
if (isMulti) setCustomChecked(true)
@@ -438,9 +469,12 @@ export function QuestionDisplay({
438469
/**
439470
* A step's combined answer: selected option labels in option order, with the
440471
* typed "Something else" entry appended last. single_select carries at most
441-
* one selection, so this collapses to the chosen label or the typed text.
472+
* one selection, so this collapses to the chosen label or the typed text; a
473+
* resource_select pick is not one of the question's options, so it is the
474+
* picked resource's name or the typed text.
442475
*/
443476
function answerFor(question: QuestionItem, selected: string[], custom: string): string {
477+
if (question.type === 'resource_select') return selected[0] ?? custom.trim()
444478
const ordered = question.options
445479
.map((option) => option.label)
446480
.filter((label) => selected.includes(label))
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
'use client'
2+
3+
import { useMemo } from 'react'
4+
import { ArrowRight, cn } from '@sim/emcn'
5+
import { useParams } from 'next/navigation'
6+
import { INTERACTION_CARD_ROW_CLASSES } from '@/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card'
7+
import type { QuestionResourceType } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
8+
import { resourceFromItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
9+
import type { AvailableItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree'
10+
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
11+
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
12+
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
13+
import { useTablesList } from '@/hooks/queries/tables'
14+
import { useWorkflows } from '@/hooks/queries/workflows'
15+
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
16+
17+
/**
18+
* The current workspace's live resources of one family. Only the requested
19+
* family's list is fetched; the others stay disabled. Outside a workspace (an
20+
* organization chat) there is nothing to list and the card falls back to its
21+
* free-text row.
22+
*/
23+
function useResourceCandidates(type: QuestionResourceType): {
24+
candidates: AvailableItem[]
25+
isPending: boolean
26+
} {
27+
const { workspaceId } = useParams<{ workspaceId?: string }>()
28+
const has = Boolean(workspaceId)
29+
const workflows = useWorkflows(workspaceId, { enabled: has && type === 'workflow' })
30+
const tables = useTablesList(workspaceId, 'active', { enabled: has && type === 'table' })
31+
const files = useWorkspaceFiles(workspaceId ?? '', 'active', { enabled: has && type === 'file' })
32+
const knowledgeBases = useKnowledgeBasesQuery(workspaceId, {
33+
enabled: has && type === 'knowledgebase',
34+
})
35+
const query = { workflow: workflows, table: tables, file: files, knowledgebase: knowledgeBases }[
36+
type
37+
]
38+
const candidates = useMemo(
39+
() => (query.data ?? []).map((item) => ({ id: item.id, name: item.name })),
40+
[query.data]
41+
)
42+
return { candidates, isPending: has && query.isPending }
43+
}
44+
45+
/** Rows mounted at once; the scroll box shows about five, and typing searches the rest. */
46+
const PREVIEW_LIMIT = 50
47+
48+
interface ResourceQuestionRowsProps {
49+
resourceType: QuestionResourceType
50+
/** The card's free-text entry, which doubles as the list's search filter. */
51+
query: string
52+
disabled: boolean
53+
onPick: (resource: MothershipResource) => void
54+
}
55+
56+
/**
57+
* Option rows for a `resource_select` question: the workspace's resources of
58+
* the requested family, rendered with the same row the add-resource menu uses
59+
* and filtered by what the user types into the card's free-text row.
60+
*/
61+
export function ResourceQuestionRows({
62+
resourceType,
63+
query,
64+
disabled,
65+
onPick,
66+
}: ResourceQuestionRowsProps) {
67+
const { candidates, isPending } = useResourceCandidates(resourceType)
68+
const config = getResourceConfig(resourceType)
69+
const needle = query.trim().toLowerCase()
70+
const matches = useMemo(
71+
() =>
72+
needle
73+
? candidates.filter((candidate) => candidate.name.toLowerCase().includes(needle))
74+
: candidates,
75+
[candidates, needle]
76+
)
77+
const visible = matches.slice(0, PREVIEW_LIMIT)
78+
const label = config.label.toLowerCase()
79+
80+
if (visible.length === 0) {
81+
return (
82+
<p className={cn(INTERACTION_CARD_ROW_CLASSES, 'text-[var(--text-muted)] text-sm')}>
83+
{emptyMessage(label, isPending, needle.length > 0)}
84+
</p>
85+
)
86+
}
87+
88+
return (
89+
<div className='max-h-[180px] overflow-y-auto'>
90+
{visible.map((candidate, i) => (
91+
<button
92+
key={candidate.id}
93+
type='button'
94+
disabled={disabled}
95+
onClick={() => onPick(resourceFromItem(resourceType, candidate))}
96+
className={cn(
97+
INTERACTION_CARD_ROW_CLASSES,
98+
'w-full',
99+
disabled ? 'cursor-not-allowed' : 'hover-hover:bg-[var(--surface-5)]',
100+
i > 0 && 'border-t'
101+
)}
102+
>
103+
<span className='flex min-w-0 flex-1 items-center gap-2 text-[var(--text-body)] text-sm'>
104+
{config.renderDropdownItem({ item: candidate })}
105+
</span>
106+
<ArrowRight className='size-[16px] shrink-0 text-[var(--text-icon)]' />
107+
</button>
108+
))}
109+
{matches.length > visible.length && (
110+
<p
111+
className={cn(INTERACTION_CARD_ROW_CLASSES, 'border-t text-[var(--text-muted)] text-sm')}
112+
>
113+
{`${matches.length - visible.length} more ${label} — type to search`}
114+
</p>
115+
)}
116+
</div>
117+
)
118+
}
119+
120+
function emptyMessage(label: string, isPending: boolean, searching: boolean): string {
121+
if (isPending) return `Loading ${label}…`
122+
if (searching) return `No matching ${label}`
123+
return `No ${label} yet`
124+
}

0 commit comments

Comments
 (0)