feat: tell test flow conversations from deployed ones and rename a chat (#11179)

* feat: mark test flow conversations apart from deployed ones and allow renaming a chat

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: keep the conversation kind across refreshes and reject NUL titles

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: ignore conversation lists for a kind no longer selected

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: start a fresh conversation listing when the kind changes on a later page

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: refuse sending into a conversation of the other kind

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: refuse cross-kind conversation continuations on the server

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the conversation filter unavailable while an answer runs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-09-17 11:41:49 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 381d4470ef
commit 4eab995cf7
28 changed files with 727 additions and 85 deletions
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by\n FROM flow_conversation\n WHERE id = $1 AND workspace_id = $2\n FOR UPDATE",
"query": "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test\n FROM flow_conversation\n WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
@@ -37,6 +37,11 @@
"ordinal": 6,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "is_test",
"type_info": "Bool"
}
],
"parameters": {
@@ -52,8 +57,9 @@
true,
false,
false,
false,
false
]
},
"hash": "6f32c1feed096ff706ae359ad6a3ca33b3f82ca38289dfa4a69aa95041027d57"
"hash": "48c8522a4fed219c5011f4ba63c81cfe028a8b2a32bd790840cef65c452a8c31"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE flow_conversation SET title = $1, updated_at = updated_at\n WHERE id = $2 AND workspace_id = $3\n RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Varchar",
"Uuid",
"Text"
]
},
"nullable": [
false
]
},
"hash": "5b9c9eb64051f291fed4be9bc0b0cc0aef2e7bde732899976eddac36a2da7658"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (id) DO NOTHING\n RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by",
"query": "INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title, is_test)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (id) DO NOTHING\n RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test",
"describe": {
"columns": [
{
@@ -37,6 +37,11 @@
"ordinal": 6,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "is_test",
"type_info": "Bool"
}
],
"parameters": {
@@ -45,7 +50,8 @@
"Varchar",
"Varchar",
"Varchar",
"Varchar"
"Varchar",
"Bool"
]
},
"nullable": [
@@ -55,8 +61,9 @@
true,
false,
false,
false,
false
]
},
"hash": "c1e3ed3ecc3bcb98f60ba8196d33fee4a74f61b061e5025ecb75882208b3ba8f"
"hash": "6d259b8cce5da5fecefe4ce322789b6b2cc43b51f2056c677d58f39c31fb26cb"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by\n FROM flow_conversation\n WHERE id = $1 AND workspace_id = $2",
"query": "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test\n FROM flow_conversation\n WHERE id = $1 AND workspace_id = $2\n FOR UPDATE",
"describe": {
"columns": [
{
@@ -37,6 +37,11 @@
"ordinal": 6,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "is_test",
"type_info": "Bool"
}
],
"parameters": {
@@ -52,8 +57,9 @@
true,
false,
false,
false,
false
]
},
"hash": "c383cc023714b361d10c10e8fef1fc148ab1da942951ee9ffdddaecee76a6be9"
"hash": "dd84f9dfb238d18cb74f9e43228345427131bf8008eba6920021d6a449791534"
}
@@ -0,0 +1 @@
ALTER TABLE flow_conversation DROP COLUMN is_test;
@@ -0,0 +1,26 @@
-- A chat run from the flow editor's test panel is stored exactly like one from the
-- deployed flow, so the two were indistinguishable once written. Marking them lets the
-- lists tell a trial apart from a real conversation.
ALTER TABLE flow_conversation ADD COLUMN is_test BOOLEAN NOT NULL DEFAULT false;
-- Existing rows: a conversation whose messages came from a flowpreview run was a test.
-- Derived once here because the job is purged on retention, after which the origin of an
-- old conversation is unknowable.
--
-- Walked to the root job rather than matched directly: an existing message row never holds
-- the flow job itself. The rows point at the step that produced them — the AI agent's job
-- for an answer, the tool's own job for a tool call — whose kind is never 'flowpreview'.
--
-- `root_job` first, matching `get_root_job_id` (windmill-worker/src/common.rs): only it
-- reaches the top of the run. `flow_innermost_root_job` stops at the closest flow scope by
-- design, so an agent inside a subflow would land on that subflow's 'flow' row and the
-- conversation would read as deployed.
UPDATE flow_conversation c
SET is_test = true
WHERE EXISTS (
SELECT 1 FROM flow_conversation_message m
JOIN v2_job j ON j.id = m.job_id
JOIN v2_job root
ON root.id = coalesce(j.root_job, j.flow_innermost_root_job, j.parent_job, j.id)
WHERE m.conversation_id = c.id AND root.kind = 'flowpreview'
);
+1 -1
View File
@@ -99,7 +99,7 @@ email_trigger: path(char), local_part(char), workspaced_local_part(bool), script
favorite: usr(char), workspace_id(char), path(char), favorite_kind(favorite_kind)
flow: workspace_id(char), path(char), summary(text), description(text), value(jsonb), edited_by(char), edited_at(ts), archived(bool), schema(json), extra_perms(jsonb), dependency_job(uuid), draft_only(bool), tag(char), ws_error_handler_muted(bool), dedicated_worker(bool), timeout(int), visible_to_runner_only(bool), concurrency_key(char), versions(bigint[]), on_behalf_of(varchar), on_behalf_of_email(text), lock_error_logs(text), labels(text[])
FK: (workspace_id) -> workspace(id)
flow_conversation: id(uuid), workspace_id(char), flow_path(char), title(char), created_at(ts), updated_at(ts), created_by(char)
flow_conversation: id(uuid), workspace_id(char), flow_path(char), title(char), created_at(ts), updated_at(ts), created_by(char), is_test(bool)
FK: (workspace_id) -> workspace(id)
flow_conversation_message: id(uuid), conversation_id(uuid), message_type(message_type), content(text), job_id(uuid), created_at(ts), created_seq(int8), step_name(char), success(bool)
FK: (conversation_id) -> flow_conversation(id) | (job_id) -> v2_job(id)
+1
View File
@@ -258,6 +258,7 @@ async fn test_new_turns_wait_for_conversation_cleanup_and_recreate(
"test-user",
"hi again",
conv_id,
false,
)
.await?;
windmill_common::flow_conversations::add_message_to_conversation_tx(
@@ -1,6 +1,6 @@
use axum::{
extract::{Path, Query},
routing::{delete, get},
routing::{delete, get, post},
Extension, Json, Router,
};
use chrono::{DateTime, Utc};
@@ -15,13 +15,14 @@ use windmill_common::{
db::{UserDB, DB},
error::{JsonResult, Result},
flow_conversations::MessageType,
utils::{not_found_if_none, paginate, Pagination},
utils::{not_found_if_none, paginate, truncate_with_ellipsis, Pagination},
};
pub fn workspaced_service() -> Router {
Router::new()
.route("/list", get(list_conversations))
.route("/delete/{conversation_id}", delete(delete_conversation))
.route("/update/{conversation_id}", post(update_conversation))
.route("/{conversation_id}/messages", get(list_messages))
}
@@ -38,9 +39,22 @@ pub struct FlowConversationMessage {
pub success: bool,
}
/// Which conversations a listing holds. A test chat was started from the editor's test
/// panel; a deployed one from the flow itself.
#[derive(Deserialize, Default, Clone, Copy)]
#[serde(rename_all = "lowercase")]
pub enum ConversationKind {
Test,
/// The default: a deployed flow's chat should not surface someone's trial runs.
#[default]
Deployed,
All,
}
#[derive(Deserialize)]
pub struct ListConversationsQuery {
pub flow_path: Option<String>,
pub kind: Option<ConversationKind>,
}
#[derive(Deserialize)]
@@ -67,6 +81,7 @@ async fn list_conversations(
"created_at",
"updated_at",
"created_by",
"is_test",
])
.and_where_eq("workspace_id", "?".bind(&w_id));
@@ -74,6 +89,16 @@ async fn list_conversations(
sqlb.and_where_eq("flow_path", "?".bind(flow_path));
}
match query.kind.unwrap_or_default() {
ConversationKind::Test => {
sqlb.and_where_eq("is_test", "true");
}
ConversationKind::Deployed => {
sqlb.and_where_eq("is_test", "false");
}
ConversationKind::All => {}
}
sqlb.order_by("updated_at", true)
.limit(per_page as i64)
.offset(offset as i64);
@@ -101,7 +126,7 @@ async fn delete_conversation(
// Verify the conversation exists and belongs to the user
let conversation = sqlx::query_as!(
FlowConversation,
"SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by
"SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test
FROM flow_conversation
WHERE id = $1 AND workspace_id = $2",
conversation_id,
@@ -148,6 +173,50 @@ async fn delete_conversation(
Ok(format!("Conversation {} deleted", conversation_id))
}
#[derive(Deserialize)]
pub struct UpdateConversation {
pub title: String,
}
async fn update_conversation(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, conversation_id)): Path<(String, Uuid)>,
Json(update): Json<UpdateConversation>,
) -> Result<String> {
// Postgres refuses a NUL in a text column, so it must not reach the query as a 500.
if update.title.contains('\0') {
return Err(windmill_common::error::Error::BadRequest(
"title cannot contain a NUL character".to_string(),
));
}
// The column is VARCHAR(255) and the helper appends an ellipsis to what it cuts, so the
// bound it takes is three short of the column's. A longer title would otherwise reach
// Postgres as a 22001 and come back a 500.
let title = truncate_with_ellipsis(update.title.trim(), 252);
let mut tx = user_db.clone().begin(&authed).await?;
// `updated_at` is kept: the list is ordered by it, and a rename must not move the
// chat to the top the way a new turn does.
let updated = sqlx::query_scalar!(
"UPDATE flow_conversation SET title = $1, updated_at = updated_at
WHERE id = $2 AND workspace_id = $3
RETURNING id",
title,
conversation_id,
&w_id
)
.fetch_optional(&mut *tx)
.await?;
not_found_if_none(updated, "Conversation", conversation_id.to_string())?;
tx.commit().await?;
Ok(format!("Conversation {} updated", conversation_id))
}
async fn list_messages(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
@@ -669,6 +669,7 @@ pub async fn handle_chat_conversation_messages(
run_query: &RunJobQuery,
user_message_raw: Option<&Box<serde_json::value::RawValue>>,
job_id: Uuid,
is_test: bool,
) -> error::Result<()> {
// Names the query parameter rather than the field: it is not a flow argument, and
// supplying it as one is the first thing tried on reading `memory_id is required`.
@@ -701,6 +702,7 @@ pub async fn handle_chat_conversation_messages(
&authed.username,
&user_message,
memory_id,
is_test,
)
.await?;
@@ -836,6 +838,7 @@ pub async fn run_flow<'c>(
&run_query,
args.args.get("user_message"),
uuid,
false,
)
.await?;
}
+47 -1
View File
@@ -12464,6 +12464,15 @@ paths:
in: query
schema:
type: string
- name: kind
description: which conversations to list - the flow editor's test chats, the deployed flow's own (the default), or both
in: query
schema:
type: string
enum:
- test
- deployed
- all
responses:
"200":
description: flow conversations list
@@ -12474,6 +12483,40 @@ paths:
items:
$ref: "#/components/schemas/FlowConversation"
/w/{workspace}/flow_conversations/update/{conversation_id}:
post:
summary: rename flow conversation
operationId: updateFlowConversation
tags:
- flow_conversations
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: conversation_id
description: conversation id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [title]
properties:
title:
type: string
description: the chat's name
responses:
"200":
description: flow conversation updated
content:
text/plain:
schema:
type: string
/w/{workspace}/flow_conversations/delete/{conversation_id}:
delete:
summary: delete flow conversation
@@ -28301,7 +28344,7 @@ components:
FlowConversation:
type: object
required:
[id, workspace_id, flow_path, created_at, updated_at, created_by]
[id, workspace_id, flow_path, created_at, updated_at, created_by, is_test]
properties:
id:
type: string
@@ -28328,6 +28371,9 @@ components:
created_by:
type: string
description: Username who created the conversation
is_test:
type: boolean
description: Started from the flow editor's test panel rather than a deployed run
FlowConversationMessage:
type: object
+2
View File
@@ -9554,6 +9554,8 @@ async fn run_preview_flow_job(
&run_query,
user_message.as_ref(),
uuid,
// Run from the editor's test panel: a trial, not a real conversation.
true,
)
.await?;
}
@@ -26,8 +26,12 @@ pub struct FlowConversation {
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub created_by: String,
/// Started from the flow editor's test panel rather than a deployed run.
pub is_test: bool,
}
/// `is_test` is written on insert. An existing conversation of the other kind refuses the
/// turn, so preview and deployed runs never share one.
pub async fn get_or_create_conversation_with_id(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
w_id: &str,
@@ -35,9 +39,10 @@ pub async fn get_or_create_conversation_with_id(
username: &str,
title: &str,
conversation_id: Uuid,
is_test: bool,
) -> Result<FlowConversation> {
if let Some(existing) = lock_conversation(tx, w_id, conversation_id).await? {
return Ok(existing);
return same_kind(existing, is_test);
}
// Truncate title to 25 characters max
@@ -47,15 +52,16 @@ pub async fn get_or_create_conversation_with_id(
// wins, the others wait on it, do nothing, and read the row it created.
let created = sqlx::query_as!(
FlowConversation,
"INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title)
VALUES ($1, $2, $3, $4, $5)
"INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title, is_test)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id) DO NOTHING
RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by",
RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test",
conversation_id,
w_id,
flow_path,
username,
title
title,
is_test
)
.fetch_optional(&mut **tx)
.await?;
@@ -63,13 +69,29 @@ pub async fn get_or_create_conversation_with_id(
return Ok(conversation);
}
lock_conversation(tx, w_id, conversation_id)
// The concurrent first turn that won the insert may have been of the other kind.
let existing = lock_conversation(tx, w_id, conversation_id)
.await?
.ok_or_else(|| {
crate::error::Error::BadRequest(format!(
"conversation {conversation_id} belongs to another workspace"
))
})
})?;
same_kind(existing, is_test)
}
/// `memory_id` is the caller's to choose, so a preview run could name a deployed
/// conversation and the reverse. A conversation's kind is fixed at creation and nothing
/// would show the mixing afterwards, so the turn is refused before it starts.
fn same_kind(existing: FlowConversation, is_test: bool) -> Result<FlowConversation> {
if existing.is_test == is_test {
return Ok(existing);
}
Err(crate::error::Error::BadRequest(if existing.is_test {
"this conversation was started from the flow editor's test panel; start a new conversation to run the deployed flow".to_string()
} else {
"this conversation belongs to the deployed flow; start a new conversation to test from the flow editor".to_string()
}))
}
/// Locked, so a turn orders against retention collecting the conversation
@@ -83,7 +105,7 @@ async fn lock_conversation(
) -> Result<Option<FlowConversation>> {
Ok(sqlx::query_as!(
FlowConversation,
"SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by
"SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test
FROM flow_conversation
WHERE id = $1 AND workspace_id = $2
FOR UPDATE",
+5 -2
View File
@@ -225,8 +225,11 @@ answer, an `assistant` message with `success: false`. `status: 'error'` (with `e
set) means the turn could not run or be followed at all, such as a refused request.
Methods: `sendMessage(text, { inputs? })`, `stop()`, `newConversation()`,
`selectConversation(id)`, `loadConversations({ page?, perPage? })`,
`deleteConversation(id)`, `loadOlderMessages()`, `destroy()`. Switching conversations
`selectConversation(id)`, `loadConversations({ page?, perPage?, kind? })`,
`deleteConversation(id)`, `renameConversation(id, title)`, `loadOlderMessages()`,
`destroy()`. `kind` lists the flow editor's test chats (`'test'`), the deployed flow's
own (`'deployed'`, the server's default) or both (`'all'`); each `Conversation` carries
`isTest`. A rename keeps the conversation's place in the list. Switching conversations
stops following the current answer; the flow keeps running and, with server history,
its answer is there when you come back.
+20 -2
View File
@@ -28,8 +28,16 @@ export interface FlowConversation {
created_at: string
updated_at: string
created_by: string
/** Started from the flow editor's test panel rather than a deployed run. */
is_test: boolean
}
/**
* Which conversations a listing holds: the flow editor's test chats, the deployed flow's
* own (the server's default), or both.
*/
export type ConversationKind = 'test' | 'deployed' | 'all'
export interface FlowConversationMessage {
id: string
conversation_id: string
@@ -167,15 +175,25 @@ export class WindmillChatApi {
async listConversations(
flowPath: string,
options: { page?: number; perPage?: number; signal?: AbortSignal } = {}
options: { page?: number; perPage?: number; kind?: ConversationKind; signal?: AbortSignal } = {}
): Promise<FlowConversation[]> {
const extra: Record<string, string> = { flow_path: flowPath }
if (options.kind !== undefined) extra.kind = options.kind
const res = await this.#request('flow_conversations/list', {
query: pagination(options, { flow_path: flowPath }),
query: pagination(options, extra),
signal: options.signal
})
return (await res.json()) as FlowConversation[]
}
/** Sets a conversation's title. Its place in the list is kept: only a turn moves one. */
async renameConversation(conversationId: string, title: string): Promise<void> {
await this.#request(`flow_conversations/update/${encodeURIComponent(conversationId)}`, {
method: 'POST',
body: { title }
})
}
/**
* Without `afterSeq`: one page counted from the newest message, returned oldest first.
* With `afterSeq`: the messages created after that cursor, oldest first.
+2 -1
View File
@@ -53,7 +53,8 @@ export function useWindmillRuntime(options: WindmillRuntimeOptions): AssistantRu
threads: chat.conversations.map((c) => ({ status: 'regular' as const, id: c.id, title: c.title })),
onSwitchToNewThread: () => chat.newConversation(),
onSwitchToThread: (id) => chat.selectConversation(id),
onDelete: (id) => chat.deleteConversation(id)
onDelete: (id) => chat.deleteConversation(id),
onRename: (id, title) => chat.renameConversation(id, title)
}
}
: undefined
+36 -4
View File
@@ -1,6 +1,7 @@
import {
WindmillApiError,
WindmillChatApi,
type ConversationKind,
type FlowConversation,
type FlowConversationMessage
} from './api'
@@ -18,6 +19,7 @@ import type {
} from './types'
import {
conversationTitle,
truncateTitle,
errorResultMessage,
extractChatAnswer,
isAbortError,
@@ -59,6 +61,8 @@ class ChatImpl implements Chat {
#state: ChatState
#turn: Turn | undefined
#page = 1
/** The kind the caller last listed, so the refresh after a new turn lists the same rows. */
#conversationKind: ConversationKind | undefined
#persistTimer: ReturnType<typeof setTimeout> | undefined
constructor(options: ChatOptions) {
@@ -229,15 +233,21 @@ class ChatImpl implements Chat {
}
loadConversations = async (
options: { page?: number; perPage?: number } = {}
options: { page?: number; perPage?: number; kind?: ConversationKind } = {}
): Promise<Conversation[]> => {
const page = options.page ?? 1
// A different kind is a different listing: its first rows replace the held ones, on
// whichever page they were asked for.
const kindChanged = 'kind' in options && options.kind !== this.#conversationKind
if ('kind' in options) this.#conversationKind = options.kind
const kind = this.#conversationKind
let conversations: Conversation[]
if (this.#state.history === 'server') {
try {
const rows = await this.#api.listConversations(this.#config.flowPath, {
page,
perPage: options.perPage ?? this.#config.pageSize
perPage: options.perPage ?? this.#config.pageSize,
kind
})
conversations = rows.map(fromConversation)
} catch (e) {
@@ -247,10 +257,13 @@ class ChatImpl implements Chat {
} else {
conversations = this.#state.history === 'local' ? this.#local.listConversations() : []
}
// Another kind was asked for while this list was on its way: its rows are not the
// listing any more, whichever response lands last.
if (kind !== this.#conversationKind) return conversations
const known = new Set(this.#state.conversations.map((c) => c.id))
this.#set({
conversations:
page === 1
page === 1 || kindChanged
? conversations
: [...this.#state.conversations, ...conversations.filter((c) => !known.has(c.id))]
})
@@ -273,6 +286,24 @@ class ChatImpl implements Chat {
this.#set({ conversations: this.#state.conversations.filter((c) => c.id !== conversationId) })
}
renameConversation = async (conversationId: string, title: string): Promise<void> => {
// Cut here as the server cuts, so the title shown is the one stored.
const trimmed = truncateTitle(title.trim())
if (!trimmed) return
if (this.#state.history === 'server') {
await this.#api.renameConversation(conversationId, trimmed)
} else if (this.#state.history === 'local') {
this.#local.renameConversation(conversationId, trimmed)
}
// Patched in place: the server keeps `updated_at` on a rename, so the list order the
// next load returns is the one shown now.
this.#set({
conversations: this.#state.conversations.map((c) =>
c.id === conversationId ? { ...c, title: trimmed } : c
)
})
}
loadOlderMessages = async (): Promise<void> => {
const conversationId = this.#state.conversationId
if (
@@ -734,7 +765,8 @@ function fromConversation(row: FlowConversation): Conversation {
id: row.id,
title: row.title ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at
updatedAt: row.updated_at,
isTest: row.is_test
}
}
+7
View File
@@ -4,6 +4,8 @@ export interface LocalHistory {
listConversations(): Conversation[]
getMessages(conversationId: string): ChatMessage[]
upsertConversation(conversation: Conversation): void
/** Changes a stored conversation's title in place; unlike `upsertConversation`, its position is kept. */
renameConversation(conversationId: string, title: string): void
saveMessages(conversationId: string, messages: ChatMessage[]): void
deleteConversation(conversationId: string): void
}
@@ -55,6 +57,11 @@ export function createLocalHistory(storage: StorageLike | undefined, key: string
}
write(s)
},
renameConversation(id, title) {
const s = read()
s.conversations = s.conversations.map((c) => (c.id === id ? { ...c, title } : c))
write(s)
},
saveMessages(id, messages) {
const s = read()
s.messages[id] = messages.map((m) => ({ ...m, pending: false }))
+1
View File
@@ -5,6 +5,7 @@ export {
WindmillApiError,
readServerSentEvents,
type WindmillChatApiOptions,
type ConversationKind,
type FlowConversation,
type FlowConversationMessage,
type JobUpdateEvent,
+2
View File
@@ -11,6 +11,7 @@ export type UseWindmillChat = ChatState &
| 'selectConversation'
| 'loadConversations'
| 'deleteConversation'
| 'renameConversation'
| 'loadOlderMessages'
> & { chat: Chat }
@@ -65,6 +66,7 @@ export function useWindmillChat(options: ChatOptions): UseWindmillChat {
selectConversation: chat.selectConversation,
loadConversations: chat.loadConversations,
deleteConversation: chat.deleteConversation,
renameConversation: chat.renameConversation,
loadOlderMessages: chat.loadOlderMessages
}),
[state, chat]
+16 -1
View File
@@ -52,6 +52,11 @@ export interface Conversation {
title: string | undefined
createdAt: string
updatedAt: string
/**
* Started from the flow editor's test panel rather than a deployed run. Known once the
* server has listed the conversation; unset for one only this client has seen.
*/
isTest?: boolean
}
export interface ChatState {
@@ -128,8 +133,18 @@ export interface Chat {
stop(): Promise<void>
newConversation(): void
selectConversation(conversationId: string): Promise<void>
loadConversations(options?: { page?: number; perPage?: number }): Promise<Conversation[]>
/**
* `kind` narrows server history to the flow editor's test chats, the deployed flow's
* own (the server's default), or both. Local history has no test chats and ignores it.
*/
loadConversations(options?: {
page?: number
perPage?: number
kind?: 'test' | 'deployed' | 'all'
}): Promise<Conversation[]>
deleteConversation(conversationId: string): Promise<void>
/** Sets a conversation's title. The list keeps its order: only a turn moves a conversation. */
renameConversation(conversationId: string, title: string): Promise<void>
loadOlderMessages(): Promise<void>
/** Stops background work (stream, polling) and writes local history out. The chat stays usable. */
destroy(): void
+6
View File
@@ -69,6 +69,12 @@ export function conversationTitle(firstMessage: string): string {
return chars.length > 25 ? `${chars.slice(0, 25).join('')}...` : firstMessage
}
/** The server's bound on a typed title: 252 characters plus an ellipsis fits its 255-char column. */
export function truncateTitle(title: string): string {
const chars = Array.from(title)
return chars.length > 252 ? `${chars.slice(0, 252).join('')}...` : title
}
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(abortError())
+127
View File
@@ -414,6 +414,112 @@ describe('createChat with server history', () => {
expect(chat.getState().conversations.map((c) => c.id)).toEqual(['c1', 'c2'])
})
test('lists one kind of conversation and carries which kind each one is', async () => {
const row = (id: string, is_test: boolean) => ({
id,
workspace_id: 'ws',
flow_path: FLOW,
title: id,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
created_by: 'admin',
is_test
})
const { fetch, calls } = fetchMock((c) =>
c.url.pathname === '/api/w/ws/flow_conversations/list'
? json(c.url.searchParams.get('kind') === 'test' ? [row('t1', true)] : [row('d1', false)])
: undefined
)
const chat = createChat(options({}, fetch))
await chat.loadConversations()
expect(calls[0].url.searchParams.has('kind')).toBe(false)
expect(chat.getState().conversations.map((c) => [c.id, c.isTest])).toEqual([['d1', false]])
await chat.loadConversations({ kind: 'test' })
expect(calls[1].url.searchParams.get('kind')).toBe('test')
expect(chat.getState().conversations.map((c) => [c.id, c.isTest])).toEqual([['t1', true]])
})
test('a list for a kind no longer asked for does not replace the newer one', async () => {
const row = (id: string, is_test: boolean) => ({
id,
workspace_id: 'ws',
flow_path: FLOW,
title: id,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
created_by: 'admin',
is_test
})
const { fetch } = fetchMock((c) => {
if (c.url.pathname !== '/api/w/ws/flow_conversations/list') return undefined
if (c.url.searchParams.get('kind') === 'test') {
return new Promise<Response>((r) => setTimeout(() => r(json([row('t1', true)])), 50))
}
return json([row('d1', false)])
})
const chat = createChat(options({}, fetch))
const slow = chat.loadConversations({ kind: 'test' })
await chat.loadConversations({ kind: 'deployed' })
await slow
expect(chat.getState().conversations.map((c) => c.id)).toEqual(['d1'])
// Another kind asked for on a later page starts its own listing rather than appending.
await chat.loadConversations({ page: 2, kind: 'test' })
expect(chat.getState().conversations.map((c) => c.id)).toEqual(['t1'])
})
test('the refresh after a new turn lists the kind last asked for', async () => {
const { fetch, calls } = fetchMock(
run,
(c) =>
c.url.pathname === streamPath
? sse([{ type: 'update', completed: true, only_result: { output: 'Hello', messages: [] } }])
: undefined,
(c) =>
c.method === 'GET' && c.url.pathname.endsWith('/messages')
? json([messageRow(11, 'user', 'hi'), messageRow(12, 'assistant', 'Hello', { job_id: 'agent-job' })])
: undefined,
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined)
)
const chat = createChat(options({}, fetch))
await chat.loadConversations({ kind: 'test' })
await chat.sendMessage('hi')
const lists = calls.filter((c) => c.url.pathname === '/api/w/ws/flow_conversations/list')
expect(lists.length).toBeGreaterThan(1)
expect(lists.every((c) => c.url.searchParams.get('kind') === 'test')).toBe(true)
})
test('renaming a conversation keeps its place in the list', async () => {
const row = (id: string) => ({
id,
workspace_id: 'ws',
flow_path: FLOW,
title: id,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
created_by: 'admin',
is_test: false
})
const { fetch, calls } = fetchMock(
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([row('c1'), row('c2')]) : undefined),
(c) =>
c.method === 'POST' && c.url.pathname === '/api/w/ws/flow_conversations/update/c2'
? text('Conversation c2 updated')
: undefined
)
const chat = createChat(options({}, fetch))
await chat.loadConversations()
await chat.renameConversation('c2', ' Budget review ')
expect(calls[1].body).toEqual({ title: 'Budget review' })
expect(chat.getState().conversations.map((c) => [c.id, c.title])).toEqual([
['c1', 'c1'],
['c2', 'Budget review']
])
// Cut as the server cuts, so what is shown is what is stored.
await chat.renameConversation('c2', 'x'.repeat(300))
expect(chat.getState().conversations[1].title).toBe('x'.repeat(252) + '...')
expect(calls[2].body).toEqual({ title: 'x'.repeat(252) + '...' })
})
test('a turn started right after stop() is not touched by the stop sync', async () => {
let jobs = 0
const { fetch } = fetchMock(
@@ -726,6 +832,27 @@ describe('createChat with server history', () => {
expect((await again.loadConversations()).map((c) => c.id)).toEqual([newer, older])
})
test('renaming a local conversation persists the title without reordering history', async () => {
const storage = memoryStorage()
const { fetch, calls } = fetchMock(run, (c) =>
c.url.pathname === streamPath ? sse([{ type: 'update', completed: true, only_result: 'ok' }]) : undefined
)
const chat = createChat(options({ token: 'tok', storage }, fetch))
await chat.sendMessage('older')
const older = chat.getState().conversationId!
chat.newConversation()
await chat.sendMessage('newer')
const newer = chat.getState().conversationId!
const before = calls.length
await chat.renameConversation(older, 'Renamed')
expect(calls.length).toBe(before)
const again = createChat(options({ token: 'tok', storage }, fetch))
expect((await again.loadConversations()).map((c) => [c.id, c.title])).toEqual([
[newer, 'newer'],
[older, 'Renamed']
])
})
test('destroying the chat mid-turn leaves it idle', async () => {
const { fetch } = fetchMock(run, (c) =>
c.url.pathname === streamPath
@@ -470,7 +470,7 @@
)
return jobId ?? ''
}}
hideSidebar={true}
conversationKind="test"
path={$pathStore}
inputSchema={flowStore.val.schema}
flowModules={flowStore.val.value?.modules}
@@ -849,7 +849,7 @@
<FlowChat
onRunFlow={runFlowWithMessage}
path={$pathStore}
hideSidebar={true}
conversationKind="test"
inputSchema={flowStore.val.schema}
flowModules={flowStore.val.value?.modules}
/>
@@ -28,6 +28,13 @@
/** The flow's description, shown under the empty transcript's prompt. */
description?: string
wideLayout?: boolean
/**
* What this surface's own runs are: the editor runs previews and lists its test
* chats, the flow page runs the deployed flow and lists only its users' chats.
* The sidebar offers the kind filter everywhere but on the deployed flow, whose
* users have no test chats to look at.
*/
conversationKind?: 'test' | 'deployed'
}
let {
@@ -38,7 +45,8 @@
inputSchema = undefined,
flowModules = undefined,
description = undefined,
wideLayout = false
wideLayout = false,
conversationKind = 'deployed'
}: Props = $props()
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
@@ -97,7 +105,13 @@
<div class="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden flex-1">
{#if chat && chatState}
{#if !hideSidebar}
<FlowConversationsSidebar bind:this={sidebar} {chat} {chatState} />
<FlowConversationsSidebar
bind:this={sidebar}
{chat}
{chatState}
defaultKind={conversationKind}
canFilterKind={conversationKind !== 'deployed'}
/>
{/if}
<!-- The interface's host subscribes to the chat it was given, so a replaced chat
(another flow or workspace) mounts a fresh interface rather than a stale host. -->
@@ -111,6 +125,7 @@
{workspace}
{description}
{wideLayout}
{conversationKind}
/>
{/key}
{/if}
@@ -32,6 +32,8 @@
/** The flow's description, shown under the empty transcript's prompt. */
description?: string
wideLayout?: boolean
/** What this surface's runs create: previews in the editor, deployed runs on the flow page. */
conversationKind?: 'test' | 'deployed'
}
let {
@@ -42,7 +44,8 @@
path,
workspace = undefined,
description = undefined,
wideLayout = false
wideLayout = false,
conversationKind = 'deployed'
}: Props = $props()
// Derive helperScript for dynamic inputs from schema
@@ -150,10 +153,22 @@
{
additionalInputs: () => (additionalInputsSchema ? { ...runInputs } : undefined),
workspace: () => workspace,
sendDisabled: () => deploymentInProgress || !!modelGap
sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason
}
)
setChatViewHost(chatHost)
// A chat of the other kind can be read from here but not added to: the server refuses a
// preview run into a deployed conversation and the reverse, so the composer says why first.
const wrongKindReason = $derived.by(() => {
const { conversationId, conversations } = chatHost.state
const open = conversations.find((c) => c.id === conversationId)
if (open?.isTest === undefined || open.isTest === (conversationKind === 'test'))
return undefined
return open.isTest
? 'This chat was run from the flow editor. Start a new chat to continue here.'
: 'This chat belongs to the deployed flow. Start a new chat to test.'
})
onDestroy(() => chatHost.dispose())
// What the Configure-inputs modal asks for: every flow input the composer does not
@@ -287,8 +302,10 @@
{emptyHint}
footerSettings={modalSchema || showModelButton ? footerSettings : undefined}
placeholder="Send a message to run the flow"
disabled={deploymentInProgress || !!modelGap}
disabledMessage={deploymentInProgress ? 'Deployment in progress' : (modelGap ?? '')}
disabled={deploymentInProgress || !!modelGap || !!wrongKindReason}
disabledMessage={deploymentInProgress
? 'Deployment in progress'
: (modelGap ?? wrongKindReason ?? '')}
loadPastChat={() => {}}
deletePastChat={() => {}}
saveAndClear={() => {}}
@@ -1,20 +1,46 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { MessageCircle, Plus, Trash2, PanelLeftClose, PanelLeftOpen } from 'lucide-svelte'
import {
MessageCircle,
Plus,
Trash2,
Pen,
Filter,
PanelLeftClose,
PanelLeftOpen
} from 'lucide-svelte'
import CountBadge from '$lib/components/common/badge/CountBadge.svelte'
import InfiniteList from '$lib/components/InfiniteList.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { sendUserToast } from '$lib/toast'
import type { Item } from '$lib/utils'
import { twMerge } from 'tailwind-merge'
import { fade } from 'svelte/transition'
import { untrack } from 'svelte'
import type { Chat, ChatState, Conversation } from 'windmill-chat'
import { tick, untrack } from 'svelte'
import type { Chat, ChatState, Conversation, ConversationKind } from 'windmill-chat'
interface Props {
chat: Chat
chatState: ChatState
/**
* Which conversations the list holds at first. The editor shows its own test chats,
* since testing is what happens there; a deployed flow shows the chats its users
* started, so nobody's trial runs are mixed into them.
*/
defaultKind?: ConversationKind
/**
* Whether the filter is offered. Only the editor does: a deployed flow has no test
* chats of its own to show, and offering to list someone's trial runs there would
* put editor scratch in front of the flow's users.
*/
canFilterKind?: boolean
}
let { chat, chatState }: Props = $props()
let { chat, chatState, defaultKind = 'deployed', canFilterKind = false }: Props = $props()
let expanded = $state(false)
let list = $state<InfiniteList | undefined>(undefined)
@@ -23,13 +49,32 @@
// A conversation exists on the server only once its first turn ran, so "New chat"
// shows a draft row until then.
let draft = $state(false)
// The prop seeds the filter; the filter is then the user's.
let kind = $state<ConversationKind>(untrack(() => defaultKind))
// The chat being renamed, and the text typed so far. One at a time: the input is the
// row's own label, so a second one would have nowhere to go.
let renamingId = $state<string | undefined>(undefined)
let renameDraft = $state('')
let renameInput = $state<TextInput | undefined>(undefined)
const turnInFlight = $derived(
chatState.status === 'submitted' || chatState.status === 'streaming'
)
$effect(() => {
const l = list
const c = chat
if (!l) return
untrack(() => {
l.setLoader((page, perPage) => c.loadConversations({ page, perPage }))
// Every load goes through here, the first one and infinite scroll included. A
// response for a kind no longer selected keeps the rows shown: the load for the
// selected kind brings its own, whichever of the two lands last.
l.setLoader(async (page, perPage) => {
const requested = kind
const rows = await c.loadConversations({ page, perPage, kind: requested })
return requested === kind ? rows : items
})
l.setDeleteItemFn(async (id: string) => {
deletingId = id
try {
@@ -46,10 +91,15 @@
})
})
/** The container reports a started turn: a conversation's first one creates its server entry. */
/**
* The container reports a started turn: a conversation's first one creates its server
* entry. A new conversation is of this surface's own kind, so a filter that would not
* list it goes back to that kind rather than hiding the chat that was just started.
*/
export async function conversationStarted(conversationId: string) {
if (items.some((c) => c.id === conversationId)) return
draft = false
if (kind !== 'all' && kind !== defaultKind) kind = defaultKind
await list?.loadData('forceRefresh')
}
@@ -60,6 +110,70 @@
draft = true
}
const KIND_LABELS: Record<ConversationKind, string> = {
test: 'Test',
deployed: 'Deployed',
all: 'All'
}
/**
* Narrow the list to one kind of chat and reload it. The open conversation goes with it
* when it is not of the new kind: the composer sends into whatever is selected, and a
* conversation keeps the kind it was created with, so a turn sent into one the list no
* longer shows would be stored where nothing here lists it.
*/
async function setKind(next: ConversationKind) {
// A turn writes into the open conversation, which a kind that excludes it would close.
if (next === kind || turnInFlight) return
kind = next
const open = items.find((c) => c.id === chatState.conversationId)
const stillListed = open === undefined || next === 'all' || (next === 'test') === open.isTest
if (!stillListed) chat.newConversation()
await list?.loadData('forceRefresh')
}
async function startRename(conversation: Conversation) {
renamingId = conversation.id
renameDraft = getConversationTitle(conversation)
// The field replaces the row, so it exists only after this render.
await tick()
renameInput?.focus()
renameInput?.select()
}
async function commitRename() {
const id = renamingId
renamingId = undefined
if (!id) return
const title = renameDraft.trim()
const current = items.find((c) => c.id === id)
if (!current || title === '' || title === current.title) return
try {
await chat.renameConversation(id, title)
// The list holds its own rows, loaded through the loader: patched rather than
// reloaded, so the row keeps its place without a round trip. The title is read
// back from the chat, which holds it as the server stored it (a long one is cut).
const stored = chat.getState().conversations.find((c) => c.id === id)?.title ?? title
items = items.map((c) => (c.id === id ? { ...c, title: stored } : c))
} catch (error) {
console.error('Failed to rename conversation:', error)
sendUserToast('Failed to rename conversation', true)
}
}
function rowActions(conversation: Conversation): Item[] {
return [
{ displayName: 'Rename', icon: Pen, action: () => startRename(conversation) },
{
displayName: 'Delete',
icon: Trash2,
type: 'delete',
disabled: deletingId === conversation.id,
action: () => list?.deleteItem(conversation.id)
}
]
}
function getConversationTitle(conversation: Conversation): string {
return conversation.title || `Conversation ${conversation.createdAt.slice(0, 10)}`
}
@@ -87,17 +201,68 @@
>
<div transition:fade={{ duration: 100 }}> Conversations </div>
</Button>
<Button
unifiedSize="md"
variant="subtle"
startIcon={{ icon: Plus, classes: 'ml-[2px]' }}
onClick={newChat}
title="Start new conversation"
iconOnly={!expanded}
btnClasses={'justify-start transition-all duration-150 whitespace-nowrap'}
>
<div transition:fade={{ duration: 100 }}> New chat </div>
</Button>
<!-- Side by side while there is width for both; stacked once collapsed, where the
rail fits one icon across. -->
<div class={expanded ? 'flex flex-row gap-1 items-center' : 'flex flex-col gap-2'}>
<Button
unifiedSize="md"
variant="subtle"
startIcon={{ icon: Plus, classes: 'ml-[2px]' }}
onClick={newChat}
title="Start new conversation"
iconOnly={!expanded}
wrapperClasses={expanded ? 'grow min-w-0' : ''}
btnClasses={'w-full justify-start transition-all duration-150 whitespace-nowrap'}
>
<div transition:fade={{ duration: 100 }}> New chat </div>
</Button>
{#if canFilterKind}
<!-- No focus trap: opening a row's menu does not close this popover, and a
trapped popover pulls focus back from the rename field that menu opens. -->
<Popover
placement="bottom-start"
closeButton={false}
disableFocusTrap
disabled={turnInFlight}
>
{#snippet trigger()}
<!-- Icon-only next to the wider New chat: which kind is listed is named in
the title and by the group inside. -->
<Button
nonCaptureEvent
unifiedSize="md"
variant="subtle"
startIcon={{ icon: Filter }}
disabled={turnInFlight}
title={turnInFlight
? 'Wait for the current answer to change which chats are listed'
: `Filter conversations · ${KIND_LABELS[kind]}`}
iconOnly
/>
{/snippet}
{#snippet content()}
<div class="p-3">
<ToggleButtonGroup
selected={kind}
onSelected={(next) => setKind(next as ConversationKind)}
disabled={turnInFlight}
noWFull
>
{#snippet children({ item })}
<ToggleButton size="sm" value="test" label={KIND_LABELS.test} {item} />
<ToggleButton size="sm" value="deployed" label={KIND_LABELS.deployed} {item} />
<ToggleButton size="sm" value="all" label={KIND_LABELS.all} {item} />
{/snippet}
</ToggleButtonGroup>
<p class="text-2xs text-tertiary mt-1.5 max-w-[190px]">
Test chats are the ones run from the flow editor's test panel, kept apart from the
conversations the deployed flow's users started.
</p>
</div>
{/snippet}
</Popover>
{/if}
</div>
</div>
</div>
@@ -157,38 +322,58 @@
{#snippet customRow({ item: conversation })}
{#if expanded}
<div class={twMerge('w-full pb-1')} transition:fade={{ duration: 100, delay: 30 }}>
<Button
unifiedSize="md"
variant="subtle"
onClick={() => {
draft = false
chat.selectConversation(conversation.id)
}}
selected={chatState.conversationId === conversation.id}
btnClasses="transition-all duration-150 group"
>
<span class="flex-1 text-left truncate">
{getConversationTitle(conversation)}
</span>
{#if renamingId === conversation.id}
<!-- While renaming, the field replaces the row rather than sitting inside its
button: a text input nested in a button is a nested interactive control,
and every keystroke would have to be kept from reaching the row. -->
<div class="flex flex-row items-center h-8 px-2 rounded-md bg-surface-selected">
<TextInput
bind:this={renameInput}
bind:value={renameDraft}
class="min-w-0 flex-1"
size="sm"
inputProps={{
'aria-label': 'Chat name',
onblur: commitRename,
onkeydown: (e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
commitRename()
} else if (e.key === 'Escape') {
e.preventDefault()
renamingId = undefined
}
}
}}
/>
</div>
{:else}
<Button
wrapperClasses={twMerge(
'ml-2 transition-all duration-100 opacity-0 group-hover:opacity-100',
deletingId === conversation.id ? 'opacity-100' : ' '
)}
disabled={deletingId === conversation.id}
onClick={(e) => {
e?.stopPropagation()
list?.deleteItem(conversation.id)
}}
title="Delete conversation"
destructive
unifiedSize="xs"
unifiedSize="md"
variant="subtle"
loading={deletingId === conversation.id}
iconOnly
startIcon={{ icon: Trash2 }}
/>
</Button>
onClick={() => {
draft = false
chat.selectConversation(conversation.id)
}}
selected={chatState.conversationId === conversation.id}
btnClasses="transition-all duration-150 group"
>
<span class="flex-1 text-left truncate">
{getConversationTitle(conversation)}
</span>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={twMerge(
'ml-2 transition-all duration-100 opacity-0 group-hover:opacity-100',
deletingId === conversation.id ? 'opacity-100' : ''
)}
onclick={(e) => e.stopPropagation()}
>
<DropdownV2 items={() => rowActions(conversation)} size="xs" />
</div>
</Button>
{/if}
</div>
{/if}
{/snippet}