feat: allow adding workspace scripts and flows as AI chat context (#7882)

* feat: allow adding workspace scripts and flows as AI chat context

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* cleaning

* cleaning

* cleaning

* better

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
centdix
2026-02-16 15:46:17 +00:00
committed by GitHub
co-authored by Claude Opus 4.5
parent c63fe9770d
commit 2d3202b151
5 changed files with 380 additions and 42 deletions
@@ -110,30 +110,39 @@
}
function addContextToSelection(contextElement: ContextElement) {
if (!selectedContext || !availableContext) return
const alreadySelected = selectedContext.find(
(c) => c.type === contextElement.type && c.title === contextElement.title
)
if (alreadySelected) return
// Workspace items are fetched on-demand and not in availableContext,
// so skip the availableContext check for them
const isWorkspaceItem =
contextElement.type === 'workspace_script' || contextElement.type === 'workspace_flow'
if (
selectedContext &&
availableContext &&
!selectedContext.find(
(c) => c.type === contextElement.type && c.title === contextElement.title
) &&
availableContext.find(
!isWorkspaceItem &&
!availableContext.find(
(c) => c.type === contextElement.type && c.title === contextElement.title
)
) {
selectedContext = [...selectedContext, contextElement]
return
}
// If it's a datatable table, add it to the app's whitelisted tables
if (
contextElement.type === 'app_datatable' &&
aiChatManager.mode === AIMode.APP &&
aiChatManager.appAiChatHelpers
) {
aiChatManager.appAiChatHelpers.addTableToWhitelist(
contextElement.datatableName,
contextElement.schemaName,
contextElement.tableName
)
}
selectedContext = [...selectedContext, contextElement]
// If it's a datatable table, add it to the app's whitelisted tables
if (
contextElement.type === 'app_datatable' &&
aiChatManager.mode === AIMode.APP &&
aiChatManager.appAiChatHelpers
) {
aiChatManager.appAiChatHelpers.addTableToWhitelist(
contextElement.datatableName,
contextElement.schemaName,
contextElement.tableName
)
}
}
@@ -368,6 +377,10 @@
addContextToSelection(element)
close()
}}
onSelectWorkspaceItem={(element) => {
addContextToSelection(element)
close()
}}
/>
</svelte:fragment>
</Popover>
@@ -2,13 +2,28 @@
import FlowModuleIcon from '$lib/components/flows/FlowModuleIcon.svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import type { FlowModule } from '$lib/gen/types.gen'
import { ContextIconMap, type ContextElement } from './context'
import { ArrowLeft, Diff, Database, ChevronRight } from 'lucide-svelte'
import { workspaceStore } from '$lib/stores'
import { workspaceRunnablesSearch, MAX_RUNNABLE_CONTENT_LENGTH } from './shared'
import {
ContextIconMap,
type ContextElement,
type WorkspaceScriptElement,
type WorkspaceFlowElement
} from './context'
import {
ArrowLeft,
Diff,
Database,
ChevronRight,
Code2,
Loader2
} from 'lucide-svelte'
interface Props {
availableContext: ContextElement[]
selectedContext: ContextElement[]
onSelect: (element: ContextElement) => void
onSelectWorkspaceItem?: (element: ContextElement) => void
setShowing?: (showing: boolean) => void
showAllAvailable?: boolean
stringSearch?: string
@@ -19,6 +34,7 @@
availableContext,
selectedContext,
onSelect,
onSelectWorkspaceItem,
setShowing,
showAllAvailable = false,
stringSearch = '',
@@ -26,19 +42,34 @@
}: Props = $props()
// Current view state: 'categories' or specific category type
let currentView = $state<'categories' | 'diffs' | 'modules' | 'databases'>('categories')
let currentView = $state<
'categories' | 'diffs' | 'modules' | 'databases' | 'scripts' | 'flows'
>('categories')
// Selected index for keyboard navigation
let itemSelectedIndex = $state(0)
let categorySelectedIndex = $state(0)
// Workspace search state
let workspaceSearchQuery = $state('')
let workspaceSearchResults = $state<{ path: string; summary: string }[]>([])
let workspaceSearchLoading = $state(false)
let searchInputElement = $state<HTMLInputElement | undefined>(undefined)
let searchDebounceTimer: ReturnType<typeof setTimeout> | undefined = undefined
// Category definitions
const categories = [
{ id: 'diffs', label: 'Diffs', icon: Diff },
{ id: 'modules', label: 'Modules', icon: BarsStaggered },
{ id: 'databases', label: 'Databases', icon: Database }
{ id: 'diffs' as const, label: 'Diffs', icon: Diff, searchable: false },
{ id: 'modules' as const, label: 'Modules', icon: BarsStaggered, searchable: false },
{ id: 'databases' as const, label: 'Databases', icon: Database, searchable: false },
{ id: 'scripts' as const, label: 'Scripts', icon: Code2, searchable: true },
{ id: 'flows' as const, label: 'Flows', icon: BarsStaggered, searchable: true }
]
const isSearchableView = $derived(
currentView === 'scripts' || currentView === 'flows'
)
const filteredAvailableContext = $derived(
availableContext.filter((context) => {
const filtered =
@@ -68,12 +99,14 @@
})
const currentCategoryItems = $derived(
currentView !== 'categories' ? contextByCategory[currentView] : []
currentView !== 'categories' && !isSearchableView ? contextByCategory[currentView] : []
)
// Filter to only show categories with items
// Filter to only show categories with items (non-searchable) or always show (searchable)
const availableCategories = $derived(
categories.filter((cat) => contextByCategory[cat.id].length > 0)
categories.filter(
(cat) => cat.searchable || contextByCategory[cat.id]?.length > 0
)
)
// Report view changes
@@ -81,6 +114,8 @@
if (onViewChange) {
if (currentView === 'categories') {
onViewChange(availableCategories.length)
} else if (isSearchableView) {
onViewChange(workspaceSearchResults.length + 2) // +2 for back button and search input
} else {
onViewChange(currentCategoryItems.length + 1)
}
@@ -89,15 +124,126 @@
function handleCategoryClick(categoryId: string) {
currentView = categoryId as typeof currentView
itemSelectedIndex = 0
if (categoryId === 'scripts' || categoryId === 'flows') {
workspaceSearchQuery = ''
workspaceSearchResults = []
setTimeout(() => searchInputElement?.focus(), 0)
}
}
function handleBackClick() {
currentView = 'categories'
itemSelectedIndex = 0
workspaceSearchQuery = ''
workspaceSearchResults = []
}
async function searchWorkspaceItems(query: string) {
const workspace = $workspaceStore
if (!workspace) return
workspaceSearchLoading = true
try {
const type = currentView === 'scripts' ? 'scripts' : 'flows'
const results = await workspaceRunnablesSearch.search(query, workspace, type)
workspaceSearchResults = results.map((r) => ({
path: r.path,
summary: r.summary
}))
} catch (err) {
console.error('Error searching workspace items', err)
workspaceSearchResults = []
} finally {
workspaceSearchLoading = false
}
}
function handleSearchInput() {
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
searchDebounceTimer = setTimeout(() => {
searchWorkspaceItems(workspaceSearchQuery)
}, 300)
}
async function handleWorkspaceItemSelect(path: string) {
const workspace = $workspaceStore
if (!workspace || !onSelectWorkspaceItem) return
try {
if (currentView === 'scripts') {
const script = await workspaceRunnablesSearch.getScript(path, workspace)
const element: WorkspaceScriptElement & { deletable: boolean } = {
type: 'workspace_script',
path: script.path,
title: script.path,
summary: script.summary,
language: script.language,
content: script.content,
schema: script.schema,
deletable: true
}
onSelectWorkspaceItem(element)
} else if (currentView === 'flows') {
const flow = await workspaceRunnablesSearch.getFlow(path, workspace)
const flowValue = JSON.stringify(flow.value, null, 2)
const truncatedValue =
flowValue.length > MAX_RUNNABLE_CONTENT_LENGTH
? flowValue.slice(0, MAX_RUNNABLE_CONTENT_LENGTH) + '\n... (truncated)'
: flowValue
const element: WorkspaceFlowElement & { deletable: boolean } = {
type: 'workspace_flow',
path: flow.path,
title: flow.path,
summary: flow.summary,
description: flow.description || '',
value: truncatedValue,
schema: flow.schema,
deletable: true
}
onSelectWorkspaceItem(element)
}
} catch (err) {
console.error('Error fetching workspace item', err)
}
currentView = 'categories'
workspaceSearchQuery = ''
workspaceSearchResults = []
}
function handleKeyDown(e: KeyboardEvent) {
if (stringSearch.length > 0) {
if (isSearchableView) {
// Navigation in workspace search view
if (e.key === 'ArrowDown') {
e.preventDefault()
e.stopPropagation()
if (workspaceSearchResults.length > 0) {
itemSelectedIndex = (itemSelectedIndex + 1) % workspaceSearchResults.length
}
} else if (e.key === 'ArrowUp') {
e.preventDefault()
e.stopPropagation()
if (workspaceSearchResults.length > 0) {
itemSelectedIndex =
(itemSelectedIndex - 1 + workspaceSearchResults.length) %
workspaceSearchResults.length
}
} else if (e.key === 'Enter') {
// Only select if not typing in the search input, or if results exist
if (workspaceSearchResults.length > 0) {
e.preventDefault()
e.stopPropagation()
const selectedItem = workspaceSearchResults[itemSelectedIndex]
if (selectedItem) {
handleWorkspaceItemSelect(selectedItem.path)
}
}
} else if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
handleBackClick()
}
} else if (stringSearch.length > 0) {
// Navigation in search view (flat list)
if (e.key === 'ArrowDown') {
e.preventDefault()
@@ -188,13 +334,24 @@
itemSelectedIndex = 0
}
})
// Trigger initial search when entering a searchable category
$effect(() => {
if (isSearchableView) {
searchWorkspaceItems('')
}
})
</script>
<div
class="flex flex-col gap-1 text-primary text-xs p-1 pr-0 min-w-24 max-h-48 overflow-y-scroll"
onmousedown={(e) =>
onmousedown={(e) => {
// avoids triggering onblur on the textinput and closing the tooltip
e.preventDefault()}
// but allow input elements to receive focus for the search input
if (!(e.target instanceof HTMLInputElement)) {
e.preventDefault()
}
}}
role="listbox"
tabindex={0}
>
@@ -245,6 +402,68 @@
{#if availableCategories.length === 0}
<div class="text-center text-primary text-xs py-2">No available context</div>
{/if}
{:else if isSearchableView}
<!-- Workspace search view (scripts/flows) -->
<button
class="hover:bg-surface-hover rounded-md text-left flex flex-row gap-1 items-center font-normal transition-colors mb-1"
onclick={handleBackClick}
>
<ArrowLeft size={12} />
<span class="text-xs">Go back</span>
</button>
<input
bind:this={searchInputElement}
bind:value={workspaceSearchQuery}
oninput={handleSearchInput}
type="text"
placeholder="Search {currentView}..."
class="w-full text-xs px-2 py-1 rounded-md border border-gray-200 dark:border-gray-700 bg-surface mb-1 outline-none focus:border-blue-500"
/>
{#if workspaceSearchLoading}
<div class="flex items-center justify-center py-2 gap-1">
<Loader2 size={14} class="animate-spin" />
<span class="text-xs text-secondary">Searching...</span>
</div>
{:else if workspaceSearchResults.length === 0}
<div class="text-center text-secondary text-xs py-2">
No results found
</div>
{:else}
{#each workspaceSearchResults as item, i}
{@const isAlreadySelected = selectedContext.some(
(c) =>
((c.type === 'workspace_script' && currentView === 'scripts') ||
(c.type === 'workspace_flow' && currentView === 'flows')) &&
c.title === item.path
)}
<button
class="hover:bg-surface-hover rounded-md p-1 text-left flex flex-col font-normal transition-colors {i ===
itemSelectedIndex
? 'bg-surface-hover'
: ''} {isAlreadySelected ? 'opacity-50' : ''}"
onclick={() => {
if (!isAlreadySelected) {
handleWorkspaceItemSelect(item.path)
}
}}
disabled={isAlreadySelected}
>
<div class="flex flex-row gap-1 items-center">
{#if currentView === 'scripts'}
<Code2 size={14} class="shrink-0" />
{:else}
<BarsStaggered size={14} class="shrink-0" />
{/if}
<span class="truncate">{item.path}</span>
</div>
{#if item.summary}
<span class="truncate text-secondary pl-5">{item.summary}</span>
{/if}
</button>
{/each}
{/if}
{:else}
<!-- Category items view -->
<button
@@ -156,8 +156,11 @@
function getHighlightedText(text: string) {
return text.replace(/@[\w/.\-\[\]]+/g, (match) => {
const contextElement = availableContext.find((c) => c.title === match.slice(1))
if (contextElement) {
const title = match.slice(1)
const inContext =
availableContext.find((c) => c.title === title) ||
selectedContext.find((c) => c.title === title)
if (inContext) {
return `<span class="bg-black dark:bg-white text-white dark:text-black z-10">${match}</span>`
}
return match
@@ -316,6 +319,10 @@
oninput={handleInput}
onblur={() => {
setTimeout(() => {
// Don't close if focus moved to inside the tooltip (e.g., search input)
if (tooltipElement?.contains(document.activeElement)) {
return
}
showContextTooltip = false
}, 200)
}}
@@ -342,6 +349,13 @@
onSelect={(element) => {
handleContextSelection(element)
}}
onSelectWorkspaceItem={(element) => {
onAddContext(element)
updateInstructionsWithContext(element)
showContextTooltip = false
// Refocus the textarea since focus may have been on the search input
setTimeout(() => textarea?.focus(), 0)
}}
showAllAvailable={true}
stringSearch={contextTooltipWord.slice(1)}
onViewChange={(newNumber) => {
@@ -1,5 +1,15 @@
import { Code, Database, TriangleAlert, Diff, FileCode, Code2, TextSelect, Table2 } from 'lucide-svelte'
import type { ScriptLang } from '$lib/gen/types.gen'
import {
Code,
Database,
TriangleAlert,
Diff,
FileCode,
Code2,
TextSelect,
Table2
} from 'lucide-svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import type { ScriptLang, Script, OpenFlow } from '$lib/gen/types.gen'
import { type DBSchema } from '$lib/stores'
import { type Change } from 'diff'
import type { BackendRunnable } from './app/core'
@@ -13,7 +23,9 @@ export const ContextIconMap = {
app_frontend_file: FileCode,
app_backend_runnable: Code2,
app_code_selection: TextSelect,
app_datatable: Table2
app_datatable: Table2,
workspace_script: Code2,
workspace_flow: BarsStaggered
// flow_module type is handled with FlowModuleIcon
}
@@ -128,6 +140,23 @@ export interface AppDatatableElement {
columns: Record<string, string>
}
/** Workspace script context element — reference to a script in the workspace */
export interface WorkspaceScriptElement
extends Pick<Script, 'path' | 'summary' | 'language' | 'content' | 'schema'> {
type: 'workspace_script'
title: string
}
/** Workspace flow context element — reference to a flow in the workspace */
export interface WorkspaceFlowElement extends Pick<OpenFlow, 'summary' | 'schema'> {
type: 'workspace_flow'
path: string
title: string
description: string
/** Full flow value, JSON-stringified and possibly truncated */
value: string
}
export type ContextElement = (
| CodeElement
| ErrorElement
@@ -140,6 +169,8 @@ export type ContextElement = (
| AppBackendRunnableElement
| AppCodeSelectionElement
| AppDatatableElement
| WorkspaceScriptElement
| WorkspaceFlowElement
) & {
deletable?: boolean
}
@@ -277,6 +277,7 @@ export function buildContextString(selectedContext: ContextElement[]): string {
let hasDiff = false
let hasFlowModule = false
let hasError = false
let workspaceItemsContext = ''
let result = '\n\n'
for (const context of selectedContext) {
@@ -311,6 +312,22 @@ export function buildContextString(selectedContext: ContextElement[]): string {
} else if (context.type === 'flow_module') {
hasFlowModule = true
flowModuleContext += `${context.id}\n`
} else if (context.type === 'workspace_script') {
workspaceItemsContext += `\nWORKSPACE SCRIPT (${context.path}):\n`
workspaceItemsContext += `Summary: ${context.summary}\n`
workspaceItemsContext += `Language: ${context.language}\n`
if (context.schema) {
workspaceItemsContext += `Inputs: ${JSON.stringify(context.schema)}\n`
}
workspaceItemsContext += `Code:\n${context.content}\n`
} else if (context.type === 'workspace_flow') {
workspaceItemsContext += `\nWORKSPACE FLOW (${context.path}):\n`
workspaceItemsContext += `Summary: ${context.summary}\n`
workspaceItemsContext += `Description: ${context.description}\n`
if (context.schema) {
workspaceItemsContext += `Inputs: ${JSON.stringify(context.schema)}\n`
}
workspaceItemsContext += `Value:\n${context.value}\n`
}
}
@@ -329,6 +346,9 @@ export function buildContextString(selectedContext: ContextElement[]): string {
if (hasFlowModule) {
result += '\n' + flowModuleContext
}
if (workspaceItemsContext) {
result += '\n' + workspaceItemsContext
}
return result
}
@@ -668,7 +688,7 @@ export async function buildSchemaForTool(
// Constants for result formatting
const MAX_RESULT_LENGTH = 12000
const MAX_LOG_LENGTH = 4000
const MAX_RUNNABLE_CONTENT_LENGTH = 20000
export const MAX_RUNNABLE_CONTENT_LENGTH = 20000
export interface TestRunConfig {
jobStarter: () => Promise<string>
@@ -906,6 +926,9 @@ export class WorkspaceRunnablesSearch {
private flowsWorkspace: string | undefined = undefined
private scripts: Script[] | undefined = undefined
private flows: Flow[] | undefined = undefined
private scriptCache: Map<string, Awaited<ReturnType<typeof ScriptService.getScriptByPath>>> =
new Map()
private flowCache: Map<string, Awaited<ReturnType<typeof FlowService.getFlowByPath>>> = new Map()
constructor() {
this.uf = new uFuzzy()
@@ -930,10 +953,19 @@ export class WorkspaceRunnablesSearch {
const scripts = this.scripts
if (!scripts) return []
const trimmed = query.trim()
if (!trimmed) {
return scripts.map((s) => ({
type: 'script' as const,
path: s.path,
summary: s.summary
}))
}
const haystack = scripts.map((s) =>
emptyString(s.summary) ? s.path : s.summary + ' (' + s.path + ')'
)
const [idxs, , order] = this.uf.search(haystack, query.trim())
const [idxs, , order] = this.uf.search(haystack, trimmed)
if (!idxs || !order) return []
return order.map((orderIdx) => {
const haystackIdx = idxs[orderIdx]
@@ -950,10 +982,19 @@ export class WorkspaceRunnablesSearch {
const flows = this.flows
if (!flows) return []
const trimmed = query.trim()
if (!trimmed) {
return flows.map((f) => ({
type: 'flow' as const,
path: f.path,
summary: f.summary
}))
}
const haystack = flows.map((f) =>
emptyString(f.summary) ? f.path : f.summary + ' (' + f.path + ')'
)
const [idxs, , order] = this.uf.search(haystack, query.trim())
const [idxs, , order] = this.uf.search(haystack, trimmed)
if (!idxs || !order) return []
return order.map((orderIdx) => {
const haystackIdx = idxs[orderIdx]
@@ -977,6 +1018,26 @@ export class WorkspaceRunnablesSearch {
return results
}
async getScript(path: string, workspace: string) {
const key = `${workspace}:${path}`
let cached = this.scriptCache.get(key)
if (!cached) {
cached = await ScriptService.getScriptByPath({ workspace, path })
this.scriptCache.set(key, cached)
}
return cached
}
async getFlow(path: string, workspace: string) {
const key = `${workspace}:${path}`
let cached = this.flowCache.get(key)
if (!cached) {
cached = await FlowService.getFlowByPath({ workspace, path })
this.flowCache.set(key, cached)
}
return cached
}
}
const searchWorkspaceSchema = z.object({
@@ -996,7 +1057,7 @@ const searchWorkspaceToolDef = createToolDef(
'Search for scripts and flows in the workspace. Use this when a user asks about existing building blocks, wants to find a script/flow, or asks "what do I have for X". ALWAYS search really broadly.'
)
const workspaceRunnablesSearch = new WorkspaceRunnablesSearch()
export const workspaceRunnablesSearch = new WorkspaceRunnablesSearch()
export const createSearchWorkspaceTool = () => ({
def: searchWorkspaceToolDef,
@@ -1069,7 +1130,7 @@ export const createGetRunnableDetailsTool = () => ({
try {
if (type === 'script') {
const script = await ScriptService.getScriptByPath({ workspace, path })
const script = await workspaceRunnablesSearch.getScript(path, workspace)
toolCallbacks.setToolStatus(toolId, {
content: `Retrieved script details for "${path}"`
})
@@ -1091,7 +1152,7 @@ export const createGetRunnableDetailsTool = () => ({
2
)
} else {
const flow = await FlowService.getFlowByPath({ workspace, path })
const flow = await workspaceRunnablesSearch.getFlow(path, workspace)
toolCallbacks.setToolStatus(toolId, {
content: `Retrieved flow details for "${path}"`
})