fix(chat): list a flow's chats under the path its runs record

The editor's chat asked for conversations under the path the flow was
opened at, while a preview run recorded them under the path being
edited. On a flow that has never been deployed those differ from the
first keystroke, so every test chat was written somewhere the sidebar
never looked and vanished as soon as the list refreshed.

`path` is now the path a run records, and what makes the panel a
different panel moves to `identity`. They are not the same question: a
chat holds conversations, a running turn and the rows it is writing, so
it is replaced rather than re-pointed — but replacing it on every
keystroke in the path field would throw away the turn being typed
alongside. `chatFlowKey` is the flow half of that identity and
`chatIdentity` the whole of it, so the panel key and anything filed
under the flow cannot drift apart.

A conversation keeps the path it was created under, so renaming a flow
that already has chats leaves those behind under the old path. That is
the trade: the sidebar follows what runs record, and what ran before a
rename recorded something else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-16 10:08:33 +02:00
co-authored by Claude Opus 5
parent 404419588c
commit 769e7eebbe
6 changed files with 66 additions and 11 deletions
@@ -481,7 +481,8 @@
}}
conversationKind="test"
frame="boxed"
path={$initialPathStore || fakeInitialPath}
path={$pathStore}
identity={$initialPathStore || fakeInitialPath}
inputSchema={flowStore.val.schema}
flowModules={flowStore.val.value?.modules}
/>
@@ -814,7 +814,8 @@
<FlowChat
onRunFlow={runFlowWithMessage}
conversationKind="test"
path={$initialPathStore || fakeInitialPath}
path={$pathStore}
identity={$initialPathStore || fakeInitialPath}
useStreaming={shouldUseStreaming}
inputSchema={flowStore.val.schema}
flowModules={flowStore.val.value?.modules}
@@ -2,7 +2,7 @@
import { workspaceStore } from '$lib/stores'
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../types'
import type { FlowChatProps } from './flowChatProps'
import { chatIdentity, type FlowChatProps } from './flowChatProps'
import FlowChatPanel from './FlowChatPanel.svelte'
let props: FlowChatProps = $props()
@@ -19,7 +19,7 @@
* editor acts on a session's fork while `workspaceStore` stays where the reader left it.
*/
const chatKey = $derived(
`${flowEditorContext?.opWorkspace?.() ?? $workspaceStore ?? ''}:${props.path}`
chatIdentity(flowEditorContext?.opWorkspace?.() ?? $workspaceStore, props)
)
</script>
@@ -14,6 +14,7 @@
import { useWorkspaceStorageConfigured } from '$lib/components/inputTransformEnv.svelte'
import { workspaceStore } from '$lib/stores'
import FlowChatModelSettings from './FlowChatModelSettings.svelte'
import { chatFlowKey } from './flowChatProps'
import {
agentModelGap,
agentModelWiringInputs,
@@ -33,6 +34,8 @@
/** The flow's modules, used to find which inputs an AI agent step reads directly. */
flowModules?: FlowModule[]
path: string
/** What makes this a different chat, when that is not the path — see `FlowChatProps`. */
identity?: string
/** The flow's description, shown under the empty transcript's prompt. */
description?: string
wideLayout?: boolean
@@ -44,6 +47,7 @@
additionalInputsSchema,
flowModules,
path,
identity = undefined,
description = undefined,
wideLayout = false
}: Props = $props()
@@ -113,8 +117,14 @@
// value, an author's default — is made safe before it reaches the provider.
const runInputs = $derived(withoutRejectedEffort(modelWiring, effectiveInputs))
// Filed under the flow alone, which is what these settings have always been keyed on —
// so the same path in two workspaces shares them, and a model or resource stored by one
// reaches the other. Scoping the key to the workspace is a change of its own: it orphans
// every entry readers already have, and the fix belongs with whatever migrates them.
// `wmill dev` is the one surface whose key moves: it names no flow, so every flow it
// opened shared a single bucket, and the path it does have is the better key.
function getStorageKey(): string {
return `${STORAGE_KEY_PREFIX}${path}`
return `${STORAGE_KEY_PREFIX}${chatFlowKey({ path, identity })}`
}
function loadInputsFromStorage(): Record<string, any> | null {
@@ -1,8 +1,10 @@
<!--
One chat, for one flow in one workspace. Mounted only by `FlowChat`, which keys it on
that pair: the manager created here holds the conversations, the turn running in one of
them and the rows that turn is writing, so pointing an existing panel at another flow
would leave an upload, a launch or a poll landing in the chat that replaced it.
`chatIdentity` — the workspace and, where the path is still being typed, something that
holds still for the flow instead. The manager created here holds the conversations, the
turn running in one of them and the rows that turn is writing, so pointing an existing
panel at another flow would leave an upload, a launch or a poll landing in the chat that
replaced it.
-->
<script lang="ts">
import { workspaceStore } from '$lib/stores'
@@ -24,6 +26,7 @@
deploymentInProgress = false,
useStreaming = false,
path,
identity = undefined,
description = undefined,
inputSchema = undefined,
flowModules = undefined,
@@ -35,8 +38,8 @@
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
// The flow and the workspace this panel was built for. `FlowChat` keys on the same pair,
// so neither can change under it: what they change is which panel exists.
// The workspace this panel was built for. `FlowChat` keys on it, so it cannot change
// under the panel: what it changes is which panel exists.
const workspace = $derived(flowEditorContext?.opWorkspace?.() ?? $workspaceStore)
const manager = createFlowChatManager()
@@ -51,7 +54,13 @@
manager.canFilterConversationKind = conversationKind !== 'deployed'
// The manager's inputs, kept current rather than set once: `useStreaming` follows the
// flow's last step, which an edit can change while a turn is running.
// flow's last step, which an edit can change while a turn is running, and `path` follows
// the path field as its author types. A path that moves takes the next list request and
// the next run with it, so what a turn records is what the sidebar asks for — which is
// the point, since listing under a path runs do not record is how a chat loses the
// conversations it is creating. A conversation keeps the path it was created under
// (`get_or_create_conversation_with_id` leaves an existing row alone), so renaming a
// flow that already has chats leaves those behind under the old path.
$effect(() => {
manager.initialize(onRunFlow, path, useStreaming)
})
@@ -109,6 +118,7 @@
{additionalInputsSchema}
{flowModules}
{path}
{identity}
{description}
{wideLayout}
/>
@@ -18,7 +18,15 @@ export interface FlowChatProps {
) => Promise<string | undefined>
useStreaming?: boolean
deploymentInProgress?: boolean
/** The flow the chat runs and lists conversations for. Must be the path a run records,
* or a conversation is stored under one path and looked for under another. */
path: string
/**
* What makes this a different chat, when that is not the path. An unsaved flow's path
* changes as its author types, and the chat is replaced whenever this changes — so the
* editor passes something that holds still for the flow it is editing.
*/
identity?: string
/** The flow's own description, shown where the chat has room for it: the empty
* transcript, and the sidebar once a conversation has replaced it. */
description?: string
@@ -34,3 +42,28 @@ export interface FlowChatProps {
* owns the surface — the editor's panel shows it on the graph. */
parallelTurns?: boolean
}
/**
* Which flow a chat is for, as something that holds still.
*
* `identity` where a surface has one, because `path` follows the path field as its author
* types and anything filed under the flow has to survive that — the panel is deliberately
* not remounted for a path change. A surface with no identity to give has no such field
* either, so its path is already the stable answer.
*/
export function chatFlowKey(props: Pick<FlowChatProps, 'path' | 'identity'>): string {
// `||`, not `??`: the editor builds `identity` from values that are both empty on a
// surface with no flow of its own to name, and an empty identity is no identity.
return props.identity || props.path
}
/**
* What makes one chat a different chat: the flow it is for, in the workspace it runs in.
* `FlowChat` keys the panel on this, so nothing inside a mounted panel can see it change.
*/
export function chatIdentity(
workspace: string | undefined,
props: Pick<FlowChatProps, 'path' | 'identity'>
): string {
return `${workspace ?? ''}:${chatFlowKey(props)}`
}