Compare commits

...

6 Commits

Author SHA1 Message Date
Waleed
a54dcbe949 v0.6.24: copilot feedback wiring, captcha fixes 2026-04-04 12:52:05 -07:00
Waleed
c2b12cf21f fix(captcha): use getResponsePromise for Turnstile execute-on-submit flow (#3943) 2026-04-04 12:34:53 -07:00
Waleed
4a9439e952 improvement(models): tighten model metadata and crawl discovery (#3942)
* improvement(models): tighten model metadata and crawl discovery

Made-with: Cursor

* revert hardcoded FF

* fix(models): narrow structured output ranking signal

Made-with: Cursor

* fix(models): remove generic best-for copy

Made-with: Cursor

* fix(models): restore best-for with stricter criteria

Made-with: Cursor

* fix

* models
2026-04-04 11:53:54 -07:00
Waleed
893e322a49 fix(envvars): restore workflowUserId fallback for scheduled execution env var resolution (#3941)
* fix(envvars): restore workflowUserId fallback for scheduled execution env var resolution

* test(envvars): add coverage for env var user resolution branches
2026-04-04 11:22:52 -07:00
Emir Karabeg
b0cb95be2f feat: mothership/copilot feedback (#3940)
* feat: mothership/copilot feedback

* fix(feedback): remove mutation object from useCallback deps
2026-04-04 10:46:49 -07:00
Waleed
0b9019d9a2 v0.6.23: MCP fixes, remove local state in favor of server state, mothership workflow edits via sockets, ui improvements 2026-04-03 23:30:26 -07:00
22 changed files with 861 additions and 421 deletions

View File

@@ -99,8 +99,6 @@ function SignupFormContent({
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [formError, setFormError] = useState<string | null>(null)
const turnstileRef = useRef<TurnstileInstance>(null)
const captchaResolveRef = useRef<((token: string) => void) | null>(null)
const captchaRejectRef = useRef<((reason: Error) => void) | null>(null)
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
const redirectUrl = useMemo(
() => searchParams.get('redirect') || searchParams.get('callbackUrl') || '',
@@ -258,27 +256,14 @@ function SignupFormContent({
let token: string | undefined
const widget = turnstileRef.current
if (turnstileSiteKey && widget) {
let timeoutId: ReturnType<typeof setTimeout> | undefined
try {
widget.reset()
token = await Promise.race([
new Promise<string>((resolve, reject) => {
captchaResolveRef.current = resolve
captchaRejectRef.current = reject
widget.execute()
}),
new Promise<string>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error('Captcha timed out')), 15_000)
}),
])
widget.execute()
token = await widget.getResponsePromise()
} catch {
setFormError('Captcha verification failed. Please try again.')
setIsLoading(false)
return
} finally {
clearTimeout(timeoutId)
captchaResolveRef.current = null
captchaRejectRef.current = null
}
}
@@ -535,10 +520,7 @@ function SignupFormContent({
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
onSuccess={(token) => captchaResolveRef.current?.(token)}
onError={() => captchaRejectRef.current?.(new Error('Captcha verification failed'))}
onExpire={() => captchaRejectRef.current?.(new Error('Captcha token expired'))}
options={{ execution: 'execute' }}
options={{ execution: 'execute', appearance: 'execute' }}
/>
)}

View File

@@ -18,6 +18,7 @@ import {
formatPrice,
formatTokenCount,
formatUpdatedAt,
getEffectiveMaxOutputTokens,
getModelBySlug,
getPricingBounds,
getProviderBySlug,
@@ -198,7 +199,8 @@ export default async function ModelPage({
</div>
<p className='max-w-[820px] text-[17px] text-[var(--landing-text-muted)] leading-relaxed'>
{model.summary} {model.bestFor}
{model.summary}
{model.bestFor ? ` ${model.bestFor}` : ''}
</p>
<div className='mt-8 flex flex-wrap gap-3'>
@@ -229,13 +231,11 @@ export default async function ModelPage({
? `${formatPrice(model.pricing.cachedInput)}/1M`
: 'N/A'
}
compact
/>
<StatCard label='Output price' value={`${formatPrice(model.pricing.output)}/1M`} />
<StatCard
label='Context window'
value={model.contextWindow ? formatTokenCount(model.contextWindow) : 'Unknown'}
compact
/>
</section>
@@ -280,12 +280,12 @@ export default async function ModelPage({
label='Max output'
value={
model.capabilities.maxOutputTokens
? `${formatTokenCount(model.capabilities.maxOutputTokens)} tokens`
: 'Standard defaults'
? `${formatTokenCount(getEffectiveMaxOutputTokens(model.capabilities))} tokens`
: 'Not published'
}
/>
<DetailItem label='Provider' value={provider.name} />
<DetailItem label='Best for' value={model.bestFor} />
{model.bestFor ? <DetailItem label='Best for' value={model.bestFor} /> : null}
</div>
</section>

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { buildModelCapabilityFacts, getEffectiveMaxOutputTokens, getModelBySlug } from './utils'
describe('model catalog capability facts', () => {
it.concurrent(
'shows structured outputs support and published max output tokens for gpt-4o',
() => {
const model = getModelBySlug('openai', 'gpt-4o')
expect(model).not.toBeNull()
expect(model).toBeDefined()
const capabilityFacts = buildModelCapabilityFacts(model!)
const structuredOutputs = capabilityFacts.find((fact) => fact.label === 'Structured outputs')
const maxOutputTokens = capabilityFacts.find((fact) => fact.label === 'Max output tokens')
expect(getEffectiveMaxOutputTokens(model!.capabilities)).toBe(16384)
expect(structuredOutputs?.value).toBe('Supported')
expect(maxOutputTokens?.value).toBe('16k')
}
)
it.concurrent('preserves native structured outputs labeling for claude models', () => {
const model = getModelBySlug('anthropic', 'claude-sonnet-4-6')
expect(model).not.toBeNull()
expect(model).toBeDefined()
const capabilityFacts = buildModelCapabilityFacts(model!)
const structuredOutputs = capabilityFacts.find((fact) => fact.label === 'Structured outputs')
expect(structuredOutputs?.value).toBe('Supported (native)')
})
it.concurrent('does not invent a max output token limit when one is not published', () => {
expect(getEffectiveMaxOutputTokens({})).toBeNull()
})
it.concurrent('keeps best-for copy for clearly differentiated models only', () => {
const researchModel = getModelBySlug('google', 'deep-research-pro-preview-12-2025')
const generalModel = getModelBySlug('xai', 'grok-4-latest')
expect(researchModel).not.toBeNull()
expect(generalModel).not.toBeNull()
expect(researchModel?.bestFor).toContain('research workflows')
expect(generalModel?.bestFor).toBeUndefined()
})
})

View File

@@ -112,7 +112,7 @@ export interface CatalogModel {
capabilities: ModelCapabilities
capabilityTags: string[]
summary: string
bestFor: string
bestFor?: string
searchText: string
}
@@ -190,6 +190,14 @@ export function formatCapabilityBoolean(
return value ? positive : negative
}
function supportsCatalogStructuredOutputs(capabilities: ModelCapabilities): boolean {
return !capabilities.deepResearch
}
export function getEffectiveMaxOutputTokens(capabilities: ModelCapabilities): number | null {
return capabilities.maxOutputTokens ?? null
}
function trimTrailingZeros(value: string): string {
return value.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1')
}
@@ -326,7 +334,7 @@ function buildCapabilityTags(capabilities: ModelCapabilities): string[] {
tags.push('Tool choice')
}
if (capabilities.nativeStructuredOutputs) {
if (supportsCatalogStructuredOutputs(capabilities)) {
tags.push('Structured outputs')
}
@@ -365,7 +373,7 @@ function buildBestForLine(model: {
pricing: PricingInfo
capabilities: ModelCapabilities
contextWindow: number | null
}): string {
}): string | null {
const { pricing, capabilities, contextWindow } = model
if (capabilities.deepResearch) {
@@ -376,10 +384,6 @@ function buildBestForLine(model: {
return 'Best for reasoning-heavy tasks that need more deliberate model control.'
}
if (pricing.input <= 0.2 && pricing.output <= 1.25) {
return 'Best for cost-sensitive automations, background tasks, and high-volume workloads.'
}
if (contextWindow && contextWindow >= 1000000) {
return 'Best for long-context retrieval, large documents, and high-memory workflows.'
}
@@ -388,7 +392,11 @@ function buildBestForLine(model: {
return 'Best for production workflows that need reliable typed outputs.'
}
return 'Best for general-purpose AI workflows inside Sim.'
if (pricing.input <= 0.2 && pricing.output <= 1.25) {
return 'Best for cost-sensitive automations, background tasks, and high-volume workloads.'
}
return null
}
function buildModelSummary(
@@ -437,6 +445,11 @@ const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
const shortId = stripProviderPrefix(provider.id, model.id)
const mergedCapabilities = { ...provider.capabilities, ...model.capabilities }
const capabilityTags = buildCapabilityTags(mergedCapabilities)
const bestFor = buildBestForLine({
pricing: model.pricing,
capabilities: mergedCapabilities,
contextWindow: model.contextWindow ?? null,
})
const displayName = formatModelDisplayName(provider.id, model.id)
const modelSlug = slugify(shortId)
const href = `/models/${providerSlug}/${modelSlug}`
@@ -461,11 +474,7 @@ const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
model.contextWindow ?? null,
capabilityTags
),
bestFor: buildBestForLine({
pricing: model.pricing,
capabilities: mergedCapabilities,
contextWindow: model.contextWindow ?? null,
}),
...(bestFor ? { bestFor } : {}),
searchText: [
provider.name,
providerDisplayName,
@@ -683,6 +692,7 @@ export function buildModelFaqs(provider: CatalogProvider, model: CatalogModel):
export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[] {
const { capabilities } = model
const supportsStructuredOutputs = supportsCatalogStructuredOutputs(capabilities)
return [
{
@@ -711,7 +721,11 @@ export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[]
},
{
label: 'Structured outputs',
value: formatCapabilityBoolean(capabilities.nativeStructuredOutputs),
value: supportsStructuredOutputs
? capabilities.nativeStructuredOutputs
? 'Supported (native)'
: 'Supported'
: 'Not supported',
},
{
label: 'Tool choice',
@@ -732,8 +746,8 @@ export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[]
{
label: 'Max output tokens',
value: capabilities.maxOutputTokens
? formatTokenCount(capabilities.maxOutputTokens)
: 'Standard defaults',
? formatTokenCount(getEffectiveMaxOutputTokens(capabilities))
: 'Not published',
},
]
}
@@ -752,8 +766,8 @@ export function getProviderCapabilitySummary(provider: CatalogProvider): Capabil
const reasoningCount = provider.models.filter(
(model) => model.capabilities.reasoningEffort || model.capabilities.thinking
).length
const structuredCount = provider.models.filter(
(model) => model.capabilities.nativeStructuredOutputs
const structuredCount = provider.models.filter((model) =>
supportsCatalogStructuredOutputs(model.capabilities)
).length
const deepResearchCount = provider.models.filter(
(model) => model.capabilities.deepResearch

View File

@@ -1,42 +1,44 @@
import { getBaseUrl } from '@/lib/core/utils/urls'
export async function GET() {
export function GET() {
const baseUrl = getBaseUrl()
const llmsContent = `# Sim
const content = `# Sim
> Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.
> Sim is the open-source platform to build AI agents and run your agentic workforce. Connect integrations and LLMs to deploy and orchestrate agentic workflows.
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over 100,000 builders use Sim — from startups to Fortune 500 companies. SOC2 compliant.
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. It supports both product discovery pages and deeper technical documentation.
## Core Pages
## Preferred URLs
- [Homepage](${baseUrl}): Product overview, features, and pricing
- [Homepage](${baseUrl}): Product overview and primary entry point
- [Integrations directory](${baseUrl}/integrations): Public catalog of integrations and automation capabilities
- [Models directory](${baseUrl}/models): Public catalog of AI models, pricing, context windows, and capabilities
- [Blog](${baseUrl}/blog): Announcements, guides, and product context
- [Changelog](${baseUrl}/changelog): Product updates and release notes
- [Sim Blog](${baseUrl}/blog): Announcements, insights, and guides
## Documentation
- [Documentation](https://docs.sim.ai): Complete guides and API reference
- [Quickstart](https://docs.sim.ai/quickstart): Get started in 5 minutes
- [API Reference](https://docs.sim.ai/api): REST API documentation
- [Documentation](https://docs.sim.ai): Product guides and technical reference
- [Quickstart](https://docs.sim.ai/quickstart): Fastest path to getting started
- [API Reference](https://docs.sim.ai/api): API documentation
## Key Concepts
- **Workspace**: Container for workflows, data sources, and executions
- **Workflow**: Directed graph of blocks defining an agentic process
- **Block**: Individual step (LLM call, tool call, HTTP request, code execution)
- **Block**: Individual step such as an LLM call, tool call, HTTP request, or code execution
- **Trigger**: Event or schedule that initiates workflow execution
- **Execution**: A single run of a workflow with logs and outputs
- **Knowledge Base**: Vector-indexed document store for retrieval-augmented generation
- **Knowledge Base**: Document store used for retrieval-augmented generation
## Capabilities
- AI agent creation and deployment
- Agentic workflow orchestration
- 1,000+ integrations (Slack, Gmail, Notion, Airtable, databases, and more)
- Multi-model LLM orchestration (OpenAI, Anthropic, Google, Mistral, xAI, Perplexity)
- Knowledge base creation with retrieval-augmented generation (RAG)
- Integrations across business tools, databases, and communication platforms
- Multi-model LLM orchestration
- Knowledge bases and retrieval-augmented generation
- Table creation and management
- Document creation and processing
- Scheduled and webhook-triggered executions
@@ -45,24 +47,19 @@ Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over
- AI agent deployment and orchestration
- Knowledge bases and RAG pipelines
- Document creation and processing
- Customer support automation
- Internal operations (sales, marketing, legal, finance)
- Internal operations workflows across sales, marketing, legal, and finance
## Links
## Additional Links
- [GitHub Repository](https://github.com/simstudioai/sim): Open-source codebase
- [Discord Community](https://discord.gg/Hr4UWYEcTT): Get help and connect with 100,000+ builders
- [X/Twitter](https://x.com/simdotai): Product updates and announcements
## Optional
- [Careers](https://jobs.ashbyhq.com/sim): Join the Sim team
- [Docs](https://docs.sim.ai): Canonical documentation source
- [Terms of Service](${baseUrl}/terms): Legal terms
- [Privacy Policy](${baseUrl}/privacy): Data handling practices
- [Sitemap](${baseUrl}/sitemap.xml): Public URL inventory
`
return new Response(llmsContent, {
return new Response(content, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Cache-Control': 'public, max-age=86400, s-maxage=86400',

View File

@@ -8,6 +8,34 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = getBaseUrl()
const now = new Date()
const integrationPages: MetadataRoute.Sitemap = integrations.map((integration) => ({
url: `${baseUrl}/integrations/${integration.slug}`,
lastModified: now,
}))
const modelHubPages: MetadataRoute.Sitemap = [
{
url: `${baseUrl}/integrations`,
lastModified: now,
},
{
url: `${baseUrl}/models`,
lastModified: now,
},
{
url: `${baseUrl}/partners`,
lastModified: now,
},
]
const providerPages: MetadataRoute.Sitemap = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
url: `${baseUrl}${provider.href}`,
lastModified: new Date(
Math.max(...provider.models.map((model) => new Date(model.pricing.updatedAt).getTime()))
),
}))
const modelPages: MetadataRoute.Sitemap = ALL_CATALOG_MODELS.map((model) => ({
url: `${baseUrl}${model.href}`,
lastModified: new Date(model.pricing.updatedAt),
}))
const staticPages: MetadataRoute.Sitemap = [
{
@@ -26,14 +54,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// url: `${baseUrl}/templates`,
// lastModified: now,
// },
{
url: `${baseUrl}/integrations`,
lastModified: now,
},
{
url: `${baseUrl}/models`,
lastModified: now,
},
{
url: `${baseUrl}/changelog`,
lastModified: now,
@@ -54,20 +74,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
lastModified: new Date(p.updated ?? p.date),
}))
const integrationPages: MetadataRoute.Sitemap = integrations.map((i) => ({
url: `${baseUrl}/integrations/${i.slug}`,
lastModified: now,
}))
const providerPages: MetadataRoute.Sitemap = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
url: `${baseUrl}${provider.href}`,
lastModified: now,
}))
const modelPages: MetadataRoute.Sitemap = ALL_CATALOG_MODELS.map((model) => ({
url: `${baseUrl}${model.href}`,
lastModified: new Date(model.pricing.updatedAt),
}))
return [...staticPages, ...blogPages, ...integrationPages, ...providerPages, ...modelPages]
return [
...staticPages,
...modelHubPages,
...integrationPages,
...providerPages,
...modelPages,
...blogPages,
]
}

View File

@@ -1,22 +1,59 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, Copy, Ellipsis, Hash } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Button,
Check,
Copy,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
ThumbsDown,
ThumbsUp,
} from '@/components/emcn'
import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file'
function toPlainText(raw: string): string {
return (
raw
// Strip special tags and their contents
.replace(new RegExp(`<\\/?(${SPECIAL_TAGS})(?:>[\\s\\S]*?<\\/(${SPECIAL_TAGS})>|>)`, 'g'), '')
// Strip markdown
.replace(/^#{1,6}\s+/gm, '')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/`{3}[\s\S]*?`{3}/g, '')
.replace(/`(.+?)`/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/^[>\-*]\s+/gm, '')
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
// Normalize whitespace
.replace(/\n{3,}/g, '\n\n')
.trim()
)
}
const ICON_CLASS = 'h-[14px] w-[14px]'
const BUTTON_CLASS =
'flex h-[26px] w-[26px] items-center justify-center rounded-[6px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-hover)] focus-visible:outline-none'
interface MessageActionsProps {
content: string
requestId?: string
chatId?: string
userQuery?: string
}
export function MessageActions({ content, requestId }: MessageActionsProps) {
const [copied, setCopied] = useState<'message' | 'request' | null>(null)
export function MessageActions({ content, chatId, userQuery }: MessageActionsProps) {
const [copied, setCopied] = useState(false)
const [pendingFeedback, setPendingFeedback] = useState<'up' | 'down' | null>(null)
const [feedbackText, setFeedbackText] = useState('')
const resetTimeoutRef = useRef<number | null>(null)
const submitFeedback = useSubmitCopilotFeedback()
useEffect(() => {
return () => {
@@ -26,59 +63,119 @@ export function MessageActions({ content, requestId }: MessageActionsProps) {
}
}, [])
const copyToClipboard = useCallback(async (text: string, type: 'message' | 'request') => {
const copyToClipboard = useCallback(async () => {
if (!content) return
const text = toPlainText(content)
if (!text) return
try {
await navigator.clipboard.writeText(text)
setCopied(type)
setCopied(true)
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current)
}
resetTimeoutRef.current = window.setTimeout(() => setCopied(null), 1500)
resetTimeoutRef.current = window.setTimeout(() => setCopied(false), 1500)
} catch {
/* clipboard unavailable */
}
}, [content])
const handleFeedbackClick = useCallback(
(type: 'up' | 'down') => {
if (chatId && userQuery) {
setPendingFeedback(type)
setFeedbackText('')
}
},
[chatId, userQuery]
)
const handleSubmitFeedback = useCallback(() => {
if (!pendingFeedback || !chatId || !userQuery) return
const text = feedbackText.trim()
if (!text) {
setPendingFeedback(null)
setFeedbackText('')
return
}
submitFeedback.mutate({
chatId,
userQuery,
agentResponse: content,
isPositiveFeedback: pendingFeedback === 'up',
feedback: text,
})
setPendingFeedback(null)
setFeedbackText('')
}, [pendingFeedback, chatId, userQuery, content, feedbackText])
const handleModalClose = useCallback((open: boolean) => {
if (!open) {
setPendingFeedback(null)
setFeedbackText('')
}
}, [])
if (!content && !requestId) {
return null
}
if (!content) return null
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<>
<div className='flex items-center gap-0.5'>
<button
type='button'
aria-label='More options'
className='flex h-5 w-5 items-center justify-center rounded-sm text-[var(--text-icon)] opacity-0 transition-colors transition-opacity hover-hover:bg-[var(--surface-3)] hover-hover:text-[var(--text-primary)] focus-visible:opacity-100 focus-visible:outline-none group-hover/msg:opacity-100 data-[state=open]:opacity-100'
onClick={(event) => event.stopPropagation()}
aria-label='Copy message'
onClick={copyToClipboard}
className={BUTTON_CLASS}
>
<Ellipsis className='h-3 w-3' strokeWidth={2} />
{copied ? <Check className={ICON_CLASS} /> : <Copy className={ICON_CLASS} />}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' side='top' sideOffset={4}>
<DropdownMenuItem
disabled={!content}
onSelect={(event) => {
event.stopPropagation()
void copyToClipboard(content, 'message')
}}
<button
type='button'
aria-label='Like'
onClick={() => handleFeedbackClick('up')}
className={BUTTON_CLASS}
>
{copied === 'message' ? <Check /> : <Copy />}
<span>Copy Message</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!requestId}
onSelect={(event) => {
event.stopPropagation()
if (requestId) {
void copyToClipboard(requestId, 'request')
}
}}
<ThumbsUp className={ICON_CLASS} />
</button>
<button
type='button'
aria-label='Dislike'
onClick={() => handleFeedbackClick('down')}
className={BUTTON_CLASS}
>
{copied === 'request' ? <Check /> : <Hash />}
<span>Copy Request ID</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ThumbsDown className={ICON_CLASS} />
</button>
</div>
<Modal open={pendingFeedback !== null} onOpenChange={handleModalClose}>
<ModalContent size='sm'>
<ModalHeader>Give feedback</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-2'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
{pendingFeedback === 'up' ? 'What did you like?' : 'What could be improved?'}
</p>
<Textarea
placeholder={
pendingFeedback === 'up'
? 'Tell us what was helpful...'
: 'Tell us what went wrong...'
}
value={feedbackText}
onChange={(e) => setFeedbackText(e.target.value)}
rows={3}
/>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => handleModalClose(false)}>
Cancel
</Button>
<Button variant='primary' onClick={handleSubmitFeedback}>
Submit
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -473,9 +473,9 @@ function MothershipErrorDisplay({ data }: { data: MothershipErrorTagData }) {
const detail = data.code ? `${data.message} (${data.code})` : data.message
return (
<span className='animate-stream-fade-in font-base text-[13px] text-[var(--text-secondary)] italic leading-[20px]'>
<p className='animate-stream-fade-in font-base text-[13px] text-[var(--text-secondary)] italic leading-[20px]'>
{detail}
</span>
</p>
)
}

View File

@@ -35,6 +35,7 @@ interface MothershipChatProps {
onSendQueuedMessage: (id: string) => Promise<void>
onEditQueuedMessage: (id: string) => void
userId?: string
chatId?: string
onContextAdd?: (context: ChatContext) => void
editValue?: string
onEditValueConsumed?: () => void
@@ -53,7 +54,7 @@ const LAYOUT_STYLES = {
userRow: 'flex flex-col items-end gap-[6px] pt-3',
attachmentWidth: 'max-w-[70%]',
userBubble: 'max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2',
assistantRow: 'group/msg relative pb-5',
assistantRow: 'group/msg',
footer: 'flex-shrink-0 px-[24px] pb-[16px]',
footerInner: 'mx-auto max-w-[42rem]',
},
@@ -63,7 +64,7 @@ const LAYOUT_STYLES = {
userRow: 'flex flex-col items-end gap-[6px] pt-2',
attachmentWidth: 'max-w-[85%]',
userBubble: 'max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2',
assistantRow: 'group/msg relative pb-3',
assistantRow: 'group/msg',
footer: 'flex-shrink-0 px-3 pb-3',
footerInner: '',
},
@@ -80,6 +81,7 @@ export function MothershipChat({
onSendQueuedMessage,
onEditQueuedMessage,
userId,
chatId,
onContextAdd,
editValue,
onEditValueConsumed,
@@ -147,20 +149,28 @@ export function MothershipChat({
}
const isLastMessage = index === messages.length - 1
const precedingUserMsg = [...messages]
.slice(0, index)
.reverse()
.find((m) => m.role === 'user')
return (
<div key={msg.id} className={styles.assistantRow}>
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
<div className='absolute right-0 bottom-0 z-10'>
<MessageActions content={msg.content} requestId={msg.requestId} />
</div>
)}
<MessageContent
blocks={msg.contentBlocks || []}
fallbackContent={msg.content}
isStreaming={isThisStreaming}
onOptionSelect={isLastMessage ? onSubmit : undefined}
/>
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
<div className='mt-2.5'>
<MessageActions
content={msg.content}
chatId={chatId}
userQuery={precedingUserMsg?.content}
/>
</div>
)}
</div>
)
})}

View File

@@ -115,7 +115,7 @@ export const MothershipView = memo(
<div
ref={ref}
className={cn(
'relative z-10 flex h-full flex-col overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]',
'relative z-10 flex h-full flex-col overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-200 ease-[cubic-bezier(0.25,0.1,0.25,1)]',
isCollapsed ? 'w-0 min-w-0 border-l-0' : 'w-1/2 border-l',
className
)}

View File

@@ -348,6 +348,7 @@ export function Home({ chatId }: HomeProps = {}) {
onSendQueuedMessage={sendNow}
onEditQueuedMessage={handleEditQueuedMessage}
userId={session?.user?.id}
chatId={resolvedChatId}
onContextAdd={handleContextAdd}
editValue={editingInputValue}
onEditValueConsumed={clearEditingValue}

View File

@@ -839,6 +839,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
onSendQueuedMessage={copilotSendNow}
onEditQueuedMessage={handleCopilotEditQueuedMessage}
userId={session?.user?.id}
chatId={copilotResolvedChatId}
editValue={copilotEditingInputValue}
onEditValueConsumed={clearCopilotEditingValue}
layout='copilot-view'

View File

@@ -12,7 +12,6 @@ import {
Button,
Combobox,
Input,
Label,
Modal,
ModalBody,
ModalContent,
@@ -432,7 +431,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-3'>
<div className='flex flex-col gap-2'>
<Label htmlFor='type'>Request</Label>
<p className='font-medium text-[var(--text-secondary)] text-sm'>Request</p>
<Combobox
id='type'
options={REQUEST_TYPE_OPTIONS}
@@ -447,7 +446,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
</div>
<div className='flex flex-col gap-2'>
<Label htmlFor='subject'>Subject</Label>
<p className='font-medium text-[var(--text-secondary)] text-sm'>Subject</p>
<Input
id='subject'
placeholder='Brief description of your request'
@@ -457,7 +456,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
</div>
<div className='flex flex-col gap-2'>
<Label htmlFor='message'>Message</Label>
<p className='font-medium text-[var(--text-secondary)] text-sm'>Message</p>
<Textarea
id='message'
placeholder='Please provide details about your request...'
@@ -468,7 +467,9 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
</div>
<div className='flex flex-col gap-2'>
<Label>Attach Images (Optional)</Label>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
Attach Images (Optional)
</p>
<Button
type='button'
variant='default'
@@ -505,7 +506,9 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
{images.length > 0 && (
<div className='space-y-2'>
<Label>Uploaded Images</Label>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
Uploaded Images
</p>
<div className='grid grid-cols-2 gap-3'>
{images.map((image, index) => (
<div

View File

@@ -316,6 +316,7 @@ export const Sidebar = memo(function Sidebar() {
const sidebarRef = useRef<HTMLElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const scrollContentRef = useRef<HTMLDivElement>(null)
const posthog = usePostHog()
const { data: sessionData, isPending: sessionLoading } = useSession()
@@ -894,6 +895,9 @@ export const Sidebar = memo(function Sidebar() {
container.addEventListener('scroll', updateScrollState, { passive: true })
const observer = new ResizeObserver(updateScrollState)
observer.observe(container)
if (scrollContentRef.current) {
observer.observe(scrollContentRef.current)
}
return () => {
container.removeEventListener('scroll', updateScrollState)
@@ -1336,275 +1340,286 @@ export const Sidebar = memo(function Sidebar() {
!hasOverflowTop && 'border-transparent'
)}
>
<div className='tasks-section flex flex-shrink-0 flex-col' data-tour='nav-tasks'>
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
<div className='font-base text-[var(--text-icon)] text-small'>All tasks</div>
{!isCollapsed && (
<div className='flex items-center justify-center gap-2'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
onClick={handleNewTask}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<Tooltip.Shortcut keys={isMac ? '⌘⇧K' : 'Ctrl+Shift+K'}>
New task
</Tooltip.Shortcut>
</Tooltip.Content>
</Tooltip.Root>
<div ref={scrollContentRef} className='flex flex-col'>
<div
className='tasks-section flex flex-shrink-0 flex-col'
data-tour='nav-tasks'
>
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
<div className='font-base text-[var(--text-icon)] text-small'>
All tasks
</div>
)}
</div>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={tasksCollapsedIcon}
hover={tasksHover}
ariaLabel='Tasks'
className='mt-1.5'
primaryAction={tasksPrimaryAction}
>
{tasksLoading ? (
<DropdownMenuItem disabled>
<Loader className='h-[14px] w-[14px]' animate />
Loading...
</DropdownMenuItem>
) : (
tasks.map((task) => (
<CollapsedTaskFlyoutItem
key={task.id}
task={task}
isCurrentRoute={task.id !== 'new' && pathname === task.href}
isMenuOpen={menuOpenTaskId === task.id}
isEditing={task.id === taskFlyoutRename.editingId}
editValue={taskFlyoutRename.value}
inputRef={taskFlyoutRename.inputRef}
isRenaming={taskFlyoutRename.isSaving}
onEditValueChange={taskFlyoutRename.setValue}
onEditKeyDown={taskFlyoutRename.handleKeyDown}
onEditBlur={handleTaskRenameBlur}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
onMoreClick={handleTaskMoreClick}
/>
))
)}
</CollapsedSidebarMenu>
) : (
<div className='mt-1.5 flex flex-col gap-0.5 px-2'>
{tasksLoading ? (
<SidebarItemSkeleton />
) : (
<>
{tasks.slice(0, visibleTaskCount).map((task) => {
const isCurrentRoute = task.id !== 'new' && pathname === task.href
const isRenaming = taskFlyoutRename.editingId === task.id
const isSelected = task.id !== 'new' && selectedTasks.has(task.id)
if (isRenaming) {
return (
<div
key={task.id}
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg bg-[var(--surface-active)] px-2 text-sm'
>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<input
ref={taskFlyoutRename.inputRef}
value={taskFlyoutRename.value}
onChange={(e) => taskFlyoutRename.setValue(e.target.value)}
onKeyDown={taskFlyoutRename.handleKeyDown}
onBlur={handleTaskRenameBlur}
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
/>
</div>
)
}
return (
<SidebarTaskItem
key={task.id}
task={task}
isCurrentRoute={isCurrentRoute}
isSelected={isSelected}
isActive={!!task.isActive}
isUnread={!!task.isUnread}
isMenuOpen={menuOpenTaskId === task.id}
showCollapsedTooltips={showCollapsedTooltips}
onMultiSelectClick={handleTaskClick}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
onMoreClick={handleTaskMoreClick}
/>
)
})}
{tasks.length > visibleTaskCount && (
<button
type='button'
onClick={handleSeeMoreTasks}
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-[var(--text-icon)] text-sm hover-hover:bg-[var(--surface-hover)]'
>
<MoreHorizontal className='h-[16px] w-[16px] flex-shrink-0' />
<span className='font-base'>See more</span>
</button>
)}
</>
)}
</div>
)}
</div>
<div
className='workflows-section relative mt-3.5 flex flex-col'
data-tour='nav-workflows'
>
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
<div className='font-base text-[var(--text-icon)] text-small'>Workflows</div>
{!isCollapsed && (
<div className='flex items-center justify-center gap-2'>
<DropdownMenu>
{!isCollapsed && (
<div className='flex items-center justify-center gap-2'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
disabled={!canEdit}
>
{isImporting || isCreatingFolder ? (
<Loader className='h-[16px] w-[16px]' animate />
) : (
<MoreHorizontal className='h-[16px] w-[16px]' />
)}
</Button>
</DropdownMenuTrigger>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
onClick={handleNewTask}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>More actions</p>
<Tooltip.Shortcut keys={isMac ? '⌘⇧K' : 'Ctrl+Shift+K'}>
New task
</Tooltip.Shortcut>
</Tooltip.Content>
</Tooltip.Root>
<DropdownMenuContent
align='start'
sideOffset={8}
className='min-w-[160px]'
>
<DropdownMenuItem
onSelect={handleImportWorkflow}
disabled={!canEdit || isImporting}
>
<Download />
{isImporting ? 'Importing...' : 'Import workflow'}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={handleCreateFolder}
disabled={!canEdit || isCreatingFolder}
>
<FolderPlus />
{isCreatingFolder ? 'Creating folder...' : 'Create folder'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
onClick={handleCreateWorkflow}
disabled={isCreatingWorkflow || !canEdit}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
{isCreatingWorkflow ? (
<p>Creating workflow...</p>
) : (
<Tooltip.Shortcut keys={isMac ? '⌘⇧P' : 'Ctrl+Shift+P'}>
New workflow
</Tooltip.Shortcut>
</div>
)}
</div>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={tasksCollapsedIcon}
hover={tasksHover}
ariaLabel='Tasks'
className='mt-1.5'
primaryAction={tasksPrimaryAction}
>
{tasksLoading ? (
<DropdownMenuItem disabled>
<Loader className='h-[14px] w-[14px]' animate />
Loading...
</DropdownMenuItem>
) : (
tasks.map((task) => (
<CollapsedTaskFlyoutItem
key={task.id}
task={task}
isCurrentRoute={task.id !== 'new' && pathname === task.href}
isMenuOpen={menuOpenTaskId === task.id}
isEditing={task.id === taskFlyoutRename.editingId}
editValue={taskFlyoutRename.value}
inputRef={taskFlyoutRename.inputRef}
isRenaming={taskFlyoutRename.isSaving}
onEditValueChange={taskFlyoutRename.setValue}
onEditKeyDown={taskFlyoutRename.handleKeyDown}
onEditBlur={handleTaskRenameBlur}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
onMoreClick={handleTaskMoreClick}
/>
))
)}
</CollapsedSidebarMenu>
) : (
<div className='mt-1.5 flex flex-col gap-0.5 px-2'>
{tasksLoading ? (
<SidebarItemSkeleton />
) : (
<>
{tasks.slice(0, visibleTaskCount).map((task) => {
const isCurrentRoute = task.id !== 'new' && pathname === task.href
const isRenaming = taskFlyoutRename.editingId === task.id
const isSelected = task.id !== 'new' && selectedTasks.has(task.id)
if (isRenaming) {
return (
<div
key={task.id}
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg bg-[var(--surface-active)] px-2 text-sm'
>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<input
ref={taskFlyoutRename.inputRef}
value={taskFlyoutRename.value}
onChange={(e) => taskFlyoutRename.setValue(e.target.value)}
onKeyDown={taskFlyoutRename.handleKeyDown}
onBlur={handleTaskRenameBlur}
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
/>
</div>
)
}
return (
<SidebarTaskItem
key={task.id}
task={task}
isCurrentRoute={isCurrentRoute}
isSelected={isSelected}
isActive={!!task.isActive}
isUnread={!!task.isUnread}
isMenuOpen={menuOpenTaskId === task.id}
showCollapsedTooltips={showCollapsedTooltips}
onMultiSelectClick={handleTaskClick}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
onMoreClick={handleTaskMoreClick}
/>
)
})}
{tasks.length > visibleTaskCount && (
<button
type='button'
onClick={handleSeeMoreTasks}
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-[var(--text-icon)] text-sm hover-hover:bg-[var(--surface-hover)]'
>
<MoreHorizontal className='h-[16px] w-[16px] flex-shrink-0' />
<span className='font-base'>See more</span>
</button>
)}
</Tooltip.Content>
</Tooltip.Root>
</>
)}
</div>
)}
</div>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={workflowsCollapsedIcon}
hover={workflowsHover}
ariaLabel='Workflows'
className='mt-1.5'
primaryAction={workflowsPrimaryAction}
>
{workflowsLoading && regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>
<Loader className='h-[14px] w-[14px]' animate />
Loading...
</DropdownMenuItem>
) : regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>No workflows yet</DropdownMenuItem>
) : (
<>
<CollapsedFolderItems
nodes={folderTree}
workflowsByFolder={workflowsByFolder}
workspaceId={workspaceId}
currentWorkflowId={workflowId}
editingWorkflowId={workflowFlyoutRename.editingId}
editingValue={workflowFlyoutRename.value}
editInputRef={workflowFlyoutRename.inputRef}
isRenamingWorkflow={workflowFlyoutRename.isSaving}
onEditValueChange={workflowFlyoutRename.setValue}
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
onEditBlur={handleWorkflowRenameBlur}
onWorkflowOpenInNewTab={handleCollapsedWorkflowOpenInNewTab}
onWorkflowRename={handleCollapsedWorkflowRename}
canRenameWorkflow={canEdit}
/>
{(workflowsByFolder.root || []).map((workflow) => (
<CollapsedWorkflowFlyoutItem
key={workflow.id}
workflow={workflow}
href={`/workspace/${workspaceId}/w/${workflow.id}`}
isCurrentRoute={workflow.id === workflowId}
isEditing={workflow.id === workflowFlyoutRename.editingId}
editValue={workflowFlyoutRename.value}
inputRef={workflowFlyoutRename.inputRef}
isRenaming={workflowFlyoutRename.isSaving}
<div
className='workflows-section relative mt-3.5 flex flex-col'
data-tour='nav-workflows'
>
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
<div className='font-base text-[var(--text-icon)] text-small'>
Workflows
</div>
{!isCollapsed && (
<div className='flex items-center justify-center gap-2'>
<DropdownMenu>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
disabled={!canEdit}
>
{isImporting || isCreatingFolder ? (
<Loader className='h-[16px] w-[16px]' animate />
) : (
<MoreHorizontal className='h-[16px] w-[16px]' />
)}
</Button>
</DropdownMenuTrigger>
</Tooltip.Trigger>
<Tooltip.Content>
<p>More actions</p>
</Tooltip.Content>
</Tooltip.Root>
<DropdownMenuContent
align='start'
sideOffset={8}
className='min-w-[160px]'
>
<DropdownMenuItem
onSelect={handleImportWorkflow}
disabled={!canEdit || isImporting}
>
<Download />
{isImporting ? 'Importing...' : 'Import workflow'}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={handleCreateFolder}
disabled={!canEdit || isCreatingFolder}
>
<FolderPlus />
{isCreatingFolder ? 'Creating folder...' : 'Create folder'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
onClick={handleCreateWorkflow}
disabled={isCreatingWorkflow || !canEdit}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
{isCreatingWorkflow ? (
<p>Creating workflow...</p>
) : (
<Tooltip.Shortcut keys={isMac ? '⌘⇧P' : 'Ctrl+Shift+P'}>
New workflow
</Tooltip.Shortcut>
)}
</Tooltip.Content>
</Tooltip.Root>
</div>
)}
</div>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={workflowsCollapsedIcon}
hover={workflowsHover}
ariaLabel='Workflows'
className='mt-1.5'
primaryAction={workflowsPrimaryAction}
>
{workflowsLoading && regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>
<Loader className='h-[14px] w-[14px]' animate />
Loading...
</DropdownMenuItem>
) : regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>No workflows yet</DropdownMenuItem>
) : (
<>
<CollapsedFolderItems
nodes={folderTree}
workflowsByFolder={workflowsByFolder}
workspaceId={workspaceId}
currentWorkflowId={workflowId}
editingWorkflowId={workflowFlyoutRename.editingId}
editingValue={workflowFlyoutRename.value}
editInputRef={workflowFlyoutRename.inputRef}
isRenamingWorkflow={workflowFlyoutRename.isSaving}
onEditValueChange={workflowFlyoutRename.setValue}
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
onEditBlur={handleWorkflowRenameBlur}
onOpenInNewTab={() => handleCollapsedWorkflowOpenInNewTab(workflow)}
onRename={() => handleCollapsedWorkflowRename(workflow)}
canRename={canEdit}
onWorkflowOpenInNewTab={handleCollapsedWorkflowOpenInNewTab}
onWorkflowRename={handleCollapsedWorkflowRename}
canRenameWorkflow={canEdit}
/>
))}
</>
)}
</CollapsedSidebarMenu>
) : (
<div className='mt-1.5 px-2'>
{workflowsLoading && regularWorkflows.length === 0 && (
<SidebarItemSkeleton />
)}
<WorkflowList
workspaceId={workspaceId}
workflowId={workflowId}
regularWorkflows={regularWorkflows}
isLoading={isLoading}
canReorder={canEdit}
handleFileChange={handleImportFileChange}
fileInputRef={fileInputRef}
scrollContainerRef={scrollContainerRef}
onCreateWorkflow={handleCreateWorkflow}
onCreateFolder={handleCreateFolder}
disableCreate={!canEdit || isCreatingWorkflow || isCreatingFolder}
/>
</div>
)}
{(workflowsByFolder.root || []).map((workflow) => (
<CollapsedWorkflowFlyoutItem
key={workflow.id}
workflow={workflow}
href={`/workspace/${workspaceId}/w/${workflow.id}`}
isCurrentRoute={workflow.id === workflowId}
isEditing={workflow.id === workflowFlyoutRename.editingId}
editValue={workflowFlyoutRename.value}
inputRef={workflowFlyoutRename.inputRef}
isRenaming={workflowFlyoutRename.isSaving}
onEditValueChange={workflowFlyoutRename.setValue}
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
onEditBlur={handleWorkflowRenameBlur}
onOpenInNewTab={() =>
handleCollapsedWorkflowOpenInNewTab(workflow)
}
onRename={() => handleCollapsedWorkflowRename(workflow)}
canRename={canEdit}
/>
))}
</>
)}
</CollapsedSidebarMenu>
) : (
<div className='mt-1.5 px-2'>
{workflowsLoading && regularWorkflows.length === 0 && (
<SidebarItemSkeleton />
)}
<WorkflowList
workspaceId={workspaceId}
workflowId={workflowId}
regularWorkflows={regularWorkflows}
isLoading={isLoading}
canReorder={canEdit}
handleFileChange={handleImportFileChange}
fileInputRef={fileInputRef}
scrollContainerRef={scrollContainerRef}
onCreateWorkflow={handleCreateWorkflow}
onCreateFolder={handleCreateFolder}
disableCreate={!canEdit || isCreatingWorkflow || isCreatingFolder}
/>
</div>
)}
</div>
</div>
</div>

View File

@@ -72,6 +72,8 @@ export { Table } from './table'
export { TableX } from './table-x'
export { TagIcon } from './tag'
export { TerminalWindow } from './terminal-window'
export { ThumbsDown } from './thumbs-down'
export { ThumbsUp } from './thumbs-up'
export { Trash } from './trash'
export { TrashOutline } from './trash-outline'
export { Trash2 } from './trash2'

View File

@@ -0,0 +1,28 @@
import type { SVGProps } from 'react'
/**
* ThumbsDown icon component
* @param props - SVG properties including className, fill, etc.
*/
export function ThumbsDown(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.75'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<g transform='scale(1,-1) translate(0,-20)'>
<path d='M6 8v12' />
<path d='M14 3.88L13 8h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 16.5 20H3a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L11 0a3.13 3.13 0 0 1 3 3.88Z' />
</g>
</svg>
)
}

View File

@@ -0,0 +1,26 @@
import type { SVGProps } from 'react'
/**
* ThumbsUp icon component
* @param props - SVG properties including className, fill, etc.
*/
export function ThumbsUp(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.75'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path d='M6 8v12' />
<path d='M14 3.88L13 8h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 16.5 20H3a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L11 0a3.13 3.13 0 0 1 3 3.88Z' />
</svg>
)
}

View File

@@ -0,0 +1,39 @@
import { createLogger } from '@sim/logger'
import { useMutation } from '@tanstack/react-query'
const logger = createLogger('CopilotFeedbackMutation')
interface SubmitFeedbackVariables {
chatId: string
userQuery: string
agentResponse: string
isPositiveFeedback: boolean
feedback?: string
}
interface SubmitFeedbackResponse {
success: boolean
feedbackId: string
}
export function useSubmitCopilotFeedback() {
return useMutation({
mutationFn: async (variables: SubmitFeedbackVariables): Promise<SubmitFeedbackResponse> => {
const response = await fetch('/api/copilot/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(variables),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error || 'Failed to submit feedback')
}
return response.json()
},
onError: (error) => {
logger.error('Failed to submit copilot feedback:', error)
},
})
}

View File

@@ -123,6 +123,7 @@ describe('executeWorkflowCore terminal finalization sequencing', () => {
requestId: 'req-1',
workflowId: 'workflow-1',
userId: 'user-1',
workflowUserId: 'workflow-owner',
workspaceId: 'workspace-1',
triggerType: 'api',
executionId: 'execution-1',
@@ -755,4 +756,92 @@ describe('executeWorkflowCore terminal finalization sequencing', () => {
expect(safeCompleteWithErrorMock).not.toHaveBeenCalled()
expect(wasExecutionFinalizedByCore(envError, 'execution-no-log-start')).toBe(false)
})
it('uses sessionUserId for env resolution when isClientSession is true', async () => {
const snapshot = {
...createSnapshot(),
metadata: {
...createSnapshot().metadata,
isClientSession: true,
sessionUserId: 'session-user',
workflowUserId: 'workflow-owner',
},
}
getPersonalAndWorkspaceEnvMock.mockResolvedValue({
personalEncrypted: {},
workspaceEncrypted: {},
personalDecrypted: {},
workspaceDecrypted: {},
})
safeStartMock.mockResolvedValue(true)
executorExecuteMock.mockResolvedValue({
output: { done: true },
logs: [],
metadata: { duration: 123, startTime: 'start', endTime: 'end' },
})
await executeWorkflowCore({
snapshot: snapshot as any,
callbacks: {},
loggingSession: loggingSession as any,
})
expect(getPersonalAndWorkspaceEnvMock).toHaveBeenCalledWith('session-user', 'workspace-1')
})
it('uses workflowUserId for env resolution in server-side execution', async () => {
const snapshot = {
...createSnapshot(),
metadata: {
...createSnapshot().metadata,
isClientSession: false,
sessionUserId: undefined,
workflowUserId: 'workflow-owner',
userId: 'billing-actor',
},
}
getPersonalAndWorkspaceEnvMock.mockResolvedValue({
personalEncrypted: {},
workspaceEncrypted: {},
personalDecrypted: {},
workspaceDecrypted: {},
})
safeStartMock.mockResolvedValue(true)
executorExecuteMock.mockResolvedValue({
output: { done: true },
logs: [],
metadata: { duration: 123, startTime: 'start', endTime: 'end' },
})
await executeWorkflowCore({
snapshot: snapshot as any,
callbacks: {},
loggingSession: loggingSession as any,
})
expect(getPersonalAndWorkspaceEnvMock).toHaveBeenCalledWith('workflow-owner', 'workspace-1')
})
it('throws when workflowUserId is missing in server-side execution', async () => {
const snapshot = {
...createSnapshot(),
metadata: {
...createSnapshot().metadata,
isClientSession: false,
sessionUserId: undefined,
workflowUserId: undefined,
userId: 'billing-actor',
},
}
await expect(
executeWorkflowCore({
snapshot: snapshot as any,
callbacks: {},
loggingSession: loggingSession as any,
})
).rejects.toThrow('Missing workflowUserId in execution metadata')
})
})

View File

@@ -325,10 +325,13 @@ export async function executeWorkflowCore(
const mergedStates = mergeSubblockStateWithValues(blocks)
const personalEnvUserId = metadata.sessionUserId || metadata.userId
const personalEnvUserId =
metadata.isClientSession && metadata.sessionUserId
? metadata.sessionUserId
: metadata.workflowUserId
if (!personalEnvUserId) {
throw new Error('Missing execution actor for environment resolution')
throw new Error('Missing workflowUserId in execution metadata')
}
const { personalEncrypted, workspaceEncrypted, personalDecrypted, workspaceDecrypted } =

View File

@@ -271,6 +271,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -290,6 +291,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -324,6 +326,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -342,6 +345,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -360,6 +364,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -373,6 +378,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
maxOutputTokens: 16384,
},
contextWindow: 128000,
},
@@ -449,6 +455,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
reasoningEffort: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 100000,
},
contextWindow: 200000,
},
@@ -463,6 +470,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
maxOutputTokens: 16384,
},
contextWindow: 128000,
},
@@ -509,7 +517,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true,
maxOutputTokens: 128000,
maxOutputTokens: 64000,
thinking: {
levels: ['low', 'medium', 'high', 'max'],
default: 'high',
@@ -741,6 +749,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -759,6 +768,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -777,6 +787,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -795,6 +806,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -813,6 +825,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -831,6 +844,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -1067,6 +1081,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
levels: ['minimal', 'low', 'medium', 'high'],
default: 'high',
},
maxOutputTokens: 65536,
},
contextWindow: 1048576,
},
@@ -1084,6 +1099,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
levels: ['minimal', 'low', 'medium', 'high'],
default: 'minimal',
},
maxOutputTokens: 65536,
},
contextWindow: 1048576,
},
@@ -1101,6 +1117,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
levels: ['minimal', 'low', 'medium', 'high'],
default: 'high',
},
maxOutputTokens: 65536,
},
contextWindow: 1000000,
},
@@ -1114,6 +1131,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
maxOutputTokens: 65536,
},
contextWindow: 1048576,
},
@@ -1127,6 +1145,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
maxOutputTokens: 65536,
},
contextWindow: 1048576,
},
@@ -1140,6 +1159,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
maxOutputTokens: 65536,
},
contextWindow: 1048576,
},
@@ -1153,6 +1173,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
maxOutputTokens: 8192,
},
contextWindow: 1048576,
},
@@ -1165,6 +1186,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
maxOutputTokens: 8192,
},
contextWindow: 1048576,
},
@@ -1178,6 +1200,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
deepResearch: true,
memory: false,
maxOutputTokens: 65536,
},
contextWindow: 1000000,
},
@@ -2094,6 +2117,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true,
maxOutputTokens: 64000,
},
contextWindow: 200000,
},
@@ -2107,6 +2131,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true,
maxOutputTokens: 64000,
},
contextWindow: 200000,
},
@@ -2120,6 +2145,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true,
maxOutputTokens: 64000,
},
contextWindow: 200000,
},
@@ -2133,6 +2159,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true,
maxOutputTokens: 64000,
},
contextWindow: 200000,
},
@@ -2337,6 +2364,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
maxOutputTokens: 32768,
},
contextWindow: 128000,
},
@@ -2373,6 +2401,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
maxOutputTokens: 16384,
},
contextWindow: 128000,
},
@@ -2385,6 +2414,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
maxOutputTokens: 40000,
},
contextWindow: 128000,
},
@@ -2397,6 +2427,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
maxOutputTokens: 8192,
},
contextWindow: 128000,
},
@@ -2409,6 +2440,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
maxOutputTokens: 8192,
},
contextWindow: 128000,
},
@@ -2421,6 +2453,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
maxOutputTokens: 8192,
},
contextWindow: 128000,
},
@@ -2863,13 +2896,17 @@ export function getModelsWithoutMemory(): string[] {
export function getMaxOutputTokensForModel(modelId: string): number {
const normalizedModelId = modelId.toLowerCase()
const STANDARD_MAX_OUTPUT_TOKENS = 4096
const allModels = Object.values(PROVIDER_DEFINITIONS).flatMap((provider) => provider.models)
for (const provider of Object.values(PROVIDER_DEFINITIONS)) {
for (const model of provider.models) {
const baseModelId = model.id.toLowerCase()
if (normalizedModelId === baseModelId || normalizedModelId.startsWith(`${baseModelId}-`)) {
return model.capabilities.maxOutputTokens || STANDARD_MAX_OUTPUT_TOKENS
}
const exactMatch = allModels.find((model) => model.id.toLowerCase() === normalizedModelId)
if (exactMatch) {
return exactMatch.capabilities.maxOutputTokens || STANDARD_MAX_OUTPUT_TOKENS
}
for (const model of allModels) {
const baseModelId = model.id.toLowerCase()
if (normalizedModelId.startsWith(`${baseModelId}-`)) {
return model.capabilities.maxOutputTokens || STANDARD_MAX_OUTPUT_TOKENS
}
}

View File

@@ -664,6 +664,45 @@ describe('Model Capabilities', () => {
describe('Max Output Tokens', () => {
describe('getMaxOutputTokensForModel', () => {
it.concurrent('should return published max for OpenAI GPT-4o', () => {
expect(getMaxOutputTokensForModel('gpt-4o')).toBe(16384)
})
it.concurrent('should return published max for OpenAI GPT-5.1', () => {
expect(getMaxOutputTokensForModel('gpt-5.1')).toBe(128000)
})
it.concurrent('should return published max for OpenAI GPT-5 Chat', () => {
expect(getMaxOutputTokensForModel('gpt-5-chat-latest')).toBe(16384)
})
it.concurrent('should return published max for OpenAI o1', () => {
expect(getMaxOutputTokensForModel('o1')).toBe(100000)
})
it.concurrent('should return updated max for Claude Sonnet 4.6', () => {
expect(getMaxOutputTokensForModel('claude-sonnet-4-6')).toBe(64000)
})
it.concurrent('should return published max for Gemini 2.5 Pro', () => {
expect(getMaxOutputTokensForModel('gemini-2.5-pro')).toBe(65536)
})
it.concurrent('should return published max for Azure GPT-5.2', () => {
expect(getMaxOutputTokensForModel('azure/gpt-5.2')).toBe(128000)
})
it.concurrent('should return standard default for models without maxOutputTokens', () => {
expect(getMaxOutputTokensForModel('deepseek-reasoner')).toBe(4096)
expect(getMaxOutputTokensForModel('grok-4-latest')).toBe(4096)
})
it.concurrent('should return published max for Bedrock Claude Opus 4.1', () => {
expect(getMaxOutputTokensForModel('bedrock/anthropic.claude-opus-4-1-20250805-v1:0')).toBe(
64000
)
})
it.concurrent('should return correct max for Claude Opus 4.6', () => {
expect(getMaxOutputTokensForModel('claude-opus-4-6')).toBe(128000)
})
@@ -676,10 +715,6 @@ describe('Max Output Tokens', () => {
expect(getMaxOutputTokensForModel('claude-opus-4-1')).toBe(32000)
})
it.concurrent('should return standard default for models without maxOutputTokens', () => {
expect(getMaxOutputTokensForModel('gpt-4o')).toBe(4096)
})
it.concurrent('should return standard default for unknown models', () => {
expect(getMaxOutputTokensForModel('unknown-model')).toBe(4096)
})