mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 08:01:35 +00:00
feat(ai): session chat nits — empty sends, command picker polish, session-state prompt (#10233)
* feat(ai): session chat nits: empty sends, picker polish, session state * fix(ai): scope empty-send turns to global chat and pin new behavior * fix(ai): align grouped search nav with display order, enable empty-send button * fix(ai): fork fallback for unlisted workspaces, section headers across branches * style(ai): hint-colored 3xs picker section headers, drop inline row descriptions * style(ai): more spacing between picker sections * fix(ai): require context elements for empty global-chat sends * style(ai): no empty bubble for text-free messages * fix(ai): review round 2 — keyboard tooltip access, requestedMode guard, no display names in prompt * fix(ai): queue context-only drafts pressed while a response streams * fix(ai): shared context identity for queued badges and full queue-context union
This commit is contained in:
@@ -16,6 +16,10 @@ leaves and ignores the current scope.
|
||||
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import Portal from '$lib/components/Portal.svelte'
|
||||
import { zIndexes } from '$lib/zIndexes'
|
||||
import { createFloatingActions } from 'svelte-floating-ui'
|
||||
import { flip, offset, shift } from 'svelte-floating-ui/dom'
|
||||
import { generateRandomString } from '$lib/utils'
|
||||
import { onMount, untrack, type Snippet } from 'svelte'
|
||||
import {
|
||||
@@ -65,6 +69,9 @@ leaves and ignores the current scope.
|
||||
* branch to carry a `loading` flag. Needed by flat workspace layouts
|
||||
* whose root entries only exist once items are fetched. */
|
||||
rootLoading?: boolean
|
||||
/** Full-text hover tooltip for a leaf row (rows truncate their text).
|
||||
* Return `undefined` to show none for that leaf. */
|
||||
rowTooltip?: (leaf: DrillLeaf<L>) => string | undefined
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -80,7 +87,8 @@ leaves and ignores the current scope.
|
||||
leafSecondary,
|
||||
onScopeChange,
|
||||
onFilterChange,
|
||||
rootLoading = false
|
||||
rootLoading = false,
|
||||
rowTooltip
|
||||
}: Props = $props()
|
||||
|
||||
let searchInput: TextInput | undefined = $state()
|
||||
@@ -116,6 +124,9 @@ leaves and ignores the current scope.
|
||||
$effect(() => {
|
||||
void filter
|
||||
onFilterChange?.(filter)
|
||||
// Rows re-render under a stationary pointer without firing mouseleave,
|
||||
// which would strand the tooltip on a removed row.
|
||||
tooltipLeave()
|
||||
})
|
||||
|
||||
/** Tracks whether the last user action was mouse movement (true) or
|
||||
@@ -141,18 +152,24 @@ leaves and ignores the current scope.
|
||||
)
|
||||
let searchedItems: (SearchEntry & { marked: string })[] | undefined = $state(undefined)
|
||||
|
||||
// Group filtered results by their nearest-branch ancestor for display.
|
||||
// Group filtered results for display: nearest `searchGroup` branch ancestor
|
||||
// first, the leaf's own `section` otherwise.
|
||||
const searchResultsByGroup = $derived.by(() => {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ group: DrillBranch<L> | null; items: (SearchEntry & { marked: string })[] }
|
||||
{ key: string; label: string | null; items: (SearchEntry & { marked: string })[] }
|
||||
>()
|
||||
if (!searchedItems) return [] as { group: DrillBranch<L> | null; items: SearchEntry[] }[]
|
||||
if (!searchedItems) return [] as { key: string; label: string | null; items: SearchEntry[] }[]
|
||||
for (const r of searchedItems) {
|
||||
const gkey = r.group?.key ?? '__none'
|
||||
const gkey = r.group?.key ?? (r.leaf.section ? `__section:${r.leaf.section}` : '__none')
|
||||
const existing = groups.get(gkey)
|
||||
if (existing) existing.items.push(r)
|
||||
else groups.set(gkey, { group: r.group, items: [r] })
|
||||
else
|
||||
groups.set(gkey, {
|
||||
key: gkey,
|
||||
label: r.group?.label ?? r.leaf.section ?? null,
|
||||
items: [r]
|
||||
})
|
||||
}
|
||||
return Array.from(groups.values())
|
||||
})
|
||||
@@ -169,9 +186,26 @@ leaves and ignores the current scope.
|
||||
)
|
||||
)
|
||||
|
||||
// Browse rows annotated with the section header rendered above them. A
|
||||
// header is emitted when a leaf's section differs from the previous LEAF's
|
||||
// section — comparing against the previous entry of any type would insert a
|
||||
// duplicate header after every branch interleaved between same-section leaves.
|
||||
const entryRows = $derived.by(() => {
|
||||
let lastLeafSection: string | undefined
|
||||
return entryList.map((entry) => {
|
||||
const section = entry.type === 'leaf' ? entry.node.section : undefined
|
||||
const header = entry.type === 'leaf' && section !== lastLeafSection ? section : undefined
|
||||
if (entry.type === 'leaf') lastLeafSection = section
|
||||
return { entry, header }
|
||||
})
|
||||
})
|
||||
|
||||
// Search nav must follow DISPLAY order (the grouped blocks), not the raw
|
||||
// fuzzy ranking — grouping reorders interleaved results, and rank-ordered
|
||||
// keys would make ArrowUp/Down jump between non-adjacent visible rows.
|
||||
const navKeys = $derived(
|
||||
isSearching
|
||||
? (searchedItems ?? ([] as typeof searchItems)).map((r) => r.leaf.key)
|
||||
? searchResultsByGroup.flatMap((g) => g.items.map((r) => r.leaf.key))
|
||||
: entryList.map((e) => e.key)
|
||||
)
|
||||
|
||||
@@ -210,13 +244,30 @@ leaves and ignores the current scope.
|
||||
el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function leafForKey(key: string): DrillLeaf<L> | undefined {
|
||||
if (isSearching) return (searchedItems ?? []).find((r) => r.leaf.key === key)?.leaf
|
||||
const entry = entryList.find((e) => e.key === key)
|
||||
return entry?.type === 'leaf' ? entry.node : undefined
|
||||
}
|
||||
|
||||
function moveHighlight(delta: 1 | -1) {
|
||||
if (navKeys.length === 0) return
|
||||
const cur = navKeys.indexOf(highlightedKey ?? '')
|
||||
const next = cur < 0 ? 0 : (cur + delta + navKeys.length) % navKeys.length
|
||||
highlightedKey = navKeys[next]
|
||||
mouseActive = false
|
||||
requestAnimationFrame(scrollHighlightIntoView)
|
||||
requestAnimationFrame(() => {
|
||||
scrollHighlightIntoView()
|
||||
// Keyboard parity for the tooltip: anchor it to the row the highlight
|
||||
// landed on — otherwise the full descriptions are pointer-only.
|
||||
if (!rowTooltip || !highlightedKey) return
|
||||
const leaf = leafForKey(highlightedKey)
|
||||
const el = pickerRoot?.querySelector<HTMLElement>(
|
||||
`[data-nav-key="${CSS.escape(highlightedKey)}"]`
|
||||
)
|
||||
if (leaf && el) tooltipEnter(el, leaf)
|
||||
else tooltipLeave()
|
||||
})
|
||||
}
|
||||
|
||||
function setHoverHighlight(key: string) {
|
||||
@@ -231,6 +282,40 @@ leaves and ignores the current scope.
|
||||
onPick(leaf)
|
||||
}
|
||||
|
||||
// Single shared hover tooltip (opt-in via `rowTooltip`): one floating node
|
||||
// re-anchored to the hovered row, instead of a popover per row — rows can
|
||||
// number in the hundreds. Portaled to body because the picker often lives
|
||||
// inside an overflow-clipped floating container that would cut it off.
|
||||
let tooltipText = $state<string | undefined>(undefined)
|
||||
let tooltipTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const [tooltipRef, tooltipContent] = createFloatingActions({
|
||||
strategy: 'fixed',
|
||||
placement: 'right-start',
|
||||
middleware: [offset(8), flip(), shift({ padding: 8 })],
|
||||
autoUpdate: true
|
||||
})
|
||||
|
||||
function tooltipEnter(el: HTMLElement, leaf: DrillLeaf<L>) {
|
||||
if (!rowTooltip) return
|
||||
clearTimeout(tooltipTimer)
|
||||
const text = rowTooltip(leaf)
|
||||
if (!text) {
|
||||
tooltipText = undefined
|
||||
return
|
||||
}
|
||||
tooltipTimer = setTimeout(() => {
|
||||
tooltipRef(el)
|
||||
tooltipText = text
|
||||
}, 250)
|
||||
}
|
||||
|
||||
function tooltipLeave() {
|
||||
clearTimeout(tooltipTimer)
|
||||
tooltipText = undefined
|
||||
}
|
||||
|
||||
$effect(() => () => clearTimeout(tooltipTimer))
|
||||
|
||||
function activate(key: string | undefined) {
|
||||
if (!key) return
|
||||
if (isSearching) {
|
||||
@@ -390,6 +475,7 @@ leaves and ignores the current scope.
|
||||
{@const key = leaf.key}
|
||||
{@const isHl = key === highlightedKey}
|
||||
{@const isCur = !!leaf.current}
|
||||
{@const tip = rowTooltip?.(leaf)}
|
||||
<button
|
||||
type="button"
|
||||
id={idFor(key)}
|
||||
@@ -397,6 +483,7 @@ leaves and ignores the current scope.
|
||||
aria-selected={isHl}
|
||||
data-nav-key={key}
|
||||
aria-current={isCur ? 'true' : undefined}
|
||||
aria-label={tip ? `${leaf.label} — ${tip}` : undefined}
|
||||
class="w-full text-left flex items-center gap-2 px-3 transition-colors {baseClass} {isHl
|
||||
? 'bg-surface-hover'
|
||||
: ''} {isCur ? 'cursor-default text-emphasis font-medium' : ''} {leaf.disabled
|
||||
@@ -405,7 +492,11 @@ leaves and ignores the current scope.
|
||||
disabled={leaf.disabled}
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
onclick={() => pick(leaf)}
|
||||
onmouseenter={() => setHoverHighlight(key)}
|
||||
onmouseenter={(e) => {
|
||||
setHoverHighlight(key)
|
||||
tooltipEnter(e.currentTarget as HTMLElement, leaf)
|
||||
}}
|
||||
onmouseleave={tooltipLeave}
|
||||
>
|
||||
{@render defaultLeafIcon(leaf)}
|
||||
<div class="min-w-0 flex-1">
|
||||
@@ -479,10 +570,12 @@ leaves and ignores the current scope.
|
||||
{:else if total === 0}
|
||||
<div role="status" class="px-3 py-2 text-xs text-tertiary">No matches</div>
|
||||
{:else}
|
||||
{#each searchResultsByGroup as { group, items } (group?.key ?? '__none')}
|
||||
{#if group}
|
||||
<div class="px-3 pt-3 pb-1 text-2xs uppercase tracking-wide text-tertiary font-medium">
|
||||
{group.label}
|
||||
{#each searchResultsByGroup as { key, label, items } (key)}
|
||||
{#if label}
|
||||
<div
|
||||
class="px-3 pt-3 pb-1 mt-2 first:mt-0 text-3xs uppercase tracking-wide text-hint font-medium"
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
{/if}
|
||||
<ul class="pb-1">
|
||||
@@ -500,8 +593,15 @@ leaves and ignores the current scope.
|
||||
<div role="status" class="px-3 py-2 text-xs text-tertiary">Empty</div>
|
||||
{:else}
|
||||
<div class="flex flex-col py-1">
|
||||
{#each entryList as entry (entry.key)}
|
||||
{#each entryRows as { entry, header } (entry.key)}
|
||||
{@const isHl = entry.key === highlightedKey}
|
||||
{#if header}
|
||||
<div
|
||||
class="px-3 pt-2 pb-1 mt-2 first:mt-0 text-3xs uppercase tracking-wide text-hint font-medium"
|
||||
>
|
||||
{header}
|
||||
</div>
|
||||
{/if}
|
||||
{#if entry.type === 'leaf'}
|
||||
{@render leafRow(
|
||||
entry.node,
|
||||
@@ -544,6 +644,19 @@ leaves and ignores the current scope.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if tooltipText}
|
||||
<Portal target="body">
|
||||
<div
|
||||
use:tooltipContent
|
||||
class="max-w-72 rounded-md border bg-surface px-2.5 py-1.5 text-xs text-primary shadow-md whitespace-pre-wrap break-words"
|
||||
style="z-index: {zIndexes.tooltip};"
|
||||
role="tooltip"
|
||||
>
|
||||
{tooltipText}
|
||||
</div>
|
||||
</Portal>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Path truncates from the start (left ellipsis) so the deepest
|
||||
* (rightmost) folder stays visible. `unicode-bidi: plaintext` keeps
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
import ContextTextarea from './ContextTextarea.svelte'
|
||||
import AttachedFilesBar from './files/AttachedFilesBar.svelte'
|
||||
import autosize from '$lib/autosize'
|
||||
import type { AppDomSelectorElement, ContextElement } from './context'
|
||||
import {
|
||||
contextElementKey,
|
||||
isSameContextElement,
|
||||
type AppDomSelectorElement,
|
||||
type ContextElement
|
||||
} from './context'
|
||||
import { AIMode } from './AIChatManager.svelte'
|
||||
import { CHAT_INPUT_PADDING, getAiChatManager } from './aiChatManagerContext'
|
||||
import { formatMention } from './mention'
|
||||
@@ -235,20 +240,7 @@
|
||||
(selectedContext ?? []).filter((c): c is AppDomSelectorElement => c.type === 'app_dom_selector')
|
||||
)
|
||||
|
||||
// DOM selector chips can share a display title (two `button.btn` from repeated
|
||||
// elements), so identify them by (appPath, selector) — keying or removing by
|
||||
// title would collide, giving repeated chips one Svelte key and deleting them
|
||||
// together. Other context types stay identified by (type, title).
|
||||
function contextKey(c: ContextElement): string {
|
||||
return c.type === 'app_dom_selector' ? `dom:${c.appPath}:${c.selector}` : `${c.type}:${c.title}`
|
||||
}
|
||||
function isSameContextElement(a: ContextElement, b: ContextElement): boolean {
|
||||
if (a.type !== b.type) return false
|
||||
if (a.type === 'app_dom_selector' && b.type === 'app_dom_selector') {
|
||||
return a.selector === b.selector && a.appPath === b.appPath
|
||||
}
|
||||
return a.title === b.title
|
||||
}
|
||||
const contextKey = contextElementKey
|
||||
|
||||
/** Append `@title` to the textarea so the button-picker path stays in
|
||||
* sync with the inline `@<word>` mention path — both leave a visible
|
||||
@@ -446,9 +438,19 @@
|
||||
// auto-sent when the streaming turn completes successfully.
|
||||
// Editing-while-loading keeps the old discard behavior. Paste
|
||||
// tokens are expanded into the queued text (the queue is plain
|
||||
// strings), so the full content survives the auto-send.
|
||||
if (editingMessageIndex === null && (instructions.trim() || images.length > 0)) {
|
||||
aiChatManager.queueMessage(expanded(chatDraft(instructions, pastes)), images)
|
||||
// strings), so the full content survives the auto-send. A GLOBAL
|
||||
// context-only draft counts too (mirrors the idle send guard), and
|
||||
// the selection is pinned to the queued entry so the flush sends the
|
||||
// chips picked at press time.
|
||||
if (
|
||||
editingMessageIndex === null &&
|
||||
(instructions.trim() ||
|
||||
images.length > 0 ||
|
||||
(aiChatManager.mode === AIMode.GLOBAL && selectedContext.length > 0))
|
||||
) {
|
||||
aiChatManager.queueMessage(expanded(chatDraft(instructions, pastes)), images, [
|
||||
...selectedContext
|
||||
])
|
||||
contextTextareaComponent?.clearForSend()
|
||||
instructions = ''
|
||||
pastes = []
|
||||
@@ -694,8 +696,18 @@
|
||||
|
||||
{#snippet sendStopButton()}
|
||||
{@const isLoading = loading ?? aiChatManager.loading}
|
||||
{@const emptyDraft = instructions.trim().length === 0 && images.length === 0}
|
||||
<!-- A text-free GLOBAL draft with context chips is a valid turn (Enter
|
||||
already sends it), so the button stays enabled there for pointer/touch
|
||||
parity — mirrors the sendRequest guard. Custom onSendRequest consumers
|
||||
(inline ⌘K) and editor copilots need content. -->
|
||||
{@const sendDisabled =
|
||||
disabled || (instructions.trim().length === 0 && images.length === 0) || pendingImages > 0}
|
||||
disabled ||
|
||||
pendingImages > 0 ||
|
||||
(emptyDraft &&
|
||||
(onSendRequest !== undefined ||
|
||||
aiChatManager.mode !== AIMode.GLOBAL ||
|
||||
selectedContext.length === 0))}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="md"
|
||||
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
createAppBackendRunnableContextElement,
|
||||
createAppFrontendFileContextElement,
|
||||
flattenDatatablesToAppContextElements,
|
||||
isSameContextElement,
|
||||
type ContextElement,
|
||||
type AppDatatableElement
|
||||
} from './context'
|
||||
@@ -107,6 +108,9 @@ import {
|
||||
prepareGlobalSystemMessage,
|
||||
prepareGlobalUserMessage,
|
||||
type AiSkillListItem,
|
||||
type ChatCommandItem,
|
||||
type SessionPromptContext,
|
||||
getSessionContextPromptSection,
|
||||
type GlobalToolHelpers
|
||||
} from './global/core'
|
||||
import { formatChatJobCompletion } from './datatableTools'
|
||||
@@ -512,6 +516,11 @@ export class AIChatManager {
|
||||
// tool `helpers` in GLOBAL mode so the preview/deploy tools dispatch to THIS
|
||||
// session rather than the UI-active one — keeps backgrounded sessions isolated.
|
||||
sessionId: string | undefined = undefined
|
||||
// Live session facts (fork vs live workspace) for the GLOBAL system prompt.
|
||||
// A resolver set by the session runtime — copilot must not import the
|
||||
// sessions modules — and re-read on every system-message rebuild; the send
|
||||
// path rebuilds after beforeSend, so a fork committed there is picked up.
|
||||
sessionContextResolver: (() => SessionPromptContext | undefined) | undefined = undefined
|
||||
// Resolves the workspace this chat operates on. Session chats set it to their
|
||||
// own (possibly forked) workspace so the chat targets it WITHOUT switching the
|
||||
// global workspaceStore. Undefined for the global side-panel chat, which
|
||||
@@ -944,18 +953,28 @@ export class AIChatManager {
|
||||
// alongside workspace skills. Unlike a skill, these run locally and never
|
||||
// reach the model; the submit path intercepts them first, so they shadow any
|
||||
// workspace skill of the same name.
|
||||
readonly sessionBuiltinCommands: AiSkillListItem[] = [
|
||||
{ name: COMPACT_COMMAND_NAME, description: 'Summarize the conversation to free up context' },
|
||||
{ name: CLEAR_COMMAND_NAME, description: 'Clear the conversation and start a new chat' }
|
||||
readonly sessionBuiltinCommands: ChatCommandItem[] = [
|
||||
{
|
||||
name: COMPACT_COMMAND_NAME,
|
||||
description: 'Summarize the conversation to free up context',
|
||||
kind: 'action'
|
||||
},
|
||||
{
|
||||
name: CLEAR_COMMAND_NAME,
|
||||
description: 'Clear the conversation and start a new chat',
|
||||
kind: 'action'
|
||||
}
|
||||
]
|
||||
|
||||
// Built-ins followed by workspace skills, with any skill whose name collides
|
||||
// with a built-in dropped: the picker keys leaves by name, so a duplicate
|
||||
// would break its keyed list and ambiguous-resolve nav. Built-ins win — they
|
||||
// already shadow same-named skills at execution (the submit interception).
|
||||
sessionCommands: AiSkillListItem[] = $derived([
|
||||
sessionCommands: ChatCommandItem[] = $derived([
|
||||
...this.sessionBuiltinCommands,
|
||||
...this.globalSkills.filter((s) => !this.sessionBuiltinCommands.some((b) => b.name === s.name))
|
||||
...this.globalSkills
|
||||
.filter((s) => !this.sessionBuiltinCommands.some((b) => b.name === s.name))
|
||||
.map((s) => ({ ...s, kind: 'skill' as const }))
|
||||
])
|
||||
|
||||
allowedModes: Record<AIMode, boolean> = $derived({
|
||||
@@ -1445,8 +1464,9 @@ export class AIChatManager {
|
||||
* alongside it. */
|
||||
queueMessage(text: string, images: AttachedImage[] = [], context?: ContextElement[]) {
|
||||
const trimmed = text.trim()
|
||||
// An image with no text is still a message; only a fully empty send is ignored.
|
||||
if (!trimmed && images.length === 0) {
|
||||
// An image-only or context-only draft is still a message; only a fully
|
||||
// empty send is ignored (mirrors the idle empty-send guard).
|
||||
if (!trimmed && images.length === 0 && (context?.length ?? 0) === 0) {
|
||||
return
|
||||
}
|
||||
if (trimmed) {
|
||||
@@ -1459,36 +1479,29 @@ export class AIChatManager {
|
||||
}
|
||||
this.queuedImages = merged.slice(0, MAX_ATTACHED_IMAGES)
|
||||
}
|
||||
// Pin the context snapshot to the queued message. The queued text accumulates
|
||||
// (several inline prompts can queue during one stream), so union the DOM
|
||||
// selector chips too — replacing would drop an earlier prompt's element and
|
||||
// misapply its instruction. Non-DOM context comes from the latest snapshot.
|
||||
// Pin the context snapshot to the queued message. Several prompts can
|
||||
// queue during one stream and each pinned the selection at its press —
|
||||
// union by identity so a later press doesn't drop an earlier prompt's
|
||||
// chips (all pinned entries ride the single flushed turn together).
|
||||
if (context && context.length > 0) {
|
||||
if (!this.queuedContext) {
|
||||
this.queuedContext = context
|
||||
} else {
|
||||
const merged = [...context]
|
||||
for (const c of this.queuedContext) {
|
||||
if (
|
||||
c.type === 'app_dom_selector' &&
|
||||
!merged.some(
|
||||
(m) =>
|
||||
m.type === 'app_dom_selector' &&
|
||||
m.selector === c.selector &&
|
||||
m.appPath === c.appPath
|
||||
)
|
||||
) {
|
||||
merged.push(c)
|
||||
}
|
||||
const merged = [...(this.queuedContext ?? [])]
|
||||
for (const c of context) {
|
||||
if (!merged.some((m) => isSameContextElement(m, c))) {
|
||||
merged.push(c)
|
||||
}
|
||||
this.queuedContext = merged
|
||||
}
|
||||
this.queuedContext = merged
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether anything is waiting in the queue — an image-only message has empty text. */
|
||||
/** Whether anything is waiting in the queue — an image-only or context-only
|
||||
* message has empty text. */
|
||||
#hasQueuedMessage(): boolean {
|
||||
return this.queuedMessage !== '' || this.queuedImages.length > 0
|
||||
return (
|
||||
this.queuedMessage !== '' ||
|
||||
this.queuedImages.length > 0 ||
|
||||
(this.queuedContext?.length ?? 0) > 0
|
||||
)
|
||||
}
|
||||
|
||||
/** Detach the queue for sending. Text, images and context always leave together. */
|
||||
@@ -1707,6 +1720,10 @@ export class AIChatManager {
|
||||
previewTools: this.isSessionChat,
|
||||
skills: this.globalSkills
|
||||
})
|
||||
const sessionCtx = this.sessionContextResolver?.()
|
||||
if (sessionCtx) {
|
||||
systemMessage.content += getSessionContextPromptSection(sessionCtx)
|
||||
}
|
||||
const baseHelpers: GlobalToolHelpers = {
|
||||
// A session targets its own fixed (possibly forked) workspace, so capture it for
|
||||
// permission gating. The global side-panel chat follows the live navigation
|
||||
@@ -1772,9 +1789,13 @@ export class AIChatManager {
|
||||
previewTools: this.isSessionChat,
|
||||
skills: this.globalSkills
|
||||
})
|
||||
// Preserve the active pipeline-editor augmentation that configureGlobalMode
|
||||
// adds — otherwise update_user_instructions (which calls this) would drop the
|
||||
// /pipeline/<folder> context + direct-draft/materialize guidance mid-session.
|
||||
// Preserve the session-state and active pipeline-editor augmentations that
|
||||
// configureGlobalMode adds — otherwise update_user_instructions (which calls
|
||||
// this) would drop them mid-session.
|
||||
const sessionCtx = this.sessionContextResolver?.()
|
||||
if (sessionCtx) {
|
||||
systemMessage.content += getSessionContextPromptSection(sessionCtx)
|
||||
}
|
||||
const pipeline = this.pipelineAiChatHelpers
|
||||
if (pipeline) {
|
||||
systemMessage.content += getPipelinePromptSection(pipeline.getPipelineContext())
|
||||
@@ -2220,9 +2241,9 @@ export class AIChatManager {
|
||||
) => {
|
||||
// Returns whether the input was consumed: true when it was sent as a chat
|
||||
// turn OR handled as a local built-in command, false when it was dropped
|
||||
// without being acted on (mode hidden, empty, beforeSend failed). The
|
||||
// queue flush restores the queued message only on false, so a consumed
|
||||
// command isn't re-queued and re-fired into the next conversation.
|
||||
// without being acted on (mode hidden, empty non-GLOBAL draft, beforeSend
|
||||
// failed). The queue flush restores the queued message only on false, so a
|
||||
// consumed command isn't re-queued and re-fired into the next conversation.
|
||||
const requestedMode = options.mode ?? this.mode
|
||||
if (!isAIModeVisible(requestedMode)) {
|
||||
return false
|
||||
@@ -2237,12 +2258,20 @@ export class AIChatManager {
|
||||
if (options.instructions !== undefined) {
|
||||
this.instructions = options.instructions
|
||||
}
|
||||
// Only a truly empty draft is dropped here. An image with no text is a
|
||||
// valid GLOBAL-mode message; outside GLOBAL an image-bearing draft must
|
||||
// still get past this guard to reach the refusal below, which puts it
|
||||
// back in the composer instead of silently losing it.
|
||||
// A text-free GLOBAL draft is a real turn — rendered as its context chips
|
||||
// (no bubble), with the empty-message marker substituted further down —
|
||||
// but only when it carries something for the model: images or selected
|
||||
// context elements. A bare accidental Enter is dropped in every mode (in
|
||||
// editor copilots it would burn a turn for nothing). Gate on requestedMode,
|
||||
// not this.mode: changeMode can decline a switch (e.g. SCRIPT with no
|
||||
// model), and a declined non-GLOBAL request must not slip through as a
|
||||
// GLOBAL empty turn. Image-bearing non-GLOBAL drafts still pass through
|
||||
// to the switch-back refusal below so attachments aren't silently lost.
|
||||
if (!this.instructions.trim() && (options.images?.length ?? 0) === 0) {
|
||||
return false
|
||||
const contextEls = options.contextOverride ?? this.contextManager?.getSelectedContext() ?? []
|
||||
if (requestedMode !== AIMode.GLOBAL || contextEls.length === 0) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Built-in session commands run locally instead of becoming a chat turn.
|
||||
// Intercepted here — before the beforeSend workspace commit, file regrants,
|
||||
@@ -2507,7 +2536,15 @@ export class AIChatManager {
|
||||
const sentImages = images
|
||||
// The LLM gets the full pasted content; the display message above keeps
|
||||
// the compact tokens + registry so the bubble can render/expand chips.
|
||||
const oldInstructions = expanded(chatDraft(this.instructions, pastes))
|
||||
// A text-free send (and image-only sends carry their images as the
|
||||
// content) gets an explicit model-facing marker: every mode's template
|
||||
// interpolates the text under an INSTRUCTIONS header, and a dangling
|
||||
// header confuses models into echoing it back verbatim.
|
||||
const expandedInstructions = expanded(chatDraft(this.instructions, pastes))
|
||||
const oldInstructions =
|
||||
expandedInstructions.trim() || sentImages.length > 0
|
||||
? expandedInstructions
|
||||
: '(the user sent an empty message)'
|
||||
// Deliver background-job completions to the model as a preamble on this
|
||||
// turn (notify-only wake). Folded into the model-facing text only — the
|
||||
// display bubble keeps this.instructions, and no extra message is added, so
|
||||
|
||||
@@ -914,11 +914,66 @@ describe('AIChatManager queued messages', () => {
|
||||
expect(hasImage).toBe(true)
|
||||
})
|
||||
|
||||
it('still ignores a send with no text and no images', async () => {
|
||||
// A text-free GLOBAL send carrying context chips is a real turn — the
|
||||
// transcript renders just the chips (no bubble), and the model-facing text
|
||||
// carries an explicit marker instead of a dangling INSTRUCTIONS header the
|
||||
// model would echo back.
|
||||
it('sends a context-only GLOBAL draft as a turn with an empty-message marker', async () => {
|
||||
replyWith('done')
|
||||
const manager = createManager(createInputMock())
|
||||
manager.mode = AIMode.GLOBAL
|
||||
|
||||
await manager.sendRequest({
|
||||
instructions: '',
|
||||
contextOverride: [{ type: 'code', content: 'x', title: 'snippet', lang: 'bun' }]
|
||||
})
|
||||
|
||||
expect(mocks.runChatLoop).toHaveBeenCalled()
|
||||
const sent = mocks.runChatLoop.mock.calls[0][0].messages.at(-1)
|
||||
expect(sent.content).toContain('(the user sent an empty message)')
|
||||
// The stored message keeps what the user typed — nothing — so the
|
||||
// transcript renders chips only, and edit/retry restores an empty draft.
|
||||
expect(manager.displayMessages.find((m) => m.role === 'user')?.content).toBe('')
|
||||
})
|
||||
|
||||
// With nothing riding the draft at all — no text, images, or context — the
|
||||
// send is dropped in every mode; a bare accidental Enter must not burn a turn.
|
||||
it('ignores an empty send with no context in GLOBAL mode', async () => {
|
||||
replyWith('done')
|
||||
const manager = createManager(createInputMock())
|
||||
manager.mode = AIMode.GLOBAL
|
||||
|
||||
await manager.sendRequest({ instructions: '' })
|
||||
|
||||
expect(mocks.runChatLoop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// A context-only draft queued mid-stream must be retained — the queue guard
|
||||
// previously dropped anything with no text and no images, silently eating
|
||||
// the draft the idle path would have sent.
|
||||
it('queues a context-only draft while streaming', () => {
|
||||
const manager = createManager(createInputMock())
|
||||
manager.mode = AIMode.GLOBAL
|
||||
const a = { type: 'code' as const, content: 'x', title: 'snippet', lang: 'bun' as const }
|
||||
const b = { type: 'code' as const, content: 'y', title: 'other', lang: 'bun' as const }
|
||||
|
||||
manager.queueMessage('', [], [a])
|
||||
// A second queued prompt pins its own selection; the union must keep the
|
||||
// earlier prompt's chip and not duplicate re-selected ones.
|
||||
manager.queueMessage('', [], [b, a])
|
||||
|
||||
expect(manager.queuedContext).toEqual([a, b])
|
||||
// A fully empty queue attempt still leaves nothing behind.
|
||||
manager.dequeueMessage()
|
||||
manager.queueMessage('', [], [])
|
||||
expect(manager.queuedContext).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still ignores an empty send outside GLOBAL mode', async () => {
|
||||
replyWith('done')
|
||||
const manager = createManager(createInputMock())
|
||||
manager.mode = AIMode.NAVIGATOR
|
||||
|
||||
await manager.sendRequest({ instructions: '' })
|
||||
|
||||
expect(mocks.runChatLoop).not.toHaveBeenCalled()
|
||||
|
||||
@@ -119,7 +119,9 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if message.content.trim() !== '' || !(message.images && message.images.length > 0)}
|
||||
<!-- Text-free messages show only their context chips / images — no
|
||||
empty bubble (empty sends require chips or images to go out). -->
|
||||
{#if message.content.trim() !== ''}
|
||||
<div
|
||||
class="text-xs px-3 py-2 w-fit max-w-[min(32rem,100%)] bg-surface-accent-selected text-accent rounded-lg relative group break-words"
|
||||
>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Sparkles } from 'lucide-svelte'
|
||||
import DrillPicker from '$lib/components/DrillPicker.svelte'
|
||||
import type { DrillLeaf, DrillNode } from '$lib/components/drillPicker'
|
||||
import type { AiSkillListItem } from './global/core'
|
||||
import type { ChatCommandItem } from './global/core'
|
||||
|
||||
interface Props {
|
||||
skills: AiSkillListItem[]
|
||||
onSelect: (skill: AiSkillListItem) => void
|
||||
skills: ChatCommandItem[]
|
||||
onSelect: (skill: ChatCommandItem) => void
|
||||
setShowing?: (showing: boolean) => void
|
||||
externalFilter?: string
|
||||
autoFocus?: boolean
|
||||
@@ -20,13 +19,21 @@
|
||||
|
||||
let inner = $state<DrillPickerHandle | undefined>(undefined)
|
||||
|
||||
const tree = $derived<DrillNode<AiSkillListItem>[]>(
|
||||
const SECTION_LABELS: Record<NonNullable<ChatCommandItem['kind']>, string> = {
|
||||
action: 'Actions',
|
||||
skill: 'Skills'
|
||||
}
|
||||
|
||||
// No `secondary`: rows show just the command; the full description lives in
|
||||
// the hover tooltip (rowTooltip below). It stays in `searchableText` so
|
||||
// filtering by description keeps working.
|
||||
const tree = $derived<DrillNode<ChatCommandItem>[]>(
|
||||
skills.map((skill) => ({
|
||||
type: 'leaf' as const,
|
||||
key: `skill:${skill.name}`,
|
||||
label: `/${skill.name}`,
|
||||
secondary: skill.description,
|
||||
searchableText: `${skill.name} ${skill.description}`,
|
||||
section: skill.kind ? SECTION_LABELS[skill.kind] : undefined,
|
||||
data: skill
|
||||
}))
|
||||
)
|
||||
@@ -35,7 +42,7 @@
|
||||
inner?.handleKeydown(e)
|
||||
}
|
||||
|
||||
function handlePick(leaf: DrillLeaf<AiSkillListItem>) {
|
||||
function handlePick(leaf: DrillLeaf<ChatCommandItem>) {
|
||||
onSelect(leaf.data)
|
||||
}
|
||||
|
||||
@@ -51,18 +58,17 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
{#snippet skillIcon(_leaf: DrillLeaf<AiSkillListItem>)}
|
||||
<Sparkles size={12} class="shrink-0 text-tertiary" />
|
||||
{/snippet}
|
||||
|
||||
<div class="w-[min(340px,calc(100vw-20px))] max-h-64 overflow-hidden">
|
||||
<!-- This wrapper is the scroll container: the flush DrillPicker sizes to its
|
||||
content (h-full can't resolve against a max-h-only parent), so overflow
|
||||
must scroll here or the list is just clipped at 16rem. -->
|
||||
<div class="w-[min(340px,calc(100vw-20px))] max-h-64 overflow-y-auto">
|
||||
<DrillPicker
|
||||
bind:this={inner}
|
||||
{tree}
|
||||
onPick={handlePick}
|
||||
{externalFilter}
|
||||
{autoFocus}
|
||||
leafIcon={skillIcon}
|
||||
rowTooltip={(leaf) => leaf.data.description}
|
||||
flush
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import { X } from 'lucide-svelte'
|
||||
import ContextElementBadge from './ContextElementBadge.svelte'
|
||||
import { contextElementKey } from './context'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
|
||||
// The single message typed while a turn was streaming, waiting to be
|
||||
@@ -11,9 +13,12 @@
|
||||
const aiChatManager = getAiChatManager()
|
||||
</script>
|
||||
|
||||
<!-- Image-only queues have empty text; without the image row the queued draft
|
||||
would be invisible — undismissable, then auto-sent as a surprise turn. -->
|
||||
{#if aiChatManager.queuedMessage || aiChatManager.queuedImages.length > 0}
|
||||
<!-- Image-only and context-only queues have empty text; without their image /
|
||||
badge row the queued draft would be invisible — undismissable, then
|
||||
auto-sent as a surprise turn. Badges render here only for context-ONLY
|
||||
queues: text queues pin the same chips, but those stay visible in the
|
||||
composer, and repeating them would read as two selections. -->
|
||||
{#if aiChatManager.queuedMessage || aiChatManager.queuedImages.length > 0 || (aiChatManager.queuedContext?.length ?? 0) > 0}
|
||||
<div
|
||||
class="mb-1 flex flex-row items-start gap-1 rounded-md bg-surface-input px-3 py-2 opacity-60"
|
||||
title={aiChatManager.queuedMessage}
|
||||
@@ -34,6 +39,12 @@
|
||||
<p class="text-xs text-secondary whitespace-pre-wrap line-clamp-2">
|
||||
{aiChatManager.queuedMessage}
|
||||
</p>
|
||||
{:else if aiChatManager.queuedImages.length === 0 && aiChatManager.queuedContext?.length}
|
||||
<div class="flex flex-row flex-wrap gap-1">
|
||||
{#each aiChatManager.queuedContext as element (contextElementKey(element))}
|
||||
<ContextElementBadge contextElement={element} compact />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -302,3 +302,19 @@ export type ContextElement = (
|
||||
) & {
|
||||
deletable?: boolean
|
||||
}
|
||||
|
||||
// DOM selector chips can share a display title (two `button.btn` from repeated
|
||||
// elements), so identify them by (appPath, selector) — keying or removing by
|
||||
// title would collide, giving repeated chips one Svelte key and deleting them
|
||||
// together. Other context types stay identified by (type, title).
|
||||
export function contextElementKey(c: ContextElement): string {
|
||||
return c.type === 'app_dom_selector' ? `dom:${c.appPath}:${c.selector}` : `${c.type}:${c.title}`
|
||||
}
|
||||
|
||||
export function isSameContextElement(a: ContextElement, b: ContextElement): boolean {
|
||||
if (a.type !== b.type) return false
|
||||
if (a.type === 'app_dom_selector' && b.type === 'app_dom_selector') {
|
||||
return a.selector === b.selector && a.appPath === b.appPath
|
||||
}
|
||||
return a.title === b.title
|
||||
}
|
||||
|
||||
@@ -257,6 +257,7 @@ import {
|
||||
globalTools,
|
||||
globalToolsFor,
|
||||
prepareGlobalSystemMessage,
|
||||
getSessionContextPromptSection,
|
||||
prepareGlobalUserMessage,
|
||||
setDeployedInSessionHandler,
|
||||
setGetPreviewStatusHandler,
|
||||
@@ -3572,6 +3573,46 @@ describe('session pipeline gate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSessionContextPromptSection', () => {
|
||||
it('describes an ephemeral staged fork with its parent and deploy semantics', () => {
|
||||
const s = getSessionContextPromptSection({
|
||||
workspaceId: 'wm-fork-foo',
|
||||
parentWorkspaceId: 'prod'
|
||||
})
|
||||
expect(s).toContain('STAGED FORK of workspace "prod"')
|
||||
expect(s).toContain('Never present a change as live in "prod"')
|
||||
})
|
||||
|
||||
it('distinguishes a persistent dev workspace from a staged fork', () => {
|
||||
const s = getSessionContextPromptSection({
|
||||
workspaceId: 'guilhem',
|
||||
parentWorkspaceId: 'prod',
|
||||
isDevWorkspace: true
|
||||
})
|
||||
expect(s).toContain('persistent DEV WORKSPACE')
|
||||
expect(s).not.toContain('STAGED FORK')
|
||||
})
|
||||
|
||||
it('marks a parentless workspace as the live workspace', () => {
|
||||
const s = getSessionContextPromptSection({ workspaceId: 'prod' })
|
||||
expect(s).toContain('the live workspace itself')
|
||||
})
|
||||
|
||||
it('announces a pending fork before the first send commits it', () => {
|
||||
const s = getSessionContextPromptSection({ workspaceId: 'prod', pendingForkOf: 'prod' })
|
||||
expect(s).toContain('staged fork of workspace "prod" is created automatically')
|
||||
})
|
||||
|
||||
it('never calls a committed-but-unlisted workspace the live workspace', () => {
|
||||
const s = getSessionContextPromptSection({
|
||||
workspaceId: 'wm-fork-gone',
|
||||
forkParentUnknown: true
|
||||
})
|
||||
expect(s).toContain('parent workspace is not currently visible')
|
||||
expect(s).not.toContain('the live workspace itself')
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareGlobalSystemMessage', () => {
|
||||
it('keeps global chat draft instructions concise and user-facing', () => {
|
||||
const message = prepareGlobalSystemMessage()
|
||||
|
||||
@@ -1862,6 +1862,71 @@ function getInstructions(subject: InstructionSubject, language?: ScriptLang): st
|
||||
|
||||
export type AiSkillListItem = { name: string; description: string }
|
||||
|
||||
/** Live session facts appended to the GLOBAL system prompt for session chats.
|
||||
* Provided by the session runtime as a resolver (copilot must not import the
|
||||
* sessions modules) and re-read on every system-message rebuild — the fork
|
||||
* commits at first send, and the user can re-point the session's workspace. */
|
||||
export type SessionPromptContext = {
|
||||
/** Operating workspace (undefined while the session is an unsent draft with
|
||||
* no pick). Only slug-validated workspace IDs belong here — free-form
|
||||
* metadata like display names is user-controlled text that must not be
|
||||
* interpolated into the system prompt. */
|
||||
workspaceId?: string
|
||||
/** Set when the operating workspace is a fork of this workspace (staged
|
||||
* session fork or persistent dev workspace — `isDevWorkspace` splits them). */
|
||||
parentWorkspaceId?: string
|
||||
/** The operating workspace is a persistent dev workspace, not an ephemeral
|
||||
* staged fork. Same promote-to-parent deploy flow; different lifecycle. */
|
||||
isDevWorkspace?: boolean
|
||||
/** Committed workspace missing from the user's workspace list (access lost /
|
||||
* stale store): still a fork per `isForkSession`, but the parent is unknown —
|
||||
* must not be presented as the live workspace. */
|
||||
forkParentUnknown?: boolean
|
||||
/** Pre-send intent: a staged fork of this workspace is created at first send. */
|
||||
pendingForkOf?: string
|
||||
}
|
||||
|
||||
/** Session-state guidance appended to the global system prompt so the model
|
||||
* knows where its work lands (staged fork vs the live workspace). */
|
||||
export function getSessionContextPromptSection(ctx: SessionPromptContext): string {
|
||||
const lines = [
|
||||
'',
|
||||
'',
|
||||
'Session state:',
|
||||
'- This chat is a Windmill AI session with its own operating workspace: every tool call (reads, drafts, test runs, deploys) targets that workspace.'
|
||||
]
|
||||
if (ctx.pendingForkOf) {
|
||||
lines.push(
|
||||
`- No workspace is committed yet: a staged fork of workspace "${ctx.pendingForkOf}" is created automatically when the first message is sent, and all work lands in that fork.`
|
||||
)
|
||||
} else if (ctx.parentWorkspaceId && ctx.isDevWorkspace) {
|
||||
lines.push(
|
||||
`- Operating workspace: "${ctx.workspaceId}" — the user's persistent DEV WORKSPACE, forked from workspace "${ctx.parentWorkspaceId}". deploy_workspace_item publishes into the dev workspace only; the user reviews & promotes changes into "${ctx.parentWorkspaceId}" from the session's deploy panel. Never present a change as live in "${ctx.parentWorkspaceId}".`
|
||||
)
|
||||
} else if (ctx.parentWorkspaceId) {
|
||||
lines.push(
|
||||
`- Operating workspace: "${ctx.workspaceId}" — an ephemeral STAGED FORK of workspace "${ctx.parentWorkspaceId}", created for session work. deploy_workspace_item publishes into the fork only, and the user reviews & promotes fork changes into "${ctx.parentWorkspaceId}" from the session's deploy panel. Never present a change as live in "${ctx.parentWorkspaceId}".`
|
||||
)
|
||||
} else if (ctx.forkParentUnknown) {
|
||||
lines.push(
|
||||
`- Operating workspace: "${ctx.workspaceId}" — a fork whose parent workspace is not currently visible to this user. deploy_workspace_item publishes into the fork only; the user promotes changes from the session's deploy panel. Never present a change as live in any other workspace.`
|
||||
)
|
||||
} else if (ctx.workspaceId) {
|
||||
lines.push(
|
||||
`- Operating workspace: "${ctx.workspaceId}" — the live workspace itself, not a fork. deploy_workspace_item publishes directly to everyone in it.`
|
||||
)
|
||||
} else {
|
||||
lines.push(
|
||||
'- No operating workspace is set yet; the user picks one (or a new staged fork) before the first message is sent.'
|
||||
)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/** `/` picker entry: a workspace skill or a built-in session action. The kind
|
||||
* drives the picker's category grouping; entries without one are ungrouped. */
|
||||
export type ChatCommandItem = AiSkillListItem & { kind?: 'action' | 'skill' }
|
||||
|
||||
/** Fetch the workspace's AI skills (name + description) for the global system prompt. */
|
||||
export async function loadWorkspaceSkills(workspace: string): Promise<AiSkillListItem[]> {
|
||||
if (!workspace) return []
|
||||
|
||||
@@ -21,6 +21,10 @@ export type DrillLeaf<L> = {
|
||||
/** Optional override for the fuzzy-search haystack. Defaults to
|
||||
* `label` (or `secondary` when label is empty). */
|
||||
searchableText?: string
|
||||
/** Category header this leaf renders under, in both the browse list and
|
||||
* search results (a `searchGroup` branch ancestor wins in search).
|
||||
* Consecutive leaves sharing a section share one header. */
|
||||
section?: string
|
||||
/** Marks this leaf as the user's current location — gets `aria-current`
|
||||
* and a styled, no-op click. */
|
||||
current?: boolean
|
||||
|
||||
@@ -25,7 +25,7 @@ type SavedScript = Omit<Script & UserDraftOverlay, 'draft'> & { draft?: NewScrip
|
||||
type SavedFlow = Omit<Flow & UserDraftOverlay, 'draft'> & { draft?: Flow }
|
||||
import type { HiddenRunnable } from '$lib/components/apps/types'
|
||||
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { loadCopilot, copilotWorkspace } from '$lib/aiStore'
|
||||
import { emptySchema, type StateStore } from '$lib/utils'
|
||||
import {
|
||||
@@ -341,6 +341,24 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
const s = sessionState.sessions.find((x) => x.id === session.id)
|
||||
return s ? getEffectiveWorkspaceId(s) : undefined
|
||||
}
|
||||
// Session facts (fork vs live workspace) for the system prompt. A resolver so
|
||||
// each rebuild reads the current record — the fork commits at first send, and
|
||||
// the user can re-point the session's workspace between sends.
|
||||
manager.sessionContextResolver = () => {
|
||||
const s = sessionState.sessions.find((x) => x.id === session.id)
|
||||
if (!s) return undefined
|
||||
const wsId = getEffectiveWorkspaceId(s)
|
||||
const ws = get(userWorkspaces).find((w) => w.id === wsId)
|
||||
return {
|
||||
workspaceId: wsId,
|
||||
parentWorkspaceId: ws?.parent_workspace_id ?? undefined,
|
||||
isDevWorkspace: ws?.is_dev_workspace,
|
||||
// Committed workspace missing from the list: still a fork (mirrors
|
||||
// isForkSession) — the prompt must not call it the live workspace.
|
||||
forkParentUnknown: !ws && !!s.workspace_id,
|
||||
pendingForkOf: s.pending_fork?.parent_workspace_id
|
||||
}
|
||||
}
|
||||
// Pre-flight: materialise the (still-transient) session, then commit
|
||||
// the workspace (creating a staged fork if needed) before any send.
|
||||
// AIChatManager awaits this so the first message hits a persisted
|
||||
|
||||
Reference in New Issue
Block a user