Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
import {
type ContentSegment,
type CredentialSubmissionPayload,
type InteractionAnswerHandler,
parseSpecialTags,
type SourceTagData,
SpecialTags,
Expand Down Expand Up @@ -520,7 +521,7 @@ interface ChatContentProps {
credentialSubmission?: CredentialSubmissionPayload
/** The user moved on without submitting this message's credential card. */
credentialAbandoned?: boolean
onOptionSelect?: (id: string) => void
onOptionSelect?: InteractionAnswerHandler
onQuestionDismiss?: () => void
onWorkspaceResourceSelect?: (resource: WorkspaceResourceRef) => void
onRevealStateChange?: (isRevealing: boolean) => void
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,14 @@ import {
InteractionCardInputRow,
InteractionCardRecap,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card'
import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import { ResourceQuestionRows } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question/resource-question-rows'
import type {
InteractionAnswerHandler,
QuestionItem,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import { mapResourceToContext } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants'
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
import type { ChatContext } from '@/stores/panel'

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

const question = data[step]
const isLast = step === data.length - 1
const options = question.options
const isResource = question.type === 'resource_select'
const options = isResource ? [] : question.options
const selected = selectedByStep[step] ?? []
const isMulti = question.type === 'multi_select'

Expand All @@ -183,21 +200,26 @@ export function QuestionDisplay({
return
}
setPhase('answered')
const contexts = resourceByStep.current
.map((resource) => (resource ? mapResourceToContext(resource) : null))
.filter((context): context is ChatContext => context !== null)
onSelect?.(
formatQuestionAnswerMessage(
data,
data.map((q, i) => answerFor(q, selections[i] ?? [], customFor(i, customs)))
)
),
contexts.length > 0 ? contexts : undefined
)
}

const handleSingleSelect = (label: string) => {
const handleSingleSelect = (label: string, resource: MothershipResource | null = null) => {
const selections = [...selectedByStep]
selections[step] = [label]
setSelectedByStep(selections)
const customs = [...customByStep]
customs[step] = ''
setCustomByStep(customs)
resourceByStep.current[step] = resource
setFreeText('')
finishStep(selections, customs)
}
Expand Down Expand Up @@ -239,6 +261,7 @@ export function QuestionDisplay({
const selections = [...selectedByStep]
selections[step] = []
setSelectedByStep(selections)
resourceByStep.current[step] = null
finishStep(selections, customs)
}

Expand Down Expand Up @@ -316,6 +339,14 @@ export function QuestionDisplay({
}
>
<div className='flex flex-col'>
{question.type === 'resource_select' && (
<ResourceQuestionRows
resourceType={question.resourceType}
query={freeText}
disabled={disabled}
onPick={(resource) => handleSingleSelect(resource.title, resource)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Multiline names lose the recap

Picking a workflow whose name contains a newline breaks the answered recap after reload. Workflow names allow internal newlines, but this passes the name unchanged into an answer format that requires one line per question. parseQuestionAnswerMessage returns null, so the chat leaves the question unpaired and shows a separate answer bubble.

Normalize line breaks in the picked label before formatting the answer. Keep the resource context unchanged.

/>
)}
{options.map((option, i) => {
const isSelected = selected.includes(option.label)
return (
Expand Down Expand Up @@ -343,7 +374,7 @@ export function QuestionDisplay({
})}
<InteractionCardInputRow
ref={freeTextInputRef}
divided={options.length > 0}
divided={options.length > 0 || isResource}
leading={
isMulti ? (
<div className='flex size-[16px] shrink-0 items-center justify-center'>
Expand Down Expand Up @@ -389,7 +420,7 @@ export function QuestionDisplay({
}
type='text'
value={freeText}
placeholder='Something else'
placeholder={isResource ? 'Search or type something else' : 'Something else'}
disabled={disabled}
onFocus={() => {
if (isMulti) setCustomChecked(true)
Expand Down Expand Up @@ -438,9 +469,12 @@ export function QuestionDisplay({
/**
* A step's combined answer: selected option labels in option order, with the
* typed "Something else" entry appended last. single_select carries at most
* one selection, so this collapses to the chosen label or the typed text.
* one selection, so this collapses to the chosen label or the typed text; a
* resource_select pick is not one of the question's options, so it is the
* picked resource's name or the typed text.
*/
function answerFor(question: QuestionItem, selected: string[], custom: string): string {
if (question.type === 'resource_select') return selected[0] ?? custom.trim()
const ordered = question.options
.map((option) => option.label)
.filter((label) => selected.includes(label))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
'use client'

import { useMemo } from 'react'
import { ArrowRight, cn } from '@sim/emcn'
import { useParams } from 'next/navigation'
import { INTERACTION_CARD_ROW_CLASSES } from '@/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card'
import type { QuestionResourceType } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import { resourceFromItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
import type { AvailableItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree'
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'

/**
* The current workspace's live resources of one family. Only the requested
* family's list is fetched; the others stay disabled. Outside a workspace (an
* organization chat) there is nothing to list and the card falls back to its
* free-text row.
*/
function useResourceCandidates(type: QuestionResourceType): {
candidates: AvailableItem[]
isPending: boolean
} {
const { workspaceId } = useParams<{ workspaceId?: string }>()
const has = Boolean(workspaceId)
const workflows = useWorkflows(workspaceId, { enabled: has && type === 'workflow' })
const tables = useTablesList(workspaceId, 'active', { enabled: has && type === 'table' })
const files = useWorkspaceFiles(workspaceId ?? '', 'active', { enabled: has && type === 'file' })
const knowledgeBases = useKnowledgeBasesQuery(workspaceId, {
enabled: has && type === 'knowledgebase',
})
const query = { workflow: workflows, table: tables, file: files, knowledgebase: knowledgeBases }[
type
]
const candidates = useMemo(
() => (query.data ?? []).map((item) => ({ id: item.id, name: item.name })),

@cubic-dev-ai cubic-dev-ai Bot Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: These workspace-scoped list hooks keep previous data when their keys change, so this renders the prior workspace’s candidates during a workspace switch and allows selecting one in the current chat. Ignore placeholder data and treat it as loading until the current workspace’s list resolves.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/resource-question-rows.tsx, line 39:

<comment>These workspace-scoped list hooks keep previous data when their keys change, so this renders the prior workspace’s candidates during a workspace switch and allows selecting one in the current chat. Ignore placeholder data and treat it as loading until the current workspace’s list resolves.</comment>

<file context>
@@ -0,0 +1,124 @@
+    type
+  ]
+  const candidates = useMemo(
+    () => (query.data ?? []).map((item) => ({ id: item.id, name: item.name })),
+    [query.data]
+  )
</file context>
Fix with cubic

[query.data]
)
return { candidates, isPending: has && query.isPending }
}

/** Rows mounted at once; the scroll box shows about five, and typing searches the rest. */
const PREVIEW_LIMIT = 50

interface ResourceQuestionRowsProps {
resourceType: QuestionResourceType
/** The card's free-text entry, which doubles as the list's search filter. */
query: string
disabled: boolean
onPick: (resource: MothershipResource) => void
}

/**
* Option rows for a `resource_select` question: the workspace's resources of
* the requested family, rendered with the same row the add-resource menu uses
* and filtered by what the user types into the card's free-text row.
*/
export function ResourceQuestionRows({
resourceType,
query,
disabled,
onPick,
}: ResourceQuestionRowsProps) {
const { candidates, isPending } = useResourceCandidates(resourceType)
const config = getResourceConfig(resourceType)
const needle = query.trim().toLowerCase()
const matches = useMemo(
() =>
needle
? candidates.filter((candidate) => candidate.name.toLowerCase().includes(needle))
: candidates,
[candidates, needle]
)
const visible = matches.slice(0, PREVIEW_LIMIT)
const label = config.label.toLowerCase()

if (visible.length === 0) {
return (
<p className={cn(INTERACTION_CARD_ROW_CLASSES, 'text-[var(--text-muted)] text-sm')}>
{emptyMessage(label, isPending, needle.length > 0)}
</p>
)
}

return (
<div className='max-h-[180px] overflow-y-auto'>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Hidden rows lack a cue

The new scroll box hides resource rows without the required edge treatment. The styling guide requires useScrollEdges, scrollFadeClass, and scrollFadeAttributes for regions that hide rows beyond an edge.

Apply those helpers so users can see when more resources are offscreen. This repository requirement must be satisfied before merging.

Context Used: Tailwind CSS and styling conventions (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

{visible.map((candidate, i) => (
<button
key={candidate.id}
type='button'
disabled={disabled}
onClick={() => onPick(resourceFromItem(resourceType, candidate))}
className={cn(
INTERACTION_CARD_ROW_CLASSES,
'w-full',
disabled ? 'cursor-not-allowed' : 'hover-hover:bg-[var(--surface-5)]',
i > 0 && 'border-t'
)}
>
<span className='flex min-w-0 flex-1 items-center gap-2 text-[var(--text-body)] text-sm'>
{config.renderDropdownItem({ item: candidate })}
</span>
<ArrowRight className='size-[16px] shrink-0 text-[var(--text-icon)]' />
</button>
))}
{matches.length > visible.length && (
<p
className={cn(INTERACTION_CARD_ROW_CLASSES, 'border-t text-[var(--text-muted)] text-sm')}
>
{`${matches.length - visible.length} more ${label} — type to search`}
</p>
)}
</div>
)
}

function emptyMessage(label: string, isPending: boolean, searching: boolean): string {
if (isPending) return `Loading ${label}…`
if (searching) return `No matching ${label}`
return `No ${label} yet`
}
Loading
Loading