feat(ai-chat): render flow chat mode through the AI session chat components

The flow chat ran on its own components; this points it at the ones the AI
session chat already uses, so the two surfaces share a transcript, a composer
and a sidebar instead of keeping two of each.

The seam is `ChatViewHost` (copilot/chat/chatViewHost.ts): the view components
read the host rather than `AIChatManager` directly, and `getChatViewHost()`
falls back to the session manager, so the copilot's call sites are unchanged.
`FlowChatViewHost` is the second adapter, over `FlowChatManager`.

What the flow chat gains from the move:

- turns running in several conversations at once, with a status, a queue and a
  Stop per chat, and an unread count on the rail
- attachments, uploaded to the workspace's object storage for the worker to read
- a composer that speaks for the agent steps a message is fed to: the model, the
  thinking effort, and the flow inputs the agent reads straight out of
  `flow_input`; everything else is asked for in a Configure-inputs modal
- tool cards with the call and the result, the model's reasoning, and a step name
  per answer once a conversation holds more than one agent
- Retry, which replays the failed turn's own run arguments read back from its job
- named and renamable conversations, and test chats kept out of the deployed
  flow's list

Backend: conversation rows carry an MCP tool's call and result and the model's
reasoning, which live nowhere else; every row gets a job so retention can empty
it; and the providers parse a non-streaming answer's reasoning.

Stacked on #11134, which keeps the windmill-chat SDK for external frontends and
raw apps while the in-app flow chat runs on these components.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-15 11:46:45 +02:00
co-authored by Claude Opus 5
parent 3895e1d579
commit 0e20a5d751
110 changed files with 9149 additions and 1260 deletions
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM flow_conversation c\n WHERE c.id = ANY($1)\n AND c.workspace_id = $2\n AND NOT EXISTS (\n SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"Text"
]
},
"nullable": []
},
"hash": "07a005f0f9e80a156cd2a5a0ae39a1fabeaa167818206a25abfe31d5582f942a"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, conversation_id, message_type as \"message_type: MessageType\", content, job_id, created_at, created_seq, step_name, success\n FROM (\n SELECT id, conversation_id, message_type, content, job_id, created_at, created_seq, step_name, success\n FROM flow_conversation_message\n WHERE conversation_id = $1\n ORDER BY created_seq DESC\n LIMIT $2 OFFSET $3\n ) AS messages\n ORDER BY created_seq ASC\n ",
"query": "SELECT id, conversation_id, message_type as \"message_type: MessageType\", content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning\n FROM flow_conversation_message\n WHERE conversation_id = $1\n AND created_seq > $2\n ORDER BY created_seq ASC\n LIMIT $3\n ",
"describe": {
"columns": [
{
@@ -58,6 +58,21 @@
"ordinal": 8,
"name": "success",
"type_info": "Bool"
},
{
"ordinal": 9,
"name": "tool_arguments",
"type_info": "Text"
},
{
"ordinal": 10,
"name": "tool_result",
"type_info": "Text"
},
{
"ordinal": 11,
"name": "reasoning",
"type_info": "Text"
}
],
"parameters": {
@@ -76,8 +91,11 @@
false,
false,
true,
false
false,
true,
true,
true
]
},
"hash": "1c3473a0f9f6b6148b2c975f9f05bdefedf8a51c4e6ddf0eca367b9cc778d051"
"hash": "318ed7a45d8326e3ecbc9727ee3812adaeeead8b39b007a32badadf67ec5ca17"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM flow_conversation_message m\n USING flow_conversation c\n WHERE m.conversation_id = c.id AND c.workspace_id = $1 AND m.job_id = ANY($2)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"UuidArray"
]
},
"nullable": []
},
"hash": "462d2b2822b185a6f51fafcfa957cb3b31ee6b69abae79a214dddba0dee4425c"
}
@@ -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",
"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": "48c8522a4fed219c5011f4ba63c81cfe028a8b2a32bd790840cef65c452a8c31"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success)\n VALUES ($1, $2, $3, $4, $5, $6)",
"query": "INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success, tool_arguments, tool_result, reasoning)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
"describe": {
"columns": [],
"parameters": {
@@ -21,10 +21,13 @@
"Text",
"Uuid",
"Varchar",
"Bool"
"Bool",
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "b1a9a433e577133869c067b2ce383fc6ce4e9df307feb5fd3edc0d1276d61ff1"
"hash": "55dbe12954489532644b91e67b2a9df7ede61e050d3e634b795aaf7f60b3b05d"
}
@@ -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"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM flow_conversation_message m\n USING flow_conversation c\n WHERE m.conversation_id = c.id AND c.workspace_id = $1 AND m.job_id = ANY($2)\n RETURNING m.conversation_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "conversation_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Text",
"UuidArray"
]
},
"nullable": [
false
]
},
"hash": "69bfbe9b39414b724488532cc3b3659915d9fcb3d58f16532aaffe06c44ec976"
}
@@ -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 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 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": "6bd23a98838e3eec309e6b696edc776bd56fc9dae1238b3272557d1562400dbe"
"hash": "79776c1a15e41edecb1a3cf749ff7e6c10efc85dc73473470078ad28f4c7cee1"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM ai_agent_memory a\n USING flow_conversation c\n WHERE c.id = ANY($1)\n AND c.workspace_id = $2\n AND a.conversation_id = c.id\n AND a.workspace_id = c.workspace_id\n AND NOT EXISTS (\n SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"Text"
]
},
"nullable": []
},
"hash": "89ea81b765550cf665e30533efc9672f8c98d2579fc72c07752361cc5fd683dc"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM flow_conversation c\n WHERE c.id = ANY($1)\n AND NOT EXISTS (\n SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray"
]
},
"nullable": []
},
"hash": "90b910da8d00a7c7bcf29c167e38e44eb1c0062a8241dc8fe0ed3dd95b65f89a"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM flow_conversation_message WHERE job_id = ANY($1) RETURNING conversation_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "conversation_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"UuidArray"
]
},
"nullable": [
false
]
},
"hash": "967f52005f4a044b3a2e9f02ceadf90dad5246681bde6caaa633b85a5e8b2352"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM ai_agent_memory a\n USING flow_conversation c\n WHERE c.id = ANY($1)\n AND a.conversation_id = c.id\n AND a.workspace_id = c.workspace_id\n AND NOT EXISTS (\n SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray"
]
},
"nullable": []
},
"hash": "a1b23f3e62c6433d95cdac58215741a51ca0ce66bf1c674097705cf2e1b72eff"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM flow_conversation_message WHERE job_id = ANY($1)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray"
]
},
"nullable": []
},
"hash": "bfdd60b42e32bd81e2d20b327462893147b4e5ff078531de36147d908132d636"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, conversation_id, message_type as \"message_type: MessageType\", content, job_id, created_at, created_seq, step_name, success\n FROM flow_conversation_message\n WHERE conversation_id = $1\n AND created_seq > $2\n ORDER BY created_seq ASC\n LIMIT $3\n ",
"query": "SELECT id, conversation_id, message_type as \"message_type: MessageType\", content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning\n FROM (\n SELECT id, conversation_id, message_type, content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning\n FROM flow_conversation_message\n WHERE conversation_id = $1\n ORDER BY created_seq DESC\n LIMIT $2 OFFSET $3\n ) AS messages\n ORDER BY created_seq ASC\n ",
"describe": {
"columns": [
{
@@ -58,6 +58,21 @@
"ordinal": 8,
"name": "success",
"type_info": "Bool"
},
{
"ordinal": 9,
"name": "tool_arguments",
"type_info": "Text"
},
{
"ordinal": 10,
"name": "tool_result",
"type_info": "Text"
},
{
"ordinal": 11,
"name": "reasoning",
"type_info": "Text"
}
],
"parameters": {
@@ -76,8 +91,11 @@
false,
false,
true,
false
false,
true,
true,
true
]
},
"hash": "e8802be9203c1e88a06e337260ccca029380139f89a01a89033e36a6ed9ac082"
"hash": "d6c65ce1d443d1e1783818d36c7eabd9633bbe5219bfd9683f336de19763cb58"
}
@@ -0,0 +1 @@
ALTER TABLE flow_conversation DROP COLUMN is_test;
@@ -0,0 +1,27 @@
-- 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. Only this migration's release starts storing it on the user row, and
-- the rows written before it 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'
);
@@ -0,0 +1,3 @@
ALTER TABLE flow_conversation_message DROP COLUMN tool_arguments;
ALTER TABLE flow_conversation_message DROP COLUMN tool_result;
ALTER TABLE flow_conversation_message DROP COLUMN reasoning;
@@ -0,0 +1,9 @@
-- A tool row's call and result are read back from the tool's own job, which an MCP tool
-- and a provider-native tool never have: they run inside the agent's job. For those the
-- row is the only record, so it carries the call itself.
ALTER TABLE flow_conversation_message ADD COLUMN tool_arguments TEXT;
ALTER TABLE flow_conversation_message ADD COLUMN tool_result TEXT;
-- The thinking that produced an answer is streamed, never returned in the response body,
-- so it exists nowhere once the stream is over.
ALTER TABLE flow_conversation_message ADD COLUMN reasoning TEXT;
+3 -3
View File
@@ -97,10 +97,10 @@ 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)
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), tool_arguments(text), tool_result(text), reasoning(text)
FK: (conversation_id) -> flow_conversation(id)
flow_iterator_data: job_id(uuid), itered(jsonb)
flow_node: id(bigint), workspace_id(char), hash(bigint), path(char), lock(text), code(text), flow(jsonb), hash_v2(char(64))
FK: (path, workspace_id) -> flow(path, workspace_id) | (workspace_id) -> workspace(id)
+153 -1
View File
@@ -37,7 +37,7 @@ async fn seed_side_rows(db: &Pool<Postgres>, ws: &str, job_id: Uuid) -> anyhow::
.bind(ws)
.execute(db)
.await?;
// created_seq is assigned by a trigger; inserting a value is rejected.
// created_seq is an identity column; supplying a value is rejected.
sqlx::query(
"INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id)
VALUES ($1, 'assistant', 'hi', $2)",
@@ -121,6 +121,90 @@ async fn test_delete_jobs_removes_side_rows(db: Pool<Postgres>) -> anyhow::Resul
Ok(())
}
/// (conversation rows, agent-memory rows) for one conversation.
async fn conversation_and_memory_counts(
db: &Pool<Postgres>,
conversation_id: Uuid,
) -> anyhow::Result<(i64, i64)> {
Ok((
count(
db,
"SELECT count(*) FROM flow_conversation WHERE id = $1",
conversation_id,
)
.await?,
count(
db,
"SELECT count(*) FROM ai_agent_memory WHERE conversation_id = $1",
conversation_id,
)
.await?,
))
}
/// A conversation outlives the jobs behind its messages until the last one goes: only then
/// are the row and the agent's memory for it left with nothing, and only then are they
/// deleted. Both halves matter — the surviving half is what a single data-modifying CTE
/// would break, since its emptiness check would read the snapshot from before the delete.
#[sqlx::test(fixtures("base"))]
async fn test_delete_jobs_removes_a_conversation_once_its_last_message_goes(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let first_job = Uuid::new_v4();
let second_job = Uuid::new_v4();
insert_job(&db, WS, first_job).await?;
insert_job(&db, WS, second_job).await?;
let conv_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by)
VALUES ($1, $2, 'f/flow', 'test-user')",
)
.bind(conv_id)
.bind(WS)
.execute(&db)
.await?;
for job_id in [first_job, second_job] {
sqlx::query(
"INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id)
VALUES ($1, 'assistant', 'hi', $2)",
)
.bind(conv_id)
.bind(job_id)
.execute(&db)
.await?;
}
sqlx::query(
"INSERT INTO ai_agent_memory (workspace_id, conversation_id, step_id, messages)
VALUES ($1, $2, 'a', '[]'::jsonb)",
)
.bind(WS)
.bind(conv_id)
.execute(&db)
.await?;
let mut conn = db.acquire().await?;
windmill_common::jobs::delete_jobs(&mut conn, &[first_job]).await?;
drop(conn);
assert_eq!(
conversation_and_memory_counts(&db, conv_id).await?,
(1, 1),
"a conversation with a message left must survive, memory included"
);
let mut conn = db.acquire().await?;
windmill_common::jobs::delete_jobs(&mut conn, &[second_job]).await?;
drop(conn);
assert_eq!(
conversation_and_memory_counts(&db, conv_id).await?,
(0, 0),
"the last message going should take the conversation and its memory"
);
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_clear_schedule_removes_side_rows(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
@@ -192,6 +276,74 @@ async fn test_workspace_delete_removes_side_rows(db: Pool<Postgres>) -> anyhow::
Ok(())
}
/// The purge endpoint carries its own copy of the emptied-conversation rule, so it gets the
/// same guard: the conversation and its memory go with the last message, and not before.
#[sqlx::test(fixtures("base"))]
async fn test_jobs_export_delete_removes_a_conversation_once_its_last_message_goes(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let first_job = Uuid::new_v4();
let second_job = Uuid::new_v4();
insert_job(&db, WS, first_job).await?;
insert_job(&db, WS, second_job).await?;
let conv_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by)
VALUES ($1, $2, 'f/flow', 'test-user')",
)
.bind(conv_id)
.bind(WS)
.execute(&db)
.await?;
for job_id in [first_job, second_job] {
sqlx::query(
"INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id)
VALUES ($1, 'assistant', 'hi', $2)",
)
.bind(conv_id)
.bind(job_id)
.execute(&db)
.await?;
}
sqlx::query(
"INSERT INTO ai_agent_memory (workspace_id, conversation_id, step_id, messages)
VALUES ($1, $2, 'a', '[]'::jsonb)",
)
.bind(WS)
.bind(conv_id)
.execute(&db)
.await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let purge = |job_id: Uuid| async move {
reqwest::Client::new()
.post(format!("http://localhost:{port}/api/w/{WS}/jobs/delete"))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&[job_id])
.send()
.await
};
assert!(purge(first_job).await?.status().is_success());
assert_eq!(
conversation_and_memory_counts(&db, conv_id).await?,
(1, 1),
"a conversation with a message left must survive the purge endpoint too"
);
assert!(purge(second_job).await?.status().is_success());
assert_eq!(
conversation_and_memory_counts(&db, conv_id).await?,
(0, 0),
"the last message going should take the conversation and its memory"
);
Ok(())
}
/// The `/jobs/delete` purge endpoint must scope every side-table delete to the path
/// workspace. A `test-workspace` admin passing a job id from another workspace must not be
/// able to delete that workspace's job or side rows (the side tables no longer cascade, so
@@ -767,6 +767,7 @@ impl QueryBuilder for AnthropicQueryBuilder {
let AnthropicSSEParser {
accumulated_content,
accumulated_reasoning,
accumulated_tool_calls,
events_str,
annotations,
@@ -790,6 +791,7 @@ impl QueryBuilder for AnthropicQueryBuilder {
} else {
Some(accumulated_content)
},
reasoning: (!accumulated_reasoning.is_empty()).then_some(accumulated_reasoning),
tool_calls: accumulated_tool_calls.into_values().collect(),
events_str: Some(events_str),
annotations,
@@ -1188,6 +1188,13 @@ impl BedrockQueryBuilder {
Some(accumulated_text)
};
// The block folded for replay is also what the reader sees as thinking. Read out
// before the block itself moves into the tool calls below.
let reasoning_text = reasoning
.as_ref()
.and_then(|r| r.reasoning_text.clone())
.filter(|t| !t.is_empty());
let tool_calls = streaming_tool_calls_to_openai(
accumulated_tool_calls.into_values().collect(),
reasoning,
@@ -1195,6 +1202,7 @@ impl BedrockQueryBuilder {
Ok(ParsedResponse::Text {
content,
reasoning: reasoning_text,
tool_calls,
events_str: if events_str.is_empty() {
None
@@ -666,6 +666,7 @@ impl QueryBuilder for GoogleAIQueryBuilder {
let GeminiSSEParser {
accumulated_content,
accumulated_reasoning,
accumulated_tool_calls,
mut events_str,
stream_event_processor,
@@ -698,6 +699,7 @@ impl QueryBuilder for GoogleAIQueryBuilder {
} else {
Some(accumulated_content)
},
reasoning: (!accumulated_reasoning.is_empty()).then_some(accumulated_reasoning),
tool_calls: accumulated_tool_calls.into_values().collect(),
events_str: Some(events_str),
annotations,
@@ -538,6 +538,9 @@ impl QueryBuilder for OpenAIQueryBuilder {
} else {
Some(parser.accumulated_content)
},
// The Responses stream has no reasoning-summary event in
// `OpenAIResponsesSSEEvent`, so nothing thinks out loud on this path yet.
reasoning: None,
tool_calls: parser.accumulated_tool_calls.into_values().collect(),
events_str: Some(parser.events_str),
annotations: parser.annotations,
@@ -251,6 +251,7 @@ impl QueryBuilder for OtherQueryBuilder {
let OpenAISSEParser {
accumulated_content,
accumulated_reasoning,
accumulated_tool_calls,
mut events_str,
stream_event_processor,
@@ -277,6 +278,7 @@ impl QueryBuilder for OtherQueryBuilder {
} else {
Some(accumulated_content)
},
reasoning: (!accumulated_reasoning.is_empty()).then_some(accumulated_reasoning),
tool_calls: accumulated_tool_calls.into_values().collect(),
events_str: Some(events_str),
annotations: Vec::new(),
+2
View File
@@ -33,6 +33,8 @@ pub struct BuildRequestArgs<'a> {
pub enum ParsedResponse {
Text {
content: Option<String>,
/// The thinking the model streamed before the answer, when it emitted any.
reasoning: Option<String>,
tool_calls: Vec<OpenAIToolCall>,
events_str: Option<String>,
annotations: Vec<UrlCitation>,
+13
View File
@@ -135,6 +135,8 @@ pub trait SSEParser {
pub struct OpenAISSEParser {
pub accumulated_content: String,
/// The thinking streamed before the answer, kept so it can be stored with it.
pub accumulated_reasoning: String,
pub accumulated_tool_calls: HashMap<i64, OpenAIToolCall>,
pub events_str: String,
pub stream_event_processor: Box<dyn StreamEventSink>,
@@ -146,6 +148,7 @@ impl OpenAISSEParser {
pub fn new(stream_event_processor: Box<dyn StreamEventSink>) -> Self {
Self {
accumulated_content: String::new(),
accumulated_reasoning: String::new(),
accumulated_tool_calls: HashMap::new(),
events_str: String::new(),
stream_event_processor,
@@ -175,6 +178,7 @@ impl SSEParser for OpenAISSEParser {
if let Some(mut choices) = event.choices.filter(|s| !s.is_empty()) {
if let Some(delta) = choices.remove(0).delta {
if let Some(reasoning) = delta.reasoning_content.filter(|s| !s.is_empty()) {
self.accumulated_reasoning.push_str(&reasoning);
let event = StreamingEvent::ReasoningTokenDelta { content: reasoning };
self.stream_event_processor
.send(event, &mut self.events_str)
@@ -353,6 +357,8 @@ enum ContentBlockState {
/// Anthropic SSE Parser for streaming responses
pub struct AnthropicSSEParser {
pub accumulated_content: String,
/// The thinking streamed before the answer, kept so it can be stored with it.
pub accumulated_reasoning: String,
pub accumulated_tool_calls: HashMap<i64, OpenAIToolCall>,
pub events_str: String,
pub stream_event_processor: Box<dyn StreamEventSink>,
@@ -375,6 +381,7 @@ impl AnthropicSSEParser {
pub fn new(stream_event_processor: Box<dyn StreamEventSink>) -> Self {
Self {
accumulated_content: String::new(),
accumulated_reasoning: String::new(),
accumulated_tool_calls: HashMap::new(),
events_str: String::new(),
stream_event_processor,
@@ -455,6 +462,7 @@ impl SSEParser for AnthropicSSEParser {
.thinking
.get_or_insert_with(String::new)
.push_str(&thinking);
self.accumulated_reasoning.push_str(&thinking);
self.stream_event_processor
.send(
StreamingEvent::ReasoningTokenDelta { content: thinking },
@@ -523,6 +531,7 @@ impl SSEParser for AnthropicSSEParser {
.thinking
.get_or_insert_with(String::new)
.push_str(&thinking);
self.accumulated_reasoning.push_str(&thinking);
self.stream_event_processor
.send(
StreamingEvent::ReasoningTokenDelta { content: thinking },
@@ -590,6 +599,8 @@ impl SSEParser for AnthropicSSEParser {
/// `windmill_ai::ai_google` so the logic can be shared with the API proxy.
pub struct GeminiSSEParser {
pub accumulated_content: String,
/// The thinking streamed before the answer, kept so it can be stored with it.
pub accumulated_reasoning: String,
pub accumulated_tool_calls: HashMap<i64, OpenAIToolCall>,
pub events_str: String,
pub stream_event_processor: Box<dyn StreamEventSink>,
@@ -603,6 +614,7 @@ impl GeminiSSEParser {
pub fn new(stream_event_processor: Box<dyn StreamEventSink>) -> Self {
Self {
accumulated_content: String::new(),
accumulated_reasoning: String::new(),
accumulated_tool_calls: HashMap::new(),
events_str: String::new(),
stream_event_processor,
@@ -621,6 +633,7 @@ impl SSEParser for GeminiSSEParser {
};
if let Some(reasoning) = parsed.reasoning.filter(|s| !s.is_empty()) {
self.accumulated_reasoning.push_str(&reasoning);
self.stream_event_processor
.send(
StreamingEvent::ReasoningTokenDelta { content: reasoning },
@@ -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))
}
@@ -36,11 +37,31 @@ pub struct FlowConversationMessage {
pub created_seq: i64,
pub step_name: Option<String>,
pub success: bool,
/// The call behind a tool row whose tool has no job of its own — an MCP tool, or a
/// provider-native one. Read back from the job otherwise, and null here.
pub tool_arguments: Option<String>,
pub tool_result: Option<String>,
/// The thinking that produced an answer, streamed by the provider and stored here
/// because nothing else keeps it.
pub reasoning: Option<String>,
}
/// 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 +88,7 @@ async fn list_conversations(
"created_at",
"updated_at",
"created_by",
"is_test",
])
.and_where_eq("workspace_id", "?".bind(&w_id));
@@ -74,6 +96,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 +133,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 +180,44 @@ async fn delete_conversation(
Ok(format!("Conversation {} deleted", conversation_id))
}
#[derive(Deserialize)]
pub struct UpdateConversation {
/// The chat's name. Set from the first message when the chat is created, and left
/// alone afterwards, so a typed one stays typed.
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> {
// 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?;
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>,
@@ -178,7 +248,7 @@ async fn list_messages(
let messages = if let Some(after_seq) = query.after_seq {
sqlx::query_as!(
FlowConversationMessage,
r#"SELECT id, conversation_id, message_type as "message_type: MessageType", content, job_id, created_at, created_seq, step_name, success
r#"SELECT id, conversation_id, message_type as "message_type: MessageType", content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning
FROM flow_conversation_message
WHERE conversation_id = $1
AND created_seq > $2
@@ -195,9 +265,9 @@ async fn list_messages(
// Fetch messages for this conversation, oldest first, but reverse the order of the messages for easy rendering on the frontend
sqlx::query_as!(
FlowConversationMessage,
r#"SELECT id, conversation_id, message_type as "message_type: MessageType", content, job_id, created_at, created_seq, step_name, success
r#"SELECT id, conversation_id, message_type as "message_type: MessageType", content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning
FROM (
SELECT id, conversation_id, message_type, content, job_id, created_at, created_seq, step_name, success
SELECT id, conversation_id, message_type, content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning
FROM flow_conversation_message
WHERE conversation_id = $1
ORDER BY created_seq DESC
+16 -2
View File
@@ -668,10 +668,17 @@ pub async fn handle_chat_conversation_messages(
flow_path: &str,
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`.
let memory_id = run_query.memory_id.ok_or_else(|| {
windmill_common::error::Error::BadRequest(
"memory_id is required for chat-enabled flows".to_string(),
"memory_id is required for chat-enabled flows. Pass it as the `memory_id` query \
parameter, not as a flow argument: it names the conversation the turn belongs to, \
so a fresh UUID starts one and reusing a UUID continues it."
.to_string(),
)
})?;
@@ -695,17 +702,22 @@ pub async fn handle_chat_conversation_messages(
&authed.username,
&user_message,
memory_id,
is_test,
)
.await?;
// The run this message started. Its args are the only record of what the message
// carried besides its text — attachments and every other flow input — and nothing
// written later points at them: an assistant row holds the AI agent step's job.
add_message_to_conversation_tx(
tx,
memory_id,
None,
Some(job_id),
&user_message,
MessageType::User,
None,
true,
None,
)
.await?;
@@ -826,6 +838,8 @@ pub async fn run_flow<'c>(
&flow_path.to_string(),
&run_query,
args.args.get("user_message"),
uuid,
false,
)
.await?;
}
+42 -5
View File
@@ -692,16 +692,53 @@ pub async fn delete_jobs(
.await?
.rows_affected();
let conversation_message_deleted = sqlx::query!(
// One row per message deleted, so the conversation of a chat losing several appears
// several times: the count is taken before the dedup below.
let mut conversation_ids: Vec<Uuid> = sqlx::query_scalar!(
"DELETE FROM flow_conversation_message m
USING flow_conversation c
WHERE m.conversation_id = c.id AND c.workspace_id = $1 AND m.job_id = ANY($2)",
WHERE m.conversation_id = c.id AND c.workspace_id = $1 AND m.job_id = ANY($2)
RETURNING m.conversation_id",
&w_id,
&job_ids
)
.execute(&mut *tx)
.await?
.rows_affected();
.fetch_all(&mut *tx)
.await?;
let conversation_message_deleted = conversation_ids.len() as u64;
// Same rule as retention (windmill_common::jobs::delete_jobs): a conversation with no
// messages left goes, and the agent's memory for it with it.
conversation_ids.sort_unstable();
conversation_ids.dedup();
if !conversation_ids.is_empty() {
sqlx::query!(
"DELETE FROM ai_agent_memory a
USING flow_conversation c
WHERE c.id = ANY($1)
AND c.workspace_id = $2
AND a.conversation_id = c.id
AND a.workspace_id = c.workspace_id
AND NOT EXISTS (
SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id
)",
&conversation_ids,
&w_id
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM flow_conversation c
WHERE c.id = ANY($1)
AND c.workspace_id = $2
AND NOT EXISTS (
SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id
)",
&conversation_ids,
&w_id
)
.execute(&mut *tx)
.await?;
}
// Resolutions are not exported, so a delete-then-reimport of the same UUID would
// otherwise resurrect the old annotation on a job that never carried one.
+66 -1
View File
@@ -12403,6 +12403,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, or both
in: query
schema:
type: string
enum:
- test
- deployed
- all
responses:
"200":
description: flow conversations list
@@ -12413,6 +12422,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
@@ -27958,7 +28001,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
@@ -27985,6 +28028,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
@@ -28024,6 +28070,25 @@ components:
success:
type: boolean
description: Whether the message is a success
tool_arguments:
type: string
nullable: true
description: >-
The call, for a tool that runs inside the agent's job and so has none of its
own: an MCP tool, or a provider-native one such as web search. A Windmill tool
is a script or flow with its own job, and its call is read from there instead.
tool_result:
type: string
nullable: true
description: >-
What that same call returned, including the citations of a provider-native web
search. Null on a call that failed, which `success` reports.
reasoning:
type: string
nullable: true
description: >-
The thinking behind this answer. Stored because it reaches the job result only
as `wm_stream`, which a step with `streaming: false` never accumulates.
EndpointTool:
type: object
+3
View File
@@ -9553,6 +9553,9 @@ async fn run_preview_flow_job(
&flow_path,
&run_query,
user_message.as_ref(),
uuid,
// Run from the editor's test panel: a trial, not a real conversation.
true,
)
.await?;
}
@@ -26,6 +26,8 @@ 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,
}
pub async fn get_or_create_conversation_with_id(
@@ -35,11 +37,12 @@ pub async fn get_or_create_conversation_with_id(
username: &str,
title: &str,
conversation_id: Uuid,
is_test: bool,
) -> Result<FlowConversation> {
// Check if conversation already exists
let existing_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,
@@ -58,14 +61,15 @@ pub async fn get_or_create_conversation_with_id(
// Create new conversation with provided ID
let conversation = sqlx::query_as!(
FlowConversation,
"INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by",
"INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title, is_test)
VALUES ($1, $2, $3, $4, $5, $6)
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_one(&mut **tx)
.await?;
@@ -73,6 +77,17 @@ pub async fn get_or_create_conversation_with_id(
Ok(conversation)
}
/// What a row carries beyond its text, for the parts of a turn no job can be asked for.
/// An MCP or provider-native tool runs inside the agent's job, which holds every call of
/// the turn and nothing tying one to a row. Thinking reaches that job's result only as
/// `wm_stream`, which a step with `streaming: false` never accumulates.
#[derive(Debug, Clone, Default)]
pub struct MessageExtras {
pub tool_arguments: Option<String>,
pub tool_result: Option<String>,
pub reasoning: Option<String>,
}
/// Add a message to a conversation using an existing transaction
/// If the conversation doesn't exist, logs a warning and returns Ok (no error thrown)
/// This allows memory_id to be used for agent memory without requiring a conversation
@@ -84,6 +99,7 @@ pub async fn add_message_to_conversation_tx(
message_type: MessageType,
step_name: Option<&str>,
success: bool,
extras: Option<&MessageExtras>,
) -> Result<()> {
// Check if conversation exists first
let conversation_exists = sqlx::query!(
@@ -104,14 +120,17 @@ pub async fn add_message_to_conversation_tx(
// Insert the message
sqlx::query!(
"INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success)
VALUES ($1, $2, $3, $4, $5, $6)",
"INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success, tool_arguments, tool_result, reasoning)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
conversation_id,
message_type as MessageType,
content,
job_id,
step_name,
success
success,
extras.and_then(|e| e.tool_arguments.as_deref()),
extras.and_then(|e| e.tool_result.as_deref()),
extras.and_then(|e| e.reasoning.as_deref())
)
.execute(&mut **tx)
.await?;
+36 -3
View File
@@ -485,12 +485,45 @@ pub async fn delete_jobs(conn: &mut sqlx::PgConnection, ids: &[uuid::Uuid]) -> e
)
.execute(&mut *conn)
.await?;
sqlx::query!(
"DELETE FROM flow_conversation_message WHERE job_id = ANY($1)",
let mut conversation_ids: Vec<uuid::Uuid> = sqlx::query_scalar!(
"DELETE FROM flow_conversation_message WHERE job_id = ANY($1) RETURNING conversation_id",
ids
)
.execute(&mut *conn)
.fetch_all(&mut *conn)
.await?;
conversation_ids.sort_unstable();
conversation_ids.dedup();
if !conversation_ids.is_empty() {
// A conversation is a view over its messages: once the last one goes with its job,
// the row and the agent's memory for it are all that is left, and nothing else
// collects them — `ai_agent_memory` carries no job id for retention to match on.
// Two statements rather than one CTE: a data-modifying CTE reads the snapshot from
// before the delete above, so every conversation would still look non-empty.
// Memory first, since it reads the conversation row for its workspace.
sqlx::query!(
"DELETE FROM ai_agent_memory a
USING flow_conversation c
WHERE c.id = ANY($1)
AND a.conversation_id = c.id
AND a.workspace_id = c.workspace_id
AND NOT EXISTS (
SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id
)",
&conversation_ids
)
.execute(&mut *conn)
.await?;
sqlx::query!(
"DELETE FROM flow_conversation c
WHERE c.id = ANY($1)
AND NOT EXISTS (
SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id
)",
&conversation_ids
)
.execute(&mut *conn)
.await?;
}
sqlx::query!("DELETE FROM zombie_job_counter WHERE job_id = ANY($1)", ids)
.execute(&mut *conn)
.await?;
+40 -6
View File
@@ -33,7 +33,7 @@ use windmill_common::{
client::AuthedClient,
db::DB,
error::Error,
flow_conversations::MessageType,
flow_conversations::{MessageExtras, MessageType},
flow_status::AgentAction,
flows::FlowModuleValue,
worker::{to_raw_value, Connection},
@@ -235,9 +235,24 @@ async fn execute_mcp_tool_call(
update_flow_status_module_with_actions_success(ctx.db, parent_job, true).await?;
}
// Add tool message to conversation if chat_input_enabled
// An MCP tool runs inside the agent's job, whose result holds every call of the
// turn and nothing tying one of them to this row: same job id for all of them,
// no call id on the row. Kept here so the card shows this call — and the row
// names that job, so retention sweeps it with every other row of the turn.
let content = format!("Used {} tool", tool_call.function.name);
add_tool_message_to_chat(ctx, None, &content, true).await;
let agent_job_id = ctx.job.id;
add_tool_message_to_chat(
ctx,
Some(agent_job_id),
&content,
true,
Some(MessageExtras {
tool_arguments: Some(tool_call.function.arguments.clone()),
tool_result: Some(result_str),
..Default::default()
}),
)
.await;
}
Err(e) => {
let error_msg = format!("MCP tool error: {}", e);
@@ -272,7 +287,18 @@ async fn execute_mcp_tool_call(
}
// Add tool message to conversation if chat_input_enabled
add_tool_message_to_chat(ctx, None, &error_msg, false).await;
let agent_job_id = ctx.job.id;
add_tool_message_to_chat(
ctx,
Some(agent_job_id),
&error_msg,
false,
Some(MessageExtras {
tool_arguments: Some(tool_call.function.arguments.clone()),
..Default::default()
}),
)
.await;
}
}
@@ -681,7 +707,7 @@ async fn handle_tool_execution_error(
}
// Add tool message to conversation if chat_input_enabled (error case)
add_tool_message_to_chat(ctx, Some(job_id), &error_message, false).await;
add_tool_message_to_chat(ctx, Some(job_id), &error_message, false, None).await;
Ok(())
}
@@ -806,7 +832,7 @@ async fn handle_tool_execution_success(
format!("Error executing {}", tool_call.function.name)
};
add_tool_message_to_chat(ctx, Some(job_id), &content, success).await;
add_tool_message_to_chat(ctx, Some(job_id), &content, success, None).await;
Ok(())
}
@@ -814,9 +840,16 @@ async fn handle_tool_execution_success(
/// Add tool message to conversation if chat is enabled
async fn add_tool_message_to_chat(
ctx: &mut ToolExecutionContext<'_>,
// The job this row belongs to: the tool's own where it has one, else the agent's, which
// is the job it ran inside. Every row names one so that retention collects the whole
// turn — `delete_jobs` removes messages by `job_id = ANY(..)` (there is no FK on the
// column; `drop_v2_job_side_table_cascades` dropped it), and a row naming no job would
// survive every purge and leave a conversation that can never become empty.
tool_job_id: Option<Uuid>,
content: &str,
success: bool,
// Only for a tool with no job of its own; a Windmill tool's call is read from its job.
extras: Option<MessageExtras>,
) {
if ctx.omit_output_from_conversation {
return;
@@ -852,6 +885,7 @@ async fn add_tool_message_to_chat(
MessageType::Tool,
&step_name,
success,
extras.as_ref(),
)
.await
{
+3 -1
View File
@@ -13,7 +13,7 @@ use windmill_common::flows::FlowModuleValue;
use windmill_common::{
db::DB,
error::Error,
flow_conversations::{add_message_to_conversation_tx, MessageType},
flow_conversations::{add_message_to_conversation_tx, MessageExtras, MessageType},
flow_status::AgentAction,
flows::{InputTransform, Step},
jobs::JobKind,
@@ -209,6 +209,7 @@ pub async fn add_message_to_conversation(
message_type: MessageType,
step_name: &Option<String>,
success: bool,
extras: Option<&MessageExtras>,
) -> Result<(), Error> {
let mut tx = db.begin().await?;
add_message_to_conversation_tx(
@@ -219,6 +220,7 @@ pub async fn add_message_to_conversation(
message_type,
step_name.as_deref(),
success,
extras,
)
.await?;
tx.commit().await?;
+55 -1
View File
@@ -40,7 +40,7 @@ use windmill_common::{
client::AuthedClient,
db::DB,
error::{self, Error},
flow_conversations::MessageType,
flow_conversations::{MessageExtras, MessageType},
flow_status::AgentAction,
flows::{AgentTool, FlowModule, FlowModuleValue, InputTransform, ToolValue},
get_latest_hash_for_path,
@@ -1355,6 +1355,7 @@ pub async fn run_agent(
match parsed {
ParsedResponse::Text {
content: response_content,
reasoning: response_reasoning,
tool_calls,
events_str,
annotations,
@@ -1394,6 +1395,13 @@ pub async fn run_agent(
let db_clone = db.clone();
let message_content = "Used websearch tool successfully".to_string();
let step_name = step_name.clone();
// The search ran inside the provider's call, so this job's args
// describe the agent, not the search: its sources reach the row
// only if they are written here.
let extras = (!annotations.is_empty()).then(|| MessageExtras {
tool_result: serde_json::to_string(&annotations).ok(),
..Default::default()
});
tokio::spawn(async move {
if let Err(e) = add_message_to_conversation(
&db_clone,
@@ -1403,6 +1411,7 @@ pub async fn run_agent(
MessageType::Tool,
&step_name,
true,
extras.as_ref(),
)
.await
{
@@ -1446,6 +1455,11 @@ pub async fn run_agent(
let db_clone = db.clone();
let message_content = response_content.clone();
let step_name = step_name.clone();
// The thinking is streamed and never returned in a response
// body, so the answer's row is the only place it can be kept.
let extras = response_reasoning.clone().map(|reasoning| {
MessageExtras { reasoning: Some(reasoning), ..Default::default() }
});
// Spawn task because we do not need to wait for the result
tokio::spawn(async move {
@@ -1457,6 +1471,7 @@ pub async fn run_agent(
MessageType::Assistant,
&step_name,
true,
extras.as_ref(),
)
.await
{
@@ -1471,6 +1486,44 @@ pub async fn run_agent(
}
}
// An iteration that answered with tool calls has no message row to carry its
// thinking, and the next iteration's row holds only its own. Stored on a row
// of its own so a reader sees what led to the call.
if persist_output_to_conversation
&& response_content.as_deref().unwrap_or("").is_empty()
{
if let (Some(memory_id), Some(reasoning)) =
(memory_id, response_reasoning.clone())
{
let agent_job_id = job.id;
let db_clone = db.clone();
let step_name = step_name.clone();
tokio::spawn(async move {
if let Err(e) = add_message_to_conversation(
&db_clone,
&memory_id,
Some(agent_job_id),
"",
MessageType::Assistant,
&step_name,
true,
Some(&MessageExtras {
reasoning: Some(reasoning),
..Default::default()
}),
)
.await
{
tracing::warn!(
"Failed to add reasoning message to conversation {}: {}",
memory_id,
e
);
}
});
}
}
if tool_calls.is_empty() {
break;
} else if i == max_iterations - 1 {
@@ -1584,6 +1637,7 @@ pub async fn run_agent(
MessageType::Assistant,
&step_name,
true,
None,
)
.await
{
@@ -2235,6 +2235,7 @@ async fn add_tool_message_to_conversation(
MessageType::Assistant,
None,
success,
None,
)
.await?;
tx.commit().await?;
+52 -2
View File
@@ -5268,9 +5268,59 @@ tool, \`websearch\` for web search.
}
\`\`\`
- \`provider\` is a static object, not a bare resource string: \`{ "kind": <provider kind>,
- \`provider\` is an object, not a bare resource string: \`{ "kind": <provider kind>,
"resource": "$res:<path>", "model": <model id> }\`. Required unless the module links to a saved
agent through \`value.agent\`
agent through \`value.agent\`. Static is right for a flow run from a form; a chat flow wires its
fields to flow inputs instead see below
### Chat-Mode Flows
A flow with \`value.chat_input_enabled: true\` is run from a chat instead of a form: the composer
sends one message per turn and renders the conversation. It needs a required \`user_message\` string
input, read by the agent.
**A static \`provider\` gives a chat that cannot change its model.** Feed it from flow inputs
instead, either way round: one input carrying the whole object (\`"expr": "flow_input.model_config"\`)
makes every field editable, or wire it field by field to fix some and expose others. A field the
chat can write becomes a control in the composer a provider picker, a model list, a thinking
slider and a field left static is fixed, with no control drawn for it. Two fields are
exceptions: \`kind\`, which the button writes only together with \`resource\` since a provider is
picked as a pair, and \`reasoning_effort\`, which gets a slider only for a model Windmill has
thinking levels for. Either way the field stays askable under Configure inputs, so nothing
the run needs becomes unreachable.
\`user_attachments\` works the same way: point it at an s3-object input and the composer gets a
paperclip.
\`\`\`json
{
"id": "chat_agent",
"value": {
"type": "aiagent",
"input_transforms": {
"provider": {
"type": "javascript",
"expr": "({ kind: 'anthropic', resource: '$res:f/ai/claude', model: flow_input.model, reasoning_effort: flow_input.thinking })"
},
"user_message": { "type": "javascript", "expr": "flow_input.user_message" },
"user_attachments": { "type": "javascript", "expr": "flow_input.files" },
"memory": { "type": "static", "value": { "kind": "auto", "context_length": 10 } },
"streaming": { "type": "static", "value": true },
"output_type": { "type": "static", "value": "text" }
},
"tools": []
}
}
\`\`\`
- \`memory\` is what lets the agent see earlier turns; without it every message starts from nothing
- \`streaming\` on makes the answer and its thinking appear token by token instead of all at once
- Running one needs a \`memory_id\` **query parameter** — not a flow argument — naming the
conversation the turn belongs to: a fresh UUID starts one, reusing a UUID continues it. The chat
supplies it itself; a run driven any other way has to pass it or the server refuses the job
- Wiring field by field means one object literal whose values are literals or bare \`flow_input.x\`
references. A spread, a call or a computed key leaves the composer unable to tell which input
feeds which field, so it offers no control at all a bare \`flow_input.x\` for the whole object
is read instead as that one input carrying every field
### Tool Naming Rules
+2
View File
@@ -45,6 +45,8 @@ export interface SchemaProperty {
required?: string[]
showExpr?: string
hideWhenChatEnabled?: boolean
/** Why the oneOf variant is chat mode's to pick. Set = selector disabled, reason shown. */
lockOneOfWhenChatEnabled?: string
password?: boolean
order?: string[]
nullable?: boolean
@@ -27,7 +27,7 @@
let capability = $derived(
provider && model
? getReasoningCapability(provider, model)
: { supported: false, levels: [], canDisable: false }
: { supported: false, levels: [], canDisable: false, known: false }
)
// The token that turns reasoning off on a model that reasons by default
@@ -123,6 +123,8 @@
workspace?: string | undefined
s3StorageConfigured?: boolean
chatInputEnabled?: boolean
/** Why the oneOf variant is fixed. Set = the selector is disabled and says so. */
oneOfLockedReason?: string
actions?: import('svelte').Snippet
innerBottomSnippet?: import('svelte').Snippet
fieldHeaderActions?: import('svelte').Snippet
@@ -184,6 +186,7 @@
workspace = undefined,
s3StorageConfigured = true,
chatInputEnabled = false,
oneOfLockedReason = undefined,
actions,
innerBottomSnippet,
fieldHeaderActions,
@@ -1104,11 +1107,15 @@
{:else if inputCat == 'object' || inputCat == 'resource-object' || isListJson}
{#if oneOf && oneOf.length >= 2}
<div class="flex flex-col gap-2 w-full border rounded-md p-4">
{#if oneOfLockedReason !== undefined}
<div class="text-2xs text-tertiary">{oneOfLockedReason}</div>
{/if}
{#if oneOf && oneOf.length >= 2}
<ToggleButtonGroup
selected={oneOfSelected}
wrap
class="mb-4"
disabled={disabled || oneOfLockedReason !== undefined}
on:selected={({ detail }) => {
oneOfSelected = detail
const selectedObjProperties =
@@ -43,6 +43,8 @@
interface Props {
schema: Schema | any
hiddenArgs?: string[]
/** Fields another part of the app owns: shown, but not renameable, deletable or retypeable. */
lockedArgs?: string[]
args?: Record<string, any>
shouldHideNoInputs?: boolean
noVariablePicker?: boolean
@@ -89,6 +91,7 @@
let {
schema = $bindable(),
hiddenArgs = [],
lockedArgs = [],
args = $bindable(undefined),
shouldHideNoInputs = false,
noVariablePicker = false,
@@ -587,6 +590,7 @@
>
{#if keys.length > 0}
{#each keys as argName, i (argName)}
{@const locked = lockedArgs.includes(argName)}
<div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -605,7 +609,7 @@
>
<div class="flex flex-row gap-2 text-sm">
{argName}
{#if !uiOnly}
{#if !uiOnly && !locked}
<div onclick={stopPropagation(preventDefault(bubble('click')))}>
<Popover placement="bottom-end" closeButton>
{#snippet trigger()}
@@ -654,7 +658,7 @@
<span class="text-red-500 text-xs"> Required </span>
{/if}
{#if !uiOnly}
{#if !uiOnly && !locked}
<button
class="delete-schema-field-button
rounded-full p-1 text-gray-500 bg-white
@@ -701,6 +705,7 @@
<ToggleButtonGroup
tabListClass="flex-wrap"
class="h-auto"
disabled={lockedArgs.includes(opened ?? '')}
bind:selected={
() => computeSelected(schema.properties[opened ?? '']),
(v) => {
@@ -44,6 +44,7 @@
import FlowRestartButton from './FlowRestartButton.svelte'
import { useNestedRestartState } from './useNestedRestartState.svelte'
import { buildFlowRecording, downloadRecordingJson } from './recording/runRecording'
import { agentStreamingEnabled } from './flows/agentFormFields'
interface Props {
previewMode: 'upTo' | 'whole'
@@ -160,6 +161,13 @@
let loadingHistory = $state(false)
let shouldUseStreaming = $derived.by(() => {
const modules = flowStore.val.value?.modules
const lastModule = modules && modules.length > 0 ? modules[modules.length - 1] : undefined
if (lastModule?.value?.type !== 'aiagent') return false
return agentStreamingEnabled(lastModule.value)
})
function extractFlow(previewMode: 'upTo' | 'whole'): OpenFlow {
if (previewMode === 'whole') {
return flowStore.val
@@ -462,6 +470,7 @@
{#if flowStore.val.value?.chat_input_enabled}
<div class="flex flex-row justify-center w-full mb-6">
<FlowChat
useStreaming={shouldUseStreaming}
onRunFlow={async (userMessage, conversationId, additionalInputs) => {
await runPreview(
{ user_message: userMessage, ...(additionalInputs ?? {}) },
@@ -470,9 +479,11 @@
)
return jobId ?? ''
}}
hideSidebar={true}
conversationKind="test"
frame="boxed"
path={$pathStore}
inputSchema={flowStore.val.schema}
flowModules={flowStore.val.value?.modules}
/>
</div>
{:else}
@@ -557,7 +568,13 @@
</div>
{/if}
{/if}
<div class="pt-4 flex flex-col border-t relative">
<!-- The rule divides the inputs form from its results. Chat mode has no form: the
chat is its own panel, and a second line right under it reads as a stray edge. -->
<div
class="pt-4 flex flex-col relative {flowStore.val.value?.chat_input_enabled
? ''
: 'border-t'}"
>
{#if flowHasChanged()}
<div class="pb-2">
<div
@@ -999,6 +999,11 @@
{helperScript}
{s3StorageConfigured}
{chatInputEnabled}
oneOfLockedReason={chatInputEnabled &&
arg?.type === 'static' &&
(arg.value as any)?.kind !== 'off'
? schema.properties[argName]?.lockOneOfWhenChatEnabled
: undefined}
otherArgs={Object.fromEntries(
Object.entries(otherArgs).map(([key, transform]) => [
key,
@@ -8,7 +8,7 @@
import type { PickableProperties } from './flows/previousResults'
import InputTransformForm from './InputTransformForm.svelte'
import InputTransformPickers from './InputTransformPickers.svelte'
import { useS3StorageConfigured } from './inputTransformEnv.svelte'
import { useWorkspaceStorageConfigured } from './inputTransformEnv.svelte'
import type ItemPicker from './ItemPicker.svelte'
import type VariableEditor from './VariableEditor.svelte'
import ResizeTransitionWrapper from './common/ResizeTransitionWrapper.svelte'
@@ -86,7 +86,7 @@
let itemPicker: ItemPicker | undefined = $state(undefined)
let variableEditor: VariableEditor | undefined = $state(undefined)
const s3Storage = useS3StorageConfigured(() => ws)
const s3Storage = useWorkspaceStorageConfigured(() => ws)
let keys: string[] = $state([])
$effect(() => {
@@ -0,0 +1,66 @@
<script lang="ts">
/**
* A soft edge on a scroller, so content scrolling out of view fades instead of being
* cut against whatever borders it.
*
* Rendered as an overlay in the scroller's positioned ancestor rather than inside the
* scroller: `sticky` would resolve against the scroller's padding box and leave the
* first few pixels unfaded. It shows only when there is something hidden in that
* direction, so a transcript that fits shows no edge at all.
*/
import { twMerge } from 'tailwind-merge'
interface Props {
/** The scrolling element this masks. */
scroller: HTMLElement | undefined
edge?: 'top' | 'bottom'
/** Tailwind colour stop to fade from — the surface the scroller sits on. */
from?: string
/** Tailwind height of the fade band. */
height?: string
class?: string
}
let {
scroller,
edge = 'top',
from = 'from-surface',
height = 'h-4',
class: className = ''
}: Props = $props()
let hidden = $state(true)
$effect(() => {
const el = scroller
if (!el) return
const update = () => {
// A pixel of slack: fractional scroll offsets otherwise leave the bottom edge
// showing on a scroller that is already at its end.
hidden =
edge === 'top' ? el.scrollTop <= 1 : el.scrollTop + el.clientHeight >= el.scrollHeight - 1
}
update()
el.addEventListener('scroll', update, { passive: true })
// Content arriving or the pane resizing changes what is hidden without a scroll.
const observer = new ResizeObserver(update)
observer.observe(el)
if (el.firstElementChild) observer.observe(el.firstElementChild)
return () => {
el.removeEventListener('scroll', update)
observer.disconnect()
}
})
</script>
<div
class={twMerge(
'pointer-events-none absolute inset-x-0 transition-opacity duration-150',
edge === 'top' ? 'top-0 bg-gradient-to-b' : 'bottom-0 bg-gradient-to-t',
from,
'to-transparent',
height,
hidden ? 'opacity-0' : 'opacity-100',
className
)}
></div>
+77 -18
View File
@@ -1,30 +1,89 @@
/**
* The AI agent's streamed events, as the worker writes them.
*
* One SSE chunk can carry several lines, so parsing returns a list: a chunk holding a
* tool call and its result must not collapse to whichever came last. Mirrors
* `StreamingEvent` in backend/windmill-ai/src/types.rs (tagged `type`, snake_case).
*/
export type StreamEvent =
| { kind: 'token'; content: string }
| { kind: 'reasoning'; content: string }
| { kind: 'tool_call'; callId: string; name: string }
| { kind: 'tool_arguments'; callId: string; name: string; arguments: string }
| { kind: 'tool_execution'; callId: string; name: string }
| { kind: 'tool_result'; callId: string; name: string; result: string; success: boolean }
export function parseStreamEvents(streamData: string): StreamEvent[] {
const events: StreamEvent[] = []
for (const line of streamData.trim().split('\n')) {
if (!line.trim()) continue
let parsed: any
try {
parsed = JSON.parse(line)
} catch (e) {
console.error('Failed to parse stream line:', line, e)
continue
}
switch (parsed?.type) {
case 'token_delta':
if (parsed.content) events.push({ kind: 'token', content: parsed.content })
break
case 'reasoning_token_delta':
if (parsed.content) events.push({ kind: 'reasoning', content: parsed.content })
break
case 'tool_call':
events.push({ kind: 'tool_call', callId: parsed.call_id, name: parsed.function_name })
break
case 'tool_call_arguments':
events.push({
kind: 'tool_arguments',
callId: parsed.call_id,
name: parsed.function_name,
arguments: parsed.arguments ?? ''
})
break
case 'tool_execution':
events.push({ kind: 'tool_execution', callId: parsed.call_id, name: parsed.function_name })
break
case 'tool_result':
events.push({
kind: 'tool_result',
callId: parsed.call_id,
name: parsed.function_name,
result: parsed.result ?? '',
success: parsed.success !== false
})
break
}
}
return events
}
/** One-line summary of a tool call, for a surface with no room for the call itself. */
export function toolSummary(name: string, success: boolean): string {
return success ? `Used ${name} tool` : `Failed to use ${name} tool`
}
/**
* Flattened view of a chunk, for callers that render a single running string.
* Keeps the shape AppChat has always consumed.
*/
export function parseStreamDeltas(streamData: string): {
content: string
type?: string
success?: boolean
} {
const lines = streamData.trim().split('\n')
let content = ''
let type = 'message'
let success = true
for (const line of lines) {
if (!line.trim()) continue
try {
const parsed = JSON.parse(line)
if (parsed.type === 'tool_result') {
type = 'tool_result'
success = parsed.success
const toolName = parsed.function_name
content = success ? `Used ${toolName} tool` : `Failed to use ${toolName} tool`
}
if (parsed.type === 'token_delta' && parsed.content) {
content += parsed.content
}
} catch (e) {
console.error('Failed to parse stream line:', line, e)
for (const event of parseStreamEvents(streamData)) {
if (event.kind === 'token') {
content += event.content
} else if (event.kind === 'tool_result') {
type = 'tool_result'
success = event.success
content = toolSummary(event.name, event.success)
}
}
return { content, type, success }
}
@@ -0,0 +1,53 @@
<script lang="ts">
/**
* How many messages have arrived somewhere since it was last read.
*
* Sits in the row's own flow by default, which is where a list uses it; `class` pins it
* to a corner for a caller that has one icon standing for the whole list. Nothing renders
* at zero — an absent badge is what "nothing new" looks like.
*/
import { twMerge } from 'tailwind-merge'
interface Props {
count: number
/** What the count is of, for the label a screen reader reads. */
noun?: string
/** Positioning for a caller that pins it to a corner rather than letting it sit in
* the row — the collapsed rail, where the count belongs to an icon button. */
class?: string
/** The 12px form, for a corner where the row-sized badge would crowd the icon. */
small?: boolean
}
let { count, noun = 'message', class: className = '', small = false }: Props = $props()
</script>
{#if count > 0}
<span
class={twMerge(
'unread-badge inline-flex items-center justify-center rounded-full bg-surface-accent-primary text-white font-medium',
small ? 'min-w-3 h-3 px-0.5 text-[8px]' : 'min-w-3.5 h-3.5 px-1 text-[9px]',
// After the size, not before: tailwind-merge counts a text size as resetting
// line-height, so a `leading-*` ahead of one is dropped from the result.
'leading-none',
className
)}
aria-label="{count} unread {noun}{count === 1 ? '' : 's'}"
>
{count > 9 ? '9+' : count}
</span>
{/if}
<style>
/*
* Centring a digit by flex centres its *line box*, which is the font's em box — and a
* digit's ink does not sit in the middle of that. Inter reserves descender space a
* figure never uses, so the glyph lands fractionally low; at these sizes that reads as
* the badge being a pixel off. Trimming the box to cap-height and baseline makes the
* ink itself what gets centred. Dropped silently where it is unsupported, which leaves
* the same near-miss as before rather than anything worse.
*/
.unread-badge {
text-box: trim-both cap alphabetic;
}
</style>
@@ -0,0 +1,293 @@
<script lang="ts">
/**
* The model button every chat puts in the bottom-right of its composer: the trigger
* names the model and its reasoning effort, and the menu holds the choices behind
* both. Driven entirely by ChatModelSettingsConfig, so the session chat and the flow
* chat render the same control from different data — see chatModelSettings.ts.
*/
import { ChevronDown, Check, Loader2 } from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import DropdownSubmenuItem from '$lib/components/DropdownSubmenuItem.svelte'
import MenuItem from '$lib/components/meltComponents/MenuItem.svelte'
import MenuItemWrapper from '$lib/components/meltComponents/MenuItemWrapper.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import ReasoningEffortSlider from './ReasoningEffortSlider.svelte'
import { getReasoningCapability, resolveEffectiveReasoning } from './reasoningRegistry'
import {
fixedReasoningReason,
reasoningControlState,
reasoningDisplay,
type ChatModelSettingsConfig,
type ChoiceSection
} from './chatModelSettings'
import type { Item } from '$lib/utils'
import type { MenubarMenuElements, createDropdownMenu } from '@melt-ui/svelte'
import { twMerge } from 'tailwind-merge'
type MeltItem = MenubarMenuElements['item']
type MeltBuilders = ReturnType<typeof createDropdownMenu>['builders']
let { config }: { config: ChatModelSettingsConfig } = $props()
const reasoning = $derived(config.reasoning)
const capability = $derived(
reasoning?.provider && reasoning.model
? getReasoningCapability(reasoning.provider, reasoning.model)
: { supported: false, levels: [] as string[], canDisable: false, known: false }
)
const controlState = $derived(reasoningControlState(reasoning, capability))
const fixedReason = $derived(fixedReasoningReason(reasoning, capability))
// Effective effort accounts for the default-on level on capable models.
const effective = $derived(
reasoning?.provider && reasoning.model
? resolveEffectiveReasoning({
provider: reasoning.provider,
model: reasoning.model,
reasoning: reasoning.value
})
: undefined
)
// The stops, the one in use and the trigger's suffix are decided together, in one
// tested place: they have to agree, and three rounds of review found them disagreeing.
const display = $derived(reasoningDisplay(reasoning, capability, effective))
const stops = $derived(display.stops)
const currentStop = $derived(display.currentStop)
const effortLabel = $derived(display.label)
let effortSlider: ReasoningEffortSlider | undefined = $state(undefined)
// The trigger label resizes when the effort changes (dragging the slider while the menu
// is open). With a `bottom-end` popover anchored to the trigger's right edge, that resize
// would shift the popover, so freeze the trigger to its width at open time and release it
// on close — no movement while open, natural sizing the rest of the time.
let menuOpen = $state(false)
let triggerEl: HTMLElement | undefined = $state(undefined)
let lockedWidth = $state<number | undefined>(undefined)
$effect(() => {
if (menuOpen) {
if (lockedWidth === undefined && triggerEl) {
lockedWidth = triggerEl.getBoundingClientRect().width
}
} else {
lockedWidth = undefined
}
})
// Blocks are separated, not prefixed: a rule belongs between two of them, so the first
// one rendered must not draw one above itself whichever block that turns out to be.
const BLOCK_CLASS =
'border-border-light [&:not(:first-child)]:border-t [&:not(:first-child)]:mt-1 [&:not(:first-child)]:pt-1'
const ROW_CLASS =
'w-full flex items-center gap-2 px-3 py-1.5 text-left font-normal hover:bg-surface-hover data-[highlighted]:bg-surface-hover rounded-sm transition-colors cursor-pointer'
</script>
{#snippet trigger()}
<div
bind:this={triggerEl}
style={lockedWidth !== undefined ? `width: ${lockedWidth}px` : undefined}
>
<Button
nonCaptureEvent
unifiedSize="2xs"
variant="subtle"
disabled={config.readOnly}
endIcon={config.readOnly ? undefined : { icon: ChevronDown }}
btnClasses="w-full max-w-[200px] text-secondary font-normal"
title={config.readOnly ? config.readOnlyReason : config.title}
>
<span class="flex items-center gap-1 min-w-0">
<span class="truncate">{config.label}</span>
{#if effortLabel}
<span class="shrink-0 text-tertiary">· {effortLabel}</span>
{/if}
{#if config.badge}
<span
class={twMerge(
'shrink-0 rounded-full px-1.5 text-2xs',
config.badge.warn
? 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/40'
: 'bg-surface-secondary text-tertiary'
)}>{config.badge.text}</span
>
{/if}
</span>
</Button>
</div>
{/snippet}
{#snippet section(sec: ChoiceSection, item: MeltItem)}
<div class="px-3 pt-1.5 pb-1 text-2xs uppercase tracking-wide text-secondary">{sec.label}</div>
{#if sec.loading}
<div class="flex items-center gap-2 px-3 py-1.5 text-tertiary">
<Loader2 size={14} class="animate-spin" /> Loading...
</div>
{:else if sec.options.length === 0}
<div class="px-3 py-1.5 text-tertiary">{sec.emptyMessage ?? 'Nothing to choose from'}</div>
{:else}
<div class={twMerge('overflow-y-auto', sec.maxHeight ?? 'max-h-48')}>
{#each sec.options as option (option.key)}
<MenuItem {item} class={ROW_CLASS} onClick={() => option.onSelect()}>
<span class="truncate grow min-w-0">{option.label}</span>
{#if option.hint}
<span class="shrink-0 text-tertiary truncate max-w-[70px]">{option.hint}</span>
{/if}
{#if option.selected}
<Check size={14} class="shrink-0 text-primary" />
{/if}
</MenuItem>
{/each}
</div>
{/if}
{/snippet}
{#snippet rows(items: Item[], item: MeltItem, builders: MeltBuilders)}
{#each items.filter((row) => !row.hide) as row (row.displayName)}
{#if row.separatorTop}
<div class="my-1 border-t border-border-light"></div>
{/if}
{#if row.submenuItems}
<!-- Melt submenu: hover-opens and is floating-positioned (flips on screen edges). -->
<DropdownSubmenuItem item={row} {builders} meltItem={item} />
{:else}
<MenuItem {item} class={ROW_CLASS} onClick={(e) => row.action?.(e)}>
{#if row.icon}
<row.icon size={14} class="shrink-0" />
{/if}
<span class="truncate grow min-w-0 text-2xs text-secondary">{row.displayName}</span>
{#if row.selected}
<Check size={14} class="shrink-0 text-primary" />
{/if}
</MenuItem>
{/if}
{/each}
{/snippet}
{#if config.readOnly}
{@render trigger()}
{:else}
<DropdownV2
customMenu
placement="bottom-end"
fixedHeight={false}
closeOnItemClick={false}
bind:open={menuOpen}
>
{#snippet buttonReplacement()}
{@render trigger()}
{/snippet}
{#snippet menu({ item, builders, close })}
<div
class="bg-surface-tertiary dark:border w-64 origin-top-right rounded-lg shadow-lg focus:outline-none py-1 text-xs"
>
{#if config.topItems}
<div class={BLOCK_CLASS}>
{@render rows(config.topItems(close), item, builders)}
</div>
{/if}
{#each config.sections ?? [] as sec (sec.label)}
<div class={BLOCK_CLASS}>
{@render section(sec, item)}
</div>
{/each}
{#if reasoning}
<div class={BLOCK_CLASS}>
{#if controlState === 'fixed'}
<ReasoningEffortSlider
stops={[]}
current=""
onSelect={() => {}}
unsupportedReason={fixedReason}
/>
{:else if controlState === 'awaiting-model'}
<ReasoningEffortSlider
stops={[]}
current=""
onSelect={() => {}}
unsupportedReason="Pick a model first"
/>
{:else if controlState === 'unknown'}
<!-- No rules for this provider, so no ladder to offer. The flow still takes a
token, so it is typed rather than picked: claiming the model cannot think
would be a guess, and offering nothing would leave it settable nowhere. -->
<div class="px-3 pt-1 pb-1.5">
<div class="text-2xs uppercase tracking-wide text-secondary mb-1">Thinking</div>
{#key reasoning.value}
<TextInput
size="sm"
value={reasoning.value ?? ''}
inputProps={{
placeholder: 'none',
onchange: (e) => reasoning?.onSelect(e.currentTarget.value.trim()),
// Capture, not bubble: Svelte delegates `keydown` to the root, which sits
// above the menu — so a bubble handler here would run only after melt's own
// listener had read the key as typeahead and moved focus. A capture key is
// not delegatable, so this becomes a real listener on the input and sees the
// event first.
onkeydowncapture: (e) => {
// Escape cancels: let it reach the menu with the value untouched.
if (e.key === 'Escape') return
// Tab closes the menu, unmounting this field before focus moves, so no
// change event would ever fire. Commit on the way past.
if (e.key === 'Tab') {
reasoning?.onSelect(e.currentTarget.value.trim())
return
}
// Enter means done: commit and close, rather than leaving the menu open
// around a field the commit is about to rebuild.
if (e.key === 'Enter') {
e.preventDefault()
reasoning?.onSelect(e.currentTarget.value.trim())
close()
return
}
// Everything else is typing; the menu reads loose keys as typeahead.
e.stopPropagation()
}
}}
/>
{/key}
<div class="text-2xs text-tertiary mt-1">
Windmill has no thinking levels for this provider — type what it accepts.
</div>
</div>
{:else if controlState === 'ladder'}
<!-- Registered as a melt item so it joins the roving focus/highlight (and arrow
up/down navigation), and so hovering it takes the highlight off the row
above. Left/right adjust the effort; the slider's input handler also drives it. -->
<MenuItemWrapper
{item}
onKeydown={(e) => effortSlider?.adjust(e)}
class="block group"
>
<ReasoningEffortSlider
bind:this={effortSlider}
{stops}
current={currentStop}
onSelect={reasoning.onSelect}
format={(stop) => (stop === reasoning?.offToken ? 'off' : stop)}
overrideLabel={stops.includes(currentStop) ? undefined : effortLabel}
/>
</MenuItemWrapper>
{:else}
<!-- Kept in place rather than dropped: the row saying the model cannot think
is the answer to why there is no slider. -->
<ReasoningEffortSlider
stops={[]}
current=""
onSelect={() => {}}
unsupportedReason="Not supported by this model"
/>
{/if}
</div>
{/if}
{#if config.bottomItems}
<div class={BLOCK_CLASS}>
{@render rows(config.bottomItems(close), item, builders)}
</div>
{/if}
</div>
{/snippet}
</DropdownV2>
{/if}
@@ -0,0 +1,172 @@
<script lang="ts">
/**
* The reasoning-effort control: a thin slider over a model's ordered effort stops.
*
* Presentational on purpose. Callers keep their own value convention — the copilot's
* REASONING_OFF sentinel and an agent's `reasoning_effort` token mean off in
* different ways — and hand this component a resolved list of stops plus the current
* one, so the two never have to agree on anything but the ordering.
*/
interface Props {
/** Ordered stops, least effort first. Fewer than two renders no slider. */
stops: string[]
current: string
onSelect: (stop: string) => void
/** When set, the section renders disabled with this as the explanation. */
unsupportedReason?: string
/** Display name for a stop whose value is a provider sentinel rather than a word. */
format?: (stop: string) => string
/** Shown in place of the current stop — a state the slider has no position for. */
overrideLabel?: string
}
let {
stops,
current,
onSelect,
unsupportedReason,
format = (stop: string) => stop,
overrideLabel
}: Props = $props()
/**
* A `current` naming no stop is a real state, not a missing one: an agent that leaves the
* effort unset sends nothing and the provider decides. Three things follow, and each has
* been got wrong on its own — keep them together.
*
* The thumb rests at the start, because a range input always has one somewhere, and
* `overrideLabel` is what tells the reader this is not the lowest stop. The track is
* unfilled there, which index 0 gives for free. And since the input's value already reads
* 0, picking the lowest stop by pointer fires no `input` event — so a click has to be
* committed explicitly, or that stop is reachable only by keyboard.
*/
const hasPosition = $derived(stops.indexOf(current) >= 0)
const stopIndex = $derived(Math.max(0, stops.indexOf(current)))
// Percentage filled (accent) up to the thumb; the rest of the track stays surface-secondary.
const fillPct = $derived(
stops.length > 1 ? Math.round((stopIndex / (stops.length - 1)) * 100) : 0
)
/** Left/right stepping, for a caller that owns the keyboard (a melt menu item). */
export function adjust(e: KeyboardEvent) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
e.preventDefault()
const next = Math.min(
stops.length - 1,
Math.max(0, stopIndex + (e.key === 'ArrowRight' ? 1 : -1))
)
onSelect(stops[next])
}
// Melt's roving focus blurs the focused element on pointermove, which aborts a native
// thumb drag. Direct (non-delegated) listeners so they run before melt's item listener.
function isolatePointer(node: HTMLElement) {
const stop = (e: Event) => e.stopPropagation()
node.addEventListener('pointerdown', stop)
node.addEventListener('pointermove', stop)
return {
destroy() {
node.removeEventListener('pointerdown', stop)
node.removeEventListener('pointermove', stop)
}
}
}
</script>
{#if unsupportedReason}
<!-- Kept visible rather than hidden: the absence of the control is itself the answer,
but only if it says why. -->
<div class="px-3 pt-1 pb-1.5 opacity-60 cursor-default" aria-disabled="true">
<div class="text-2xs uppercase tracking-wide text-secondary">Thinking</div>
<div class="text-2xs text-tertiary mt-0.5">{unsupportedReason}</div>
</div>
{:else}
<div class="px-3 pt-1 pb-0.5 flex items-center justify-between">
<span class="text-2xs uppercase tracking-wide text-secondary">Thinking</span>
<span class="text-2xs text-secondary tabular-nums">{overrideLabel ?? format(current)}</span>
</div>
{#if stops.length > 1}
<!-- Only the slider area reflects an enclosing menu item's highlight, not the header. -->
<div class="px-3 py-1.5 rounded-sm transition-colors group-data-[highlighted]:bg-surface-hover">
<input
type="range"
min="0"
max={stops.length - 1}
step="1"
value={stopIndex}
style="--fill: {fillPct}%"
oninput={(e) => onSelect(stops[+e.currentTarget.value])}
onclick={(e) => {
// `click`, not `pointerup`: it is the event that means pressed and released on
// the track, so a press that began on the row above cannot commit an effort
// nobody chose. Only the click that moved nothing — any other stop has already
// committed through `oninput`, and doing it again would write it twice.
if (!hasPosition && +e.currentTarget.value === stopIndex) {
onSelect(stops[stopIndex])
}
}}
use:isolatePointer
class="lean-range no-default-style w-full"
aria-label="Reasoning effort"
/>
</div>
{/if}
{/if}
<style>
/* Lean reasoning slider: a thin track and a small, borderless accent thumb. Native range
thumbs can't be styled with Tailwind, and Svelte prunes scoped vendor pseudo-element
rules — so they are wrapped in :global (the class is unique to this component). */
.lean-range {
-webkit-appearance: none;
appearance: none;
height: 10px;
margin: 0;
padding: 0;
/* override the global `input { background-color: ... !important }` so only the
thin track shows, not a full-height band behind it */
background-color: transparent !important;
cursor: pointer;
outline: none;
}
.lean-range:focus,
.lean-range:focus-visible {
outline: none;
}
:global(.lean-range::-webkit-slider-runnable-track) {
height: 3px;
border-radius: 9999px;
background: linear-gradient(
to right,
rgb(var(--color-surface-accent-primary)) var(--fill, 0%),
rgb(var(--color-surface-secondary)) var(--fill, 0%)
);
}
:global(.lean-range::-webkit-slider-thumb) {
-webkit-appearance: none;
appearance: none;
margin-top: -3.5px;
width: 10px;
height: 10px;
border: none;
border-radius: 9999px;
background: rgb(var(--color-surface-accent-primary));
}
:global(.lean-range::-moz-range-track) {
height: 3px;
border-radius: 9999px;
background: rgb(var(--color-surface-secondary));
}
:global(.lean-range::-moz-range-progress) {
height: 3px;
border-radius: 9999px;
background: rgb(var(--color-surface-accent-primary));
}
:global(.lean-range::-moz-range-thumb) {
width: 10px;
height: 10px;
border: none;
border-radius: 9999px;
background: rgb(var(--color-surface-accent-primary));
}
</style>
@@ -35,6 +35,7 @@
import ChatQuickActions from './ChatQuickActions.svelte'
import ContextUsageIndicator from './ContextUsageIndicator.svelte'
import AIChatModelSettings from './AIChatModelSettings.svelte'
import ScrollFade from '$lib/components/ScrollFade.svelte'
import AssistantSettingsModal from './AssistantSettingsModal.svelte'
import { SkillsMenu } from './skills/skillsMenu.svelte'
import { McpMenu } from '$lib/components/mcp/mcpMenu.svelte'
@@ -44,6 +45,7 @@
import Markdown from 'svelte-exmarkdown'
import { twMerge } from 'tailwind-merge'
import { AIAutonomyMode, AIMode } from './AIChatManager.svelte'
import { getChatViewHost } from './chatViewHost'
import { getAiChatManager } from './aiChatManagerContext'
import ChatTypingIndicator from './ChatTypingIndicator.svelte'
import AIChatInput from './AIChatInput.svelte'
@@ -68,6 +70,9 @@
import { base } from '$lib/base'
const MAX_YOLO_TOOLTIP_TOOLS = 8
const chatHost = getChatViewHost()
// The skill and MCP menus take an AIChatManager itself, which the seam deliberately
// doesn't carry. They render only under GLOBAL, which a non-copilot host never sets.
const aiChatManager = getAiChatManager()
// The user spent their one-time free Windmill AI grant: there is no model left to send
@@ -174,8 +179,12 @@
wideLayout = false,
emptyHint,
inputPreface,
footerSettings,
initialInstructions = undefined,
onDraftChange = undefined
onDraftChange = undefined,
placeholder = undefined,
scrollElement = $bindable(),
onTranscriptScroll = undefined
}: {
messages: DisplayMessage[]
pastChats: { id: string; title: string }[]
@@ -202,9 +211,18 @@
wideLayout?: boolean
emptyHint?: Snippet
inputPreface?: Snippet
/** The settings control at the footer's right edge, where the copilot puts its
* model picker. A host that configures its turn elsewhere replaces it here. */
footerSettings?: Snippet
// Seed / observe the main composer's draft text (see AIChatInput).
initialInstructions?: string
onDraftChange?: (text: string) => void
/** Composer placeholder. Falls back to the per-AI-mode wording. */
placeholder?: string
/** The transcript's scroll container. A host that paginates older messages
* needs it to measure and restore the scroll position. */
scrollElement?: HTMLDivElement | undefined
onTranscriptScroll?: () => void
} = $props()
let aiChatInput: AIChatInput | undefined = $state()
@@ -223,7 +241,7 @@
let panelEl: HTMLDivElement | undefined = $state()
$effect(() => {
function onWindowKeydownCapture(e: KeyboardEvent) {
if (e.key !== 'Escape' || !aiChatManager.loading) return
if (e.key !== 'Escape' || !chatHost.loading) return
const active = document.activeElement
const focusOnChat =
!active || active === document.body || (panelEl?.contains(active) ?? false)
@@ -231,22 +249,21 @@
// row alone stops the turn — wherever it is mounted, since the preview panel holds the
// form outside `panelEl`. Matched by call: two chats can be loading at once, and one's
// row must not answer for the other.
if (aiChatManager.hasPendingRunForm) {
if (chatHost.hasPendingRunForm) {
const row = active?.closest('[data-run-form-actions]')
const toolCallId = row?.getAttribute('data-run-form-actions')
if (!toolCallId || !aiChatManager.isRunFormPending(toolCallId)) return
if (!toolCallId || !chatHost.isRunFormPending(toolCallId)) return
} else if (!focusOnChat) return
e.preventDefault()
// Immediate form: other chat panels' identical listeners must not
// also cancel on body focus, nor a drawer/modal close on this press.
e.stopImmediatePropagation()
aiChatManager.cancel()
chatHost.cancel()
}
window.addEventListener('keydown', onWindowKeydownCapture, true)
return () => window.removeEventListener('keydown', onWindowKeydownCapture, true)
})
let scrollEl: HTMLDivElement | undefined = $state()
// Programmatic-scroll guard. `scrollDown()` triggers an async `scroll`
// event; if a token-append between the scrollTo and the dispatch makes
// scrollHeight grow, the gap can briefly exceed STICK_TO_BOTTOM_PX and
@@ -259,22 +276,23 @@
// Instant scroll — smooth would animate every token append, racing with
// the next scrollDown and confusing the onscroll bottom-detection below.
function scrollDown() {
if (!scrollEl) return
if (!scrollElement) return
programmaticScrollAt = Date.now()
scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: 'auto' })
scrollElement.scrollTo({ top: scrollElement.scrollHeight, behavior: 'auto' })
}
let height = $state(0)
$effect(() => {
if (aiChatManager.automaticScroll && height) {
if (chatHost.automaticScroll && height) {
scrollDown()
}
// Recompute the scroll-to-latest visibility on every content-height
// change. `onScroll` only fires for actual scroll events, so without
// this the arrow can go stale when content grows past the threshold
// while auto-scroll is disabled (user scrolled up mid-stream).
if (scrollEl && height) {
const distance = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight
if (scrollElement && height) {
const distance =
scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight
showScrollToLatest = distance > SCROLL_TO_LATEST_THRESHOLD_PX
}
})
@@ -289,8 +307,9 @@
const SCROLL_TO_LATEST_THRESHOLD_PX = 200
let showScrollToLatest = $state(false)
function onScroll() {
if (!scrollEl) return
const distance = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight
if (!scrollElement) return
const distance =
scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight
// Always refresh the arrow visibility — even during the cooldown,
// because clicking the arrow itself triggers a programmatic scroll
// whose only event would otherwise be swallowed, leaving the arrow
@@ -303,14 +322,15 @@
return
}
if (distance <= STICK_TO_BOTTOM_PX) {
aiChatManager.enableAutomaticScroll()
chatHost.enableAutomaticScroll()
} else {
aiChatManager.disableAutomaticScroll()
chatHost.disableAutomaticScroll()
}
onTranscriptScroll?.()
}
function submitSuggestion(suggestion: string) {
aiChatManager.sendRequest({ instructions: suggestion })
chatHost.sendRequest({ instructions: suggestion })
}
export function focusInput() {
@@ -319,35 +339,39 @@
$effect(() => {
if (aiChatInput) {
aiChatManager.setAiChatInput(aiChatInput)
chatHost.setAiChatInput(aiChatInput)
}
return () => {
aiChatManager.setAiChatInput(null)
chatHost.setAiChatInput(null)
}
})
// Also shown for a run held by another tab, labeled with where it is: the
// dots say a turn is in flight even before the reader reaches the footer
// note. Remote runs pause nothing and offer no Stop — this tab can't cancel.
const showTypingIndicator = $derived(aiChatManager.loading || aiChatManager.runHeldElsewhere)
const showTypingIndicator = $derived(chatHost.loading || chatHost.runHeldElsewhere)
// The manual `@` context-picker button. Shown in SCRIPT/FLOW (workspace items +
// code blocks) and APP (datatables, frontend files). Hidden in GLOBAL — there
// `@`-context is still invoked inline by typing `@` in the input, so the button
// is redundant. NAVIGATOR/ASK/API don't take @-context at all.
const showContextPicker = $derived(
aiChatManager.mode === AIMode.SCRIPT ||
aiChatManager.mode === AIMode.FLOW ||
aiChatManager.mode === AIMode.APP
chatHost.mode === AIMode.SCRIPT || chatHost.mode === AIMode.FLOW || chatHost.mode === AIMode.APP
)
// File attachment is GLOBAL-mode only.
const canAttachFiles = $derived(aiChatManager.mode === AIMode.GLOBAL && !disabled)
// Steers the OS file picker toward text + image formats (soft hint; both attach
// to the message — text files after a content sniff).
const TEXT_FILE_ACCEPT =
'image/*,text/*,.txt,.csv,.tsv,.json,.jsonl,.ndjson,.md,.markdown,.log,.yaml,.yml,.toml,.ini,.cfg,.conf,.env,.xml,.html,.htm,.css,.js,.mjs,.cjs,.ts,.tsx,.jsx,.py,.rb,.rs,.go,.java,.kt,.c,.h,.cpp,.cc,.cs,.php,.sh,.bash,.zsh,.sql,.svelte,.vue,.dockerfile'
// Why attaching is off, when this chat takes attachments but cannot right now. The `+` is
// kept and disabled rather than dropped: the input is the composer's either way, so the
// reader has to be able to see here why nothing can be attached.
const attachmentsOffReason = $derived(
chatHost.supportsMessageAttachments ? chatHost.attachmentsUnavailableReason : undefined
)
const canAttachFiles = $derived(
chatHost.supportsMessageAttachments && !disabled && !attachmentsOffReason
)
// Folders are linked as session-wide assets, which only a host that reads files in
// the browser can do — a host running the turn server-side takes attachments only.
const canLinkFolders = $derived(chatHost.supportsLinkedFolders && !disabled)
let fileInputEl = $state<HTMLInputElement | null>(null)
let folderInputEl = $state<HTMLInputElement | null>(null)
let dragDepth = $state(0)
@@ -373,12 +397,12 @@
}
async function handleAddFiles(files: FileList | FileToAttach[]) {
const { added, rejected } = await aiChatManager.attachedFiles.addFiles(files)
const { added, rejected } = await chatHost.attachedFiles.addFiles(files)
reportAddResult(added, rejected)
}
async function addDirHandle(dir: FileSystemDirectoryHandle) {
const { added, rejected } = await aiChatManager.attachedFiles.addFolder(dir)
const { added, rejected } = await chatHost.attachedFiles.addFolder(dir)
reportAddResult(added, rejected)
}
@@ -467,12 +491,16 @@
handles.length === 0
? flatFiles
: await Promise.all(handles.filter(isFileHandle).map((h) => h.getFile()))
// Loose text files attach to the message, like images.
const textFiles = looseFiles.filter((f) => !isImageFile(f))
if (textFiles.length > 0) await aiChatInput?.addTextFiles(textFiles)
// Loose files attach to the message, like images.
await attachNonImageFiles(looseFiles.filter((f) => !isImageFile(f)))
// Folders link as a live handle.
for (const h of handles.filter(isDirectoryHandle)) {
await addDirHandle(h)
const dirs = handles.filter(isDirectoryHandle)
if (dirs.length > 0 && !canLinkFolders) {
sendUserToast('Folders cannot be attached in this chat — drop individual files.', true)
} else {
for (const h of dirs) {
await addDirHandle(h)
}
}
} else {
// Fallback (no File System Access API): snapshot dropped files AND folders by walking
@@ -497,25 +525,35 @@
topLevelText.push(file)
}
}
if (folderEntries.length > 0) await handleAddFiles(folderEntries)
if (topLevelText.length > 0) await aiChatInput?.addTextFiles(topLevelText)
if (folderEntries.length > 0) {
if (canLinkFolders) await handleAddFiles(folderEntries)
else sendUserToast('Folders cannot be attached in this chat — drop individual files.', true)
}
await attachNonImageFiles(topLevelText)
}
}
async function onFileInputChange(e: Event) {
const input = e.currentTarget as HTMLInputElement
if (input.files && input.files.length > 0) {
const picked = Array.from(input.files)
const imageFiles = picked.filter(isImageFile)
const textFiles = picked.filter((f) => !isImageFile(f))
// Reserved before the text work is awaited — see onPanelDrop.
const imageWork = imageFiles.length > 0 ? aiChatInput?.addImages(imageFiles) : undefined
if (textFiles.length > 0) await aiChatInput?.addTextFiles(textFiles)
await imageWork
await attachPickedFiles(Array.from(input.files))
}
input.value = '' // allow re-selecting the same file
}
async function attachNonImageFiles(files: File[]) {
await aiChatInput?.addNonImageFiles(files)
}
async function attachPickedFiles(picked: File[]) {
const imageFiles = picked.filter(isImageFile)
const others = picked.filter((f) => !isImageFile(f))
// Reserved before the other work is awaited — see onPanelDrop.
const imageWork = imageFiles.length > 0 ? aiChatInput?.addImages(imageFiles) : undefined
await attachNonImageFiles(others)
await imageWork
}
function onFolderInputChange(e: Event) {
const input = e.currentTarget as HTMLInputElement
// webkitdirectory files carry webkitRelativePath (`folder/sub/file`); addFiles groups
@@ -524,9 +562,9 @@
input.value = ''
}
const autonomyAvailability = $derived({
autoAcceptEditsAvailable: aiChatManager.autoAcceptEditsAvailable,
autoAcceptToolConfirmationsAvailable: aiChatManager.autoAcceptToolConfirmationsAvailable,
planModeAvailable: aiChatManager.planModeAvailable
autoAcceptEditsAvailable: chatHost.autoAcceptEditsAvailable,
autoAcceptToolConfirmationsAvailable: chatHost.autoAcceptToolConfirmationsAvailable,
planModeAvailable: chatHost.planModeAvailable
})
const availableAutonomyModeOptions = $derived(
autonomyModeOptions.filter((option) => option.isAvailable(autonomyAvailability))
@@ -534,8 +572,8 @@
// Fall back to ask-permission when the persisted mode isn't applicable in the
// current AI mode (e.g. auto-accept edits while in a mode without edits).
const effectiveAutonomyMode = $derived(
availableAutonomyModeOptions.some((option) => option.mode === aiChatManager.autonomyMode)
? aiChatManager.autonomyMode
availableAutonomyModeOptions.some((option) => option.mode === chatHost.autonomyMode)
? chatHost.autonomyMode
: AIAutonomyMode.DEFAULT
)
const showAutonomyModeSelector = $derived(!disabled && availableAutonomyModeOptions.length > 1)
@@ -544,13 +582,25 @@
// The typing-dots indicator implies the AI is busy, which is misleading while
// the loop is parked on the user; surface a text pill instead so users know to
// act on the tool above.
const waitingForUserAction = $derived(aiChatManager.loading && !!pendingUserAction(messages))
/**
* An agent step's answer hangs its icon in the margin beside the text, so the column has
* to carry enough padding for it to land in. Widened on both sides, not just the left:
* the column is centred, and padding one side alone would shift the text off centre.
*/
const agentGutter = $derived(messages.some((m) => m.role === 'assistant' && m.stepName))
const columnClass = $derived(
wideLayout
? `w-full max-w-3xl mx-auto ${agentGutter ? 'px-8' : 'px-7'}`
: `w-full max-w-2xl mx-auto ${agentGutter ? 'px-8' : 'px-3'}`
)
const waitingForUserAction = $derived(chatHost.loading && !!pendingUserAction(messages))
// Gated on `loading` because a card restored from history still looks parked:
// its resolver left with the old page, so the composer must not advertise an
// answer it cannot deliver.
const pendingQuestionToolCallId = $derived.by(() => {
if (!aiChatManager.loading) {
if (!chatHost.loading) {
return undefined
}
const pending = pendingUserActionDetail(messages)
@@ -559,14 +609,14 @@
// Get app context for display when in APP mode
const appContext = $derived.by((): SelectedContext | undefined => {
if (aiChatManager.mode !== AIMode.APP || !aiChatManager.appAiChatHelpers) {
if (chatHost.mode !== AIMode.APP || !chatHost.appAiChatHelpers) {
return undefined
}
return aiChatManager.appAiChatHelpers.getSelectedContext()
return chatHost.appAiChatHelpers.getSelectedContext()
})
const yoloBypassedTools = $derived.by(() => {
return aiChatManager.tools
return chatHost.tools
.filter((tool) => tool.requiresConfirmation === true || tool.bypassedByAutoAccept === true)
.map((tool) => ({
name: tool.def.function.name,
@@ -583,8 +633,7 @@
Math.max(0, yoloBypassedTools.length - visibleYoloBypassedTools.length)
)
const showFlowPendingActionControls = $derived(
(aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false) &&
!aiChatManager.autoAcceptEditsActive
(chatHost.flowAiChatHelpers?.hasPendingChanges() ?? false) && !chatHost.autoAcceptEditsActive
)
// A disabled state with no message (a remote hold, a spent free grant) keeps
// the footer toolbar in place — swapping it for an empty strip would make
@@ -592,11 +641,16 @@
// a real message (archived, AI off) still shows it, hold or not, matching
// the precedence disabledMessage itself encodes.
const footerMessageShown = $derived(disabled && disabledMessage !== '')
// `canAttachFiles` belongs in the group too: in GLOBAL mode the `+` always has the
// context picker or the autonomy selector beside it, but a host with attachments and
// nothing else would lose the group and the `+` with it.
const showFooterLeftControls = $derived(
!footerMessageShown &&
(showContextPicker ||
(canAttachFiles ||
attachmentsOffReason !== undefined ||
showContextPicker ||
showAutonomyModeSelector ||
(aiChatManager.mode === AIMode.SCRIPT && hasDiff))
(chatHost.mode === AIMode.SCRIPT && hasDiff))
)
</script>
@@ -694,12 +748,12 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{#each pastChats as chat (chat.id)}
<button
class="text-left flex flex-row items-center gap-2 justify-between hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md p-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent dark:disabled:hover:bg-transparent"
disabled={aiChatManager.loading ||
aiChatManager.sendInFlight ||
aiChatManager.runHeldElsewhere}
title={aiChatManager.runHeldElsewhere
disabled={chatHost.loading ||
chatHost.sendInFlight ||
chatHost.runHeldElsewhere}
title={chatHost.runHeldElsewhere
? 'Wait for the turn in the other tab to switch conversation'
: aiChatManager.loading || aiChatManager.sendInFlight
: chatHost.loading || chatHost.sendInFlight
? 'Stop the current answer to switch conversation'
: undefined}
onclick={() => {
@@ -731,10 +785,10 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/snippet}
</Popover>
<Button
title={aiChatManager.runHeldElsewhere
title={chatHost.runHeldElsewhere
? 'Wait for the turn in the other tab to start a new chat'
: 'New chat'}
disabled={aiChatManager.runHeldElsewhere}
disabled={chatHost.runHeldElsewhere}
on:click={() => {
saveAndClear()
}}
@@ -769,15 +823,10 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
<div class="flex-1 min-h-0 relative">
<div
class="absolute inset-0 overflow-y-scroll pt-2 scrollbar-subtle"
bind:this={scrollEl}
bind:this={scrollElement}
onscroll={onScroll}
>
<div
class={wideLayout
? 'w-full max-w-3xl mx-auto px-7 flex flex-col pb-2'
: 'w-full max-w-2xl mx-auto px-3 flex flex-col pb-2'}
bind:clientHeight={height}
>
<div class="{columnClass} flex flex-col pb-2" bind:clientHeight={height}>
{#each messages as message, messageIndex (messageIndex)}
<AIChatMessage
{message}
@@ -800,22 +849,24 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
<ChatTypingIndicator
loading={showTypingIndicator}
paused={waitingForUserAction}
label={aiChatManager.runHeldElsewhere
label={chatHost.runHeldElsewhere
? 'Running in another tab'
: aiChatManager.loadingLabel
? aiChatManager.loadingLabel
: aiChatManager.compacting
: chatHost.loadingLabel
? chatHost.loadingLabel
: chatHost.compacting
? 'Compacting conversation'
: aiChatManager.currentReasoningActive &&
!aiChatManager.currentReply &&
!aiChatManager.currentReasoning
? (aiChatManager.reasoningHiddenIndicatorLabel ?? 'Thinking')
: chatHost.currentReasoningActive &&
!chatHost.currentReply &&
!chatHost.currentReasoning
? (chatHost.reasoningHiddenIndicatorLabel ?? 'Thinking')
: undefined}
/>
</div>
{/if}
</div>
</div>
<!-- Sits below the scroll-to-latest button, which carries z-10. -->
<ScrollFade scroller={scrollElement} />
{#if showScrollToLatest}
<div
transition:fade={{ duration: 120 }}
@@ -832,7 +883,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
aria-label="Scroll to latest message"
startIcon={{ icon: ArrowDown }}
on:click={() => {
aiChatManager.enableAutomaticScroll()
chatHost.enableAutomaticScroll()
scrollDown()
}}
/>
@@ -841,11 +892,9 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
</div>
{/if}
<div
class={wideLayout
? 'relative w-full max-w-3xl mx-auto px-6 pb-2'
: 'relative w-full max-w-2xl mx-auto px-2 pb-2'}
>
<!-- Same horizontal padding as the transcript above: the composer's edges line up with
the messages rather than sitting closer to the panel edge. -->
<div class="relative {columnClass} pb-2">
{#if showFlowPendingActionControls}
<div class="absolute -top-10 w-full flex flex-row justify-center gap-2">
<Button
@@ -854,7 +903,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
variant="default"
btnClasses="bg-green-500 hover:bg-green-600 text-white hover:text-white"
onclick={() => {
aiChatManager.flowAiChatHelpers?.acceptAllModuleActions()
chatHost.flowAiChatHelpers?.acceptAllModuleActions()
}}
>
Accept all
@@ -866,7 +915,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
variant="default"
btnClasses="dark:opacity-50 opacity-60 hover:opacity-100"
onclick={() => {
aiChatManager.flowAiChatHelpers?.rejectAllModuleActions()
chatHost.flowAiChatHelpers?.rejectAllModuleActions()
}}
>
Reject all
@@ -876,7 +925,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/if}
<div>
<QueuedMessageChip />
{#if aiChatManager.mode === AIMode.GLOBAL && !aiChatManager.isSessionChat}
{#if chatHost.mode === AIMode.GLOBAL && !chatHost.isSessionChat}
<!-- Standalone Jobs bar for the global side-panel chat. In /sessions the
Jobs segment lives inside the session bar (SessionChangesBar). -->
<div class="mb-1">
@@ -898,9 +947,10 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
bind:this={aiChatInput}
bind:selectedContext
{availableContext}
{placeholder}
{initialInstructions}
{onDraftChange}
showContext={aiChatManager.mode !== AIMode.GLOBAL}
showContext={chatHost.mode !== AIMode.GLOBAL}
{disabled}
{pendingQuestionToolCallId}
isFirstMessage={messages.length === 0}
@@ -925,7 +975,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
/>
{/snippet}
{#snippet content({ close })}
{#if aiChatManager.mode === AIMode.APP}
{#if chatHost.mode === AIMode.APP}
<AppAvailableContextList
{availableContext}
{selectedContext}
@@ -962,14 +1012,28 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/snippet}
</Popover>
{/if}
{#if canAttachFiles}
{#if attachmentsOffReason}
<Tooltip small placement="top">
<Button
nonCaptureEvent
unifiedSize="2xs"
variant="default"
iconOnly
disabled
startIcon={{ icon: Plus }}
/>
{#snippet text()}
<div class="max-w-64 text-xs">{attachmentsOffReason}</div>
{/snippet}
</Tooltip>
{:else if canAttachFiles}
<DropdownV2
items={async () => {
// Both submenus fetch on the menu's first open, so they start
// together: awaited inline they queue, and the whole menu —
// attachments included — waits out two round trips.
const closeMenu = () => (plusMenuOpen = false)
const inGlobal = aiChatManager.mode === AIMode.GLOBAL
const inGlobal = chatHost.mode === AIMode.GLOBAL
const [skillItems, mcpItems] = await Promise.all([
inGlobal ? skillsMenu.items(closeMenu) : undefined,
inGlobal ? mcpMenu.items(closeMenu) : undefined
@@ -983,19 +1047,23 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
linkFiles()
}
},
{
// A real (live) link needs the File System Access API; without it the
// folder is only snapshotted, so call it "Add folder", not "Link folder".
displayName: canUseFsAccess ? 'Link folder' : 'Add folder',
icon: Folder,
tooltip: canUseFsAccess
? 'Linked live — the assistant reads the folders current files from disk and refreshes each turn.'
: 'Loaded as a snapshot — the folders files are copied into your browser (they wont auto-update). For a live link that refreshes from disk, use a Chromium-based browser (Chrome, Edge).',
action: () => {
plusMenuOpen = false
linkFolder()
}
},
...(canLinkFolders
? [
{
// A real (live) link needs the File System Access API; without it the
// folder is only snapshotted, so call it "Add folder", not "Link folder".
displayName: canUseFsAccess ? 'Link folder' : 'Add folder',
icon: Folder,
tooltip: canUseFsAccess
? 'Linked live — the assistant reads the folders current files from disk and refreshes each turn.'
: 'Loaded as a snapshot — the folders files are copied into your browser (they wont auto-update). For a live link that refreshes from disk, use a Chromium-based browser (Chrome, Edge).',
action: () => {
plusMenuOpen = false
linkFolder()
}
}
]
: []),
...(skillItems
? [
{
@@ -1054,7 +1122,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
bind:this={fileInputEl}
type="file"
multiple
accept={TEXT_FILE_ACCEPT}
accept={chatHost.attachmentAccept}
class="hidden no-default-style"
onchange={onFileInputChange}
/>
@@ -1075,7 +1143,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
availableAutonomyModeOptions.map((option) => ({
displayName: option.label,
selected: effectiveAutonomyMode === option.mode,
action: () => aiChatManager.setAutonomyMode(option.mode)
action: () => chatHost.setAutonomyMode(option.mode)
}))}
placement="bottom-start"
fixedHeight={false}
@@ -1102,18 +1170,18 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{#if effectiveAutonomyMode === AIAutonomyMode.PLAN}
<span class="text-2xs text-secondary">{PLAN_MODE_MESSAGES.modeNote}</span>
{/if}
{#if effectiveAutonomyMode === AIAutonomyMode.YOLO && aiChatManager.autoAcceptToolConfirmationsAvailable}
{#if effectiveAutonomyMode === AIAutonomyMode.YOLO && chatHost.autoAcceptToolConfirmationsAvailable}
<Tooltip small placement="top">
<AlertTriangle class="w-3 h-3 text-red-500" />
{#snippet text()}
<div class="max-w-64 text-xs">
<p class="font-semibold">
{aiChatManager.autoAcceptEditsAvailable
{chatHost.autoAcceptEditsAvailable
? 'Bypass permissions auto-accepts edits and tool usage.'
: 'Bypass permissions auto-accepts tool usage.'}
</p>
<p class="mt-1">
{aiChatManager.autoAcceptEditsAvailable
{chatHost.autoAcceptEditsAvailable
? 'This can result in edits being applied or tools being called without user confirmation.'
: 'This can result in tools being called without user confirmation.'}
</p>
@@ -1134,7 +1202,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/snippet}
</Tooltip>
{/if}
{#if aiChatManager.mode === AIMode.SCRIPT && hasDiff && !disabled}
{#if chatHost.mode === AIMode.SCRIPT && hasDiff && !disabled}
<ChatQuickActions {askAi} {diffMode} />
{/if}
</div>
@@ -1145,25 +1213,27 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
</div>
{:else}
<div class="flex flex-row gap-x-1.5 min-w-0 flex-wrap items-center">
{#if aiChatManager.mode === AIMode.GLOBAL}
{#if chatHost.mode === AIMode.GLOBAL}
<AttachedFilesBar />
{/if}
{#if !hideModeSelector}
<ChatMode />
{/if}
{#if aiChatManager.mode === AIMode.APP}
{#if chatHost.mode === AIMode.APP}
<DatatableCreationPolicy />
{/if}
<ContextUsageIndicator />
<!-- Unconditional: this composer mounts only via `AIChat` ← `SessionWrapper`,
and `sessionRuntime` locks a session to GLOBAL, where the settings
modal's Instructions section owns the prompt entries. -->
<AIChatModelSettings promptSettings={false} />
{#if aiChatManager.mode === AIMode.GLOBAL}
{#if chatHost.supportsModelSettings}
<!-- `promptSettings={false}`: in a session, GLOBAL, the settings modal's
Instructions section owns the prompt entries. -->
<AIChatModelSettings promptSettings={false} />
{/if}
{@render footerSettings?.()}
{#if chatHost.mode === AIMode.GLOBAL}
<AssistantSettingsModal bind:this={assistantSettings} />
{/if}
{#if aiChatManager.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)}
{#if chatHost.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)}
{#if appContext.inspectorElement}
<div
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-2xs"
@@ -1210,7 +1280,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/if}
</div>
</div>
{#if (aiChatManager.mode === AIMode.NAVIGATOR || aiChatManager.mode === AIMode.ASK) && suggestions.length > 0 && messages.filter((m) => m.role === 'user').length === 0 && !disabled}
{#if (chatHost.mode === AIMode.NAVIGATOR || chatHost.mode === AIMode.ASK) && suggestions.length > 0 && messages.filter((m) => m.role === 'user').length === 0 && !disabled}
<div class="px-2 mt-4">
<div class="flex flex-col gap-2">
{#each suggestions as suggestion (suggestion)}
@@ -11,7 +11,10 @@
type ContextElement
} from './context'
import { AIMode } from './AIChatManager.svelte'
import { CHAT_INPUT_PADDING, getAiChatManager } from './aiChatManagerContext'
import { CHAT_INPUT_PADDING } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
import { composerBoxClass, COMPOSER_FIELD_RESET } from './composerBox'
import { getAiChatManager } from './aiChatManagerContext'
import { formatMention } from './mention'
import { twMerge } from 'tailwind-merge'
import { tick, untrack, type Snippet } from 'svelte'
@@ -40,12 +43,22 @@
textByteLength,
type AttachedTextFile
} from './textFileUtils'
import {
fileToAttachedBlob,
matchesAccept,
MAX_ATTACHED_BLOBS,
MAX_BLOB_BYTES,
type AttachedBlob
} from './blobUtils'
import { MessageDraft } from './messageDraft.svelte'
import ExpandableImage, {
isImageViewerOpen
} from '$lib/components/common/image/ExpandableImage.svelte'
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
// Resolved here, not where it is used: getContext is only legal during component
// initialisation, and the mention consumer below runs inside the send gesture.
const chatManager = getAiChatManager()
interface Props {
availableContext: ContextElement[]
@@ -65,15 +78,15 @@
showContext?: boolean
bottomRightSnippet?: Snippet
onKeyDown?: (e: KeyboardEvent) => void
// When provided, overrides `aiChatManager.loading` for the send/stop
// When provided, overrides `chatHost.loading` for the send/stop
// button — useful for callers driving their own request lifecycle
// (e.g. the inline ⌘K widget runs requests outside the global
// `aiChatManager.loading` flag).
// `chatHost.loading` flag).
loading?: boolean
// Called when the user clicks Stop. Defaults to `aiChatManager.cancel()`.
// Called when the user clicks Stop. Defaults to `chatHost.cancel()`.
onCancel?: () => void
// Observe the composer draft as it changes (the text is local state —
// `aiChatManager.instructions` only carries programmatic prompts). Used by
// `chatHost.instructions` only carries programmatic prompts). Used by
// sessions to persist the typed-but-unsent prompt with the session draft.
onDraftChange?: (text: string) => void
// tool_call_id of the askUserQuestion the turn is parked on, when it is. A
@@ -132,7 +145,7 @@
// The composer unlocks by itself when the other tab's turn ends, so the
// placeholder names what it is waiting on (the typing indicator says
// where the run is).
if (aiChatManager.runHeldElsewhere) {
if (chatHost.runHeldElsewhere) {
return 'Waiting for the turn in the other tab to finish'
}
if (pendingQuestionToolCallId !== undefined) {
@@ -147,7 +160,7 @@
return placeholder
}
switch (aiChatManager.mode) {
switch (chatHost.mode) {
case AIMode.SCRIPT:
return 'Modify this script...'
case AIMode.FLOW:
@@ -208,31 +221,88 @@
: undefined
)
/**
* Free slots in one attachment lane, against both that lane's own cap and any limit the
* host's consumer imposes on the turn as a whole — a flow input holding a single file
* caps images and blobs together, not one each. In-flight decodes count: two drops that
* both read the staged count before either resolves would claim the same slots twice.
*/
function attachmentSlots(laneCap: number, laneStaged: number): number {
const laneRemaining = laneCap - laneStaged
const turnCap = chatHost.maxMessageAttachments
if (turnCap === undefined) return laneRemaining
const staged =
draft.images.length +
pendingImages +
draft.files.length +
pendingFiles +
draft.blobs.length +
pendingBlobs
// A queue counts too: what is held mid-run merges into one turn on flush, so a
// second file accepted now would be dropped there instead of refused here.
const queued =
chatHost.queuedImages.length + chatHost.queuedFiles.length + chatHost.queuedBlobs.length
return Math.min(laneRemaining, Math.max(0, turnCap - staged - queued))
}
/** What to say when the host's own limit is the one that bit. */
function turnCapMessage(): string {
const turnCap = chatHost.maxMessageAttachments
return turnCap === 1
? 'This chat sends one attachment per message.'
: `This chat sends up to ${turnCap} attachments per message.`
}
/** Why some of what was picked did not fit, naming whichever limit actually bit. */
function skippedMessage(laneCap: number, lane: 'images' | 'files', skipped: number): string {
return chatHost.maxMessageAttachments !== undefined
? `${turnCapMessage()} ${skipped} file(s) were not attached.`
: `You can attach up to ${laneCap} ${lane}; ${skipped} were skipped.`
}
// Images being decoded right now. Holds off sending so a message can never go
// out without an attachment the user already dropped, and reserves cap slots
// against a concurrent drop.
let pendingImages = $state(0)
/** Attach dropped/pasted image files (downscaled + bounded). GLOBAL mode only. */
/** Attach dropped/pasted image files (downscaled + bounded). */
export async function addImages(files: (File | Blob)[]) {
if (aiChatManager.mode !== AIMode.GLOBAL) return
if (!chatHost.supportsMessageAttachments) return
// Attaching can be off despite the chat taking attachments — no object storage to
// upload to, say. The `+` renders disabled with the reason; a drop and a paste reach
// here instead, and would otherwise become a chip that only fails once sent.
const unavailable = chatHost.attachmentsUnavailableReason
if (unavailable) {
sendUserToast(unavailable, true)
return
}
const imageFiles = files.filter(isImageFile)
if (imageFiles.length === 0) return
// tryGetCurrentModel returns undefined instead of throwing: this runs from a
// drop/paste handler that can't surface a rejection.
const model = tryGetCurrentModel()
// Only known text-only models fail this, so attaching would certainly 400 the
// next turn — refuse rather than warn and send it anyway.
if (model && !modelSupportsVision(model.provider, model.model)) {
sendUserToast(`${model.model} can't read images. Switch to a vision model first.`, true)
return
// The vision check is about the model this composer's own turn will hit, so it
// only applies to a host that picks that model. Elsewhere the model is chosen
// in the flow and tryGetCurrentModel would answer for the wrong one.
if (chatHost.supportsModelSettings) {
// tryGetCurrentModel returns undefined instead of throwing: this runs from a
// drop/paste handler that can't surface a rejection.
const model = tryGetCurrentModel()
// Only known text-only models fail this, so attaching would certainly 400 the
// next turn — refuse rather than warn and send it anyway.
if (model && !modelSupportsVision(model.provider, model.model)) {
sendUserToast(`${model.model} can't read images. Switch to a vision model first.`, true)
return
}
}
// Count decodes already in flight: two drops that both read the image count
// before either resolves would each claim the same free slots and overshoot
// the cap.
const remaining = MAX_ATTACHED_IMAGES - draft.images.length - pendingImages
const remaining = attachmentSlots(MAX_ATTACHED_IMAGES, draft.images.length + pendingImages)
if (remaining <= 0) {
sendUserToast(`You can attach up to ${MAX_ATTACHED_IMAGES} images.`, true)
sendUserToast(
chatHost.maxMessageAttachments !== undefined
? turnCapMessage()
: `You can attach up to ${MAX_ATTACHED_IMAGES} images.`,
true
)
return
}
const oversized = imageFiles.filter((f) => f.size > MAX_IMAGE_BYTES)
@@ -245,7 +315,7 @@
const batch = usable.slice(0, remaining)
if (batch.length < usable.length) {
sendUserToast(
`You can attach up to ${MAX_ATTACHED_IMAGES} images; ${usable.length - batch.length} were skipped.`,
skippedMessage(MAX_ATTACHED_IMAGES, 'images', usable.length - batch.length),
true
)
}
@@ -308,17 +378,22 @@
draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) + pendingFileBytes
)
$effect(() => {
aiChatManager.setComposerStaged(composerKey, editingMessageIndex, stagedBytes)
chatHost.setComposerStaged(composerKey, editingMessageIndex, stagedBytes)
})
$effect(() => () => aiChatManager.clearComposerStaged(composerKey))
$effect(() => () => chatHost.clearComposerStaged(composerKey))
/** Attach dropped/picked text files (sniffed + bounded). GLOBAL mode only. */
/** Attach dropped/picked text files (sniffed + bounded). */
export async function addTextFiles(candidates: File[]) {
if (aiChatManager.mode !== AIMode.GLOBAL) return
if (!chatHost.supportsMessageAttachments) return
if (candidates.length === 0) return
const remaining = MAX_ATTACHED_FILES - draft.files.length - pendingFiles
const remaining = attachmentSlots(MAX_ATTACHED_FILES, draft.files.length + pendingFiles)
if (remaining <= 0) {
sendUserToast(`You can attach up to ${MAX_ATTACHED_FILES} files.`, true)
sendUserToast(
chatHost.maxMessageAttachments !== undefined
? turnCapMessage()
: `You can attach up to ${MAX_ATTACHED_FILES} files.`,
true
)
return
}
const oversized = candidates.filter((f) => f.size > MAX_TEXT_FILE_BYTES)
@@ -333,10 +408,7 @@
if (usable.length === 0) return
let batch = usable.slice(0, remaining)
if (batch.length < usable.length) {
sendUserToast(
`You can attach up to ${MAX_ATTACHED_FILES} files; ${usable.length - batch.length} were skipped.`,
true
)
sendUserToast(skippedMessage(MAX_ATTACHED_FILES, 'files', usable.length - batch.length), true)
}
// Conversation-level byte budget: transcript + queue + every live
// composer's stage (this one and, mid-edit, the other) + this composer's
@@ -346,7 +418,7 @@
// stage stands in for it, so counting both would charge those bytes twice.
let budget =
MAX_CONVERSATION_FILE_BYTES -
aiChatManager.attachmentBytesExcluding(composerKey) -
chatHost.attachmentBytesExcluding(composerKey) -
draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) -
pendingFileBytes
const withinBudget: File[] = []
@@ -388,7 +460,7 @@
// from the budget — the decoded sizes replace it.
const liveBudget =
MAX_CONVERSATION_FILE_BYTES -
aiChatManager.attachmentBytesExcluding(composerKey) -
chatHost.attachmentBytesExcluding(composerKey) -
draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) -
(pendingFileBytes - reservedBytes)
const { droppedAtBudget } = draft.addFiles(reads, liveBudget)
@@ -410,6 +482,86 @@
draft.files = draft.files.filter((_, i) => i !== index)
}
// Blobs being read right now — same send-hold/slot-reservation role as pendingImages.
let pendingBlobs = $state(0)
/**
* Attach non-image files through the lane the host actually reads: a host that decodes
* them takes text, one that forwards them verbatim (to object storage) takes blobs, and
* its narrower `accept` is re-applied because a drop and a paste both bypass the picker's
* own filtering. Every way of attaching goes through here, so no route can take the lane
* the host ignores and drop the file at send.
*/
export async function addNonImageFiles(files: File[]) {
if (files.length === 0) return
// Attaching can be off despite the chat taking attachments — no object storage to
// upload to, say. The `+` renders disabled with the reason; a drop and a paste reach
// here instead, and would otherwise become a chip that only fails once sent.
const unavailable = chatHost.attachmentsUnavailableReason
if (unavailable) {
sendUserToast(unavailable, true)
return
}
if (!chatHost.attachmentsAsBlobs) {
await addTextFiles(files)
return
}
const allowed = files.filter((f) => matchesAccept(f, chatHost.attachmentAccept))
if (allowed.length < files.length) {
sendUserToast(
`${files.length - allowed.length} file(s) skipped — this chat accepts ${chatHost.attachmentAccept}.`,
true
)
}
await addBlobs(allowed)
}
/** Attach files the host takes verbatim (a PDF, say). Kept out of addTextFiles:
* that one decodes to a string and drops anything the binary sniff rejects. */
export async function addBlobs(candidates: File[]) {
if (!chatHost.supportsMessageAttachments) return
if (candidates.length === 0) return
const oversized = candidates.filter((f) => f.size > MAX_BLOB_BYTES)
if (oversized.length > 0) {
const mb = Math.round(MAX_BLOB_BYTES / 1_000_000)
sendUserToast(`${oversized.length} file(s) over ${mb}MB were skipped.`, true)
}
const usable = candidates.filter((f) => f.size <= MAX_BLOB_BYTES)
if (usable.length === 0) return
const remaining = attachmentSlots(MAX_ATTACHED_BLOBS, draft.blobs.length + pendingBlobs)
if (remaining <= 0) {
sendUserToast(
chatHost.maxMessageAttachments !== undefined
? turnCapMessage()
: `You can attach up to ${MAX_ATTACHED_BLOBS} files.`,
true
)
return
}
const batch = usable.slice(0, remaining)
if (batch.length < usable.length) {
sendUserToast(skippedMessage(MAX_ATTACHED_BLOBS, 'files', usable.length - batch.length), true)
}
pendingBlobs += batch.length
try {
const added: AttachedBlob[] = []
for (const file of batch) {
try {
added.push(await fileToAttachedBlob(file))
} catch (e) {
sendUserToast(`Could not read ${file.name}`, true)
}
}
if (added.length > 0) draft.addBlobs(added)
} finally {
pendingBlobs -= batch.length
}
}
function removeBlob(index: number) {
draft.blobs = draft.blobs.filter((_, i) => i !== index)
}
// App mode @ mention state
let showAppContextTooltip = $state(false)
let appContextTooltipWord = $state('')
@@ -420,9 +572,9 @@
// Modes that show the rich textarea with @-context support (workspace
// scripts, workspace flows, code blocks, DBs, etc.).
const isContextEnabledMode = $derived(
aiChatManager.mode === AIMode.SCRIPT ||
aiChatManager.mode === AIMode.FLOW ||
aiChatManager.mode === AIMode.GLOBAL
chatHost.mode === AIMode.SCRIPT ||
chatHost.mode === AIMode.FLOW ||
chatHost.mode === AIMode.GLOBAL
)
const domSelectorChips = $derived(
@@ -491,7 +643,8 @@
// Attachments still decoding/reading (or mid-drop-routing) count as
// occupancy too — they belong to a draft the user started even though
// their lane is still empty.
if (pendingImages > 0 || pendingFiles > 0 || ingestionHolds > 0) return false
if (pendingImages > 0 || pendingFiles > 0 || pendingBlobs > 0 || ingestionHolds > 0)
return false
if (
!draft.replaceIfEmpty({
text: value,
@@ -514,15 +667,17 @@
export function prependText(
text: string,
restoredImages: AttachedImage[] = [],
restoredFiles: AttachedTextFile[] = []
restoredFiles: AttachedTextFile[] = [],
restoredBlobs: AttachedBlob[] = []
): boolean {
// mergedIntoDraft: the restored text landed on top of a draft the user was
// already writing — both instructions now share one composer, so the caller
// must keep both their contexts rather than replacing one with the other.
const { mergedIntoDraft, droppedImages, droppedFiles } = draft.prepend({
const { mergedIntoDraft, droppedImages, droppedFiles, droppedBlobs } = draft.prepend({
text,
images: restoredImages,
files: restoredFiles
files: restoredFiles,
blobs: restoredBlobs
})
if (droppedImages > 0) {
sendUserToast(
@@ -536,6 +691,12 @@
true
)
}
if (droppedBlobs > 0) {
sendUserToast(
`You can attach up to ${MAX_ATTACHED_BLOBS} files; ${droppedBlobs} restored file(s) were dropped.`,
true
)
}
focusInput()
return mergedIntoDraft
}
@@ -551,14 +712,14 @@
* the composer. The conversation is left untouched — resending creates a new
* message, unlike the bubble's edit pencil which rewinds the conversation. */
function recallLastSentMessage(): boolean {
const messages = aiChatManager.displayMessages
const messages = chatHost.displayMessages
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (message.role !== 'user' || message.synthetic) continue
// Images come from the stored turn, never the bubble: a provider
// rejection strips them from history while the bubble keeps its copy,
// and recalling that copy would re-attach the refused image.
const images = aiChatManager.storedImages(i) ?? []
const images = chatHost.storedImages(i) ?? []
// Eligibility looks at the bubble, though: the last thing the user
// actually sent is the recall boundary, so a context-only turn (GLOBAL
// allows text-free sends with chips) recalls its chips, and a turn
@@ -582,8 +743,7 @@
// count against the conversation budget — re-admit them instead of
// copying, or resending would blow past MAX_CONVERSATION_FILE_BYTES.
if (message.files?.length) {
const budget =
MAX_CONVERSATION_FILE_BYTES - aiChatManager.attachmentBytesExcluding(composerKey)
const budget = MAX_CONVERSATION_FILE_BYTES - chatHost.attachmentBytesExcluding(composerKey)
const { droppedAtBudget } = draft.addFiles(message.files, budget)
if (droppedAtBudget > 0) {
const mb = Math.round(MAX_CONVERSATION_FILE_BYTES / 1_000_000)
@@ -654,10 +814,10 @@
if (
contextElement.type === 'app_datatable' &&
aiChatManager.mode === AIMode.APP &&
aiChatManager.appAiChatHelpers
chatHost.mode === AIMode.APP &&
chatHost.appAiChatHelpers
) {
const appAiChatHelpers = aiChatManager.appAiChatHelpers
const appAiChatHelpers = chatHost.appAiChatHelpers
appAiChatHelpers.addTableToWhitelist(
contextElement.datatableName,
contextElement.schemaName,
@@ -699,14 +859,21 @@
* consuming past them would hand this message a mention the user picked for
* the next one. */
function consumeMentionsIfGlobal() {
if (aiChatManager.mode !== AIMode.GLOBAL) return
aiChatManager.contextManager?.consumeMentionContext()
if (chatHost.mode !== AIMode.GLOBAL) return
// The mention context belongs to the copilot's own ContextManager, which only
// the manager has — the GLOBAL guard above means this host is always it.
chatManager.contextManager?.consumeMentionContext()
}
function sendRequest() {
// The send button is disabled while decoding, but Enter reaches here directly.
// Sending now would drop the in-flight attachments onto the following message.
if (pendingImages > 0 || pendingFiles > 0 || ingestionHolds > 0) {
if (pendingImages > 0 || pendingFiles > 0 || pendingBlobs > 0 || ingestionHolds > 0) {
return
}
// A host whose consumer needs a message of its own refuses an attachment-only
// turn. Returning before `take()` keeps the chips where the user put them.
if (chatHost.requiresMessageText && draft.text.trim() === '') {
return
}
// Read before `take()` empties the draft the id derives from, and only take
@@ -715,7 +882,7 @@
const answeredQuestionId = questionAnsweredBySend
if (
answeredQuestionId &&
aiChatManager.handleUserQuestionAnswer(answeredQuestionId, [
chatHost.handleUserQuestionAnswer(answeredQuestionId, [
expanded(chatDraft(draft.text.trim(), draft.pastes))
])
) {
@@ -727,7 +894,7 @@
contextTextareaComponent?.clearForSend()
return
}
if (aiChatManager.loading) {
if (chatHost.loading) {
// Queue the message instead of silently discarding it — it is
// auto-sent when the streaming turn completes successfully.
// Editing-while-loading keeps the old discard behavior. Paste
@@ -738,14 +905,15 @@
// chips picked at press time.
if (
editingMessageIndex === null &&
(!draft.isEmpty || (aiChatManager.mode === AIMode.GLOBAL && selectedContext.length > 0))
(!draft.isEmpty || (chatHost.mode === AIMode.GLOBAL && selectedContext.length > 0))
) {
const sent = draft.take()
aiChatManager.queueMessage(
chatHost.queueMessage(
expanded(chatDraft(sent.text, sent.pastes)),
sent.images,
[...selectedContext],
sent.files
sent.files,
sent.blobs
)
// Consumed at enqueue, not at flush: the entry above pinned them.
consumeMentionsIfGlobal()
@@ -758,7 +926,7 @@
// message's original chips), so send exactly what's shown — the user may
// have added or removed chips.
const sent = draft.take()
aiChatManager.restartGeneration(
chatHost.restartGeneration(
editingMessageIndex,
sent.text,
sent.pastes,
@@ -771,13 +939,17 @@
const sent = draft.take()
// Pin before consuming: the manager falls back to the live selection only
// when given no override, and the consume below empties it.
const carried = aiChatManager.mode === AIMode.GLOBAL ? [...selectedContext] : undefined
const carried = chatHost.mode === AIMode.GLOBAL ? [...selectedContext] : undefined
consumeMentionsIfGlobal()
aiChatManager.sendRequest({
// A host that refuses the turn puts the draft back itself (see AIChatManager's
// restoreToInput and FlowChatViewHost's upload failure): restoring here too
// would double the text and every attachment.
chatHost.sendRequest({
instructions: sent.text,
pastes: sent.pastes,
images: sent.images,
files: sent.files,
blobs: sent.blobs,
contextOverride: carried,
contextOverrideOrigin: carried ? 'pinned' : undefined
})
@@ -999,37 +1171,67 @@
updateAppTooltipPosition(appTooltipCurrentViewNumber)
}
})
/**
* Clipboard files on the plain composer. ContextTextarea does this for the rich one; a
* host that attaches but renders the plain field would otherwise take files from the `+`
* and from a drop and silently ignore the same file pasted.
*
* Only when the clipboard carries no text, as there: a spreadsheet or browser copy puts a
* bitmap alongside the text, and pasting a cell range must paste the cells.
*/
function handlePlainPaste(e: ClipboardEvent) {
if (!chatHost.supportsMessageAttachments) return
if ((e.clipboardData?.getData('text/plain') ?? '').trim()) return
const pasted = Array.from(e.clipboardData?.files ?? [])
const images = pasted.filter((f) => f.type.startsWith('image/'))
const others = pasted.filter((f) => !f.type.startsWith('image/'))
if (images.length === 0 && others.length === 0) return
e.preventDefault()
if (images.length > 0) void addImages(images)
if (others.length > 0) void addNonImageFiles(others)
}
</script>
{#snippet sendStopButton()}
<!-- The turn stays `loading` while parked on a question, but a drafted answer
is what the button should ship then — otherwise the only pointer action on
a typed answer would be Stop. Anything else keeps Stop. -->
{@const isLoading = (loading ?? aiChatManager.loading) && !questionAnsweredBySend}
{@const isLoading = (loading ?? chatHost.loading) && !questionAnsweredBySend}
{@const emptyDraft = draft.isEmpty}
<!-- 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 needsText = chatHost.requiresMessageText && draft.text.trim() === ''}
<!-- The wording is about the attachment, so it earns its place only once there is one:
an empty composer is the idle state, not a refusal. -->
{@const needsTextForAttachment = needsText && !emptyDraft}
{@const sendDisabled =
disabled ||
pendingImages > 0 ||
pendingFiles > 0 ||
pendingBlobs > 0 ||
ingestionHolds > 0 ||
needsText ||
(emptyDraft &&
(onSendRequest !== undefined ||
aiChatManager.mode !== AIMode.GLOBAL ||
chatHost.mode !== AIMode.GLOBAL ||
selectedContext.length === 0))}
<Button
variant="subtle"
unifiedSize="md"
iconOnly
title={isLoading ? 'Stop' : 'Send'}
title={isLoading
? 'Stop'
: needsTextForAttachment
? 'Write a message to send with the attachment'
: 'Send'}
startIcon={{ icon: isLoading ? Square : ArrowUp }}
disabled={!isLoading && sendDisabled}
on:click={() => {
if (isLoading) {
onCancel ? onCancel() : aiChatManager.cancel()
onCancel ? onCancel() : chatHost.cancel()
} else if (!sendDisabled) {
submitRequest()
}
@@ -1043,7 +1245,7 @@
thumbnails get their own row (different height). -->
{#snippet badgeRow()}
{@const contextChips = showContext ? selectedContext : domSelectorChips}
{#if contextChips.length > 0 || draft.files.length > 0 || pendingFiles > 0}
{#if contextChips.length > 0 || draft.files.length > 0 || pendingFiles > 0 || draft.blobs.length > 0 || pendingBlobs > 0}
<div class="flex flex-row flex-wrap items-center gap-1 px-2.5 pt-2">
{#each contextChips as element (contextKey(element))}
<ContextElementBadge
@@ -1062,7 +1264,19 @@
onDelete={() => removeFile(i)}
/>
{/each}
{#each { length: pendingFiles } as _, i (i)}
<!-- Blobs are shown by the same badge as text files. Their preview line stands
in for content the badge cannot render (a PDF has no text to show). -->
{#each draft.blobs as blob, i (i)}
<ContextElementBadge
contextElement={createAttachedFileContextElement(
blob.name,
`${blob.mediaType} · ${Math.max(1, Math.round(blob.size / 1024))} KB`
)}
deletable
onDelete={() => removeBlob(i)}
/>
{/each}
{#each { length: pendingFiles + pendingBlobs } as _, i (i)}
<div
class="h-6 w-24 rounded-md border bg-surface flex items-center justify-center"
title="Reading file..."
@@ -1115,9 +1329,9 @@
class="relative mt-1"
role="presentation"
onkeydown={(e) => {
if (e.key === 'Escape' && aiChatManager.loading) {
if (e.key === 'Escape' && chatHost.loading) {
e.preventDefault()
aiChatManager.cancel()
chatHost.cancel()
} else if (
e.key === 'ArrowUp' &&
!e.defaultPrevented &&
@@ -1127,6 +1341,7 @@
draft.isEmpty &&
pendingImages === 0 &&
pendingFiles === 0 &&
pendingBlobs === 0 &&
ingestionHolds === 0
) {
// Shell-style recall: ArrowUp in the empty main composer pulls the
@@ -1139,14 +1354,15 @@
// custom-send consumers (inline widget) have their own history
// semantics.
if (
aiChatManager.queuedMessage ||
aiChatManager.queuedImages.length > 0 ||
aiChatManager.queuedFiles.length > 0 ||
(aiChatManager.queuedContext?.length ?? 0) > 0
chatHost.queuedMessage ||
chatHost.queuedImages.length > 0 ||
chatHost.queuedFiles.length > 0 ||
chatHost.queuedBlobs.length > 0 ||
(chatHost.queuedContext?.length ?? 0) > 0
) {
e.preventDefault()
aiChatManager.dequeueMessage()
} else if (!aiChatManager.sendInFlight && recallLastSentMessage()) {
chatHost.dequeueMessage()
} else if (!chatHost.sendInFlight && recallLastSentMessage()) {
// History recall waits for the in-flight turn: from the moment the
// composer clears, the turn's bubble, stored images and context land
// across several awaits, so recalling now would return an incomplete
@@ -1163,10 +1379,10 @@
bind:this={contextTextareaComponent}
bind:value={draft.text}
bind:pastes={draft.pastes}
onImageFiles={aiChatManager.mode === AIMode.GLOBAL
onImageFiles={chatHost.supportsMessageAttachments
? (pasted) => void addImages(pasted)
: undefined}
onTextFiles={aiChatManager.mode === AIMode.GLOBAL
onTextFiles={chatHost.supportsMessageAttachments
? (pasted) => void addTextFiles(pasted)
: undefined}
{availableContext}
@@ -1196,7 +1412,7 @@
</div>
{/if}
</div>
{:else if aiChatManager.mode === AIMode.APP}
{:else if chatHost.mode === AIMode.APP}
{#if showContext}
{@render badgeRow()}
{/if}
@@ -1264,30 +1480,37 @@
</Portal>
{/if}
{:else}
<div class={twMerge('relative w-full scroll-pb-2 pt-2', className)}>
<textarea
bind:this={instructionsTextareaComponent}
bind:value={draft.text}
use:autosize={{ maxHeight: '40vh' }}
onkeydown={(e) => {
if (onKeyDown) {
onKeyDown(e)
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendRequest()
}
}}
rows={1}
placeholder={modePlaceholder}
class={twMerge('resize-none', CHAT_INPUT_PADDING)}
{disabled}
></textarea>
{#if !bottomRightSnippet}
<div class="absolute bottom-1 right-1">
{@render sendStopButton()}
</div>
{/if}
<!-- Same box as the rich composer above, so a host on the plain textarea shows
the identical chip rows inside the identical field. -->
<div class={composerBoxClass(disabled)}>
{@render badgeRow()}
{@render imageChipsRow()}
<div class={twMerge('relative w-full', className)}>
<textarea
bind:this={instructionsTextareaComponent}
bind:value={draft.text}
use:autosize={{ maxHeight: '40vh' }}
onpaste={handlePlainPaste}
onkeydown={(e) => {
if (onKeyDown) {
onKeyDown(e)
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendRequest()
}
}}
rows={1}
placeholder={modePlaceholder}
class={twMerge('resize-none', COMPOSER_FIELD_RESET, CHAT_INPUT_PADDING)}
{disabled}
></textarea>
{#if !bottomRightSnippet}
<div class="absolute bottom-1 right-1">
{@render sendStopButton()}
</div>
{/if}
</div>
</div>
{/if}
{#if bottomRightSnippet}
@@ -1,3 +1,5 @@
import type { AttachedBlob } from './blobUtils'
import type { ChatViewHost } from './chatViewHost'
import type { ScriptLang } from '$lib/gen/types.gen'
import { JobService, type CompletedJob } from '$lib/gen'
import type { FlowOptions, ScriptOptions } from './ContextManager.svelte'
@@ -93,7 +95,7 @@ import { copilotInfo } from '$lib/aiStore'
import { copilotWorkspaceRequested, loadCopilot } from '$lib/components/copilot/loadCopilot'
import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core'
import { readDocsPageTool, searchDocsTool } from './docs/core'
import { TypewriterReveal } from './typewriterReveal'
import { prefersInstantReveal, TypewriterReveal } from './typewriterReveal'
import { chatState, DEFAULT_SIZE, triggerablesByAi } from './sharedChatState.svelte'
import {
createAppBackendRunnableContextElement,
@@ -153,11 +155,6 @@ import { appendAttachedFilesRoster } from './files/fileTools'
import { ENTER_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL } from './planMode'
import { PlanModeController, type PlanModeHost } from './planModeController.svelte'
// SSR and users who prefer reduced motion get no typewriter pacing.
function prefersInstantReveal(): boolean {
return !BROWSER || (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false)
}
// Compaction of the stored history: once the projected request size
// (contextTokens — the provider's report when current, a fresh chars/4
// estimate otherwise — plus the new user message) reaches the trigger ratio of
@@ -445,9 +442,31 @@ function planModeHostFor(m: AIChatManager): PlanModeHost {
}
}
export class AIChatManager {
export class AIChatManager implements ChatViewHost {
contextManager = new ContextManager()
historyManager = new HistoryManager()
// The copilot owns its model choice and its own transcript, so both chat
// affordances apply here. See ChatViewHost for hosts where they don't.
supportsModelSettings = true
supportsMessageEditing = true
// The copilot turn is the attachments themselves when there is no text.
requiresMessageText = false
// Attachments and linked folders are GLOBAL-mode affordances. Declared as
// getters because `mode` changes under a mounted composer.
get supportsMessageAttachments() {
return this.mode === AIMode.GLOBAL
}
get supportsLinkedFolders() {
return this.mode === AIMode.GLOBAL
}
// The copilot reads attachments in the browser, so non-image files decode to text.
attachmentsAsBlobs = false
// The copilot decodes its attachments, so nothing ever lands in the blob lane.
queuedBlobs: AttachedBlob[] = []
// Steers the OS file picker toward text + image formats (a soft hint; both attach to
// the message — text files after a content sniff).
attachmentAccept =
'image/*,text/*,.txt,.csv,.tsv,.json,.jsonl,.ndjson,.md,.markdown,.log,.yaml,.yml,.toml,.ini,.cfg,.conf,.env,.xml,.html,.htm,.css,.js,.mjs,.cjs,.ts,.tsx,.jsx,.py,.rb,.rs,.go,.java,.kt,.c,.h,.cpp,.cc,.cs,.php,.sh,.bash,.zsh,.sql,.svelte,.vue,.dockerfile'
/** Files the user attached to the current GLOBAL-mode conversation. */
attachedFiles = new AttachedFilesStore()
/** Markdown artifacts the copilot created for the current session. */
@@ -2421,8 +2440,11 @@ export class AIChatManager {
openArtifact: this.openArtifact
}
: {}),
testActiveFlow: async (storagePath: string, args?: Record<string, any>) =>
this.flowEditorFor(storagePath)?.testFlow(args),
testActiveFlow: async (
storagePath: string,
args?: Record<string, any>,
conversationId?: string
) => this.flowEditorFor(storagePath)?.testFlow(args, conversationId),
getModifiedItems: () => (this.modifiedItems ? [...this.modifiedItems] : undefined),
attachedFiles: this.attachedFiles,
getUserInstructions: () => getUserCustomPrompts()[AIMode.GLOBAL] ?? '',
@@ -3935,6 +3957,9 @@ export class AIChatManager {
{
role: 'assistant',
content: this.currentReply,
// Stamped as it lands. A chat restored from history predates this and
// simply shows no time rather than a made-up one.
createdAt: new Date().toISOString(),
...(this.currentReasoning
? { reasoning: this.currentReasoning, reasoningDurationMs }
: {}),
@@ -858,7 +858,9 @@ describe('AIChatManager autonomy mode', () => {
const jobId = await manager.helpers.testActiveFlow('u/admin/live_flow', { name: 'Ada' })
expect(jobId).toBe('job-flow-preview')
expect(testFlow).toHaveBeenCalledWith({ name: 'Ada' })
// Second argument is the chat-mode conversation id, which only `test_run_flow`'s
// own `conversation_id` supplies — never the session id.
expect(testFlow).toHaveBeenCalledWith({ name: 'Ada' }, undefined)
// A session chat resolves an editor by its storage path, so it never names one.
expect(manager.flowAiChatHelpers).toBeUndefined()
})
@@ -3,7 +3,7 @@
import type { DisplayMessage, ToolDisplayMessage } from './shared'
import ContextElementBadge from './ContextElementBadge.svelte'
import AssistantMessage from './AssistantMessage.svelte'
import { getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
import { Button } from '$lib/components/common'
import { RefreshCwIcon, Undo2Icon } from 'lucide-svelte'
import AIChatInput from './AIChatInput.svelte'
@@ -15,7 +15,7 @@
import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte'
import { workspaceStore } from '$lib/stores'
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
// Paths in a message name items the chat's tools reach, so they resolve against the
// operating workspace, never `workspaceStore`: a fork session leaves the store on the
@@ -25,7 +25,7 @@
// Registers the dependency that `operatingWorkspace`'s own untracked
// `get(workspaceStore)` cannot.
void $workspaceStore
return aiChatManager.operatingWorkspace
return chatHost.operatingWorkspace
})
// Per-message expand/collapse state for paste chips shown in the bubble.
@@ -62,7 +62,12 @@
let editContext = $state<ContextElement[]>([])
function editMessage() {
if (message.role !== 'user' || editingMessageIndex !== null || aiChatManager.loading) {
if (
!chatHost.supportsMessageEditing ||
message.role !== 'user' ||
editingMessageIndex !== null ||
chatHost.loading
) {
return
}
editContext = [...(message.contextElements ?? [])]
@@ -79,7 +84,9 @@
message.role === 'tool' && 'mb-1',
message.role === 'user' && messageIndex > 0 && 'mt-4 mb-6',
isLast && '!mb-12',
message.role !== 'user' ? 'cursor-default' : 'cursor-pointer'
message.role !== 'user' || !chatHost.supportsMessageEditing
? 'cursor-default'
: 'cursor-pointer'
)}
role="button"
tabindex="0"
@@ -116,7 +123,7 @@
bind:selectedContext={editContext}
initialInstructions={message.content}
initialPastes={message.pastes}
initialImages={aiChatManager.storedImages(messageIndex)}
initialImages={chatHost.storedImages(messageIndex)}
initialFiles={message.files}
{editingMessageIndex}
onClickOutside={() => (editingMessageIndex = null)}
@@ -131,7 +138,9 @@
{:else}
<div class={twMerge('text-sm py-1 px-2', message.role === 'tool' && 'text-primary py-0')}>
{#if message.role === 'assistant'}
<div class="px-[1px]"><AssistantMessage {message} workspace={messageWorkspace} /></div>
<div class="px-[1px] group/answer"
><AssistantMessage {message} workspace={messageWorkspace} /></div
>
{:else if message.role === 'tool'}
<div class="px-[1px]"
><ToolExecutionDisplay message={message as ToolDisplayMessage} /></div
@@ -185,9 +194,9 @@
on:click={() => {
if (message.snapshot) {
if (message.snapshot.type === 'flow') {
aiChatManager.flowAiChatHelpers?.revertToSnapshot(message.snapshot.value)
chatHost.flowAiChatHelpers?.revertToSnapshot(message.snapshot.value)
} else if (message.snapshot.type === 'app') {
aiChatManager.appAiChatHelpers?.revertToSnapshot(message.snapshot.value)
chatHost.appAiChatHelpers?.revertToSnapshot(message.snapshot.value)
}
}
}}
@@ -206,7 +215,7 @@
variant="default"
title="Retry generation"
startIcon={{ icon: RefreshCwIcon }}
onclick={() => aiChatManager.retryRequest(messageIndex)}
onclick={() => chatHost.retryRequest(messageIndex)}
>
Retry
</Button>
@@ -1,10 +1,12 @@
<script lang="ts">
import { ChevronDown, Check, User, Building2, Settings, ExternalLink } from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import DropdownSubmenuItem from '$lib/components/DropdownSubmenuItem.svelte'
import MenuItem from '$lib/components/meltComponents/MenuItem.svelte'
import MenuItemWrapper from '$lib/components/meltComponents/MenuItemWrapper.svelte'
import Button from '$lib/components/common/button/Button.svelte'
/**
* The session chat's model button: a fixed ChatModelSettings config over the copilot's
* own state — the workspace's configured models, the session's model/effort selection
* and its localStorage pins, the custom-prompt editors, and the free-tier grant.
*/
import { User, Building2, Settings, ExternalLink } from 'lucide-svelte'
import ChatModelSettings from '../ChatModelSettings.svelte'
import { carriedReasoning, type ChatModelSettingsConfig } from '../chatModelSettings'
import {
COPILOT_SESSION_MODEL_SETTING_NAME,
COPILOT_SESSION_PROVIDER_SETTING_NAME,
@@ -28,7 +30,6 @@
import { thinkingPreferences } from './thinkingPreferences.svelte'
import {
getReasoningCapability,
resolveEffectiveReasoning,
REASONING_OFF,
type ReasoningProviderModel
} from '../reasoningRegistry'
@@ -60,57 +61,16 @@
let freeUsedPct = $derived(Math.min(100, Math.round((freeTier?.used_ratio ?? 0) * 100)))
let freeRunningLow = $derived(!!freeTier && !freeTier.exhausted && freeUsedPct >= 80)
let capability = $derived(
getReasoningCapability(providerModel.provider as AIProvider, providerModel.model)
)
// Effective effort accounts for the default-on level on capable models.
let currentEffort = $derived(resolveEffectiveReasoning(providerModel))
// Slider stops: an off position only where the model can truly disable (else the
// provider would coerce it to the lowest level), then the provider-native levels.
let stops = $derived([...(capability.canDisable ? [REASONING_OFF] : []), ...capability.levels])
let currentStop = $derived(
providerModel.reasoning === REASONING_OFF
? REASONING_OFF
: (currentEffort ?? stops[stops.length - 1])
)
let stopIndex = $derived(Math.max(0, stops.indexOf(currentStop)))
// Percentage filled (accent) up to the thumb; the rest of the track stays surface-secondary.
let fillPct = $derived(stops.length > 1 ? Math.round((stopIndex / (stops.length - 1)) * 100) : 0)
// Button suffix: the effort token, or 'off' when explicitly disabled. Omitted entirely
// for models with no reasoning support.
let effortLabel = $derived(capability.supported ? (currentEffort ?? REASONING_OFF) : undefined)
// The trigger label resizes when the effort changes (e.g. dragging the slider while the menu
// is open). With a `bottom-end` popover anchored to the trigger's right edge, that resize would
// shift the popover. So we freeze the trigger to its width at open time and release it on close —
// no movement while open, and natural sizing (no reserved padding) the rest of the time.
let menuOpen = $state(false)
let triggerEl: HTMLElement | undefined = $state(undefined)
let lockedWidth = $state<number | undefined>(undefined)
$effect(() => {
if (menuOpen) {
if (lockedWidth === undefined && triggerEl) {
lockedWidth = triggerEl.getBoundingClientRect().width
}
} else {
lockedWidth = undefined
}
})
function selectModel(m: AIProviderModel) {
// Carry the effort onto the new model only if it supports that level ('off'
// only where the model can truly disable); otherwise drop it so the model's
// default applies.
const carried = providerModel.reasoning
const cap = getReasoningCapability(m.provider, m.model)
const keep =
carried === REASONING_OFF
? cap.canDisable
: carried !== undefined && cap.levels.includes(carried)
$copilotSessionModel = { ...m, ...(keep ? { reasoning: carried } : {}) }
const keep = carriedReasoning(
providerModel.reasoning,
REASONING_OFF,
getReasoningCapability(m.provider, m.model)
)
$copilotSessionModel = { ...m, ...(keep !== undefined ? { reasoning: keep } : {}) }
storeLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME, m.model)
storeLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME, m.provider)
storeLocalSetting(COPILOT_SESSION_REASONING_SETTING_NAME, keep ? carried : undefined)
storeLocalSetting(COPILOT_SESSION_REASONING_SETTING_NAME, keep)
}
function selectReasoning(value: string) {
@@ -233,9 +193,8 @@
}
}
// Prompt parameters, surfaced as a melt submenu (hover-opens and is floating-positioned,
// so it flips on screen edges instead of overflowing). The menu keeps itself open on
// item click (closeOnItemClick=false), so these actions close it explicitly via `close`.
// Prompt parameters, surfaced as a melt submenu. The menu keeps itself open on item
// click, so these actions close it explicitly before opening a modal.
function paramItems(close: () => void): Item {
return {
displayName: 'Parameters',
@@ -270,155 +229,55 @@
}
}
// Keep the slider's pointer events from bubbling to the enclosing melt item: melt's
// roving focus blurs the focused element on pointermove, which would abort the native
// thumb drag. Direct (non-delegated) listeners so they run before melt's item listener.
function isolatePointer(node: HTMLElement) {
const stop = (e: Event) => e.stopPropagation()
node.addEventListener('pointerdown', stop)
node.addEventListener('pointermove', stop)
return {
destroy() {
node.removeEventListener('pointerdown', stop)
node.removeEventListener('pointermove', stop)
const config = $derived<ChatModelSettingsConfig>({
label: providerModel.model,
title: 'Model & reasoning settings',
badge: freeTier && !freeTier.exhausted ? { text: 'Free', warn: freeRunningLow } : undefined,
// Off in a session: the assistant settings modal's Instructions section owns the
// prompt entries there, so the menu would offer the same thing twice.
topItems: promptSettings ? (close) => [paramItems(close)] : undefined,
sections: [
{
label: 'Model',
options: models.map((m) => ({
key: `${m.provider}/${m.model}`,
label: m.model,
selected: m.model === providerModel.model && m.provider === providerModel.provider,
onSelect: () => selectModel(m)
}))
}
}
}
// Adjust the reasoning effort with the arrow keys while the Thinking item is focused.
function adjustEffort(e: KeyboardEvent) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
e.preventDefault()
const next = Math.min(
stops.length - 1,
Math.max(0, stopIndex + (e.key === 'ArrowRight' ? 1 : -1))
)
selectReasoning(stops[next])
}
],
reasoning: {
provider: providerModel.provider as AIProvider,
model: providerModel.model,
value: providerModel.reasoning,
offToken: REASONING_OFF,
// The copilot fills an unset effort in before it calls the provider, so unset
// really does run at the default level and the button may name it.
sendsDefaultWhenUnset: true,
// The session chat's model is always its own to change.
writable: true,
onSelect: selectReasoning
},
// A reading preference rather than a model parameter: it applies to every chat in
// this browser, including thinking already in the transcript. No close(): flipping
// it should not dismiss the menu.
bottomItems: () => [
{
displayName: 'Always expand thinking',
selected: thinkingPreferences.expandByDefault,
action: () => (thinkingPreferences.expandByDefault = !thinkingPreferences.expandByDefault)
}
]
})
</script>
{#snippet externalLinkIcon()}
<ExternalLink size={14} class="shrink-0 text-secondary" />
{/snippet}
<DropdownV2
customMenu
placement="bottom-end"
fixedHeight={false}
closeOnItemClick={false}
bind:open={menuOpen}
>
{#snippet buttonReplacement()}
<div
bind:this={triggerEl}
style={lockedWidth !== undefined ? `width: ${lockedWidth}px` : undefined}
>
<Button
nonCaptureEvent
unifiedSize="2xs"
variant="subtle"
endIcon={{ icon: ChevronDown }}
btnClasses="w-full max-w-[200px] text-secondary font-normal"
title="Model & reasoning settings"
>
<span class="flex items-center gap-1 min-w-0">
<span class="truncate">{providerModel.model}</span>
{#if effortLabel}
<span class="shrink-0 text-tertiary">· {effortLabel}</span>
{/if}
{#if freeTier && !freeTier.exhausted}
<span
class="shrink-0 rounded-full px-1.5 text-2xs {freeRunningLow
? 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/40'
: 'bg-surface-secondary text-tertiary'}">Free</span
>
{/if}
</span>
</Button>
</div>
{/snippet}
{#snippet menu({ item, builders, close })}
<div
class="bg-surface-tertiary dark:border w-64 origin-top-right rounded-lg shadow-lg focus:outline-none py-1 text-xs"
>
<!-- Melt submenu: hover-opens and is floating-positioned (flips on screen edges). -->
{#if promptSettings}
<DropdownSubmenuItem item={paramItems(close)} {builders} meltItem={item} />
{/if}
<ChatModelSettings {config} />
<div class="my-1 border-t border-border-light"></div>
<div class="px-3 pt-1.5 pb-1 text-2xs uppercase tracking-wide text-secondary">Model</div>
<div class="max-h-48 overflow-y-auto">
{#each models as m (m.provider + m.model)}
<MenuItem
{item}
class="w-full flex items-center gap-2 px-3 py-1.5 text-left font-normal hover:bg-surface-hover data-[highlighted]:bg-surface-hover rounded-sm transition-colors cursor-pointer"
onClick={() => selectModel(m)}
>
<span class="truncate grow min-w-0">{m.model}</span>
{#if m.model === providerModel.model && m.provider === providerModel.provider}
<Check size={14} class="shrink-0 text-primary" />
{/if}
</MenuItem>
{/each}
</div>
<div class="my-1 border-t border-border-light"></div>
{#if capability.supported}
<!-- Registered as a melt item so it joins the roving focus/highlight (and arrow
up/down navigation), and so hovering it takes the highlight off the Parameters
trigger. Left/right adjust the effort; the slider's input handler also drives it. -->
<MenuItemWrapper {item} onKeydown={adjustEffort} class="block group">
<div class="px-3 pt-1 pb-0.5 flex items-center justify-between">
<span class="text-2xs uppercase tracking-wide text-secondary">Thinking</span>
<span class="text-2xs text-secondary tabular-nums">{currentStop}</span>
</div>
{#if stops.length > 1}
<!-- Only the slider area reflects the item's highlight, not the header. -->
<div
class="px-3 py-1.5 rounded-sm transition-colors group-data-[highlighted]:bg-surface-hover"
>
<input
type="range"
min="0"
max={stops.length - 1}
step="1"
value={stopIndex}
style="--fill: {fillPct}%"
oninput={(e) => selectReasoning(stops[+e.currentTarget.value])}
use:isolatePointer
class="lean-range no-default-style w-full"
aria-label="Reasoning effort"
/>
</div>
{/if}
</MenuItemWrapper>
{:else}
<!-- Reasoning unsupported: keep the section but show it disabled with a reason,
rather than hiding it. Not a melt item, so it's skipped by keyboard navigation. -->
<div class="px-3 pt-1 pb-1.5 opacity-60 cursor-default" aria-disabled="true">
<div class="text-2xs uppercase tracking-wide text-secondary">Thinking</div>
<div class="text-2xs text-tertiary mt-0.5">Not supported by this model</div>
</div>
{/if}
<!-- A reading preference rather than a model parameter: it applies to every
chat in this browser, including thinking already in the transcript. -->
<MenuItem
{item}
class="w-full flex items-center gap-2 px-3 py-1.5 text-left font-normal hover:bg-surface-hover data-[highlighted]:bg-surface-hover rounded-sm transition-colors cursor-pointer"
onClick={() => (thinkingPreferences.expandByDefault = !thinkingPreferences.expandByDefault)}
>
<span class="truncate grow min-w-0 text-2xs text-secondary">Always expand thinking</span>
{#if thinkingPreferences.expandByDefault}
<Check size={14} class="shrink-0 text-primary" />
{/if}
</MenuItem>
</div>
{/snippet}
</DropdownV2>
<!-- Only where the entries that open it are rendered. -->
{#if promptSettings}
<AIPromptsModal
bind:open={modalOpen}
@@ -436,61 +295,3 @@
settingsHref={isAdmin ? AI_SETTINGS_HREF : undefined}
/>
{/if}
<style>
/* Lean reasoning slider: a thin track and a small, borderless accent thumb. Native range
thumbs can't be styled with Tailwind, and Svelte prunes scoped vendor pseudo-element
rules — so they are wrapped in :global (the class is unique to this component). */
.lean-range {
-webkit-appearance: none;
appearance: none;
height: 10px;
margin: 0;
padding: 0;
/* override the global `input { background-color: ... !important }` so only the
thin track shows, not a full-height band behind it */
background-color: transparent !important;
cursor: pointer;
outline: none;
}
.lean-range:focus,
.lean-range:focus-visible {
outline: none;
}
:global(.lean-range::-webkit-slider-runnable-track) {
height: 3px;
border-radius: 9999px;
background: linear-gradient(
to right,
rgb(var(--color-surface-accent-primary)) var(--fill, 0%),
rgb(var(--color-surface-secondary)) var(--fill, 0%)
);
}
:global(.lean-range::-webkit-slider-thumb) {
-webkit-appearance: none;
appearance: none;
margin-top: -3.5px;
width: 10px;
height: 10px;
border: none;
border-radius: 9999px;
background: rgb(var(--color-surface-accent-primary));
}
:global(.lean-range::-moz-range-track) {
height: 3px;
border-radius: 9999px;
background: rgb(var(--color-surface-secondary));
}
:global(.lean-range::-moz-range-progress) {
height: 3px;
border-radius: 9999px;
background: rgb(var(--color-surface-accent-primary));
}
:global(.lean-range::-moz-range-thumb) {
width: 10px;
height: 10px;
border: none;
border-radius: 9999px;
background: rgb(var(--color-surface-accent-primary));
}
</style>
@@ -4,7 +4,7 @@
import { CircleHelp, ArrowUp, Plus, Square, SquareCheck } from 'lucide-svelte'
import Button from '$lib/components/common/button/Button.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
import type { UserQuestionDisplay } from './shared'
// Sessions inject a per-pane `AIChatManager` via context; outside of
@@ -12,7 +12,7 @@
// this, answers clicked inside a session would dispatch to the singleton's
// pending callbacks map (which doesn't have the session manager's question
// callback), and the AI loop would stall.
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
interface Props {
toolCallId: string
@@ -93,14 +93,14 @@
}
return
}
aiChatManager.handleUserQuestionAnswer(toolCallId, [choice])
chatHost.handleUserQuestionAnswer(toolCallId, [choice])
}
function submitPicked() {
if (!multiSelect || picked.size === 0) {
return
}
aiChatManager.handleUserQuestionAnswer(toolCallId, [...picked])
chatHost.handleUserQuestionAnswer(toolCallId, [...picked])
}
function submitCustomAnswer() {
@@ -119,7 +119,7 @@
return
}
aiChatManager.handleUserQuestionAnswer(toolCallId, [answer])
chatHost.handleUserQuestionAnswer(toolCallId, [answer])
}
function handleChoiceKeydown(event: KeyboardEvent, choice: string, index: number) {
@@ -12,6 +12,11 @@
workspaceItemRegistry
} from './workspaceItems.svelte'
import { markdownProse } from '$lib/components/markdownProse'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import { Bot, ExternalLink } from 'lucide-svelte'
import CopyButton from '$lib/components/common/button/CopyButton.svelte'
import { base } from '$lib/base'
import { displayDate } from '$lib/utils'
interface Props {
message: DisplayMessage
@@ -22,6 +27,21 @@
let { message, workspace }: Props = $props()
// The run this answer came out of. Only a flow chat has one — a copilot turn runs in
// the browser — so the footer is absent rather than empty elsewhere.
const jobId = $derived(message.role === 'assistant' ? message.jobId : undefined)
const createdAt = $derived(message.role === 'assistant' ? message.createdAt : undefined)
const runHref = $derived(jobId ? `${base}/run/${jobId}?workspace=${workspace}` : undefined)
// Today's answers show the time alone; the day earns its place only on a conversation
// read back later. Resolved at render, so a chat left open across midnight keeps
// yesterday's format until it is reopened.
const timestamp = $derived.by(() => {
if (!createdAt) return undefined
const at = new Date(createdAt)
const today = new Date().toDateString() === at.toDateString()
return displayDate(at, false, !today)
})
const reasoning = $derived(
message.role === 'assistant' ? message.reasoning?.trim() || undefined : undefined
)
@@ -60,6 +80,20 @@
return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`
}
const stepName = $derived(message.role === 'assistant' ? message.stepName : undefined)
// A flow step can return a file rather than text; the raw JSON would be
// unreadable, so hand it to the result viewer instead of the markdown renderer.
const s3Object = $derived.by(() => {
if (!message.content.startsWith('{')) return undefined
try {
const parsed = JSON.parse(message.content)
return parsed?.type === 'windmill_s3_object' && parsed?.s3 ? parsed : undefined
} catch {
return undefined
}
})
const candidatePaths = $derived(extractCandidatePaths(message.content))
const rendererPlugin = {
renderer: {
@@ -98,6 +132,19 @@
})
</script>
<!-- An agent step's answer is headed by the agent's own icon, hung in the margin so the
answer itself stays on the same left edge as the reader's messages. The icon sits in
the padding the message column already carries. -->
{#if stepName}
<div
class="flex items-center gap-2 -ml-6 mb-1 text-2xs text-tertiary"
title="Answered by {stepName}"
>
<Bot size={16} class="shrink-0" />
<span class="font-mono truncate">{stepName}</span>
</div>
{/if}
{#if reasoning}
<ChatCollapsibleCard
label={reasoningLabel}
@@ -112,8 +159,38 @@
</ChatCollapsibleCard>
{/if}
{#if message.content}
{#if s3Object}
<DisplayResult result={s3Object} workspaceId={workspace} noControls={true} />
{:else if message.content}
<div class="w-full space-y-2 {markdownProse.sm}">
<Markdown md={message.content} {plugins} />
</div>
{/if}
{#if message.content}
<!-- Present but invisible until the answer is hovered: kept in flow so revealing it
does not nudge the message below, and with no margin of its own so it sits in the
gap the transcript already leaves between messages. A row carrying only thinking
has no answer to copy or date, and the run behind it is the one the next row
already links. -->
<div
class="flex items-center gap-2 text-2xs text-tertiary opacity-0 transition-opacity duration-150 group-hover/answer:opacity-100 focus-within:opacity-100"
>
<CopyButton value={message.content} title="Copy answer" class="-ml-1" />
{#if timestamp}
<span>{timestamp}</span>
{/if}
{#if runHref}
<a
href={runHref}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 hover:text-primary hover:underline"
title="Open this run"
>
<span>job <span class="font-mono">{jobId?.slice(0, 8)}</span></span>
<ExternalLink size={11} class="shrink-0" />
</a>
{/if}
</div>
{/if}
@@ -1,4 +1,5 @@
<script lang="ts">
import { composerBoxClass, COMPOSER_FIELD_RESET } from './composerBox'
import autosize from '$lib/autosize'
import { tick, type Snippet } from 'svelte'
import type { ContextElement } from './context'
@@ -767,21 +768,7 @@
}
</script>
<!-- The composer box: border + rounded live HERE (on the wrapper), not on the
textarea, so context chips can sit INSIDE the box, above the text. The
textarea's own @tailwindcss/forms border/ring is neutralized below. -->
<!-- The disabled treatment lives on the wrapper for the same reason the box
does: `disabled` on the textarea alone leaves the field looking exactly
like a usable one, so the only cue that typing is refused is placeholder
text the eye reads as an invitation. -->
<div
class={twMerge(
'w-full scroll-pb-2 rounded-md border border-border-light transition-colors',
disabled
? 'bg-surface-disabled cursor-not-allowed'
: 'bg-surface-input focus-within:border-border-selected'
)}
>
<div class={composerBoxClass(disabled)}>
<!-- Context chips live inside the input box, above the textarea. The snippet
self-guards (renders nothing when empty) so no blank row appears. -->
{@render leading?.()}
@@ -830,11 +817,7 @@
{placeholder}
class={twMerge(
'textarea-input resize-none caret-black dark:caret-white overflow-clip',
// The box (border/ring) lives on the wrapper; kill the textarea's own
// @tailwindcss/forms border, focus ring, and background so only the
// wrapper reads as the field.
'!border-transparent !bg-transparent !shadow-none focus:!border-transparent focus:!ring-0',
'disabled:cursor-not-allowed disabled:placeholder:text-disabled',
COMPOSER_FIELD_RESET,
CHAT_INPUT_PADDING,
className
)}
@@ -1,16 +1,16 @@
<script lang="ts">
import { copilotInfo, copilotSessionModel } from '$lib/aiStore'
import { getKnownModelContextWindow, getModelContextWindow } from '../modelConfig'
import { getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
import { AIMode } from './AIChatManager.svelte'
import UsageMeter from './UsageMeter.svelte'
import { formatTokenCount } from './tokenUsage'
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
// The `/compact` slash command is only wired up in session-chat GLOBAL mode,
// so only advertise it where it actually works.
let canCompact = $derived(aiChatManager.isSessionChat && aiChatManager.mode === AIMode.GLOBAL)
let canCompact = $derived(chatHost.isSessionChat && chatHost.mode === AIMode.GLOBAL)
let providerModel = $derived(
$copilotSessionModel ?? $copilotInfo.defaultModel ?? $copilotInfo.aiModels[0]
@@ -27,10 +27,10 @@
// The same number the compaction trigger uses: the provider's report when
// one describes the current history (one turn stale by nature), otherwise
// a live chars/4 estimate of the stored context.
let usedTokens = $derived(Math.round(aiChatManager.contextTokens))
let usedTokens = $derived(Math.round(chatHost.contextTokens))
// Always surface usage once a conversation has started, at any fill level, so
// the user can watch context grow toward the compaction threshold.
let visible = $derived(usedTokens > 0 && aiChatManager.messages.length > 0)
let visible = $derived(usedTokens > 0 && chatHost.messages.length > 0)
// Compaction triggers at 80% of the window (COMPACTION_TRIGGER_RATIO); the
// gauge fills toward that point and turns red once it is reached.
@@ -3,7 +3,7 @@
import { FileText, X } from 'lucide-svelte'
import ContextElementBadge from './ContextElementBadge.svelte'
import { contextElementKey } from './context'
import { getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
// The single message typed while a turn was streaming, waiting to be
// auto-sent when the turn finishes. Rendered above the whole input stack
@@ -11,7 +11,7 @@
// conversation". Pressing Enter again appends another line to it; clicking
// the chip body (its X, or ArrowUp in the empty input) removes it and
// restores its content into the input so nothing is lost.
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
</script>
<!-- Attachment-only and context-only queues have empty text; without their
@@ -20,23 +20,23 @@
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.queuedFiles.length > 0 || (aiChatManager.queuedContext?.length ?? 0) > 0}
{#if chatHost.queuedMessage || chatHost.queuedImages.length > 0 || chatHost.queuedFiles.length > 0 || chatHost.queuedBlobs.length > 0 || (chatHost.queuedContext?.length ?? 0) > 0}
<!-- The body and the X are sibling buttons for the same action (an X inside a
clickable chip would be a nested interactive control, invalid ARIA). -->
<div
class="mb-1 flex flex-row items-start gap-1 rounded-md bg-surface-input px-3 py-2 opacity-60 hover:opacity-100"
>
{#if aiChatManager.queuedMessage || aiChatManager.queuedImages.length > 0 || aiChatManager.queuedFiles.length > 0}
{#if chatHost.queuedMessage || chatHost.queuedImages.length > 0 || chatHost.queuedFiles.length > 0 || chatHost.queuedBlobs.length > 0}
<button
type="button"
class="min-w-0 grow text-left cursor-pointer"
title={aiChatManager.queuedMessage}
title={chatHost.queuedMessage}
aria-label="Remove queued message and put it back in the input"
onclick={() => aiChatManager.dequeueMessage()}
onclick={() => chatHost.dequeueMessage()}
>
{#if aiChatManager.queuedImages.length > 0}
<div class="flex flex-row flex-wrap gap-1 {aiChatManager.queuedMessage ? 'mb-1' : ''}">
{#each aiChatManager.queuedImages as image, i (i)}
{#if chatHost.queuedImages.length > 0}
<div class="flex flex-row flex-wrap gap-1 {chatHost.queuedMessage ? 'mb-1' : ''}">
{#each chatHost.queuedImages as image, i (i)}
<img
src={image.dataUrl}
alt={image.name ?? 'queued image'}
@@ -45,9 +45,9 @@
{/each}
</div>
{/if}
{#if aiChatManager.queuedFiles.length > 0}
<div class="flex flex-row flex-wrap gap-1 {aiChatManager.queuedMessage ? 'mb-1' : ''}">
{#each aiChatManager.queuedFiles as file, i (i)}
{#if chatHost.queuedFiles.length > 0}
<div class="flex flex-row flex-wrap gap-1 {chatHost.queuedMessage ? 'mb-1' : ''}">
{#each chatHost.queuedFiles as file, i (i)}
<span
class="flex flex-row items-center gap-1 px-1.5 rounded border border-border-light text-2xs text-secondary max-w-36"
title={file.name}
@@ -58,20 +58,35 @@
{/each}
</div>
{/if}
{#if aiChatManager.queuedMessage}
{#if chatHost.queuedBlobs.length > 0}
<!-- Blobs are the same chip as files: a host that forwards bytes verbatim
queues them here instead, and a queue of them alone must still show. -->
<div class="flex flex-row flex-wrap gap-1 {chatHost.queuedMessage ? 'mb-1' : ''}">
{#each chatHost.queuedBlobs as blob, i (i)}
<span
class="flex flex-row items-center gap-1 px-1.5 rounded border border-border-light text-2xs text-secondary max-w-36"
title={blob.name}
>
<FileText size={10} class="shrink-0" />
<span class="truncate min-w-0">{blob.name}</span>
</span>
{/each}
</div>
{/if}
{#if chatHost.queuedMessage}
<p class="text-xs text-secondary whitespace-pre-wrap line-clamp-2">
{aiChatManager.queuedMessage}
{chatHost.queuedMessage}
</p>
{/if}
</button>
{:else if aiChatManager.queuedContext?.length}
{:else if chatHost.queuedContext?.length}
<!-- Context badges are interactive themselves (popover preview), so a
context-only queue gets a plain row instead of the clickable body —
nesting the badges in it would be invalid ARIA and a badge click
would dequeue out from under the opening popover. The X (and
ArrowUp in the empty input) still restores the queue. -->
<div class="min-w-0 grow flex flex-row flex-wrap gap-1">
{#each aiChatManager.queuedContext as element (contextElementKey(element))}
{#each chatHost.queuedContext as element (contextElementKey(element))}
<ContextElementBadge contextElement={element} compact />
{/each}
</div>
@@ -82,7 +97,7 @@
iconOnly
title="Remove queued message and put it back in the input"
startIcon={{ icon: X }}
on:click={() => aiChatManager.dequeueMessage()}
on:click={() => chatHost.dequeueMessage()}
/>
</div>
{/if}
@@ -1,7 +1,7 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { twMerge } from 'tailwind-merge'
import { getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
interface Props {
toolCallId: string | undefined
@@ -25,11 +25,11 @@
class: className
}: Props = $props()
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
function respond(confirmed: boolean) {
if (toolCallId) {
aiChatManager.handleToolConfirmation(toolCallId, confirmed)
chatHost.handleToolConfirmation(toolCallId, confirmed)
}
}
</script>
@@ -21,9 +21,9 @@
} from './planMode'
import { Button } from '$lib/components/common'
import { markdownProse } from '$lib/components/markdownProse'
import { getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
import { isActiveUserQuestion, type ToolDisplayMessage } from './shared'
import ChatCollapsibleCard from './ChatCollapsibleCard.svelte'
import { twMerge } from 'tailwind-merge'
@@ -69,7 +69,7 @@
const planLabel = $derived((planState && planCopy?.[planState]) ?? '')
const planDoc = $derived(
message.planArtifactId
? aiChatManager.artifacts.artifacts.find((a) => a.id === message.planArtifactId)
? chatHost.artifacts.artifacts.find((a) => a.id === message.planArtifactId)
: undefined
)
// The version this card wrote, not the document's current one, since later proposals move it on.
@@ -201,7 +201,7 @@
title="Open this plan in the side panel: {planDoc.name}"
startIcon={{ icon: FileText, classes: PLAN_MODE_TEXT_COLOR }}
endIcon={{ icon: PanelRight }}
on:click={() => aiChatManager.openArtifact?.(planDoc.id, planDoc.name, planCardVersion)}
on:click={() => chatHost.openArtifact?.(planDoc.id, planDoc.name, planCardVersion)}
>
<span class="font-main">Plan</span>
</Button>
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { dataUrlToBlob, matchesAccept } from './blobUtils'
function file(name: string, type: string): File {
return new File(['x'], name, { type })
}
describe('dataUrlToBlob', () => {
// The bytes are re-uploaded verbatim, so a decode that drops or shifts one is a
// corrupted file the reader only discovers downstream.
it('decodes base64 back to the exact bytes', async () => {
const bytes = new Uint8Array([0x00, 0xff, 0x10, 0x89, 0x50])
const b64 = btoa(String.fromCharCode(...bytes))
const blob = dataUrlToBlob(`data:application/pdf;base64,${b64}`)
expect(blob.type).toBe('application/pdf')
expect(new Uint8Array(await blob.arrayBuffer())).toEqual(bytes)
})
it('percent-decodes a url that is not base64', async () => {
const blob = dataUrlToBlob('data:text/plain,hello%20world')
expect(blob.type).toBe('text/plain')
expect(await blob.text()).toBe('hello world')
})
it('falls back to a media type when the url names none', async () => {
expect(dataUrlToBlob('data:;base64,QQ==').type).toBe('application/octet-stream')
expect(dataUrlToBlob('data:;base64,QQ==', 'image/png').type).toBe('image/png')
})
})
describe('matchesAccept', () => {
it('matches an extension, a type wildcard and an exact media type', () => {
expect(matchesAccept(file('report.PDF', ''), '.pdf')).toBe(true)
expect(matchesAccept(file('shot.png', 'image/png'), 'image/*')).toBe(true)
expect(matchesAccept(file('shot.png', 'image/png'), 'image/png')).toBe(true)
})
it('refuses a file no pattern covers, and allows everything when the list is empty', () => {
expect(matchesAccept(file('notes.txt', 'text/plain'), '.pdf, image/*')).toBe(false)
expect(matchesAccept(file('notes.txt', 'text/plain'), '')).toBe(true)
})
})
@@ -0,0 +1,79 @@
/**
* Message-scoped attachments that are neither an image nor readable text a PDF
* being the case that matters. They ride the composer next to images and files,
* as chips cleared on send, and reach the host through `ChatSendRequestOptions`.
*
* The bytes are kept verbatim, unlike an image (which normalises to a bounded
* PNG/JPEG for the model) and unlike a text file (which is decoded to a string):
* a host that forwards these to object storage has to upload what the user
* picked, not a re-encoding of it.
*/
/** Blobs one message may carry — the same slot cap images and text files use. */
export const MAX_ATTACHED_BLOBS = 8
/**
* Per-blob byte cap. The data URL sits in composer state until send, so this
* bounds what one message can hold in memory; a host uploading elsewhere pays
* the same bytes again on the wire.
*/
export const MAX_BLOB_BYTES = 20_000_000
export type AttachedBlob = {
name: string
/** The file's own media type, verbatim — the upload's Content-Type depends on it. */
mediaType: string
/** `data:<mediaType>;base64,<...>` of the original bytes. */
dataUrl: string
size: number
}
/**
* Whether a file satisfies an `accept` list the same list the OS picker gets, applied
* again on drop, where the browser enforces nothing.
*/
export function matchesAccept(file: File, accept: string): boolean {
const patterns = accept
.split(',')
.map((p) => p.trim().toLowerCase())
.filter(Boolean)
if (patterns.length === 0) return true
const type = file.type.toLowerCase()
const name = file.name.toLowerCase()
return patterns.some((pattern) => {
if (pattern.startsWith('.')) return name.endsWith(pattern)
if (pattern.endsWith('/*')) return type.startsWith(pattern.slice(0, -1))
return type === pattern
})
}
export async function fileToAttachedBlob(file: File): Promise<AttachedBlob> {
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(reader.error ?? new Error(`Could not read ${file.name}`))
reader.readAsDataURL(file)
})
return {
name: file.name,
mediaType: file.type || 'application/octet-stream',
dataUrl,
size: file.size
}
}
/** The bytes behind a `data:` URL, for a host that has to re-upload them. */
export function dataUrlToBlob(dataUrl: string, fallbackType = 'application/octet-stream'): Blob {
const comma = dataUrl.indexOf(',')
const header = dataUrl.slice(5, comma)
const isBase64 = header.endsWith(';base64')
const mediaType = (isBase64 ? header.slice(0, -';base64'.length) : header) || fallbackType
const payload = dataUrl.slice(comma + 1)
if (!isBase64) {
return new Blob([decodeURIComponent(payload)], { type: mediaType })
}
const binary = atob(payload)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return new Blob([bytes], { type: mediaType })
}
@@ -0,0 +1,177 @@
import { getContext, setContext } from 'svelte'
import type { AIMode, AIAutonomyMode } from './AIChatManager.svelte'
import { getAiChatManager } from './aiChatManagerContext'
import type { DisplayMessage, Tool } from './shared'
import type { ContextElement } from './context'
import type { AttachedBlob } from './blobUtils'
import type { AttachedImage } from './imageUtils'
import type { AttachedTextFile } from './textFileUtils'
import type { PasteAttachment } from './pasteTokens'
import type { AttachedFilesStore } from './files/attachedFiles.svelte'
import type { SessionArtifactsStore } from './artifacts/artifactsState.svelte'
import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRouter'
import type { FlowAIChatHelpers } from './flow/core'
import type { AppAIChatHelpers } from './app/core'
import type AIChatInput from './AIChatInput.svelte'
export type ChatSendRequestOptions = {
instructions?: string
pastes?: PasteAttachment[]
images?: AttachedImage[]
files?: AttachedTextFile[]
blobs?: AttachedBlob[]
/** Selected-context snapshot for this turn, in place of the live selection. Set
* whenever a send settles its context ahead of the turn. A host with no context
* of its own ignores it. */
contextOverride?: ContextElement[]
/** Where `contextOverride` came from. 'pinned': chips picked for THIS message, so
* they are consumed from the live selection on send. 'replay': an edit or retry
* resending an older message's context, already consumed long ago. */
contextOverrideOrigin?: 'pinned' | 'replay'
/** The conversation this turn belongs to, where that is not the one on screen a
* queued message going out after its own chat's run finished. Hosts with a single
* conversation ignore it. */
conversationId?: string
}
/**
* What the chat view components (AIChatDisplay and everything it renders) need
* from whatever is driving the conversation. AIChatManager implements it for the
* copilot's own LLM loop; FlowChatViewHost implements it over a flow run's
* conversation so both chats render through the same components.
*
* Each affordance is gated by the field that answers for it attachments by
* `supportsMessageAttachments`, the model button by `supportsModelSettings`, and so on
* so a host turns on exactly what it can serve, and a new one is a matter of answering
* these fields rather than of being a copilot. `mode` is the exception, still read
* directly for chrome that only the copilot has.
*/
export interface ChatViewHost {
// Transcript
displayMessages: DisplayMessage[]
/** API-level messages. Only the count is read (context usage visibility). */
messages: readonly unknown[]
contextTokens: number
/** The workspace a message's paths and jobs resolve against, which a fork session
* pins away from the navigated one. */
readonly operatingWorkspace: string | undefined
loading: boolean
/** A turn this tab can neither follow nor stop, held by another tab on the same chat. */
readonly runHeldElsewhere: boolean
loadingLabel: string | undefined
compacting: boolean
currentReply: string
currentReasoning: string
currentReasoningActive: boolean
readonly reasoningHiddenIndicatorLabel: string | undefined
readonly automaticScroll: boolean
enableAutomaticScroll: () => void
disableAutomaticScroll: () => void
// Composer
instructions: string
readonly sendInFlight: boolean
/** Resolves to whether the draft was consumed as a turn. */
sendRequest: (options?: ChatSendRequestOptions) => Promise<boolean | undefined>
cancel: (reason?: string) => void
setAiChatInput: (aiChatInput: AIChatInput | null) => void
readonly queuedMessage: string
queuedContext: ContextElement[] | undefined
readonly queuedImages: AttachedImage[]
readonly queuedFiles: AttachedTextFile[]
readonly queuedBlobs: AttachedBlob[]
queueMessage: (
text: string,
images?: AttachedImage[],
context?: ContextElement[],
files?: AttachedTextFile[],
blobs?: AttachedBlob[]
) => void
dequeueMessage: () => void
setComposerStaged: (key: string, editingIndex: number | null, bytes: number) => void
clearComposerStaged: (key: string) => void
attachmentBytesExcluding: (selfKey: string) => number
// Per-message actions
storedImages: (displayMessageIndex: number) => AttachedImage[] | undefined
retryRequest: (messageIndex: number) => void
restartGeneration: (
displayMessageIndex: number,
newContent?: string,
pastes?: PasteAttachment[],
images?: AttachedImage[],
editedContext?: ContextElement[],
files?: AttachedTextFile[]
) => void | Promise<void>
handleUserQuestionAnswer: (toolId: string, choices: string[]) => boolean
handleToolConfirmation: (toolId: string, confirmed: boolean) => void
/** A tool is waiting on a run form the user is filling in. Escape belongs to that form
* then, not to the turn see AIChatDisplay's window handler. */
readonly hasPendingRunForm: boolean
isRunFormPending: (toolCallId: string) => boolean
// Copilot-only surfaces. Left undefined/false by hosts that have no LLM loop
// of their own; the chrome they drive hides itself.
mode?: AIMode
isSessionChat: boolean
/** Model + reasoning picker. Off where the model is configured elsewhere. */
supportsModelSettings: boolean
/** Click a user message to edit and resend it. Needs a host that can rewind
* its own transcript, which a host replaying a server-side run cannot. */
supportsMessageEditing: boolean
/** The `+` menu's file entry and drag-and-drop onto the panel. Attachments ride
* one message; where they go afterwards is the host's business (see sendRequest). */
supportsMessageAttachments: boolean
/**
* Why attaching is off right now, when the host would otherwise take attachments. Distinct
* from `supportsMessageAttachments` being false, which means this chat never takes them:
* here the composer keeps the control and says what is missing, because moving the input
* elsewhere would only offer an editor that cannot work either.
*/
attachmentsUnavailableReason?: string
/** The turn needs text: attachments alone cannot be sent. True where the consumer
* requires a message of its own an AI agent step refuses a run with neither a
* `user_message` nor manual memory. */
requiresMessageText: boolean
/** The `+` menu's folder entries, backed by `attachedFiles`. A linked folder is a
* live handle on the user's disk, so only a host reading files in the browser has one. */
supportsLinkedFolders: boolean
/** `accept` for the file picker, and the drop filter. A host whose consumer only
* understands some formats narrows it so the rest are refused rather than ignored. */
attachmentAccept: string
/** How many attachments one turn can carry, when the consumer holds a fixed number
* a flow input that is a single file, say. Undefined means no limit. Enforced at the
* picker and on drop, so what the composer shows is what the turn actually sends. */
maxMessageAttachments?: number
/** Take non-image attachments verbatim (`blobs`) instead of decoding them to text.
* True where the bytes are forwarded somewhere object storage rather than read
* in the browser. */
attachmentsAsBlobs: boolean
tools: Tool<any>[]
autonomyMode: AIAutonomyMode
setAutonomyMode: (mode: AIAutonomyMode) => void
readonly autoAcceptEditsActive: boolean
readonly autoAcceptEditsAvailable: boolean
readonly autoAcceptToolConfirmationsAvailable: boolean
readonly planModeAvailable: boolean
attachedFiles: AttachedFilesStore
artifacts: SessionArtifactsStore
openArtifact?: (artifactId: string, name: string, version?: ArtifactVersionTarget) => void
flowAiChatHelpers?: FlowAIChatHelpers
appAiChatHelpers?: AppAIChatHelpers
}
const CHAT_VIEW_HOST_CONTEXT_KEY = 'chatViewHost'
export function setChatViewHost(host: ChatViewHost) {
setContext(CHAT_VIEW_HOST_CONTEXT_KEY, host)
}
/**
* Resolve the host driving the chat in this subtree. Falls back to the
* AIChatManager (scoped instance or app-wide singleton) so every existing
* copilot chat keeps working without setting anything.
*/
export function getChatViewHost(): ChatViewHost {
return getContext<ChatViewHost>(CHAT_VIEW_HOST_CONTEXT_KEY) ?? getAiChatManager()
}
@@ -0,0 +1,27 @@
/**
* The composer's box, shared by both of AIChatInput's branches the rich
* ContextTextarea and the plain textarea a host without @-context gets.
*
* Border and rounding live on the WRAPPER, never on the field, so the chip rows
* (context, files, images) sit inside the box above the text. The field's own
* @tailwindcss/forms border, ring and background are neutralised so only the
* wrapper reads as the input.
*
* The disabled treatment is on the wrapper for the same reason: `disabled` on the
* field alone leaves it looking exactly like a usable one, so the only cue that
* typing is refused is placeholder text the eye reads as an invitation.
*/
const BOX_BASE = 'w-full scroll-pb-2 rounded-md border border-border-light transition-colors'
export function composerBoxClass(disabled: boolean = false): string {
return `${BOX_BASE} ${
disabled
? 'bg-surface-disabled cursor-not-allowed'
: 'bg-surface-input focus-within:border-border-selected'
}`
}
/** Applied to the field inside the box; without it the field draws a second border. */
export const COMPOSER_FIELD_RESET =
'!border-transparent !bg-transparent !shadow-none focus:!border-transparent focus:!ring-0 disabled:cursor-not-allowed disabled:placeholder:text-disabled'
@@ -1,4 +1,5 @@
<script lang="ts">
import { randomUUID } from '$lib/utils/uuid'
import FlowModuleSchemaMap from '$lib/components/flows/map/FlowModuleSchemaMap.svelte'
import { getContext, tick, untrack } from 'svelte'
import type { ExtendedOpenFlow, FlowEditorContext } from '$lib/components/flows/types'
@@ -172,8 +173,14 @@
if (args) {
previewArgs.val = args
}
// A chat-enabled flow is refused without a conversation to run the turn in, and
// that id is a query parameter no caller can reach through `args`. A test run has
// no conversation open, so it gets one of its own rather than appending a turn to
// a chat someone is reading.
const memoryId =
conversationId ?? (flowStore.val.value.chat_input_enabled ? randomUUID() : undefined)
// Call the UI test function which opens preview panel
return await onTestFlow?.(conversationId)
return await onTestFlow?.(memoryId)
},
getLintErrors: async (moduleId: string): Promise<ScriptLintResult> => {
@@ -4925,13 +4925,64 @@ describe('global AI tools', () => {
)
// What the form submitted, not what the model proposed: the editor runs the flow, but
// the arguments are the user's.
expect(testActiveFlow).toHaveBeenCalledWith('u/admin/live_flow_storage', { name: 'Grace' })
// the arguments are the user's. The third argument is the chat-mode conversation id,
// which only `test_run_flow`'s own `conversation_id` supplies.
expect(testActiveFlow).toHaveBeenCalledWith(
'u/admin/live_flow_storage',
{ name: 'Grace' },
undefined
)
expect(FlowService.getFlowByPath).not.toHaveBeenCalled()
expect(JobService.runFlowPreview).not.toHaveBeenCalled()
expect(result).toContain('Result (SUCCESS)')
})
// A chat flow only shows its memory across turns, so the model has to be able to name
// the conversation it is continuing rather than getting a fresh one every call.
it('test_run_flow passes the conversation id it was given to the live editor hook', async () => {
seedBackendDraft(
'flow',
'',
{
path: 'u/admin/live_chat_flow',
summary: 'Live chat flow',
value: { modules: [{ id: 'live_step', value: { type: 'identity' } }] },
schema: { type: 'object', properties: { user_message: { type: 'string' } } },
edited_by: '',
edited_at: '',
archived: false,
extra_perms: {}
},
{ workspace: WORKSPACE }
)
UserDraft.setLiveEditorDraft({
workspace: WORKSPACE,
itemKind: 'flow',
storagePath: '',
effectivePath: 'u/admin/live_chat_flow'
})
const testActiveFlow = vi.fn(async () => 'job-live-chat')
await withCompletedTestJob(() =>
callGlobalTool(
'test_run_flow',
{
path: 'u/admin/live_chat_flow',
args: { user_message: 'hi' },
conversation_id: '550e8400-e29b-41d4-a716-446655440000'
},
toolCallbacks,
{ testActiveFlow }
)
)
expect(testActiveFlow).toHaveBeenCalledWith(
'',
{ user_message: 'hi' },
'550e8400-e29b-41d4-a716-446655440000'
)
})
it('test_run_flow falls back to preview when the live flow editor test hook returns undefined', async () => {
seedBackendDraft(
'flow',
@@ -4968,7 +5019,11 @@ describe('global AI tools', () => {
)
)
expect(testActiveFlow).toHaveBeenCalledWith('u/admin/live_flow_fallback', { name: 'Ada' })
expect(testActiveFlow).toHaveBeenCalledWith(
'u/admin/live_flow_fallback',
{ name: 'Ada' },
undefined
)
expect(FlowService.getFlowByPath).not.toHaveBeenCalled()
expect(JobService.runFlowPreview).toHaveBeenCalledWith({
workspace: WORKSPACE,
@@ -1,3 +1,4 @@
import { randomUUID } from '$lib/utils/uuid'
import {
AppService,
AzureTriggerService,
@@ -926,6 +927,12 @@ const runScriptToolDef = createToolDef(
const testRunFlowSchema = z.object({
path: z.string().describe('Workspace path of the flow to test.'),
args: testRunArgsSchema,
conversation_id: z
.string()
.optional()
.describe(
'Chat-mode flows only. A UUID naming the conversation this turn belongs to: reuse the same one across calls to test memory and follow-ups, and omit it for a one-off turn in a conversation of its own. Generate the UUID yourself so you can pass it again.'
),
background: backgroundArgSchema,
wait_seconds: waitSecondsArgSchema
})
@@ -4402,8 +4409,14 @@ type WriteDraftCtx = {
export type SessionToolHelpers = { sessionId?: string }
export type GlobalToolHelpers = SessionToolHelpers & {
/** Runs the flow editor mounted on `storagePath`, if one is. */
testActiveFlow?: (storagePath: string, args?: Record<string, any>) => Promise<string | undefined>
/** Runs the flow editor mounted on `storagePath`, if one is. `conversationId` names the
* chat-mode conversation the turn belongs to; the editor mints one when it is omitted and
* the flow is chat-enabled. */
testActiveFlow?: (
storagePath: string,
args?: Record<string, any>,
conversationId?: string
) => Promise<string | undefined>
attachedFiles?: AttachedFilesStore
// Read/write the user-level Global instructions. `setUserInstructions` persists the
// value and rebuilds the system message so the change applies on the next chat-loop
@@ -4440,13 +4453,18 @@ function operatingWorkspaceFromHelpers(helpers: unknown): string | undefined {
function liveFlowTestHookFromCtx(
ctx: { workspace: string; helpers?: unknown },
path: string
): ((args?: Record<string, any>) => Promise<string | undefined>) | undefined {
):
| ((args?: Record<string, any>, conversationId?: string) => Promise<string | undefined>)
| undefined {
const activeEditor = getActiveGlobalEditorContext(ctx.workspace)
if (activeEditor?.type !== 'flow' || activeEditor.path !== path) {
return undefined
}
const testActiveFlow = (ctx.helpers as GlobalToolHelpers | undefined)?.testActiveFlow
return testActiveFlow && ((args) => testActiveFlow(activeEditor.storagePath, args))
return (
testActiveFlow &&
((args, conversationId) => testActiveFlow(activeEditor.storagePath, args, conversationId))
)
}
export type OpenPreviewHandler = (req: {
@@ -5461,6 +5479,16 @@ function flowDraftValueForPreview(flowDraft: FlowDraftValue): FlowValue {
return flowDraftAsEditableInput(flowDraft).value
}
/**
* The conversation a test run of a chat-enabled flow belongs to. The server refuses such a
* run without one, and it is a query parameter rather than a flow argument, so there is no
* way for the caller to supply it through `args`. A fresh id each time is the right default:
* a test run is its own conversation, not a turn appended to one someone is reading.
*/
function chatMemoryId(value: FlowValue): string | undefined {
return value.chat_input_enabled ? randomUUID() : undefined
}
async function loadScriptForFlowStep(
moduleValue: { path: string; hash?: string },
workspace: string
@@ -5926,15 +5954,20 @@ async function testRunFlowByPath(
// An open editor runs its own in-memory flow and paints the run in its graph.
// Resolved here rather than before the form: the form waits as long as the user
// does, and the editor on screen when they press Run is the one it belongs in.
const jobId = await liveFlowTestHookFromCtx(ctx, args.path)?.(submitted)
const jobId = await liveFlowTestHookFromCtx(ctx, args.path)?.(
submitted,
args.conversation_id
)
if (jobId) {
return jobId
}
const value = flowDraftValueForPreview(flow.flow)
return JobService.runFlowPreview({
workspace,
memoryId: args.conversation_id ?? chatMemoryId(value),
requestBody: {
path: args.path,
value: flowDraftValueForPreview(flow.flow),
value,
args: submitted
}
})
@@ -1,6 +1,6 @@
/**
* A message draft: the four lanes that ship together with one send text,
* pastes, images, text files. Every place a draft accumulates or moves
* A message draft: the five lanes that ship together with one send text,
* pastes, images, text files, blobs. Every place a draft accumulates or moves
* (composer attach, queue append, dequeue restore, failure restore) goes
* through this type, so the draft rules file dedupe by source identity,
* courtesy rename, attachment slot caps, all-lanes-move-together live here
@@ -10,6 +10,7 @@
* manager-wide state enforced at the composer until it moves into the
* store) and @context/DOM picks (ContextManager owns their lifecycle).
*/
import { MAX_ATTACHED_BLOBS, type AttachedBlob } from './blobUtils'
import { MAX_ATTACHED_IMAGES, type AttachedImage } from './imageUtils'
import type { PasteAttachment } from './pasteTokens'
import {
@@ -19,12 +20,13 @@ import {
type AttachedTextFile
} from './textFileUtils'
/** A draft's four lanes as plain data — what moves between owners. */
/** A draft's five lanes as plain data — what moves between owners. */
export interface DraftSnapshot {
text: string
pastes: PasteAttachment[]
images: AttachedImage[]
files: AttachedTextFile[]
blobs: AttachedBlob[]
}
export class MessageDraft {
@@ -32,12 +34,14 @@ export class MessageDraft {
pastes = $state<PasteAttachment[]>([])
images = $state<AttachedImage[]>([])
files = $state<AttachedTextFile[]>([])
blobs = $state<AttachedBlob[]>([])
constructor(seed?: Partial<DraftSnapshot>) {
if (seed?.text) this.text = seed.text
if (seed?.pastes) this.pastes = [...seed.pastes]
if (seed?.images) this.images = [...seed.images]
if (seed?.files) this.files = [...seed.files]
if (seed?.blobs) this.blobs = [...seed.blobs]
}
get isEmpty(): boolean {
@@ -45,12 +49,13 @@ export class MessageDraft {
this.text.trim() === '' &&
this.pastes.length === 0 &&
this.images.length === 0 &&
this.files.length === 0
this.files.length === 0 &&
this.blobs.length === 0
)
}
get hasAttachments(): boolean {
return this.images.length > 0 || this.files.length > 0
return this.images.length > 0 || this.files.length > 0 || this.blobs.length > 0
}
/** Files joining a draft always fold (dedupe by source identity, courtesy
@@ -83,6 +88,14 @@ export class MessageDraft {
return dropped
}
/** Blobs join up to the slot cap. Returns the dropped count (caller toasts). */
addBlobs(blobs: AttachedBlob[]): number {
const merged = [...this.blobs, ...blobs]
const dropped = Math.max(0, merged.length - MAX_ATTACHED_BLOBS)
this.blobs = merged.slice(0, MAX_ATTACHED_BLOBS)
return dropped
}
/**
* Merge a restored draft on top of this one (queued-message delete, restore
* after a cancelled/errored turn): the restored draft was written FIRST, so
@@ -91,10 +104,16 @@ export class MessageDraft {
* Returns whether text merged onto a non-empty draft (the caller must then
* keep both drafts' context), plus dropped counts for toasts.
*/
prepend(restored: { text: string; images?: AttachedImage[]; files?: AttachedTextFile[] }): {
prepend(restored: {
text: string
images?: AttachedImage[]
files?: AttachedTextFile[]
blobs?: AttachedBlob[]
}): {
mergedIntoDraft: boolean
droppedImages: number
droppedFiles: number
droppedBlobs: number
} {
const mergedIntoDraft = !!restored.text && !!this.text.trim()
// An attachment-only restore has empty text; prepending would only add blank lines.
@@ -115,7 +134,13 @@ export class MessageDraft {
droppedFiles = Math.max(0, merged.length - MAX_ATTACHED_FILES)
this.files = merged.slice(0, MAX_ATTACHED_FILES)
}
return { mergedIntoDraft, droppedImages, droppedFiles }
let droppedBlobs = 0
if (restored.blobs?.length) {
const merged = [...restored.blobs, ...this.blobs]
droppedBlobs = Math.max(0, merged.length - MAX_ATTACHED_BLOBS)
this.blobs = merged.slice(0, MAX_ATTACHED_BLOBS)
}
return { mergedIntoDraft, droppedImages, droppedFiles, droppedBlobs }
}
/** Replace the draft with a snapshot, but only when it is empty an occupied
@@ -132,16 +157,18 @@ export class MessageDraft {
this.pastes = [...(snapshot.pastes ?? [])]
this.images = [...(snapshot.images ?? [])]
this.files = [...(snapshot.files ?? [])]
this.blobs = [...(snapshot.blobs ?? [])]
}
/** Snapshot and clear atomically the four lanes always move together, so no
/** Snapshot and clear atomically the five lanes always move together, so no
* call site can take one and forget another. */
take(): DraftSnapshot {
const snapshot: DraftSnapshot = {
text: this.text,
pastes: this.pastes,
images: this.images,
files: this.files
files: this.files,
blobs: this.blobs
}
this.clear()
return snapshot
@@ -152,5 +179,6 @@ export class MessageDraft {
this.pastes = []
this.images = []
this.files = []
this.blobs = []
}
}
@@ -671,6 +671,14 @@ export type AssistantDisplayMessage = BaseDisplayMessage & {
* would look like it is still streaming forever.
*/
streaming?: boolean
/** Flow step that produced this message, when the conversation is a flow run
* rather than a copilot turn. Rendered as a label above the content. */
stepName?: string
/** The run behind this answer, linked under it. Flow chats only: a copilot turn
* happens in the browser and has no job to open. */
jobId?: string
/** When the message was stored, shown beside the run link. */
createdAt?: string
}
/**
@@ -9,6 +9,13 @@
// state is the `onReveal` callback — so the pacing is unit-testable with an
// injected clock and scheduler.
import { BROWSER } from 'esm-env'
/** SSR and readers who prefer reduced motion get no pacing: text lands as it arrives. */
export function prefersInstantReveal(): boolean {
return !BROWSER || (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false)
}
type Schedule = (cb: () => void) => unknown
type Cancel = (handle: unknown) => void
@@ -0,0 +1,236 @@
import { describe, expect, it } from 'vitest'
import {
carriedReasoning,
fixedReasoningReason,
reasoningControlState,
reasoningDisplay,
REASONING_PROVIDER_DEFAULT,
type ChatModelSettingsReasoning
} from './chatModelSettings'
import {
getReasoningCapability,
REASONING_OFF,
resolveEffectiveReasoning
} from './reasoningRegistry'
/**
* The trigger's suffix and the slider's stops are read side by side, so they have to agree
* about one value the provider-native off token must not read as `none` on one and `off`
* on the other, and an effort the run does not send must not be named at all.
*/
function display(
reasoning: Partial<ChatModelSettingsReasoning> & { provider: any; model: string }
) {
const full = {
value: undefined,
offToken: undefined,
sendsDefaultWhenUnset: false,
onSelect: () => {},
...reasoning
} as ChatModelSettingsReasoning
const capability = getReasoningCapability(full.provider, full.model)
// Composed exactly as the component composes it, so the test exercises the real pair.
const effective = resolveEffectiveReasoning({
provider: full.provider,
model: full.model,
reasoning: full.value
})
return reasoningDisplay(full, capability, effective)
}
describe('reasoningDisplay', () => {
it('says nothing for a model that cannot reason', () => {
const shown = display({ provider: 'openai', model: 'gpt-4o' })
expect(shown.label).toBeUndefined()
expect(shown.stops).toEqual([])
})
// The session chat's own sentinel: what it stores is already the word the reader sees.
it('reads the session chat off sentinel as off', () => {
const shown = display({
provider: 'openai',
model: 'gpt-5.1',
offToken: REASONING_OFF,
value: REASONING_OFF,
sendsDefaultWhenUnset: true
})
expect(shown.label).toBe(REASONING_OFF)
expect(shown.currentStop).toBe(REASONING_OFF)
})
// An agent writes the provider's own token, which can read as anything.
it('reads a provider-native off token as off too', () => {
const shown = display({
provider: 'openai',
model: 'gpt-5.1',
offToken: 'none',
value: 'none'
})
expect(shown.label).toBe(REASONING_OFF)
expect(shown.currentStop).toBe('none')
expect(shown.stops[0]).toBe('none')
})
it('names the level a chat that fills one in will send', () => {
const shown = display({
provider: 'openai',
model: 'gpt-5.1',
offToken: REASONING_OFF,
value: undefined,
sendsDefaultWhenUnset: true
})
expect(shown.label).toBe('high')
})
// An agent step omits the field, so naming a level would claim something untrue.
it('names no level where an unset effort is simply not sent', () => {
const shown = display({
provider: 'anthropic',
model: 'claude-sonnet-5',
offToken: 'none',
value: undefined
})
expect(shown.label).toBe(REASONING_PROVIDER_DEFAULT)
expect(shown.currentStop).toBe('')
})
// Claude 4.x only thinks when asked, so an absent effort is already off — and the flow
// chat must be able to get back to it after a level has been picked.
it('offers omission as the off stop where that is how the model disables', () => {
const unset = display({ provider: 'anthropic', model: 'claude-opus-4-6', offToken: '' })
expect(unset.label).toBe(REASONING_OFF)
expect(unset.stops[0]).toBe('')
const picked = display({
provider: 'anthropic',
model: 'claude-opus-4-6',
offToken: '',
value: 'high'
})
expect(picked.currentStop).toBe('high')
expect(picked.stops).toContain('')
})
// gpt-5 reasons at medium with no effort sent, so an empty off token buys no off stop.
it('offers no off where the model cannot stop thinking', () => {
const shown = display({ provider: 'openai', model: 'gpt-5', offToken: '' })
expect(shown.stops).not.toContain('')
expect(shown.label).toBe(REASONING_PROVIDER_DEFAULT)
})
// The run sends an explicitly set effort whatever the model, so a token typed against a
// provider we have no rules for has to reach the trigger — silence would hide it.
it('names a set effort even where it can offer no ladder', () => {
const shown = display({ provider: 'customai', model: 'deepseek-r1', value: 'high' })
expect(shown.label).toBe('high')
expect(shown.stops).toEqual([])
})
})
describe('carriedReasoning', () => {
const cap = (model: string) => getReasoningCapability('openai', model)
// The bug this exists for: picking a model that cannot think left the old level in the
// flow input, and the run sent it anyway.
it('drops a level the new model does not have', () => {
expect(carriedReasoning('high', '', cap('gpt-4o'))).toBeUndefined()
expect(carriedReasoning('xhigh', '', cap('gpt-5.1'))).toBeUndefined()
})
it('keeps a level the new model does have', () => {
expect(carriedReasoning('high', '', cap('gpt-5.1'))).toBe('high')
})
it('carries off only onto a model that can truly stop thinking', () => {
expect(carriedReasoning(REASONING_OFF, REASONING_OFF, cap('gpt-5.1'))).toBe(REASONING_OFF)
expect(carriedReasoning(REASONING_OFF, REASONING_OFF, cap('gpt-5'))).toBeUndefined()
})
// A provider the registry has no rules for draws no thinking control, so a carried level
// would be invisible and unclearable — and still sent, since an explicitly set effort
// goes out whatever the model.
it('drops the effort where it has no rules for the provider', () => {
expect(
carriedReasoning('high', REASONING_OFF, getReasoningCapability('customai', 'deepseek-r1'))
).toBeUndefined()
})
it('has nothing to carry when no effort is set', () => {
expect(carriedReasoning(undefined, '', cap('gpt-5.1'))).toBeUndefined()
expect(carriedReasoning('', '', cap('gpt-5.1'))).toBeUndefined()
})
})
const asReasoning = (over: Partial<ChatModelSettingsReasoning>): ChatModelSettingsReasoning =>
({
provider: 'openai',
model: 'gpt-5.1',
value: undefined,
offToken: REASONING_OFF,
sendsDefaultWhenUnset: false,
writable: true,
onSelect: () => {},
...over
}) as ChatModelSettingsReasoning
/** The control is always drawn; this is the only thing that decides what it draws. */
describe('reasoningControlState', () => {
const cap = (model: string) => getReasoningCapability('openai', model)
it('shows the ladder for a model with levels', () => {
expect(reasoningControlState(asReasoning({}), cap('gpt-5.1'))).toBe('ladder')
})
it('says a model cannot think when the registry knows it cannot', () => {
expect(reasoningControlState(asReasoning({ model: 'gpt-4o' }), cap('gpt-4o'))).toBe(
'unsupported'
)
})
// Not the same as "cannot think": we have no rules for the provider, so the flow's own
// token is typed rather than picked.
it('asks for a typed token where it has no rules for the provider', () => {
expect(
reasoningControlState(
asReasoning({ provider: 'customai', model: 'deepseek-r1' }),
getReasoningCapability('customai', 'deepseek-r1')
)
).toBe('unknown')
})
// A provider with a full ladder must not be described as unreadable just because no
// model has been picked yet — which is the state right after choosing a resource.
it('waits for a model rather than blaming the provider', () => {
expect(
reasoningControlState(asReasoning({ model: undefined }), { supported: false, known: false })
).toBe('awaiting-model')
})
it('shows what the flow fixed when this chat cannot write it', () => {
expect(reasoningControlState(asReasoning({ writable: false }), cap('gpt-5.1'))).toBe('fixed')
})
})
describe('fixedReasoningReason', () => {
it('names the level the run will use', () => {
expect(
fixedReasoningReason(asReasoning({ value: 'high' }), { supported: true, known: true })
).toBe('high · set in the flow')
})
// The step naming no effort at all is the common shape; saying it was "set in the flow"
// would describe a line the flow does not contain.
it('does not claim a level the step never set', () => {
expect(
fixedReasoningReason(asReasoning({ value: undefined }), { supported: true, known: true })
).toBe('Not set in the flow, so the provider decides')
})
it('surfaces a level fixed on a model that cannot use it', () => {
expect(
fixedReasoningReason(asReasoning({ value: 'high', model: 'gpt-4o' }), {
supported: false,
known: true
})
).toContain('cannot think')
})
})
@@ -0,0 +1,209 @@
import type { AIProvider } from '$lib/gen'
import type { Item } from '$lib/utils'
import { REASONING_OFF } from './reasoningRegistry'
/**
* The contract between a chat and its model button.
*
* One component renders this menu for every chat the copilot's own session chat and
* the flow chat so the component knows only about rows, choices and a reasoning
* ladder. What a row means (a workspace AI resource, a prompt to edit, a reading
* preference) is the caller's business, and each caller derives its own config: a fixed
* one for the session chat, one derived from the flow's exposed inputs for flow chat.
*/
export type ModelChoice = {
/** Stable across rebuilds of the config; used as the `{#each}` key. */
key: string
label: string
/** Muted trailing text, e.g. the provider a resource speaks. */
hint?: string
selected: boolean
onSelect: () => void
}
export type ChoiceSection = {
/** Section heading, e.g. 'Provider' or 'Model'. */
label: string
options: ModelChoice[]
/** Fetched lists render one consistent loading line instead of the options. */
loading?: boolean
/** Shown when the list is empty and settled. */
emptyMessage?: string
maxHeight?: string
}
export type ChatModelSettingsConfig = {
/** The trigger's main text: the chosen model, or an invitation to choose one. */
label: string
title?: string
/** Trailing pill on the trigger, e.g. the free-tier grant this chat is spending. */
badge?: { text: string; warn?: boolean }
/** Nothing here is editable — the trigger still names the model, but no menu opens. */
readOnly?: boolean
readOnlyReason?: string
/**
* Rows above and below the choice sections. Given the menu's own `close` because a
* row that opens a modal must close the menu first, while a row that toggles a
* preference must not.
*/
topItems?: (close: () => void) => Item[]
sections?: ChoiceSection[]
bottomItems?: (close: () => void) => Item[]
/**
* The thinking slider. The ladder is derived from the provider and model here rather
* than by each caller, so a new provider's effort levels reach every chat at once.
* `value` is the raw stored effort (undefined meaning the model's default), and
* `offToken` the token this caller stores for "off" the copilot keeps its own
* sentinel and translates when it calls the provider, an agent writes the
* provider-native token straight into its step.
*/
reasoning?: ChatModelSettingsReasoning
}
export type ChatModelSettingsReasoning = {
/** Absent until the chat knows what it will run; the ladder then has nothing to stand on. */
provider: AIProvider | undefined
model: string | undefined
value: string | undefined
offToken: string | undefined
/**
* What an unset value means on the wire. The copilot fills one in before calling the
* provider, so unset really runs at the default effort and the button says so. An agent
* step omits the field entirely, so unset means whatever the provider does by itself
* naming a level there would state something the run does not do.
*/
sendsDefaultWhenUnset: boolean
/**
* Whether this chat can write the effort back. False where the flow fixes it in the step:
* the run still uses it, so the button shows it and refuses to pretend otherwise only
* the flow editor can change it.
*/
writable: boolean
onSelect: (token: string) => void
}
/** What an unset agent effort reads as: the provider decides, and we do not know what. */
export const REASONING_PROVIDER_DEFAULT = 'default'
/**
* The effort to keep when the model changes, or nothing where the new model has no such
* level. Dropped rather than carried because a model that cannot think at that level either
* rejects the request or quietly runs at another one, and the button would name a level the
* run never used. Off survives only onto a model that can truly disable.
*
* A model the registry has no rules for drops it too, for the same reason: the button draws
* no thinking control there, so a carried level would be invisible and unclearable while
* still going out on the wire `resolveEffectiveReasoning` sends an explicitly set effort
* whatever the model, and a provider that rejects the field would then fail every turn with
* nothing on screen to explain it.
*/
export function carriedReasoning(
current: string | undefined,
offToken: string | undefined,
capability: { levels: string[]; canDisable: boolean }
): string | undefined {
if (current === undefined || current === '') return undefined
if (offToken !== undefined && current === offToken) {
return capability.canDisable ? current : undefined
}
return capability.levels.includes(current) ? current : undefined
}
/**
* What the menu shows for the reasoning ladder: the stops the slider offers, the one it
* sits on, and the suffix on the trigger.
*
* Pure and here rather than in the component because these three have to agree a stop
* the slider renders as `off` must not read as the provider's own `none` on the button
* and because the rules are provider-shaped enough to be worth testing directly.
*/
export function reasoningDisplay(
reasoning: ChatModelSettingsReasoning | undefined,
capability: { supported: boolean; levels: string[]; canDisable: boolean },
effective: string | undefined
): {
stops: string[]
currentStop: string
/** Trigger suffix, or undefined when there is nothing truthful to say. */
label: string | undefined
} {
if (!reasoning) return { stops: [], currentStop: '', label: undefined }
if (!capability.supported) {
// No ladder to place it on, but an effort that is explicitly set still goes out —
// `resolveEffectiveReasoning` sends one whatever the model — so the button names it.
// Saying nothing would hide from the reader what the run is about to do.
const set = reasoning.value ? reasoning.value : undefined
return { stops: [], currentStop: '', label: set }
}
// An off position only where the model can truly disable, else the provider would
// coerce it to the lowest level; then the provider-native levels.
const offToken = capability.canDisable ? reasoning.offToken : undefined
const stops = [...(offToken !== undefined ? [offToken] : []), ...capability.levels]
// An agent whose model disables by omission stores the empty string, which the run
// treats as no effort at all — so an unset value already sits on that stop.
const isOff = offToken !== undefined && (reasoning.value ?? '') === offToken
if (isOff) {
// The off token is provider-native and can read as anything ('none', 'disabled');
// on the button and on the slider it always reads as off.
return { stops, currentStop: offToken as string, label: REASONING_OFF }
}
if (reasoning.value === undefined || reasoning.value === '') {
// Where the provider takes an explicit disable, unset is a third state — the
// provider's own level, above off — that the ladder has no position for. The
// button still names it, and every stop the ladder does offer stays reachable.
return reasoning.sendsDefaultWhenUnset
? { stops, currentStop: effective ?? '', label: effective ?? REASONING_OFF }
: { stops, currentStop: '', label: REASONING_PROVIDER_DEFAULT }
}
return { stops, currentStop: reasoning.value, label: reasoning.value }
}
/** Which thinking control a chat should draw. */
export type ReasoningControlState =
/** The flow sets the effort itself; show what the run will use. */
| 'fixed'
/** No model chosen yet, so nothing can be said about its levels. */
| 'awaiting-model'
/** No rules for this provider: the flow takes a token, so let one be typed. */
| 'unknown'
/** Known levels — the ladder. */
| 'ladder'
/** Known to have none. */
| 'unsupported'
/**
* The control is always drawn; only its state varies. Deciding that here rather than in the
* markup keeps the states in one readable place and testable, which the two predicates
* this replaced were not.
*/
export function reasoningControlState(
reasoning: ChatModelSettingsReasoning | undefined,
capability: { supported: boolean; known: boolean }
): ReasoningControlState {
if (!reasoning || !reasoning.writable) return 'fixed'
if (!reasoning.model) return 'awaiting-model'
if (!capability.known) return 'unknown'
return capability.supported ? 'ladder' : 'unsupported'
}
/**
* What the row says when the chat cannot write the effort. Naming the level is the point: a
* button that names the model a run will use should name its thinking too, and a level fixed
* on a model that cannot use one is a broken flow worth seeing rather than a silent row.
*/
export function fixedReasoningReason(
reasoning: ChatModelSettingsReasoning | undefined,
capability: { supported: boolean; known: boolean }
): string {
const cannotThink = capability.known && !capability.supported
const model = reasoning?.model ?? 'this model'
if (!reasoning?.value) {
// The step names no effort, so the provider decides — saying it was "set in the flow"
// would describe a line the flow does not contain.
return cannotThink ? `${model} cannot think` : 'Not set in the flow, so the provider decides'
}
return cannotThink
? `${reasoning.value} · set in the flow, but ${model} cannot think`
: `${reasoning.value} · set in the flow`
}
@@ -77,9 +77,9 @@ describe('supportsReasoning (static registry)', () => {
}
// Bedrock translates the same sentinel on its Converse path, but only for
// Opus 5 — AWS documents Bedrock's Sonnet 5 as always thinking.
expect(
getReasoningCapability('aws_bedrock', 'global.anthropic.claude-opus-5').canDisable
).toBe(true)
expect(getReasoningCapability('aws_bedrock', 'global.anthropic.claude-opus-5').canDisable).toBe(
true
)
expect(
resolveRequestReasoning({
provider: 'aws_bedrock',
@@ -189,13 +189,29 @@ describe('supportsReasoning (static registry)', () => {
expect(supportsReasoning('mistral', 'mistral-medium-3.5')).toBe(true)
expect(getReasoningCapability('mistral', 'mistral-medium-3-5').canDisable).toBe(true)
})
it('returns no levels for providers without a registry entry', () => {
it('returns no levels for a model its provider family has no entry for', () => {
// The family is known, so the `false` is an answer: codestral does not reason.
expect(getReasoningCapability('mistral', 'codestral-latest')).toEqual({
supported: false,
levels: [],
canDisable: false
canDisable: false,
known: true
})
})
// `customai` fronts any OpenAI-compatible endpoint, so `supported: false` there is an
// absence of rules rather than a fact about the model. A caller that shows the reader
// "this model cannot think" has to tell the two apart.
it('admits when it has no rules for the provider at all', () => {
expect(getReasoningCapability('customai', 'deepseek-r1')).toEqual({
supported: false,
levels: [],
canDisable: false,
known: false
})
expect(getReasoningCapability('openai', 'gpt-4o').known).toBe(true)
expect(getReasoningCapability('anthropic', 'claude-sonnet-5').known).toBe(true)
})
it('only offers off where the model can truly disable thinking', () => {
// Gemini Pro enforces a thinking floor — no off option.
expect(getReasoningCapability('googleai', 'gemini-2.5-pro').canDisable).toBe(false)
@@ -231,14 +231,35 @@ export type ReasoningCapability = {
* level, making the switch a lie.
*/
canDisable: boolean
/**
* Whether `supported` is an answer or an absence of one. The registry has rules per
* provider family and falls through to `false` for the rest `customai` above all,
* which fronts any OpenAI-compatible endpoint and may well serve a thinking model. A
* caller that presents `supported: false` as a fact must check this first, or it tells
* the reader a model cannot think when all we know is that we have never heard of it.
*/
known: boolean
}
/** Provider families the registry has real rules for; everything else is a shrug. */
const KNOWN_REASONING_FAMILIES: ReadonlySet<string> = new Set([
'anthropic',
'aws_bedrock',
'openai',
'azure_openai',
'openrouter',
'googleai',
'deepseek',
'mistral'
])
/** Resolve the reasoning capability of a model from the static registry. */
export function getReasoningCapability(provider: AIProvider, model: string): ReasoningCapability {
const bareModel = stripLegacyThinkingSuffix(model)
const known = KNOWN_REASONING_FAMILIES.has(reasoningProviderFamily(provider, bareModel))
const supported = supportsReasoningStatic(provider, bareModel)
if (!supported) {
return { supported: false, levels: [], canDisable: false }
return { supported: false, levels: [], canDisable: false, known }
}
const family = reasoningProviderFamily(provider, bareModel)
const levels =
@@ -251,7 +272,7 @@ export function getReasoningCapability(provider: AIProvider, model: string): Rea
: family === 'openrouter'
? openrouterReasoningLevels(bareModel)
: (PROVIDER_REASONING_LEVELS[family] ?? ['low', 'medium', 'high'])
return { supported, levels, canDisable: canDisableReasoning(provider, bareModel) }
return { supported, levels, canDisable: canDisableReasoning(provider, bareModel), known }
}
/**
@@ -362,15 +383,11 @@ export function explicitOffToken(provider: AIProvider, model: string): Reasoning
// real off there and stays the wire form. Only the 5 family, which
// thinks when the field is absent, needs the explicit disable —
// Fable and Mythos reject it outright and get no off token at all.
return /claude-(opus|sonnet)-5/.test(model.toLowerCase())
? ANTHROPIC_OFF_SENTINEL
: undefined
return /claude-(opus|sonnet)-5/.test(model.toLowerCase()) ? ANTHROPIC_OFF_SENTINEL : undefined
case 'aws_bedrock':
// Bedrock's Sonnet 5 cannot be disabled at all, so only Opus 5 gets
// the sentinel; the rest keep omission.
return model.toLowerCase().includes('claude-opus-5')
? ANTHROPIC_OFF_SENTINEL
: undefined
return model.toLowerCase().includes('claude-opus-5') ? ANTHROPIC_OFF_SENTINEL : undefined
case 'googleai':
// Gemini 2.5/3 think by default (dynamic budget / level). The backend
// proxy maps 'none' to off on Flash, or the floor on Pro (only
@@ -4,6 +4,7 @@ import {
AGENT_FIELD_BY_KEY,
AGENT_FIELDS,
agentFieldIsSet,
agentStreamingEnabled,
initialVisibleAgentFields
} from './agentFormFields'
@@ -80,3 +81,46 @@ describe('initialVisibleAgentFields', () => {
expect(Object.keys(schemaProperties).filter((k) => !registered.has(k))).toEqual([])
})
})
// Three chat surfaces decide whether to consume a stream from this, and the worker decides whether
// to send one from `streaming.unwrap_or(true)`. They agree only while absent means on here.
describe('agentStreamingEnabled', () => {
const step = (input_transforms: Record<string, any>, rest: Record<string, any> = {}) => ({
type: 'aiagent',
input_transforms,
...rest
})
it('reads an unwritten field as streaming', () => {
expect(agentStreamingEnabled(step({}))).toBe(true)
// What the API returns for the `{"type":"static"}` placeholder the schema backfill seeds.
expect(agentStreamingEnabled(step({ streaming: { type: 'static', value: null } }))).toBe(true)
expect(agentStreamingEnabled(step({ streaming: { type: 'static', value: true } }))).toBe(true)
})
it('only an explicit false holds the answer back', () => {
expect(agentStreamingEnabled(step({ streaming: { type: 'static', value: false } }))).toBe(false)
})
it('reads off what the step cannot answer for', () => {
// An image answer never streams, whatever `streaming` says.
expect(
agentStreamingEnabled(
step({
streaming: { type: 'static', value: true },
output_type: { type: 'static', value: 'image' }
})
)
).toBe(false)
// A linked step carries no brain: the agent's own `streaming: false` is invisible here.
expect(agentStreamingEnabled(step({}, { agent: 'u/admin/a' }))).toBe(false)
// An expression has no value until the run it would decide is already under way, on either
// of the two fields the answer depends on.
expect(
agentStreamingEnabled(step({ streaming: { type: 'javascript', expr: 'flow_input.s' } }))
).toBe(false)
expect(
agentStreamingEnabled(step({ output_type: { type: 'javascript', expr: 'flow_input.o' } }))
).toBe(false)
})
})
@@ -177,6 +177,31 @@ export function agentFieldIsSet(
return true
}
/**
* Whether a run of this step would stream its answer, mirroring the worker's
* `has_stream = user_wants_streaming && is_text_output`. Absence means on
* (`args.streaming.unwrap_or(true)`), so an unwritten field streams.
*
* A caller that reads this wrong does not merely mislabel the run: a chat surface that opens a
* stream for an answer the worker sends in one piece re-runs the flow when its connection times
* out. So the rule is that anything this cannot settle from the step alone reads as off, the cost
* of being wrong that way being a live answer arriving at the end instead of as it is written.
* Unsettled means either of the two fields holding an expression, whose value exists only once the
* run it decides is already under way, or a linked agent, whose brain lives in the resource where
* this has no sight of it at all.
*/
export function agentStreamingEnabled(value: Record<string, any> | undefined): boolean {
if (value?.agent) return false
const transforms = value?.input_transforms as Record<string, InputTransform | any> | undefined
const settled = (t: InputTransform | any | undefined) => t == undefined || t.type === 'static'
const outputType = transforms?.output_type
const streaming = transforms?.streaming
if (!settled(outputType) || !settled(streaming)) return false
// An image answer never streams, whatever `streaming` says.
if (outputType?.value === 'image') return false
return streaming?.value !== false
}
/**
* Whether the current schema carries this field at all. A linked step's schema is reduced to the
* flow-local inputs, which is what collapses its form to the Messages group on its own.
@@ -28,7 +28,7 @@
import StepInputsGen from '$lib/components/copilot/StepInputsGen.svelte'
import InputTransformForm from '$lib/components/InputTransformForm.svelte'
import InputTransformPickers from '$lib/components/InputTransformPickers.svelte'
import { useS3StorageConfigured } from '$lib/components/inputTransformEnv.svelte'
import { useWorkspaceStorageConfigured } from '$lib/components/inputTransformEnv.svelte'
import type ItemPicker from '$lib/components/ItemPicker.svelte'
import type VariableEditor from '$lib/components/VariableEditor.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
@@ -143,7 +143,7 @@
let itemPicker: ItemPicker | undefined = $state(undefined)
let variableEditor: VariableEditor | undefined = $state(undefined)
const s3Storage = useS3StorageConfigured(() => ws)
const s3Storage = useWorkspaceStorageConfigured(() => ws)
// The per-field copilot only ever writes a JavaScript transform, so it belongs only where one can
// be stored. On a static-only field the write lands in a key the config drops on deploy, which
@@ -26,7 +26,8 @@
Save,
X,
Check,
Settings2
Settings2,
MessageSquare
} from 'lucide-svelte'
import CaptureIcon from '$lib/components/triggers/CaptureIcon.svelte'
import FlowInputEditor from './FlowInputEditor.svelte'
@@ -47,10 +48,13 @@
import type { AiAgent, InputTransform, ScriptLang } from '$lib/gen'
import { deepEqual } from 'fast-equals'
import Toggle from '$lib/components/Toggle.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { AI_AGENT_SCHEMA } from '../flowInfers'
import { agentStreamingEnabled } from '../agentFormFields'
import { nextId } from '../flowModuleNextId'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import FlowChat from '../conversations/FlowChat.svelte'
import { isEmptyAgentChatInputValue } from '../conversations/agentChatInputs'
import { SPECIAL_MODULE_IDS } from '$lib/components/copilot/chat/shared'
interface Props {
@@ -96,9 +100,16 @@
)
let chatInputEnabled = $state(Boolean(flowStore.val.value?.chat_input_enabled))
let showChatModeWarning = $state(false)
let showAdditionalInputs = $state(false)
let shouldUseStreaming = $derived.by(() => {
const modules = flowStore.val.value?.modules
const lastModule = modules && modules.length > 0 ? modules[modules.length - 1] : undefined
if (lastModule?.value?.type !== 'aiagent') return false
return agentStreamingEnabled(lastModule.value)
})
// Chat mode shows one of the two at a time: the conversation, or the inputs it sends.
let chatPanelTab = $state<'chat' | 'inputs'>('chat')
let chatInputsEditTab = $state(false)
let chatEditableSchemaForm: EditableSchemaForm | undefined = $state(undefined)
let chatInputsAddPropertyV2: AddPropertyV2 | undefined = $state(undefined)
let addPropertyV2: AddPropertyV2 | undefined = $state(undefined)
@@ -511,23 +522,9 @@
return jobId
}
function hasOtherInputs(): boolean {
const properties = flowStore.val.schema?.properties
return Boolean(
properties &&
Object.keys(properties).length > 0 &&
!(Object.keys(properties).length === 1 && Object.keys(properties).includes('user_message'))
)
}
function handleToggleChatMode() {
if (!flowStore.val.value?.chat_input_enabled) {
// Check if there are existing inputs
if (hasOtherInputs()) {
showChatModeWarning = true
} else {
enableChatMode()
}
enableChatMode()
} else {
// Disable chat input - remove from flow.value
if (flowStore.val.value) {
@@ -536,21 +533,46 @@
}
}
/**
* Add the flow input the agent's `user_attachments` reads, and return its name. Chat
* mode means files dropped in the composer, and that only works through a flow input —
* so it is set up with the message and the memory rather than left to be discovered.
*/
function addAttachmentsInput(): string {
const schema = (flowStore.val.schema ?? {}) as Record<string, any>
const properties: Record<string, any> = (schema.properties ??= {})
let name = 'files'
for (let i = 2; name in properties; i++) name = `files_${i}`
properties[name] = {
type: 'array',
items: { type: 'object', resourceType: 's3object' },
description: 'Images or PDFs for the agent to read'
}
flowStore.val.schema = schema
return name
}
function enableChatMode() {
// Enable chat input - set in flow.value
flowStore.val.value.chat_input_enabled = true
// Set up the schema for chat input
// The chat fills the flow's form rather than standing in for it: all it needs is a
// `user_message` string, which the server requires under that exact argument name.
// Every other input stays — the composer drives the ones an agent field reads, and
// the rest are asked for in the Configure-inputs modal.
const schema = flowStore.val.schema ?? {}
const properties = { ...(schema.properties ?? {}) }
// Only a string can carry the message; anything else here cannot be what chat sends.
if (properties['user_message']?.type !== 'string') {
properties['user_message'] = { type: 'string', description: 'Message from user' }
}
const required: string[] = Array.isArray(schema.required) ? schema.required : []
flowStore.val.schema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
...schema,
type: 'object',
properties: {
user_message: {
type: 'string',
description: 'Message from user'
}
},
required: ['user_message']
properties,
required: required.includes('user_message') ? required : [...required, 'user_message']
}
// Find all AI agent modules
@@ -570,6 +592,8 @@
(accu, key) => {
if (key === 'user_message') {
accu[key] = { type: 'javascript', expr: 'flow_input.user_message' }
} else if (key === 'user_attachments') {
accu[key] = { type: 'javascript', expr: `flow_input.${addAttachmentsInput()}` }
} else if (key === 'memory') {
accu[key] = { type: 'static', value: { kind: 'auto', context_length: 10 } }
} else {
@@ -586,25 +610,24 @@
}
]
sendUserToast(
'Chat mode enabled. AI agent created with user message input and context memory set to 10.',
'Chat mode enabled. AI agent created with user message and attachments inputs, and context memory set to 10.',
false
)
} else if (aiAgentModules.length === 1) {
// Exactly one AI agent exists: fill in defaults only for inputs the
// user hasn't configured, so re-enabling chat mode on an already
// configured agent doesn't clobber a custom user_message expression
// or a deliberate memory choice (e.g. off).
// configured agent doesn't clobber a custom user_message expression.
const aiAgent = aiAgentModules[0]
const value = aiAgent.value as AiAgent
// Degenerate shapes the input form can produce without deliberate
// configuration count as unconfigured: empty static value (undefined
// persists as null through JSON round-trips), blank JS expression
// (the JS toggle seeds a bare backtick pair), or an AI transform
// (meaningless for the chat input).
// persists as null through JSON round-trips, and the step panel seeds an
// array-typed field with []), blank JS expression (the JS toggle seeds a
// bare backtick pair), or an AI transform (meaningless for the chat input).
const isUnconfigured = (transform: InputTransform | undefined) =>
transform === undefined ||
(transform.type === 'static' && (transform.value == null || transform.value === '')) ||
(transform.type === 'static' && isEmptyAgentChatInputValue(transform.value)) ||
(transform.type === 'javascript' && transform.expr.replaceAll('`', '').trim() === '') ||
transform.type === 'ai'
@@ -617,7 +640,25 @@
applied.push('user message input')
}
if (isUnconfigured(value.input_transforms['memory'])) {
if (isUnconfigured(value.input_transforms['user_attachments'])) {
value.input_transforms['user_attachments'] = {
type: 'javascript',
expr: `flow_input.${addAttachmentsInput()}`
}
applied.push('attachments input')
}
// `off` is the first oneOf variant of the memory field, so a step added by hand
// carries it without anyone choosing it — and an agent that forgets every turn
// makes the chat a series of unrelated questions. Overwritten rather than left
// alone; the toast below says it happened.
const memoryIsOff = (transform: InputTransform | undefined) =>
transform?.type === 'static' && (transform.value as any)?.kind === 'off'
if (
isUnconfigured(value.input_transforms['memory']) ||
memoryIsOff(value.input_transforms['memory'])
) {
value.input_transforms['memory'] = {
type: 'static',
value: { kind: 'auto', context_length: 10 }
@@ -625,6 +666,17 @@
applied.push('context memory set to 10')
}
// Without streaming the chat has no SSE to read, so a turn shows nothing —
// no thinking, no answer — until the run ends and its rows are written.
if (
isUnconfigured(value.input_transforms['streaming']) ||
(value.input_transforms['streaming']?.type === 'static' &&
value.input_transforms['streaming'].value === false)
) {
value.input_transforms['streaming'] = { type: 'static', value: true }
applied.push('streaming turned on')
}
sendUserToast(
applied.length > 0
? `Chat mode enabled. AI agent configured with ${applied.join(' and ')}.`
@@ -633,40 +685,48 @@
)
}
// If there are multiple AI agents, don't auto-configure (ambiguous which one to configure)
showChatModeWarning = false
}
</script>
<!-- Add svelte:window to listen for keyboard events -->
<svelte:window onkeydown={handleKeydown} />
<ConfirmationModal
open={showChatModeWarning}
title="Enable Chat Mode?"
confirmationText="Continue"
onConfirmed={enableChatMode}
onCanceled={() => {
showChatModeWarning = false
chatInputEnabled = false
}}
>
<p class="text-sm text-secondary">
Enabling Chat Mode will replace all existing flow inputs with a single
<span class="font-mono text-xs bg-surface-secondary px-1 rounded">user_message</span>
parameter.
</p>
<p class="text-sm text-secondary mt-2">
Your current input configuration will be lost. Are you sure you want to continue?
</p>
</ConfirmationModal>
<!-- The edit toggle and the add-input target, shared by both panels below: chat mode
carries a smaller set of side tabs, but the controls themselves must not differ. -->
{#snippet inputsEditButton(open: boolean, toggle: () => void)}
<Button
onClick={toggle}
{...open
? {
title: 'Close input editor',
startIcon: { icon: ChevronRight },
btnClasses: 'rounded-none rounded-tl-md'
}
: {
title: 'Open input editor',
startIcon: { icon: Pen }
}}
variant="accent"
iconOnly
wrapperClasses="h-full"
/>
{/snippet}
{#snippet inputsAddTrigger()}
<div
class="w-full py-2 flex justify-center items-center border border-dashed rounded-md hover:bg-surface-hover"
id="add-flow-input-btn"
>
<Plus size={14} />
</div>
{/snippet}
<FlowCard {noEditor} title="Flow Input">
{#snippet action()}
{#if !disabled}
<div class="flex items-center gap-2">
<Toggle
size="sm"
size="xs"
bind:checked={chatInputEnabled}
on:change={() => {
handleToggleChatMode()
@@ -674,20 +734,25 @@
options={{
right: 'Chat Mode',
rightTooltip:
'When enabled, the flow execution page will show a chat interface where each message sent runs the flow with the message as "user_message" input parameter. The flow schema will be automatically set to accept only a user_message string input.'
'Turns this flow\'s page into a chat. Each message runs the flow with the message as its "user_message" input, and is kept as a chat — one conversation per chat, each with its own AI agent memory. Chats started in the editor are marked as tests and stay out of the deployed flow\'s list.',
rightDocumentationLink:
'https://www.windmill.dev/docs/core_concepts/ai_agents#chat-mode'
}}
/>
{#if flowStore.val.value?.chat_input_enabled}
<Button
size="xs"
variant="border"
color={showAdditionalInputs ? 'blue' : 'light'}
startIcon={{ icon: Settings2 }}
title="Manage inputs"
on:click={() => (showAdditionalInputs = !showAdditionalInputs)}
>
Manage inputs
</Button>
<ToggleButtonGroup bind:selected={chatPanelTab} noWFull>
{#snippet children({ item })}
<ToggleButton size="sm" value="chat" label="Chat" icon={MessageSquare} {item} />
<ToggleButton
size="sm"
value="inputs"
label="Inputs"
icon={Settings2}
tooltip="Edit the flow inputs the chat sends alongside each message"
{item}
/>
{/snippet}
</ToggleButtonGroup>
{/if}
</div>
{/if}
@@ -696,11 +761,15 @@
<div class="flex flex-col h-full">
{#if flowStore.val.value?.chat_input_enabled}
<div class="flex flex-col h-full">
{#if showAdditionalInputs}
<div class="border-b p-2">
{#if chatPanelTab === 'inputs'}
<!-- EditableSchemaForm scrolls internally against `h-full`, so the wrapper has
to be bounded (flex-1 min-h-0) or the form grows to content height and
spills out of the panel. -->
<div class="py-2 px-4 flex-1 min-h-0">
<EditableSchemaForm
bind:this={chatEditableSchemaForm}
bind:schema={flowStore.val.schema}
hiddenArgs={['user_message']}
lockedArgs={['user_message']}
isFlowInput
showSensitiveToggle
workspace={opWs}
@@ -713,44 +782,44 @@
}}
>
{#snippet openEditTab()}
<Button
size="xs"
variant={chatInputsEditTab ? 'contained' : 'border'}
color={chatInputsEditTab ? 'blue' : 'light'}
startIcon={{ icon: chatInputsEditTab ? ChevronRight : Pen }}
title={chatInputsEditTab ? 'Close editor' : 'Edit inputs'}
onClick={() => {
chatInputsEditTab = !chatInputsEditTab
}}
/>
{@render inputsEditButton(
chatInputsEditTab,
() => (chatInputsEditTab = !chatInputsEditTab)
)}
{/snippet}
{#snippet addProperty()}
<AddPropertyV2
bind:this={chatInputsAddPropertyV2}
bind:schema={flowStore.val.schema}
onAddNew={() => {}}
onAddNew={(argName) => {
chatInputsEditTab = true
chatEditableSchemaForm?.openField(argName)
refreshStateStore(flowStore)
}}
>
{#snippet trigger()}
<Button
size="xs"
color="light"
startIcon={{ icon: Plus }}
title="Add additional input"
>
Add input
</Button>
{@render inputsAddTrigger()}
{/snippet}
</AddPropertyV2>
{/snippet}
</EditableSchemaForm>
</div>
{/if}
<FlowChat
onRunFlow={runFlowWithMessage}
path={$pathStore}
hideSidebar={true}
inputSchema={flowStore.val.schema}
/>
<!-- Hidden rather than unmounted: tearing the chat down ends every conversation's
stream and poller, so a turn started here would finish server-side with
nothing following it and the reader would come back to their own message and
no answer. One display class at a time, so the two cannot race in the
cascade. -->
<div class={chatPanelTab === 'inputs' ? 'hidden' : 'flex flex-col flex-1 min-h-0'}>
<FlowChat
onRunFlow={runFlowWithMessage}
conversationKind="test"
path={$pathStore}
useStreaming={shouldUseStreaming}
inputSchema={flowStore.val.schema}
flowModules={flowStore.val.value?.modules}
/>
</div>
</div>
{:else}
<div class="py-2 px-4 flex-1 min-h-0">
@@ -815,22 +884,9 @@
<div class={twMerge('flex flex-row divide-x', ButtonType.ColorVariants.blue.divider)}>
<SideBarTab {dropdownItems} fullMenu={!!$flowInputEditorState?.selectedTab}>
{#snippet close_button()}
<Button
onClick={() => handleEditSchema()}
{...!!$flowInputEditorState?.selectedTab
? {
title: 'Close input editor',
startIcon: { icon: ChevronRight },
btnClasses: 'rounded-none rounded-tl-md'
}
: {
title: 'Open input editor',
startIcon: { icon: Pen }
}}
variant="accent"
iconOnly
wrapperClasses="h-full"
/>
{@render inputsEditButton(!!$flowInputEditorState?.selectedTab, () =>
handleEditSchema()
)}
{/snippet}
</SideBarTab>
</div>
@@ -884,12 +940,7 @@
}}
>
{#snippet trigger()}
<div
class="w-full py-2 flex justify-center items-center border border-dashed rounded-md hover:bg-surface-hover"
id="add-flow-input-btn"
>
<Plus size={14} />
</div>
{@render inputsAddTrigger()}
{/snippet}
</AddPropertyV2>
{/if}
@@ -1,102 +1,133 @@
<script lang="ts">
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { createChat, type Chat, type ChatState } from 'windmill-chat'
import { workspaceStore } from '$lib/stores'
import { createFlowChatManager, type ConversationKind } from './FlowChatManager.svelte'
import FlowConversationsSidebar from './FlowConversationsSidebar.svelte'
import FlowChatInterface from './FlowChatInterface.svelte'
import { getContext } from 'svelte'
import { getContext, untrack } from 'svelte'
import type { FlowEditorContext } from '../types'
import type { FlowModule } from '$lib/gen'
type ChatFrame = 'boxed' | 'top' | 'none'
const FRAME_CLASS: Record<ChatFrame, string> = {
boxed: 'border rounded-md',
top: 'border-t',
none: ''
}
interface Props {
/**
* Runs the flow for one turn and returns the job id: the deployed flow on the
* flow page, a preview run in the editor. The run must carry `memory_id` =
* `conversationId`, which is what ties the job to the conversation.
*/
onRunFlow: (
userMessage: string,
conversationId: string,
additionalInputs?: Record<string, any>
) => Promise<string | undefined>
useStreaming?: boolean
deploymentInProgress?: boolean
path: string
hideSidebar?: boolean
/** 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
inputSchema?: Record<string, any>
/** The flow's modules, used to find which inputs an AI agent step reads directly. */
flowModules?: FlowModule[]
/** Wider centered column, for the full-page chat. */
wideLayout?: boolean
/** What separates the chat from what sits above it. `boxed` is its own panel, corners
* clipped so the sidebar's edge follows them; `top` a dividing line under an enclosing
* header; `none` for a surface where the chat is the whole pane. */
frame?: ChatFrame
/** Which chats the sidebar lists before the reader filters it themselves. */
conversationKind?: ConversationKind
/** Whether a turn may run while another chat's is still going. Off where one run
* owns the surface — the editor's panel shows it on the graph. */
parallelTurns?: boolean
}
let {
onRunFlow,
deploymentInProgress = false,
useStreaming = false,
path,
hideSidebar = false,
inputSchema = undefined
description = undefined,
inputSchema = undefined,
flowModules = undefined,
wideLayout = false,
frame = 'top',
conversationKind = 'deployed',
parallelTurns = false
}: Props = $props()
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
// The editor may act on a workspace other than the nav store's (AI-session live editor).
const workspace = $derived(flowEditorContext?.opWorkspace?.() ?? $workspaceStore)
let chat = $state<Chat | undefined>(undefined)
let chatState = $state<ChatState | undefined>(undefined)
let sidebar = $state<FlowConversationsSidebar | undefined>(undefined)
const manager = createFlowChatManager()
manager.operatingWorkspace = () => flowEditorContext?.opWorkspace?.()
manager.conversationKind = conversationKind
manager.allowsParallelTurns = parallelTurns
// The filter moves; what this surface runs does not.
manager.surfaceKind = conversationKind === 'test' ? 'test' : 'deployed'
// The editor is the only surface with both kinds in play, and it is the one that opens
// on test chats. A deployed flow lists what its users started, with no way to ask for
// anything else.
manager.canFilterConversationKind = conversationKind !== 'deployed'
// Initialize manager when component mounts
$effect(() => {
const ws = workspace
const flowPath = path
if (!ws || !flowPath) return
const created = createChat({
flowPath,
workspace: ws,
baseUrl: window.location.origin,
history: 'server',
// Only an enterprise server honours it; elsewhere it would just log a warning per
// poll. The license loads asynchronously, so a cold load may create the chat twice.
pollDelayMs: $enterpriseLicense ? 50 : undefined,
run: async ({ user_message, ...inputs }, { conversationId }) => {
const jobId = await onRunFlow(String(user_message), conversationId, inputs)
if (!jobId) throw new Error('the flow did not start')
// The server creates the conversation with the run, so the sidebar can list
// it now, whatever becomes of the turn.
sidebar?.conversationStarted(conversationId)
return jobId
},
onError: (error) => sendUserToast('Failed to run flow: ' + error.message, true)
})
const unsubscribe = created.subscribe((s) => (chatState = s))
chat = created
if ($workspaceStore) {
manager.initialize(onRunFlow, path, useStreaming)
// Reads the open conversation, and this effect tears down with `cleanup()`: tracked,
// the first send of a fresh chat would select the conversation it just created and
// so abort its own turn.
untrack(() => manager.selectLatestConversation())
}
return () => {
unsubscribe()
created.destroy()
manager.cleanup()
}
})
// Derive additional inputs schema (excluding user_message) for chat mode
// Initialize InfiniteList when component mounts or flowPath changes
$effect(() => {
if ($workspaceStore && path && manager.conversationListComponent) {
untrack(() => {
manager.setupInfiniteList()
})
}
})
// Everything the chat asks for beyond the message itself. `user_message` is the
// composer: the server requires that exact argument on a chat-enabled flow and
// stores it as the conversation's message (`handle_chat_conversation_messages`), so
// the name is a contract rather than the author's choice.
const additionalInputsSchema = $derived.by(() => {
const props = inputSchema?.properties ?? {}
const filtered = Object.fromEntries(Object.entries(props).filter(([k]) => k !== 'user_message'))
const messageInput = 'user_message'
const filtered = Object.fromEntries(Object.entries(props).filter(([k]) => k !== messageInput))
if (Object.keys(filtered).length === 0) return undefined
const required = inputSchema?.required
const requiredArray: string[] = Array.isArray(required) ? required : []
return {
...inputSchema,
properties: filtered,
required: requiredArray.filter((k: string) => k !== 'user_message')
required: requiredArray.filter((k: string) => k !== messageInput)
}
})
</script>
<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} />
{/if}
<!-- The column's max width and side padding come from AIChatDisplay itself. -->
<div class="flex overflow-hidden flex-1 {FRAME_CLASS[frame]}">
<FlowConversationsSidebar {manager} {description} />
<!-- pb-3 on the chat alone, not on the row: the transcript and composer stop short of
the panel edge the way the session chat does, while the sidebar and the border
dividing it from the chat still reach the bottom. -->
<div class="flex flex-1 min-w-0 min-h-0 pb-3">
<FlowChatInterface
{chat}
{chatState}
{manager}
{deploymentInProgress}
{additionalInputsSchema}
{flowModules}
{path}
{workspace}
{description}
{wideLayout}
/>
{/if}
</div>
</div>
@@ -1,79 +1,53 @@
<script lang="ts">
import { Alert, Button } from '$lib/components/common'
import { MessageCircle, Loader2, Settings2 } from 'lucide-svelte'
import ChatMessage from '$lib/components/chat/ChatMessage.svelte'
import ChatInput from '$lib/components/chat/ChatInput.svelte'
import { Button } from '$lib/components/common'
import { Loader2, MessageSquare, SlidersHorizontal } from 'lucide-svelte'
import { FlowChatManager } from './FlowChatManager.svelte'
import { FlowChatViewHost } from './flowChatViewHost.svelte'
import AIChatDisplay from '$lib/components/copilot/chat/AIChatDisplay.svelte'
import { setChatViewHost } from '$lib/components/copilot/chat/chatViewHost'
import Modal from '$lib/components/common/modal/Modal.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import { type DynamicInput } from '$lib/utils'
import { tick, untrack } from 'svelte'
import type { Chat, ChatState } from 'windmill-chat'
import { emptyString, type DynamicInput } from '$lib/utils'
import { deepEqual } from 'fast-equals'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import { type FlowModule } from '$lib/gen'
import { useWorkspaceStorageConfigured } from '$lib/components/inputTransformEnv.svelte'
import { workspaceStore } from '$lib/stores'
import FlowChatModelSettings from './FlowChatModelSettings.svelte'
import {
agentModelGap,
agentModelWiringInputs,
withoutRejectedEffort,
composerOwnedInputs,
attachmentsTargetFor,
isEmptyAgentChatInputValue,
PER_TURN_AGENT_CHAT_INPUT_KEY,
resolveAgentChatInputs,
resolveAgentModelWiring
} from './agentChatInputs'
interface Props {
chat: Chat
chatState: ChatState
manager: FlowChatManager
deploymentInProgress?: boolean
additionalInputsSchema?: Record<string, any>
/** The flow's modules, used to find which inputs an AI agent step reads directly. */
flowModules?: FlowModule[]
path: string
workspace?: string
/** The flow's description, shown under the empty transcript's prompt. */
description?: string
wideLayout?: boolean
}
let {
chat,
chatState,
manager,
deploymentInProgress = false,
additionalInputsSchema,
flowModules,
path,
workspace = undefined
description = undefined,
wideLayout = false
}: Props = $props()
let inputMessage = $state('')
let inputElement = $state<HTMLTextAreaElement | undefined>(undefined)
let messagesContainer = $state<HTMLDivElement | undefined>(undefined)
let loadingOlder = false
const busy = $derived(chatState.status === 'submitted' || chatState.status === 'streaming')
// Deriveds notify only when their value changes; `chatState` itself is a new
// object on every token, and following it would drag a reader who scrolled up
// back to the end on each one.
const messageCount = $derived(chatState.messages.length)
const conversationId = $derived(chatState.conversationId)
const loadingMessages = $derived(chatState.loadingMessages)
// Follow the conversation: new messages and a conversation switch scroll to the
// end, older pages loaded at the top keep the viewport where it was.
$effect(() => {
messageCount
conversationId
loadingMessages
untrack(() => {
if (loadingOlder) return
tick().then(() => {
if (messagesContainer) messagesContainer.scrollTop = messagesContainer.scrollHeight
})
})
})
async function handleScroll() {
if (
!messagesContainer ||
!chatState.hasMoreMessages ||
chatState.loadingMessages ||
loadingOlder
)
return
if (messagesContainer.scrollTop > 10) return
loadingOlder = true
const previousHeight = messagesContainer.scrollHeight
try {
await chat.loadOlderMessages()
await tick()
messagesContainer.scrollTop = messagesContainer.scrollHeight - previousHeight
} finally {
loadingOlder = false
}
}
// Derive helperScript for dynamic inputs from schema
const dynamicInputHelperScript = $derived.by((): DynamicInput.HelperScript | undefined => {
const dynCode = additionalInputsSchema?.['x-windmill-dyn-select-code']
@@ -84,14 +58,60 @@
return undefined
})
// The flow inputs an AI agent step reads straight out of `flow_input`, which the
// composer may then edit itself instead of asking for them in the modal.
const agentChatInputs = $derived(resolveAgentChatInputs(flowModules, additionalInputsSchema))
// The composer's attachments feed this input, and the paperclip is its whole editor.
const attachmentsInput = $derived(
agentChatInputs.find((input) => input.key === PER_TURN_AGENT_CHAT_INPUT_KEY)
)
const attachmentsTarget = $derived(attachmentsTargetFor(attachmentsInput))
const chatWorkspace = $derived(manager.operatingWorkspace?.() ?? $workspaceStore)
// Uploading needs the workspace's object storage; without one the `+` is drawn disabled
// saying so, since the modal could not upload either.
const workspaceStorage = useWorkspaceStorageConfigured(() => chatWorkspace)
// The model gets its own button, shaped like the copilot's model settings, driven by
// whichever provider fields the flow exposes. Attachments are the paperclip's. Nothing
// else is promoted, so every other flow input is asked for in the Configure-inputs modal.
const modelWiring = $derived(resolveAgentModelWiring(flowModules))
// An agent with nothing to call cannot answer, and the composer cannot fix it, so the
// chat says what to go and do instead of offering controls that write nowhere.
const modelGap = $derived(agentModelGap(modelWiring))
// LocalStorage helpers
const STORAGE_KEY_PREFIX = 'windmill_flow_chat_inputs_'
// State for additional inputs modal
let showInputsModal = $state(false)
let additionalInputsValues = $state<Record<string, any> | undefined>(
loadInputsFromStorage() ?? undefined
)
// Conversation settings, persisted per flow. These can include a value for the
// attachments input, saved while the modal was its editor; `sendRequest` drops that one
// once the paperclip takes over, so a stored file never rides a later message.
let inputValues = $state<Record<string, any>>(loadInputsFromStorage() ?? {})
let modalDraft = $state<Record<string, any>>({})
/** What the flow's own form would open on. */
function schemaDefaults(schema: Record<string, any> | undefined): Record<string, any> {
const properties: Record<string, any> = schema?.properties ?? {}
return Object.fromEntries(
Object.entries(properties)
.filter(([, property]) => property?.default !== undefined)
.map(([name, property]) => [name, property.default])
)
}
// Derived rather than seeded into `inputValues`: the schema arrives with the flow, which
// on the deployed page is after this mounts, and only what the reader actually chose
// belongs in storage. A stored value wins over the default, including a deliberate empty.
const effectiveInputs = $derived({
...schemaDefaults(additionalInputsSchema),
...inputValues
})
// What the run actually gets. The composer's own controls keep themselves consistent as
// they are used; this is where a pair that was never chosen through them — a stored
// value, an author's default — is made safe before it reaches the provider.
const runInputs = $derived(withoutRejectedEffort(modelWiring, effectiveInputs))
function getStorageKey(): string {
return `${STORAGE_KEY_PREFIX}${path}`
@@ -115,48 +135,79 @@
}
}
function setInputValue(name: string, value: any) {
inputValues = { ...inputValues, [name]: value }
saveInputsToStorage(inputValues)
}
function handleModalConfirm() {
saveInputsToStorage(additionalInputsValues ?? {})
// The modal opens on `effectiveInputs`, so its draft carries a value for every
// defaulted input whether or not the reader touched one. Storing those would pin
// today's defaults for good — `effectiveInputs` gives a stored value precedence, so
// a later change to the flow's schema would never reach this reader again.
const defaults = schemaDefaults(additionalInputsSchema)
const kept = Object.fromEntries(
Object.entries({ ...inputValues, ...modalDraft }).filter(
([name, value]) => !deepEqual(value, defaults[name])
)
)
inputValues = kept
saveInputsToStorage(inputValues)
showInputsModal = false
}
async function handleSendMessage() {
const text = inputMessage.trim()
if (!text || busy || deploymentInProgress) return
const inputs = additionalInputsSchema
? (loadInputsFromStorage() ?? additionalInputsValues)
: undefined
inputMessage = ''
// A failure is reported through the chat's `onError` and as a failed message.
await chat.sendMessage(text, { inputs }).catch(() => {})
await tick()
inputElement?.focus()
}
function openInputsModal() {
const stored = loadInputsFromStorage()
if (stored) additionalInputsValues = stored
modalDraft = { ...effectiveInputs, ...(loadInputsFromStorage() ?? inputValues) }
showInputsModal = true
}
const hasMissingRequired = $derived.by(() => {
if (!additionalInputsSchema?.required?.length) return false
const values = additionalInputsValues ?? {}
return additionalInputsSchema.required.some(
(field: string) =>
values[field] === undefined || values[field] === '' || values[field] === null
const chatHost = new FlowChatViewHost(manager, {
additionalInputs: () => (additionalInputsSchema ? { ...runInputs } : undefined),
attachmentsTarget: () => attachmentsTarget,
workspace: () => chatWorkspace,
attachmentsUnavailable: () =>
workspaceStorage.current
? undefined
: 'This workspace has no object storage, so files cannot be attached.',
inputsShownInComposer: () => agentModelWiringInputs(modelWiring),
inputsSchema: () => additionalInputsSchema
})
setChatViewHost(chatHost)
// What the Configure-inputs modal asks for: every flow input the composer does not
// edit itself. Below the host, because whether the paperclip is offered is its answer.
const modalSchema = $derived.by(() => {
if (!additionalInputsSchema) return undefined
const promoted = new Set(composerOwnedInputs(modelWiring, attachmentsTarget))
const properties = Object.fromEntries(
Object.entries(additionalInputsSchema.properties ?? {}).filter(([key]) => !promoted.has(key))
)
if (Object.keys(properties).length === 0) return undefined
const required: string[] = Array.isArray(additionalInputsSchema.required)
? additionalInputsSchema.required
: []
return {
...additionalInputsSchema,
properties,
required: required.filter((key) => !promoted.has(key))
}
})
const modalMissingRequired = $derived.by(() => {
if (!modalSchema?.required?.length) return false
return modalSchema.required.some((field: string) =>
isEmptyAgentChatInputValue(effectiveInputs[field])
)
})
</script>
<!-- Additional Inputs Modal -->
{#if additionalInputsSchema}
{#if modalSchema}
<Modal title="Configure inputs" bind:open={showInputsModal}>
<SchemaForm
schema={additionalInputsSchema}
bind:args={additionalInputsValues}
schema={modalSchema}
bind:args={modalDraft}
helperScript={dynamicInputHelperScript}
{workspace}
workspace={chatWorkspace}
/>
{#snippet actions()}
<Button onClick={handleModalConfirm} variant="accent">Save</Button>
@@ -164,82 +215,84 @@
</Modal>
{/if}
<div class="flex flex-col h-full flex-1 min-w-0">
<!-- Messages Container -->
<div
bind:this={messagesContainer}
class="flex-1 min-h-0 overflow-y-auto p-4 bg-background"
onscroll={handleScroll}
>
{#if deploymentInProgress}
<Alert type="warning" title="Deployment in progress" size="xs" />
{/if}
{#if chatState.loadingMessages && chatState.messages.length === 0}
<div class="flex items-center justify-center h-full">
<Loader2 size={32} class="animate-spin" />
</div>
{:else if chatState.messages.length === 0}
<div class="text-center text-tertiary flex items-center justify-center flex-col h-full">
<MessageCircle size={48} class="mx-auto mb-4 opacity-50" />
<p class="text-lg font-medium">Start a conversation</p>
<p class="text-sm">Send a message to run the flow and see the results</p>
</div>
{#snippet emptyHint()}
<div class="flex-1 text-center text-tertiary flex items-center justify-center flex-col">
{#if manager.isLoadingMessages}
<Loader2 size={32} class="animate-spin" />
{:else}
<div class="w-full space-y-4 xl:max-w-7xl mx-auto">
{#each chatState.messages as message (message.id)}
<ChatMessage
role={message.role}
content={message.content}
success={message.success}
stepName={message.stepName}
/>
{/each}
{#if busy}
<div class="flex items-center gap-2 text-tertiary">
<Loader2 size={16} class="animate-spin" />
<span class="text-sm">Processing...</span>
</div>
{/if}
</div>
{/if}
</div>
<!-- Chat Input -->
<div class="flex flex-col items-center p-2 xl:max-w-7xl mx-auto w-full gap-2">
{#if additionalInputsSchema}
<div class="flex items-center justify-end w-full">
<div class="relative">
<Button
unifiedSize="xs"
variant="default"
startIcon={{ icon: Settings2 }}
title="Inputs"
onClick={openInputsModal}
>
Inputs
</Button>
{#if hasMissingRequired}
<span class="absolute -top-1 -right-1 w-2 h-2 bg-yellow-500 rounded-full"></span>
{/if}
<MessageSquare size={48} class="mx-auto mb-4 opacity-50" />
<p class="text-lg font-medium">Start a conversation</p>
<p class="text-sm">Send a message to run the flow and see the results</p>
{#if !emptyString(description)}
<!-- What this particular flow is for, in the author's own words. Narrower and
dimmer than the prompt above it, and left-aligned because a description
runs to several lines where the two lines above do not. -->
<div class="mt-6 pt-4 border-t max-w-md text-left text-xs text-tertiary">
<GfmMarkdown md={description ?? ''} noPadding prose="sm" />
</div>
</div>
{/if}
{/if}
<div class="w-full" class:opacity-50={deploymentInProgress}>
<ChatInput
bind:value={inputMessage}
bind:bindTextarea={inputElement}
disabled={busy || deploymentInProgress}
onSend={handleSendMessage}
onKeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault()
handleSendMessage()
}
}}
showCancelButton={busy}
onCancel={() => chat.stop()}
sendTitle={deploymentInProgress ? 'Deployment in progress' : 'Send message (Enter)'}
/>
</div>
</div>
{/snippet}
{#snippet footerSettings()}
{#if modalSchema}
<div class="relative">
<Button
unifiedSize="2xs"
variant="subtle"
startIcon={{ icon: SlidersHorizontal }}
btnClasses="text-secondary font-normal"
title="Configure the flow inputs sent with each message"
onClick={openInputsModal}
>
Inputs
</Button>
{#if modalMissingRequired}
<span class="absolute -top-0.5 -right-0.5 w-2 h-2 bg-yellow-500 rounded-full"></span>
{/if}
</div>
{/if}
{#if modelWiring}
<FlowChatModelSettings
wiring={modelWiring}
values={effectiveInputs}
setValue={setInputValue}
workspace={chatWorkspace}
/>
{/if}
{/snippet}
<!-- The transcript scroller fills its flex row, which needs a height to resolve
against. Not every host gives one (the editor's Test-flow panel stacks the
chat above the job result in an auto-height column), so claim one: enough to
scroll in once there are messages, and before that enough for the empty-state
prompt and the composer, which otherwise crowd the panel they collapse it to. -->
<div
class="flex flex-col h-full flex-1 min-w-0"
class:min-h-96={chatHost.displayMessages.length > 0}
class:min-h-64={chatHost.displayMessages.length === 0}
>
<AIChatDisplay
messages={chatHost.displayMessages}
bind:scrollElement={manager.messagesContainer}
onTranscriptScroll={manager.handleScroll}
pastChats={[]}
diffMode={false}
selectedContext={[]}
availableContext={[]}
hideHeader
hideModeSelector
{wideLayout}
{emptyHint}
footerSettings={modalSchema || modelWiring ? footerSettings : undefined}
placeholder="Send a message to run the flow"
disabled={deploymentInProgress || !!modelGap || !!manager.wrongKindReason}
disabledMessage={deploymentInProgress
? 'Deployment in progress'
: (modelGap ?? manager.wrongKindReason ?? '')}
loadPastChat={() => {}}
deletePastChat={() => {}}
saveAndClear={() => {}}
/>
</div>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,257 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { FlowConversationsService } from '$lib/gen'
import { createFlowChatManager } from './FlowChatManager.svelte'
vi.mock('$lib/gen', () => ({
FlowConversationsService: {
listConversationMessages: vi.fn(),
deleteFlowConversation: vi.fn()
},
JobService: {},
FlowService: {}
}))
vi.mock('$lib/toast', () => ({ sendUserToast: vi.fn() }))
vi.mock('$lib/stores', () => ({
userStore: { subscribe: (run: (v: unknown) => void) => (run({ username: 'admin' }), () => {}) },
workspaceStore: { subscribe: (run: (v: unknown) => void) => (run('ws'), () => {}) }
}))
const rows = (conversationId: string, count: number) =>
Array.from({ length: count }, (_, i) => ({
id: `${conversationId}-${i}`,
content: '',
created_at: new Date().toISOString(),
created_seq: i,
conversation_id: conversationId,
message_type: i % 2 === 0 ? 'user' : 'assistant'
}))
function managerWithRows() {
const manager = createFlowChatManager()
manager.operatingWorkspace = () => 'ws'
;(manager as any).initialize(vi.fn(), 'u/admin/flow', false)
return manager
}
/**
* The unread badge is driven by a watermark the manager writes when the reader leaves a
* chat. What it must never do is keep counting a chat the reader can no longer reach
* there would be no row left in the sidebar to clear it.
*/
describe('unread bookkeeping', () => {
beforeEach(() => {
vi.mocked(FlowConversationsService.listConversationMessages).mockReset()
vi.mocked(FlowConversationsService.deleteFlowConversation).mockReset()
})
it('counts rows loaded for a chat the reader is not in', async () => {
const manager = managerWithRows()
manager.selectedConversationId = 'open'
vi.mocked(FlowConversationsService.listConversationMessages).mockResolvedValue(
rows('a', 2) as any
)
await manager.loadConversationMessages('a')
expect(manager.unreadCount('a')).toBe(2)
expect(manager.totalUnread).toBe(2)
})
it('stops counting a deleted chat', async () => {
const manager = managerWithRows()
manager.selectedConversationId = 'open'
vi.mocked(FlowConversationsService.listConversationMessages).mockResolvedValue(
rows('a', 2) as any
)
await manager.loadConversationMessages('a')
expect(manager.totalUnread).toBe(2)
vi.mocked(FlowConversationsService.deleteFlowConversation).mockResolvedValue(undefined as any)
await (manager as any).deleteConversation('a')
expect(manager.totalUnread).toBe(0)
expect(manager.unreadCount('a')).toBe(0)
})
it('treats the open chat as read', async () => {
const manager = managerWithRows()
vi.mocked(FlowConversationsService.listConversationMessages).mockResolvedValue(
rows('a', 3) as any
)
await manager.selectConversation('a')
expect(manager.unreadCount('a')).toBe(0)
expect(manager.totalUnread).toBe(0)
})
// The message the reader just typed came back as unread behind them, because this path
// moves the selection without going through `selectConversation`.
it('marks the chat being left as read when a new one is started', async () => {
const manager = managerWithRows()
vi.mocked(FlowConversationsService.listConversationMessages).mockResolvedValue(
rows('a', 2) as any
)
await manager.selectConversation('a')
await manager.createConversation({ clearMessages: true })
expect(manager.unreadCount('a')).toBe(0)
})
})
/**
* A queued message goes out when the turn ahead of it reaches a terminal state not
* merely when the chat stops looking busy. The stream dropping is the case that separates
* the two: `onerror` ends the turn's client-side state, but the flow job it was following
* keeps running on a worker, and starting the next turn there would interleave two runs
* over one conversation's agent memory.
*/
describe('queued turns wait for a settled run', () => {
it('does not flush when a turn ends without settling', () => {
const manager = managerWithRows()
const flushed: string[] = []
manager.onTurnSettled = (id) => flushed.push(id)
manager.endTurn('a')
expect(flushed).toEqual([])
})
it('flushes the chat whose run settled, naming it', () => {
const manager = managerWithRows()
const flushed: string[] = []
manager.onTurnSettled = (id) => flushed.push(id)
manager.endTurn('a', { settled: true })
expect(flushed).toEqual(['a'])
})
})
/**
* The server ends every SSE stream on its own clock (`TIMEOUT_SSE_STREAM`, 60s by default)
* and expects the client to re-attach. Re-entering the run-starting path instead spawned a
* second flow job a minute, each one writing the same conversation so this pins that a
* timeout follows the job it already has, and resumes where the last one stopped.
*/
describe('an SSE timeout re-attaches instead of re-running', () => {
class FakeEventSource {
static opened: string[] = []
static live: FakeEventSource[] = []
onmessage: ((e: { data: string }) => void) | null = null
onerror: ((e: unknown) => void) | null = null
closed = false
constructor(url: string) {
FakeEventSource.opened.push(url)
FakeEventSource.live.push(this)
}
close() {
this.closed = true
}
emit(payload: unknown) {
this.onmessage?.({ data: JSON.stringify(payload) })
}
}
let realEventSource: unknown
let live: ReturnType<typeof managerWithRows> | undefined
beforeEach(() => {
FakeEventSource.opened = []
FakeEventSource.live = []
realEventSource = (globalThis as any).EventSource
;(globalThis as any).EventSource = FakeEventSource
// `test-setup.ts` makes `window` be `globalThis`, which has no `location` — so the
// stream URL's base is what is missing, not the window itself.
;(globalThis as any).location = { origin: 'http://localhost' }
})
afterEach(() => {
// The turn is still running: its 500ms poll interval would otherwise keep calling
// into the manager after the test. Here rather than in the body, which a failing
// assertion would skip.
live?.cleanup()
live = undefined
;(globalThis as any).EventSource = realEventSource
delete (globalThis as any).location
})
it('follows the same job and carries the offset forward', async () => {
const manager = (live = managerWithRows())
const onRunFlow = vi.fn(async () => 'job-1')
;(manager as any).initialize(onRunFlow, 'u/admin/flow', true)
manager.operatingWorkspace = () => 'ws'
manager.selectedConversationId = 'a'
manager.inputMessage = 'hello'
await manager.sendMessage(undefined, undefined, 'a')
expect(onRunFlow).toHaveBeenCalledTimes(1)
// Stop has something to cancel before any token arrives: the flow job is named as
// soon as it is enqueued, not when the streaming step starts.
expect(manager.currentJobId).toBe('job-1')
FakeEventSource.live[0].emit({ type: 'update', stream_offset: 42 })
FakeEventSource.live[0].emit({ type: 'timeout' })
// No second run, and the reconnect resumes rather than replaying the answer.
expect(onRunFlow).toHaveBeenCalledTimes(1)
expect(FakeEventSource.opened).toHaveLength(2)
expect(FakeEventSource.opened[0]).toContain('/job-1')
expect(FakeEventSource.opened[1]).toContain('/job-1')
expect(FakeEventSource.opened[1]).toContain('stream_offset=42')
})
})
/** Why the row has to be stamped at all is on `#nameTurnJob`; this pins that it is, and
* that it lands in the conversation the turn was sent to. */
describe('a sent message names the run it started', () => {
let realEventSource: unknown
let live: ReturnType<typeof managerWithRows> | undefined
beforeEach(() => {
vi.mocked(FlowConversationsService.listConversationMessages).mockResolvedValue([] as any)
realEventSource = (globalThis as any).EventSource
;(globalThis as any).EventSource = class {
onmessage: unknown = null
onerror: unknown = null
close() {}
}
;(globalThis as any).location = { origin: 'http://localhost' }
})
afterEach(() => {
live?.cleanup()
live = undefined
;(globalThis as any).EventSource = realEventSource
delete (globalThis as any).location
})
it('stamps the row the turn began with, so the transcript can replay it', async () => {
const manager = (live = managerWithRows())
;(manager as any).initialize(vi.fn(async () => 'job-7'), 'u/admin/flow', true)
manager.operatingWorkspace = () => 'ws'
manager.selectedConversationId = 'a'
manager.inputMessage = 'hello'
await manager.sendMessage(undefined, undefined, 'a')
const userRow = manager.messages.find((m) => m.message_type === 'user')
expect(userRow?.job_id).toBe('job-7')
})
// A queued message flushes into the chat it was typed in, which by then need not be the
// one on screen — the row and its job must both land there, not in the open chat.
it('stamps the row in the conversation the turn was sent to, not the open one', async () => {
const manager = (live = managerWithRows())
;(manager as any).initialize(vi.fn(async () => 'job-8'), 'u/admin/flow', true)
manager.operatingWorkspace = () => 'ws'
manager.selectedConversationId = 'open-chat'
manager.inputMessage = 'sent to the background chat'
await manager.sendMessage(undefined, undefined, 'background-chat')
expect(manager.messages).toEqual([])
await manager.selectConversation('background-chat')
const userRow = manager.messages.find((m) => m.message_type === 'user')
expect(userRow?.job_id).toBe('job-8')
})
})
@@ -0,0 +1,277 @@
<script lang="ts">
/**
* The flow chat's model button: the same ChatModelSettings the session chat renders,
* over whatever the flow exposes.
*
* The agent takes one `provider` object, but an author can expose it field by field —
* fixing the resource in the step and letting the chat pick only the model, say. Each
* control here appears exactly when the flow wired the field behind it, so a chat never
* offers a knob whose value it could not write back.
*/
import ChatModelSettings from '$lib/components/copilot/ChatModelSettings.svelte'
import {
carriedReasoning,
type ChatModelSettingsConfig
} from '$lib/components/copilot/chatModelSettings'
import AppConnect from '$lib/components/AppConnectDrawer.svelte'
import { AI_PROVIDERS, fetchAvailableModels } from '$lib/components/copilot/lib'
import {
explicitOffToken,
getReasoningCapability
} from '$lib/components/copilot/reasoningRegistry'
import { ResourceService, type AIProvider } from '$lib/gen'
import type { Item } from '$lib/utils'
import { Plug, Plus } from 'lucide-svelte'
import { resource } from 'runed'
import type { AgentModelWiring, ProviderField } from './agentChatInputs'
interface Props {
wiring: AgentModelWiring
/** Every flow input value the composer holds for this conversation. */
values: Record<string, any>
setValue: (name: string, value: any) => void
workspace?: string
}
let { wiring, values, setValue, workspace }: Props = $props()
function fieldValue(field: ProviderField): any {
if (wiring.whole) return values[wiring.whole]?.[field]
const name = wiring.fields[field]
return name ? values[name] : wiring.fixed[field]
}
function editable(field: ProviderField): boolean {
return wiring.whole !== undefined || wiring.fields[field] !== undefined
}
/** Written together, because choosing a resource also invalidates the model. */
function setFields(patch: Partial<Record<ProviderField, any>>) {
if (wiring.whole) {
setValue(wiring.whole, { ...(values[wiring.whole] ?? {}), ...patch })
return
}
for (const [field, value] of Object.entries(patch)) {
const name = wiring.fields[field as ProviderField]
if (name) setValue(name, value)
}
}
const resourceEditable = $derived(editable('resource'))
const modelEditable = $derived(editable('model'))
const effortEditable = $derived(editable('reasoning_effort'))
// Nothing to write: the flow fixes the lot, so the button names it and opens nothing.
const readOnly = $derived(!resourceEditable && !modelEditable && !effortEditable)
const AI_RESOURCE_TYPES = Object.keys(AI_PROVIDERS)
// `$res:` is the stored form; the picker works in bare paths.
const resourcePath = $derived(
typeof fieldValue('resource') === 'string'
? fieldValue('resource').replace(/^\$res:/, '') || undefined
: undefined
)
const model = $derived(fieldValue('model'))
const effort = $derived(fieldValue('reasoning_effort'))
let appConnect: AppConnect | undefined = $state(undefined)
// Bumped after the connect drawer creates one, to re-list.
let resourcesVersion = $state(0)
// Set when a resource is created here: it can only be selected once the re-listing
// that follows tells us which provider it speaks.
let pendingResourcePath = $state<string | undefined>(undefined)
// A flow that fixes `kind` but exposes `resource` accepts resources of that kind only:
// `setFields` drops a `kind` it cannot write, so any other provider's resource would be
// listed, selected, and then run against the kind the flow still fixes.
const allowedResourceTypes = $derived.by(() => {
const fixedKind = editable('kind') ? undefined : (fieldValue('kind') as string | undefined)
return fixedKind && AI_RESOURCE_TYPES.includes(fixedKind) ? [fixedKind] : AI_RESOURCE_TYPES
})
const resources = resource(
() =>
resourceEditable ? { workspace, version: resourcesVersion, allowedResourceTypes } : undefined,
async (args) => {
const ws = args?.workspace
if (!ws) return []
const rows = await ResourceService.listResource({
workspace: ws,
resourceType: (args?.allowedResourceTypes ?? AI_RESOURCE_TYPES).join(',')
})
return rows.map((r) => ({
path: r.path,
// The row's own type is the provider; an unrecognised one is a custom endpoint.
provider: (AI_RESOURCE_TYPES.includes(r.resource_type ?? '')
? r.resource_type
: 'customai') as AIProvider
}))
}
)
const provider = $derived(
resources.current?.find((r) => r.path === resourcePath)?.provider ??
(fieldValue('kind') as AIProvider | undefined)
)
// Models the resource actually serves, asked of the provider. Its own catalogue is the
// fallback, so a listing that fails or is unsupported still offers real ids rather than
// an empty menu.
const models = resource(
() => ({ workspace, resourcePath, provider, modelEditable }),
async ({ workspace, resourcePath, provider, modelEditable }, _prev, { onCleanup }) => {
if (!modelEditable || !provider) return []
const fallback = AI_PROVIDERS[provider]?.defaultModels ?? []
if (!workspace || !resourcePath) return fallback
const controller = new AbortController()
onCleanup(() => controller.abort())
try {
const listed = await fetchAvailableModels(
resourcePath,
workspace,
provider,
controller.signal
)
return listed.length > 0 ? listed : fallback
} catch {
return fallback
}
}
)
$effect(() => {
if (!pendingResourcePath) return
const created = resources.current?.find((r) => r.path === pendingResourcePath)
if (created) {
pendingResourcePath = undefined
selectResource(created.path, created.provider)
}
})
/**
* The effort to write alongside a new model: `''` — the agent's "no effort" — wherever
* that model has no such level, so the composer never leaves behind a level the provider
* would reject. Writes nothing where the registry cannot speak for the model, since
* clearing a value on a guess would destroy the author's own default.
*/
function effortPatch(nextModel: string | undefined): Partial<Record<ProviderField, any>> {
if (!provider || !nextModel) return {}
const capability = getReasoningCapability(provider, nextModel)
if (!capability.known) return {}
const carried = carriedReasoning(
typeof effort === 'string' ? effort : undefined,
explicitOffToken(provider, nextModel) ?? '',
capability
)
return { reasoning_effort: carried ?? '' }
}
function selectResource(path: string, picked: AIProvider) {
setFields({
kind: picked,
resource: `$res:${path}`,
// The models of one provider mean nothing to another, and the new list only
// arrives async, so there is nothing to carry the current one against — nor the
// effort, which only means something against a model. Cleared only where the
// registry can speak for the new provider, for the same reason as `effortPatch`.
model: undefined,
...(getReasoningCapability(picked, '').known ? { reasoning_effort: '' } : {})
})
}
function providerItem(close: () => void): Item {
const rows: Item[] = resources.loading
? [{ displayName: 'Loading resources...', disabled: true }]
: (resources.current ?? []).length === 0
? [{ displayName: 'No AI resource in this workspace', disabled: true }]
: (resources.current ?? []).map((r) => ({
displayName: r.path,
selected: r.path === resourcePath,
action: () => selectResource(r.path, r.provider)
}))
return {
displayName: 'Provider',
icon: Plug,
extra: providerSummary,
submenuItems: [
...rows,
{
// The same reach the form's ResourcePicker gives: create one without
// leaving for workspace settings first.
displayName: 'Add a resource',
icon: Plus,
separatorTop: true,
action: () => {
close()
appConnect?.open()
}
}
]
}
}
const config = $derived<ChatModelSettingsConfig>({
label: typeof model === 'string' && model ? model : 'Select a model',
title: 'Model & reasoning settings',
readOnly,
readOnlyReason: 'Set in the flow',
topItems: resourceEditable ? (close) => [providerItem(close)] : undefined,
sections: modelEditable
? [
{
label: 'Model',
options: (models.current ?? []).map((m) => ({
key: m,
label: m,
selected: m === model,
onSelect: () => setFields({ model: m, ...effortPatch(m) })
})),
loading: models.loading,
emptyMessage: provider ? 'No model available' : 'Pick a provider first'
}
]
: undefined,
// Always present, whatever we can say about it: the run uses an effort either way, and
// the control is the only place it can be read or set. What varies is the state it
// renders — a ladder, a typed token, why there is none, or what the flow fixed.
reasoning: {
provider,
model: typeof model === 'string' && model ? model : undefined,
value: typeof effort === 'string' ? effort : undefined,
// An agent writes the provider-native token straight into its step, so there is no
// sentinel to translate later. Where a model disables by omission instead, the empty
// string is that off: the run reads an empty `reasoning_effort` as absent
// (types.rs `get_reasoning_effort`).
offToken:
provider && typeof model === 'string' && model
? (explicitOffToken(provider, model) ?? '')
: '',
// An agent step omits `reasoning_effort` when it is unset, so the provider picks —
// naming a level would claim something the run does not do.
sendsDefaultWhenUnset: false,
writable: effortEditable,
onSelect: (token) => setFields({ reasoning_effort: token })
}
})
</script>
{#snippet providerSummary()}
{#if resourcePath}
<span class="shrink-0 text-tertiary truncate max-w-[80px]">{resourcePath}</span>
{/if}
{/snippet}
{#if resourceEditable}
<AppConnect
bind:this={appConnect}
{workspace}
on:refresh={(e) => {
resourcesVersion++
if (e.detail) {
pendingResourcePath = e.detail
}
}}
/>
{/if}
<ChatModelSettings {config} />
@@ -1,204 +1,373 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { MessageCircle, Plus, Trash2, PanelLeftClose, PanelLeftOpen } from 'lucide-svelte'
import CountBadge from '$lib/components/common/badge/CountBadge.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { tick } from 'svelte'
import {
MessageSquare,
Pen,
Plus,
Trash2,
PanelLeftClose,
PanelLeftOpen,
Info
} from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import SessionStatusDot from '$lib/components/sessions/SessionStatusDot.svelte'
import type { SessionChatStatus } from '$lib/components/sessions/sessionRuntime.svelte'
import UnreadCountBadge from '$lib/components/common/badge/UnreadCountBadge.svelte'
import { PencilLine } from 'lucide-svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import { emptyString, type Item } from '$lib/utils'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { Filter } from 'lucide-svelte'
import { type FlowConversation } from '$lib/gen'
import InfiniteList from '$lib/components/InfiniteList.svelte'
import { sendUserToast } from '$lib/toast'
import { twMerge } from 'tailwind-merge'
import {
FlowChatManager,
type ConversationKind,
type ConversationWithDraft
} from './FlowChatManager.svelte'
import { fade } from 'svelte/transition'
import { untrack } from 'svelte'
import type { Chat, ChatState, Conversation } from 'windmill-chat'
interface Props {
chat: Chat
chatState: ChatState
manager: FlowChatManager
/** The flow's description. The empty transcript shows it in full, but that is gone
* once a chat is under way — this keeps it reachable for the rest of the session. */
description?: string
}
let { chat, chatState }: Props = $props()
let { manager, description = undefined }: Props = $props()
let expanded = $state(false)
let list = $state<InfiniteList | undefined>(undefined)
let items = $state<Conversation[]>([])
let deletingId = $state<string | undefined>(undefined)
// 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 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)
$effect(() => {
const l = list
const c = chat
if (!l) return
untrack(() => {
l.setLoader((page, perPage) => c.loadConversations({ page, perPage }))
l.setDeleteItemFn(async (id: string) => {
deletingId = id
try {
await c.deleteConversation(id)
sendUserToast('Conversation deleted successfully')
} catch (error) {
console.error('Failed to delete conversation:', error)
sendUserToast('Failed to delete conversation', true)
throw error
} finally {
deletingId = undefined
}
})
})
})
/** The container reports a started turn: a conversation's first one creates its server entry. */
export async function conversationStarted(conversationId: string) {
if (items.some((c) => c.id === conversationId)) return
draft = false
await list?.loadData('forceRefresh')
async function startRename(conversation: FlowConversation) {
renamingId = conversation.id
renameDraft = getConversationTitle(conversation)
// The field replaces the row, so it exists only after this render.
await tick()
renameInput?.focus()
renameInput?.select()
}
const draftShown = $derived(draft && !items.some((c) => c.id === chatState.conversationId))
function newChat() {
chat.newConversation()
draft = true
async function commitRename() {
const id = renamingId
renamingId = undefined
if (id) await manager.renameConversation(id, renameDraft)
}
function getConversationTitle(conversation: Conversation): string {
return conversation.title || `Conversation ${conversation.createdAt.slice(0, 10)}`
function deleteConversation(conversation: ConversationWithDraft) {
if (conversation.isDraft) {
// The draft is the first row and exists only here; there is nothing to delete.
manager.conversations = [...manager.conversations.slice(1)]
} else {
manager.conversationListComponent?.deleteItem(conversation.id)
}
}
function rowActions(conversation: ConversationWithDraft): Item[] {
return [
{ displayName: 'Rename', icon: Pen, action: () => startRename(conversation) },
{
displayName: 'Delete',
icon: Trash2,
type: 'delete',
disabled: manager.deletingConversationId === conversation.id,
action: () => deleteConversation(conversation)
}
]
}
const KIND_LABELS: Record<ConversationKind, string> = {
test: 'Test',
deployed: 'Deployed',
all: 'All'
}
/**
* The sessions sidebar's own vocabulary, so the two lists read alike: a running turn is
* its streaming signal and a failed one its error signal. A queued message is not a dot
* there either — it is the pencil beside the count.
*/
function dotStatus(conversationId: string): SessionChatStatus {
const status = manager.conversationStatus(conversationId)
return status === 'running' ? 'streaming' : status === 'error' ? 'error' : 'idle'
}
/** Why this row cannot be opened, when something stops it. */
function rowLocked(conversation: ConversationWithDraft): string | undefined {
return manager.lockedReason(conversation.id)
}
function getConversationTitle(conversation: FlowConversation): string {
return conversation.title || `Conversation ${conversation.created_at.slice(0, 10)}`
}
</script>
{#snippet statusDot(conversation: ConversationWithDraft)}
<!-- The AI session sidebar's dot, with the resting mark this list needs: a session rests
as a workspace or a fork, a conversation as a test run or one of the deployed flow's. -->
<SessionStatusDot
status={dotStatus(conversation.id)}
isFork={false}
restingTitle={conversation.is_test
? 'Test chat, run from the flow editor'
: 'Chat on the deployed flow'}
>
{#snippet resting()}
<span
class="w-[6px] h-[6px] rounded-full {conversation.is_test
? 'border border-gray-400 dark:border-gray-500'
: 'bg-gray-300 dark:bg-gray-600'}"
></span>
{/snippet}
</SessionStatusDot>
{/snippet}
<div
class="flex flex-col h-full bg-surface border-r transition-all duration-300 {expanded
class="flex flex-col h-full bg-surface border-r transition-all duration-300 {manager.isSidebarExpanded
? 'w-60'
: 'w-[44px]'}"
>
<!-- Header -->
<div class="flex-shrink-0 border-b">
<div class="flex-shrink-0">
<div class="flex flex-col gap-2 p-1">
<Button
unifiedSize="md"
variant="subtle"
startIcon={{
icon: expanded ? PanelLeftClose : PanelLeftOpen,
classes: 'ml-[2px]'
}}
onClick={() => (expanded = !expanded)}
iconOnly={!expanded}
btnClasses={'justify-start transition-all duration-150'}
title="Conversations"
<!-- Same shape as the New chat row below: the wide button takes the width and the
icon-only one sits at the end, stacking into the rail once collapsed. -->
<div
class={manager.isSidebarExpanded
? 'flex flex-row gap-1 items-center'
: 'flex flex-col gap-2'}
>
<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'}
<Button
unifiedSize="md"
variant="subtle"
startIcon={{
icon: manager.isSidebarExpanded ? PanelLeftClose : PanelLeftOpen,
classes: 'ml-[2px]'
}}
onClick={() => (manager.isSidebarExpanded = !manager.isSidebarExpanded)}
iconOnly={!manager.isSidebarExpanded}
wrapperClasses={manager.isSidebarExpanded ? 'grow min-w-0' : ''}
btnClasses={'w-full justify-start transition-all duration-150'}
title="Conversations"
>
<div transition:fade={{ duration: 100 }}> Conversations </div>
</Button>
{#if !emptyString(description)}
<!-- The icon is passed as the trigger rather than left to Tooltip's own: without
children it renders an empty trigger span beside the icon, which takes a
button's worth of height in this column.
Anchored to the icon's top: a long description centred on it would grow up
over the header above the chat. -->
<!-- Sized and inset like an icon-only Button's own icon (px-2 plus the ml-[2px]
every icon in this column carries), so it lands on their line in the rail. -->
<Tooltip
placement="right-start"
class="inline-flex items-center size-8 shrink-0 pl-[10px] text-secondary hover:text-primary"
>
<Info size={14} />
{#snippet text()}
<!-- A flow description is markdown, and is rendered as such everywhere else it
is shown. TooltipInner brings the width cap and the scroll. -->
<GfmMarkdown md={description ?? ''} noPadding prose="sm" />
{/snippet}
</Tooltip>
{/if}
</div>
<!-- Side by side while there is width for both labels; stacked once collapsed,
where the rail fits one icon across. -->
<div
class={manager.isSidebarExpanded
? 'flex flex-row gap-1 items-center'
: 'flex flex-col gap-2'}
>
<div transition:fade={{ duration: 100 }}> New chat </div>
</Button>
<Button
unifiedSize="md"
variant="subtle"
startIcon={{ icon: Plus, classes: 'ml-[2px]' }}
onClick={() => manager.createConversation({ clearMessages: true })}
disabled={!!manager.newChatReason}
title={manager.newChatReason ?? 'Start new conversation'}
iconOnly={!manager.isSidebarExpanded}
wrapperClasses={manager.isSidebarExpanded ? '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 manager.canFilterConversationKind}
<Popover placement="bottom-start" closeButton={false}>
{#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={manager.isTurnInFlight}
title={manager.isTurnInFlight
? 'Wait for the current answer to change which chats are listed'
: `Filter conversations · ${KIND_LABELS[manager.conversationKind]}`}
iconOnly
/>
{/snippet}
{#snippet content()}
<div class="p-3">
<ToggleButtonGroup
selected={manager.conversationKind}
onSelected={(kind) => manager.setConversationKind(kind as ConversationKind)}
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>
<!-- Conversations List -->
{#if !expanded}
{#if !manager.isSidebarExpanded}
<!-- Collapsed state - show single chat icon with badge -->
<div class="p-1">
<Button
unifiedSize="md"
startIcon={{ icon: MessageCircle }}
onClick={() => (expanded = true)}
title="{items.length} conversation{items.length !== 1 ? 's' : ''}"
startIcon={{ icon: MessageSquare, classes: 'ml-[2px]' }}
onClick={() => (manager.isSidebarExpanded = true)}
title="{manager.conversations.length} conversation{manager.conversations.length !== 1
? 's'
: ''}{manager.totalUnread > 0 ? `, ${manager.totalUnread} unread` : ''}"
variant="subtle"
btnClasses="w-fit px-2 relative"
>
<CountBadge count={items.length} small alwaysVisible={true} class="right-[3px] top-[3px]" />
<!-- The same badge the rows carry, over the one icon that stands for all of them:
collapsed, what is worth a number is what arrived, not how many chats exist. -->
<UnreadCountBadge
count={manager.totalUnread}
small
class="absolute right-[3px] top-[3px] pointer-events-none"
/>
</Button>
</div>
{/if}
<!-- Always mount InfiniteList, but hide it when collapsed -->
<div class="flex-1 overflow-hidden transition-all duration-150 p-1" class:hidden={!expanded}>
{#if draftShown && expanded}
<div class="w-full pb-1" transition:fade={{ duration: 100, delay: 30 }}>
<Button
unifiedSize="md"
variant="subtle"
selected={true}
btnClasses="transition-all duration-150 group"
>
<span class="flex-1 text-left truncate">New chat</span>
<Button
wrapperClasses="ml-2 transition-all duration-100 opacity-0 group-hover:opacity-100"
onClick={(e) => {
e?.stopPropagation()
draft = false
chat.newConversation()
}}
title="Discard draft"
destructive
unifiedSize="xs"
variant="subtle"
iconOnly
startIcon={{ icon: Trash2 }}
/>
</Button>
</div>
{/if}
<div
class="flex-1 overflow-hidden transition-all duration-150 p-1"
class:hidden={!manager.isSidebarExpanded}
>
<InfiniteList
bind:this={list}
bind:items
selectedItemId={chatState.conversationId}
bind:this={manager.conversationListComponent}
bind:items={manager.conversations}
selectedItemId={manager.selectedConversationId}
noBorder={true}
rounded={false}
preventXOverflow={true}
>
{#snippet customRow({ item: conversation })}
{#if expanded}
{#snippet customRow({ item: conversation, hover })}
{#if manager.isSidebarExpanded}
<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 gap-1 h-8 px-2 rounded-md bg-surface-selected">
{@render statusDot(conversation)}
<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={() => manager.selectConversation(conversation.id, conversation.isDraft)}
selected={manager.selectedConversationId === conversation.id}
disabled={!!rowLocked(conversation)}
title={rowLocked(conversation)}
btnClasses="transition-all duration-150 group gap-2"
>
<!-- In the slot New chat's icon occupies above, so the column lines up. Says
what the chat is doing where there is something to say, and which kind of
chat it is otherwise. -->
{@render statusDot(conversation)}
{@const unread = manager.unreadCount(conversation.id)}
<span
class={twMerge(
'flex-1 text-left truncate',
unread > 0 ? 'font-semibold text-primary' : ''
)}
>
{getConversationTitle(conversation)}
</span>
{#if manager.conversationStatus(conversation.id) === 'queued' || unread > 0}
<span class="shrink-0 inline-flex items-center gap-1">
{#if manager.conversationStatus(conversation.id) === 'queued'}
<PencilLine class="w-3 h-3 text-tertiary" aria-label="Message waiting to send" />
{/if}
<UnreadCountBadge count={unread} />
</span>
{/if}
<!-- Hidden while the row is disabled: it sits inside the row's button, and a
disabled button swallows every click in its subtree, so a visible menu
here would be an affordance that does nothing. -->
{#if !rowLocked(conversation)}
<!-- 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',
manager.deletingConversationId === conversation.id ? 'opacity-100' : ''
)}
onclick={(e) => e.stopPropagation()}
>
<DropdownV2 items={() => rowActions(conversation)} size="xs" />
</div>
{/if}
</Button>
{/if}
</div>
{/if}
{/snippet}
{#snippet empty()}
{#if !draftShown}
<div class="p-4 text-center">
<p class="text-sm text-secondary mb-2">No conversations yet</p>
</div>
{/if}
<div class="p-4 text-center">
<p class="text-sm text-secondary mb-2">No conversations yet</p>
</div>
{/snippet}
</InfiniteList>
</div>
@@ -0,0 +1,531 @@
import { describe, expect, it } from 'vitest'
import {
agentModelGap,
agentModelWiringInputs,
composerOwnedInputs,
attachmentsTargetFor,
parseProviderTransform,
resolveAgentChatInputs,
resolveAgentModelWiring,
withoutRejectedEffort
} from './agentChatInputs'
import type { FlowModule } from '$lib/gen'
/** An agent step with the given input transforms, as the editor stores them. */
function agentWith(input_transforms: Record<string, any>): FlowModule {
return {
id: 'a',
value: { type: 'aiagent', tools: [], input_transforms }
} as unknown as FlowModule
}
function agent(expr: string): FlowModule {
return {
id: 'a',
value: {
type: 'aiagent',
tools: [],
input_transforms: { provider: { type: 'javascript', expr } }
}
} as unknown as FlowModule
}
describe('parseProviderTransform', () => {
it('reads a whole-object reference', () => {
expect(parseProviderTransform({ type: 'javascript', expr: 'flow_input.model' })).toEqual({
whole: 'model',
fields: {},
fixed: {}
})
})
it('splits a partial expression into wired and fixed fields', () => {
const wiring = parseProviderTransform({
type: 'javascript',
expr: "{ kind: 'anthropic', resource: '$res:u/admin/claude', model: flow_input.model, reasoning_effort: flow_input.thinking }"
})
expect(wiring).toEqual({
fields: { model: 'model', reasoning_effort: 'thinking' },
fixed: { kind: 'anthropic', resource: '$res:u/admin/claude' }
})
})
// The regression this detector exists for: counting flow_input references would read
// this as one input carrying the whole provider object, and the composer would write
// {kind, resource, model} into an input the expression uses as the model name.
it('does not mistake a single-reference partial expression for a whole-object one', () => {
const wiring = parseProviderTransform({
type: 'javascript',
expr: "{ kind: 'anthropic', resource: '$res:u/admin/claude', model: flow_input.model }"
})
expect(wiring?.whole).toBeUndefined()
expect(wiring?.fields).toEqual({ model: 'model' })
})
// The flow editor's JS field commonly holds a parenthesised object, which is how an
// author writes one without it reading as a block.
it('accepts a parenthesised object expression', () => {
const wiring = parseProviderTransform({
type: 'javascript',
expr: `({
"kind": "anthropic",
"resource": "$res:u/admin/anthropic_windmill_codegen",
"model": "claude-sonnet-5",
"reasoning_effort": flow_input.thinking
})`
})
expect(wiring).toEqual({
fields: { reasoning_effort: 'thinking' },
fixed: {
kind: 'anthropic',
resource: '$res:u/admin/anthropic_windmill_codegen',
model: 'claude-sonnet-5'
}
})
})
it('treats a static provider as entirely fixed', () => {
expect(
parseProviderTransform({
type: 'static',
value: { kind: 'openai', resource: '$res:u/admin/oai', model: 'gpt-5.6' }
})
).toEqual({
fields: {},
fixed: { kind: 'openai', resource: '$res:u/admin/oai', model: 'gpt-5.6' }
})
})
it.each([
['{ ...base, model: flow_input.model }', 'a spread could supply any field'],
['{ model: pickModel(flow_input.x) }', 'a call is not classifiable'],
['flow_input.model + 1', 'not a bare reference'],
['{ model: ', 'unparseable']
])('gives up on %s', (expr) => {
expect(parseProviderTransform({ type: 'javascript', expr } as any)).toBeUndefined()
})
})
describe('resolveAgentChatInputs', () => {
const schema = { properties: { files: { type: 'array' } }, required: [] }
const reader = (name: string) =>
agentWith({ user_attachments: { type: 'javascript', expr: `flow_input.${name}` } })
// Every agent step carries a placeholder transform for each key of AI_AGENT_SCHEMA
// (loadSchemaFromModule writes them back onto the module), so an agent that reads
// nothing must not be mistaken for one reading a different input.
const seeded = () => agentWith({ user_attachments: { type: 'static', value: undefined } })
it('promotes the input one agent reads', () => {
expect(resolveAgentChatInputs([reader('files')], schema).map((i) => i.name)).toEqual(['files'])
})
it('still promotes it when another agent leaves the field unwired', () => {
expect(resolveAgentChatInputs([reader('files'), seeded()], schema).map((i) => i.name)).toEqual([
'files'
])
})
it('promotes nothing when two agents read different inputs', () => {
const twoInputs = {
properties: { files: { type: 'array' }, docs: { type: 'array' } },
required: []
}
expect(resolveAgentChatInputs([reader('files'), reader('docs')], twoInputs)).toEqual([])
})
})
describe('agentModelWiringInputs', () => {
// The button writes `kind` only alongside a resource, since a provider is picked as a
// pair. Hiding a kind input it cannot write would leave the run without one.
it('keeps a kind input the model button cannot write', () => {
const wiring = resolveAgentModelWiring([
agent(`({ kind: flow_input.k, "resource": "$res:u/admin/claude", model: flow_input.m })`)
])
expect(agentModelWiringInputs(wiring)).toEqual(['m'])
})
it('hides a kind input it writes with the resource', () => {
const wiring = resolveAgentModelWiring([
agent(`({ kind: flow_input.k, resource: flow_input.r, model: flow_input.m })`)
])
expect(agentModelWiringInputs(wiring)?.sort()).toEqual(['k', 'm', 'r'])
})
// The button always draws a thinking control — a ladder, a typed token, or why there is
// neither — so a wired effort is the button's whatever the registry knows about the model.
it('always claims a wired reasoning_effort, whatever the provider', () => {
const custom = resolveAgentModelWiring([
agent(
`({ "kind": "customai", "resource": "$res:u/admin/custom", model: flow_input.m, reasoning_effort: flow_input.thinking })`
)
])
expect(agentModelWiringInputs(custom)?.sort()).toEqual(['m', 'thinking'])
const known = resolveAgentModelWiring([
agent(
`({ "kind": "openai", "resource": "$res:u/admin/oai", "model": "gpt-4o", reasoning_effort: flow_input.thinking })`
)
])
expect(agentModelWiringInputs(known)).toEqual(['thinking'])
})
})
// The modal is whatever this does not return, so the two can no longer disagree about an
// input — and what a control can do *right now* is deliberately not part of the answer.
describe('composerOwnedInputs', () => {
const wiring = () =>
resolveAgentModelWiring([
agent(`({ "kind": "openai", "resource": "$res:u/admin/oai", model: flow_input.m })`)
])
it('claims the model wiring and the attachments target together', () => {
expect(composerOwnedInputs(wiring(), { name: 'files' })?.sort()).toEqual(['files', 'm'])
})
it('claims the attachments input whatever the workspace can do with it', () => {
// No object storage is a state the paperclip shows, not a handover: the modal could
// not upload either, and the run would fail fetching a key typed there.
expect(composerOwnedInputs(undefined, { name: 'files' })).toEqual(['files'])
})
it('leaves an input no control fits to the modal', () => {
// `attachmentsTargetFor` returns nothing for a shape the paperclip cannot write — a
// plain string key, say — and then the input is genuinely the modal's.
expect(composerOwnedInputs(wiring(), undefined)).toEqual(['m'])
})
})
describe('resolveAgentModelWiring', () => {
const fixedResource = `"kind": "anthropic", "resource": "$res:u/admin/claude"`
it('drives a field every agent reads from the same input', () => {
const wiring = resolveAgentModelWiring([
agent(
`({ ${fixedResource}, "model": "claude-sonnet-5", reasoning_effort: flow_input.thinking })`
),
agent(
`({ ${fixedResource}, "model": "claude-opus-5", reasoning_effort: flow_input.thinking })`
)
])
expect(wiring?.fields).toEqual({ reasoning_effort: 'thinking' })
// The agents run different models, so there is no single one to name.
expect(wiring?.fixed).toEqual({ kind: 'anthropic', resource: '$res:u/admin/claude' })
})
it('drops a field the agents disagree about', () => {
const wiring = resolveAgentModelWiring([
agent(`({ ${fixedResource}, reasoning_effort: flow_input.thinking })`),
agent(`({ ${fixedResource}, reasoning_effort: flow_input.other })`)
])
expect(wiring?.fields.reasoning_effort).toBeUndefined()
})
// A nested agent is the parent agent's tool, not a step the reader is talking to: the
// graph walks it as a child module, and counting it here would defeat the chat's own
// model control (this is the shape of the all-tools example flow).
it("ignores an agent carried as another agent's tool", () => {
const parent = agent('flow_input.model')
;(parent.value as any).tools = [
{
id: 'summarize',
value: {
tool_type: 'flowmodule',
type: 'aiagent',
tools: [],
input_transforms: {
provider: { type: 'static', value: { kind: 'anthropic', model: 'claude-sonnet-5' } }
}
}
}
]
expect(resolveAgentModelWiring([parent])).toEqual({
whole: 'model',
fields: {},
fixed: {},
someAgentCannotRun: false
})
})
// The control writes one flow input; an agent that fixes the field instead never reads
// it, so offering the control would move one agent and leave the other where it was.
it('does not offer a field one agent wires and another fixes', () => {
const wiring = resolveAgentModelWiring([
agent(`({ ${fixedResource}, model: flow_input.model })`),
agent(`({ ${fixedResource}, "model": "claude-opus-5" })`)
])
expect(wiring?.fields.model).toBeUndefined()
expect(wiring?.fixed.model).toBeUndefined()
})
// Disagreeing about the model is not the same as having no model: the flow runs, on a
// different one per agent, and the composer has nothing to fix.
it('says nothing about a model the agents merely disagree about', () => {
const wiring = resolveAgentModelWiring([
agent(`({ ${fixedResource}, "model": "claude-sonnet-5" })`),
agent(`({ ${fixedResource}, "model": "claude-opus-5" })`)
])
expect(agentModelGap(wiring)).toBeUndefined()
})
it('still reports an agent with nothing to call', () => {
expect(
agentModelGap(resolveAgentModelWiring([agent(`({ "kind": "openai", "model": "" })`)]))
).toBe('Pick a provider and model on the AI agent step to use this chat.')
})
// An expression the parser cannot account for could supply anything, so the agents it
// belongs to cannot be spoken for either.
it("offers nothing when one agent's provider cannot be read", () => {
expect(
resolveAgentModelWiring([
agent(`({ ${fixedResource}, model: flow_input.model })`),
agent(`({ ...base, model: flow_input.model })`)
])
).toBeUndefined()
})
// Disagreement is not the same as absence, but an agent with an empty model still
// cannot run, however well its neighbour is configured.
it('keeps warning when one agent has no model and another does', () => {
const wiring = resolveAgentModelWiring([
agent(`({ ${fixedResource}, "model": "claude-sonnet-5" })`),
agent(`({ ${fixedResource}, "model": "" })`)
])
expect(agentModelGap(wiring)).toBe(
'Pick a provider and model on the AI agent step to use this chat.'
)
})
it('refuses a flow mixing whole-object and field-by-field wiring', () => {
expect(
resolveAgentModelWiring([
agent('flow_input.provider'),
agent(`({ ${fixedResource}, model: flow_input.model })`)
])
).toBeUndefined()
})
})
describe('attachmentsTargetFor', () => {
const input = (property: Record<string, any>) =>
({ name: 'files', key: 'user_attachments', property }) as any
it('takes a list of s3 files, and says it holds several', () => {
expect(
attachmentsTargetFor(input({ type: 'array', items: { resourceType: 's3object' } }))
).toEqual({ name: 'files', multiple: true })
})
it('takes a single s3 file', () => {
expect(attachmentsTargetFor(input({ format: 'resource-s3_object' }))).toEqual({
name: 'files',
multiple: false
})
})
// The transform can build the s3 object itself, promoting an input that holds a key
// rather than a file. Uploading into it would write an object where a string is declared.
it('offers no paperclip where the input cannot hold a file', () => {
expect(attachmentsTargetFor(input({ type: 'string' }))).toBeUndefined()
expect(
attachmentsTargetFor(input({ type: 'array', items: { type: 'string' } }))
).toBeUndefined()
expect(attachmentsTargetFor(undefined)).toBeUndefined()
})
})
describe('withoutRejectedEffort', () => {
const wiring = (fields: Record<string, string>, fixed: Record<string, any> = {}) =>
({ fields, fixed }) as any
// The live 400 this guards: Anthropic turns any effort into adaptive thinking, which
// Haiku rejects outright ("adaptive thinking is not supported on this model").
it('drops an effort the chosen model rejects', () => {
const values = { model: 'claude-haiku-4-5-20251001', reasoning_effort: 'high' }
expect(
withoutRejectedEffort(
wiring({ model: 'model', reasoning_effort: 'reasoning_effort' }, { kind: 'anthropic' }),
values
)
).toEqual({ model: 'claude-haiku-4-5-20251001', reasoning_effort: '' })
})
it('keeps an effort the model takes', () => {
const values = { model: 'claude-sonnet-5', reasoning_effort: 'high' }
expect(
withoutRejectedEffort(
wiring({ model: 'model', reasoning_effort: 'reasoning_effort' }, { kind: 'anthropic' }),
values
)
).toBe(values)
})
// Clearing on a guess would override the author's own default.
it('leaves the value alone for a family the registry cannot speak for', () => {
const values = { model: 'some-model', reasoning_effort: 'high' }
expect(
withoutRejectedEffort(
wiring({ model: 'model', reasoning_effort: 'reasoning_effort' }, { kind: 'customai' }),
values
)
).toBe(values)
})
// A flow that wires `provider` as one object keeps the effort inside it, so reading
// `fields.reasoning_effort` finds nothing and the 400 would go out unchecked.
it('clears the effort inside a whole-object provider input', () => {
const wiring = resolveAgentModelWiring([agent('flow_input.provider')])
const values = {
provider: {
kind: 'anthropic',
model: 'claude-haiku-4-5-20251001',
reasoning_effort: 'high'
}
}
expect(withoutRejectedEffort(wiring, values)).toEqual({
provider: {
kind: 'anthropic',
model: 'claude-haiku-4-5-20251001',
reasoning_effort: ''
}
})
})
it('leaves a whole-object provider alone when the model takes the effort', () => {
const wiring = resolveAgentModelWiring([agent('flow_input.provider')])
const values = {
provider: { kind: 'anthropic', model: 'claude-sonnet-5', reasoning_effort: 'high' }
}
expect(withoutRejectedEffort(wiring, values)).toBe(values)
})
})
/**
* The shape agent chat is usually built in: one agent answers the reader, others do work of
* their own in branches. A sub-agent never sees the message, so what it runs on is not a
* setting this conversation has.
*/
describe('agents that do not read the message', () => {
const answerer = (provider: string) =>
({
id: 'answerer',
value: {
type: 'aiagent',
tools: [],
input_transforms: {
user_message: { type: 'javascript', expr: 'flow_input.user_message' },
provider: { type: 'javascript', expr: provider }
}
}
}) as unknown as FlowModule
const subAgent = (provider: string, message = "'critique: ' + results.answerer") =>
({
id: 'critic',
value: {
type: 'aiagent',
tools: [],
input_transforms: {
user_message: { type: 'javascript', expr: message },
provider: { type: 'javascript', expr: provider }
}
}
}) as unknown as FlowModule
const wired = `({ kind: 'anthropic', resource: '$res:u/admin/c', model: flow_input.model })`
const fixed = `({ kind: 'anthropic', resource: '$res:u/admin/c', model: 'claude-sonnet-5' })`
it('keeps the model control when only a sub-agent fixes its own model', () => {
const wiring = resolveAgentModelWiring([answerer(wired), subAgent(fixed)])
expect(wiring?.fields.model).toBe('model')
})
// Two agents both answering the reader still have to agree: either might be the one
// that replies, so a control moving one of them would be a lie about the other.
it('still needs agreement among the agents that do read the message', () => {
const wiring = resolveAgentModelWiring([
answerer(wired),
subAgent(fixed, 'flow_input.user_message')
])
expect(wiring?.fields.model).toBeUndefined()
})
// An author wraps the message in context freely; that is still the agent being talked to.
it('counts an agent whose prompt embeds the message', () => {
const wrapped = subAgent(fixed, "'Answer politely: ' + flow_input.user_message")
const wiring = resolveAgentModelWiring([answerer(wired), wrapped])
expect(wiring?.fields.model).toBeUndefined()
})
// Nothing to scope to means the flow is shaped in some way this cannot read, so every
// agent counts again rather than none.
it('falls back to every agent when none reads the message', () => {
const wiring = resolveAgentModelWiring([subAgent(wired), subAgent(fixed)])
expect(wiring?.fields.model).toBeUndefined()
})
// The editor writes the dot form, but an author may hand-edit either. A shape this does
// not recognise drops that agent out of the unanimity check it should be part of.
it.each([
["flow_input['user_message']", 'bracket access'],
['flow_input?.user_message', 'optional chaining'],
["'Answer politely: ' + flow_input.user_message", 'embedded in a prompt'],
['flow_input.user_message + flow_input.tone', 'read alongside another input']
])('treats %s as reading the message', (message) => {
const wiring = resolveAgentModelWiring([
answerer(wired),
subAgent(fixed, message as string)
])
expect(wiring?.fields.model).toBeUndefined()
})
// A trailing line comment used to swallow the closing paren the parser adds, which made
// the whole expression unreadable and dropped the agent out of the check.
it.each([
['flow_input.user_message // the message', 'a trailing comment'],
['flow_input.user_message\n// why', 'a comment on its own last line']
])('still reads the message with %s', (message) => {
const wiring = resolveAgentModelWiring([
answerer(wired),
subAgent(fixed, message as string)
])
expect(wiring?.fields.model).toBeUndefined()
})
it.each([
['// see flow_input.user_message', 'a mention in a comment'],
["'flow_input.user_message'", 'a mention in a string'],
['flow_input.user_message_extra', 'a different input with the same prefix']
])('does not treat %s as reading the message', (message) => {
const wiring = resolveAgentModelWiring([
answerer(wired),
subAgent(fixed, message as string)
])
expect(wiring?.fields.model).toBe('model')
})
})
// An author annotating their own provider must not lose the model control for it: a
// comment beside the expression is not another expression.
describe('parseProviderTransform with comments', () => {
it.each([
['flow_input.provider // the one to use', 'a trailing line comment'],
['flow_input.provider /* the one to use */', 'a trailing block comment'],
['/* pick one */ flow_input.provider', 'a leading comment']
])('reads a whole-object reference despite %s', (expr) => {
expect(parseProviderTransform({ type: 'javascript', expr })?.whole).toBe('provider')
})
it('reads an object literal with a comment inside it', () => {
const wiring = parseProviderTransform({
type: 'javascript',
expr: `({ kind: 'anthropic', /* fixed */ resource: '$res:u/admin/c', model: flow_input.model })`
})
expect(wiring?.fields.model).toBe('model')
})
// The check exists to reject an expression with something else beside it; a second
// expression is still something else.
it('still refuses a second expression beside it', () => {
expect(parseProviderTransform({ type: 'javascript', expr: 'flow_input.provider, 1' })).toBeUndefined()
})
})
@@ -0,0 +1,536 @@
import type { AIProvider, FlowModule, InputTransform } from '$lib/gen'
import { getReasoningCapability } from '$lib/components/copilot/reasoningRegistry'
import { parseExpressionAt } from 'acorn'
/**
* The flow's own AI agent steps, including those inside loops and branches but never one
* carried as another agent's tool.
*
* The graph walks an agent's tools as if they were child steps (flowTree.ts), which is
* right for the graph and wrong here: a tool agent's provider belongs to the agent that
* calls it, not to the chat. Counting it would let a nested agent's fixed model defeat the
* composer's model control on the step the reader is actually talking to.
*/
function agentSteps(modules: FlowModule[] | undefined): FlowModule[] {
const found: FlowModule[] = []
const walk = (mods: FlowModule[]) => {
for (const module of mods) {
const value = module.value as any
if (value?.type === 'aiagent') {
found.push(module)
continue
}
if (value?.type === 'forloopflow' || value?.type === 'whileloopflow') {
walk(value.modules ?? [])
} else if (value?.type === 'branchone') {
walk(value.default ?? [])
for (const branch of value.branches ?? []) walk(branch.modules ?? [])
} else if (value?.type === 'branchall') {
for (const branch of value.branches ?? []) walk(branch.modules ?? [])
}
}
}
walk(modules ?? [])
return found
}
/** Block and line comments removed, so what is left is only what affects the value. */
function withoutComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '')
}
/**
* An expression wrapped so acorn will read it: parenthesised, because a leading `{` would
* otherwise parse as a block, and on its own line, because a trailing `// comment` would
* otherwise swallow the closing paren and make the whole thing unparseable.
*/
function parenthesised(expr: string): string {
return `(\n${expr}\n)`
}
/** The flow input the server requires on a chat-enabled flow, and stores as the message. */
const MESSAGE_INPUT = 'user_message'
/**
* The agents the reader is talking to: the ones the chat's message is fed to.
*
* A flow commonly runs one agent on the message and others on work of their own a
* critic reading `results.x`, a classifier in a branch. Those never see what was typed,
* so what they run on is not a setting this conversation has: letting one of them differ
* on the model would take the model control away from the agent that does answer.
*
* The message need not be the whole prompt an author wraps it in context freely so
* this asks whether the expression reads it at all. A flow where no agent reads it is one
* shaped in some way this cannot speak for, and every agent counts again rather than none.
*/
function chatFacingAgents(modules: FlowModule[] | undefined): FlowModule[] {
const agents = agentSteps(modules)
const facing = agents.filter((module) => {
const transform = (module.value as any).input_transforms?.[MESSAGE_INPUT]
return transform?.type === 'javascript' && readsFlowInput(transform.expr, MESSAGE_INPUT)
})
return facing.length > 0 ? facing : agents
}
/**
* Whether an expression reads `flow_input.<name>` anywhere in it.
*
* Parsed rather than matched: the author may write `flow_input['user_message']` as readily
* as the dot form the editor emits, and a mention inside a comment or a string is not a
* read. Reading two inputs is still a read of each, so this cannot use `flowInputRef`,
* which answers a different question which single input feeds a field.
*/
function readsFlowInput(expr: string, name: string): boolean {
let root: unknown
try {
root = parseExpressionAt(parenthesised(expr), 0, { ecmaVersion: 'latest' })
} catch {
return false
}
let found = false
const visit = (node: any) => {
if (found || !node || typeof node !== 'object') return
if (Array.isArray(node)) {
node.forEach(visit)
return
}
if (flowInputName(node) === name) {
found = true
return
}
for (const key of Object.keys(node)) {
if (key === 'type' || key === 'start' || key === 'end') continue
visit(node[key])
}
}
visit(root)
return found
}
/**
* AI agent inputs the chat composer can drive.
*
* The composer never edits the flow: an agent field is reachable only when the author
* wired a flow input to it, so what the chip writes is a run input like any other. That
* also keeps it working on the deployed chat, where the reader has no write access, and
* on a step linked to an `ai_agent` resource, where every field but `user_message` /
* `user_attachments` comes from the resource and is not overridable at all.
*
* Only what the person chatting legitimately owns turn to turn belongs here, which today
* is the files they attach and nothing else. `system_prompt`, `temperature` and
* `max_completion_tokens` shape how the agent behaves for everyone who runs the flow
* surfacing them per conversation invites tuning the flow from the chat instead of
* fixing it in the editor. They stay flow settings, reachable through Configure inputs
* when the author deliberately exposes them. `max_iterations` is absent for the same
* reason, and because it caps the tool-use loop rather than a single generation. The
* model has its own control, resolved separately through `resolveAgentModelWiring`.
*/
export const AGENT_CHAT_INPUT_KEYS = ['user_attachments'] as const
export type AgentChatInputKey = (typeof AGENT_CHAT_INPUT_KEYS)[number]
/** The key that rides one message rather than the conversation, which the composer
* clears on send. A later key of the other sort would not be this one. */
export const PER_TURN_AGENT_CHAT_INPUT_KEY: AgentChatInputKey = 'user_attachments'
export type AgentChatInput = {
/** Flow input property feeding the agent field. */
name: string
key: AgentChatInputKey
/** The flow input's own schema entry — the chip renders it with the same editor the modal would. */
property: Record<string, any>
}
const FLOW_INPUT_REF = /flow_input\??\.([A-Za-z_$][\w$]*)/g
/**
* The flow input a transform is fed by, when exactly one feeds it.
*
* The expression need not be a bare pass-through a step commonly reshapes what it
* reads, e.g. `(flow_input.files || []).map(f => ({ bucket: f.storage, key: f.s3 }))`.
* Writing that input is still right, because the expression consumes it. Two or more
* inputs are ambiguous: the composer would have no way to say which one it is editing.
*/
export function flowInputRef(transform: InputTransform | undefined): string | undefined {
if (transform?.type !== 'javascript') return undefined
const names = new Set([...transform.expr.matchAll(FLOW_INPUT_REF)].map((match) => match[1]))
return names.size === 1 ? [...names][0] : undefined
}
/** A provider value as the agent stores it. */
export type AgentModel = { kind?: string; model?: string; reasoning_effort?: string }
/** The provider fields the composer can read or drive. */
const PROVIDER_FIELDS = ['kind', 'resource', 'model', 'reasoning_effort'] as const
export type ProviderField = (typeof PROVIDER_FIELDS)[number]
/**
* How an AI agent step's `provider` is supplied, field by field.
*
* The agent takes one `provider` object, so an author who wants the chat to choose only
* the model writes the rest as literals around it:
*
* { kind: 'anthropic', resource: '$res:u/admin/claude', model: flow_input.model }
*
* `fields` names the flow input behind each field the author exposed, `fixed` holds the
* literals, and `whole` covers the plain `flow_input.x` case where one input carries the
* entire object. Reading them apart is what lets the composer offer exactly the knobs the
* flow exposed and stops a partial expression from being mistaken for a whole-object
* one, which would write a provider object into an input the flow reads as a model name.
*/
export type AgentModelWiring = {
whole?: string
fields: Partial<Record<ProviderField, string>>
fixed: Partial<Record<ProviderField, any>>
/**
* One of the agents names no resource or no model of its own and no flow input feeds
* it, so that agent's run fails whatever the others do. Held apart from the fields,
* which describe what the composer may offer.
*/
someAgentCannotRun?: boolean
}
/** The flow input behind `flow_input.x`, `flow_input?.x` or `flow_input['x']`. */
function flowInputName(node: any): string | undefined {
const member = node?.type === 'ChainExpression' ? node.expression : node
if (member?.type !== 'MemberExpression') return undefined
if (member.object?.type !== 'Identifier' || member.object.name !== 'flow_input') return undefined
if (!member.computed && member.property?.type === 'Identifier') return member.property.name
if (member.computed && member.property?.type === 'Literal') {
return typeof member.property.value === 'string' ? member.property.value : undefined
}
return undefined
}
/** A property's name, for the plain `key:` and `'key':` forms only. */
function propertyKey(property: any): string | undefined {
if (property?.type !== 'Property' || property.computed) return undefined
if (property.key?.type === 'Identifier') return property.key.name
if (property.key?.type === 'Literal' && typeof property.key.value === 'string') {
return property.key.value
}
return undefined
}
/**
* Read a `provider` input transform. Anything this cannot account for in full returns
* undefined rather than a guess: the composer then leaves the field alone instead of
* writing into an expression it does not understand.
*/
export function parseProviderTransform(
transform: InputTransform | undefined
): AgentModelWiring | undefined {
if (transform?.type === 'static') {
const value = transform.value
if (!value || typeof value !== 'object') return undefined
const fixed: AgentModelWiring['fixed'] = {}
for (const field of PROVIDER_FIELDS) {
if (value[field] !== undefined) fixed[field] = value[field]
}
return { fields: {}, fixed }
}
if (transform?.type !== 'javascript') return undefined
// The author's own text may already be wrapped, so any balanced surround is fine — what
// the span check rejects is an expression with something else beside it.
const source = parenthesised(transform.expr)
let node: any
try {
node = parseExpressionAt(source, 0, { ecmaVersion: 'latest' })
} catch {
return undefined
}
// A comment beside the expression is not another expression: the author annotating their
// own provider must not cost them the model control. Dropped before the check so what
// remains is only what would change the value — and so a paren inside a comment is not
// counted as one of the wrapping pair.
const before = withoutComments(source.slice(0, node.start))
const after = withoutComments(source.slice(node.end))
if (!/^[\s(]*$/.test(before) || !/^[\s)]*$/.test(after)) return undefined
if ((before.match(/\(/g)?.length ?? 0) !== (after.match(/\)/g)?.length ?? 0)) return undefined
const whole = flowInputName(node)
if (whole) return { whole, fields: {}, fixed: {} }
if (node.type !== 'ObjectExpression') return undefined
const fields: AgentModelWiring['fields'] = {}
const fixed: AgentModelWiring['fixed'] = {}
for (const property of node.properties) {
const key = propertyKey(property)
// A spread or a computed key could supply any field, so nothing here is knowable.
if (!key) return undefined
if (!(PROVIDER_FIELDS as readonly string[]).includes(key)) continue
const name = flowInputName(property.value)
if (name) {
fields[key as ProviderField] = name
} else if (property.value?.type === 'Literal') {
fixed[key as ProviderField] = property.value.value
} else {
return undefined
}
}
return { fields, fixed }
}
/** How one agent supplies a provider field: from an input, as a literal, or not at all. */
type FieldSupply =
| { kind: 'wired'; name: string }
| { kind: 'fixed'; value: any }
| { kind: 'absent' }
/** Whether one agent supplies a field with nothing usable: no input, and no literal. */
function agentFieldEmpty(wiring: AgentModelWiring, field: ProviderField): boolean {
if (wiring.fields[field] !== undefined) return false
const value = wiring.fixed[field]
return value === undefined || value === ''
}
function fieldSupply(wiring: AgentModelWiring, field: ProviderField): FieldSupply {
const name = wiring.fields[field]
if (name !== undefined) return { kind: 'wired', name }
const value = wiring.fixed[field]
if (value !== undefined) return { kind: 'fixed', value }
return { kind: 'absent' }
}
/**
* The provider wiring the chat can act on, across every AI agent in the flow.
*
* With several agents a field is drivable when they agree on it: one flow input feeding
* it, or one literal fixing it. Where they disagree there is no single value to show or
* write, so that field is dropped and the others still work. A flow mixing whole-object
* and field-by-field wiring is ambiguous throughout and yields nothing.
*/
export function resolveAgentModelWiring(
modules: FlowModule[] | undefined
): AgentModelWiring | undefined {
const agents = chatFacingAgents(modules)
const parsed = agents.map((agent) =>
parseProviderTransform((agent.value as any).input_transforms?.['provider'])
)
if (parsed.length === 0) return undefined
// An agent whose provider cannot be read is an agent the composer cannot speak for:
// dropping it would let the rest declare a control that governs only some of them.
if (parsed.some((wiring) => wiring === undefined)) return undefined
const wirings = parsed as AgentModelWiring[]
// Whether any single agent has nothing to call, which stays true however the others
// are wired — the gap message is about that agent, not about their agreement.
const someAgentCannotRun = wirings.some(
(wiring) =>
!wiring.whole && (agentFieldEmpty(wiring, 'resource') || agentFieldEmpty(wiring, 'model'))
)
if (wirings.length === 1) return { ...wirings[0], someAgentCannotRun }
const wholes = new Set(wirings.map((w) => w.whole))
if (wholes.size === 1 && !wholes.has(undefined)) {
return { whole: [...wholes][0], fields: {}, fixed: {}, someAgentCannotRun }
}
if (wirings.some((w) => w.whole !== undefined)) return undefined
const fields: AgentModelWiring['fields'] = {}
const fixed: AgentModelWiring['fixed'] = {}
for (const field of PROVIDER_FIELDS) {
// Every agent has to supply the field the same way for the composer to speak for
// them all. One wired name among agents that otherwise fix it is not agreement:
// the control would move that one agent and leave the others where they are.
const supplies = new Set(wirings.map((w) => JSON.stringify(fieldSupply(w, field))))
// Disagreement leaves the field neither editable nor known: a control offered here
// would govern one agent while the rest ran on something else.
if (supplies.size > 1) continue
const supply: FieldSupply = JSON.parse([...supplies][0])
if (supply.kind === 'wired') fields[field] = supply.name
else if (supply.kind === 'fixed') fixed[field] = supply.value
}
return { fields, fixed, someAgentCannotRun }
}
/**
* Why the chat cannot run, when the agent's own provider is incomplete.
*
* A freshly added agent carries `{ kind: 'openai', model: '', resource: '' }`, so it names
* a provider kind while having nothing to call the run fails and the chat can do nothing
* about it, because no flow input feeds either field. Saying so beats a dead model button.
* A field the flow exposes is never a gap: the reader picks it in the composer.
*/
export function agentModelGap(wiring: AgentModelWiring | undefined): string | undefined {
// No agent, several of them, or an expression we cannot read: not ours to judge.
if (!wiring || wiring.whole) return undefined
// Asked of each agent rather than of what they agree on: agents that merely disagree
// about the model all have one, and the message would be false — while an agent with
// an empty model still cannot run, however well the others are configured.
return wiring.someAgentCannotRun
? 'Pick a provider and model on the AI agent step to use this chat.'
: undefined
}
/**
* The flow inputs the model button actually writes, so the modal does not ask for them a
* second time and, just as much, so it still asks for the ones the button cannot reach.
*
* `kind` is the one to watch: the button writes it only alongside a resource, since a
* provider is picked as a pair. A flow that wires `kind` to an input while fixing the
* resource leaves the button nothing to write it with, and hiding it would leave the run
* without a provider kind and no way to supply one.
*
* `reasoning_effort` needs no such condition: the button always draws a thinking control,
* whatever it can say about the model a ladder, a typed token, or why there is neither so
* a wired effort is always the button's. Asking for it in the modal as well would be a second
* editor for a field that already has one.
*/
export function agentModelWiringInputs(wiring: AgentModelWiring | undefined): string[] {
if (!wiring) return []
if (wiring.whole) return [wiring.whole]
const driven: ProviderField[] = ['resource', 'model', 'reasoning_effort']
if (wiring.fields.resource !== undefined) driven.push('kind')
return driven.map((field) => wiring.fields[field]).filter((name): name is string => !!name)
}
/**
* The flow inputs the composer edits, and therefore the ones the Configure-inputs modal must
* not ask for. The modal is whatever is left, so this is the single answer to "who edits
* this" rather than a list kept in step with the controls that render.
*
* Only the shape of the flow decides it. Whether a control can act *right now* no object
* storage to upload to, no rules for a provider's thinking levels is a state that control
* shows, not a reason to hand the input to an editor that would be no more able.
*/
export function composerOwnedInputs(
wiring: AgentModelWiring | undefined,
attachmentsTarget: { name: string } | undefined
): string[] {
return [...agentModelWiringInputs(wiring), ...(attachmentsTarget ? [attachmentsTarget.name] : [])]
}
/** Whether a schema entry holds an s3 file, as the flow input editor recognises one. */
function holdsS3File(property: Record<string, any> | undefined): boolean {
return (
property?.format === 'resource-s3_object' ||
property?.resourceType === 's3object' ||
property?.resourceType === 's3_object'
)
}
/**
* Where the composer's attachments go, or nothing when there is nowhere they fit.
*
* The agent reads `user_attachments` through a transform that may reshape what it takes, so
* the flow input feeding it is not necessarily an s3 field: an expression building the s3
* object itself promotes a plain string. Writing `{ s3, filename }` into that input fails at
* run time, so the paperclip appears only where the schema says the value belongs.
*/
export function attachmentsTargetFor(
input: AgentChatInput | undefined
): { name: string; multiple: boolean } | undefined {
if (!input) return undefined
if (holdsS3File(input.property)) return { name: input.name, multiple: false }
return input.property?.type === 'array' && holdsS3File(input.property.items)
? { name: input.name, multiple: true }
: undefined
}
export function isEmptyAgentChatInputValue(value: any): boolean {
if (value === undefined || value === null || value === '') return true
return Array.isArray(value) && value.length === 0
}
/**
* The flow inputs that an AI agent step reads directly into one of its chat-relevant
* fields. Several agents may resolve to the same flow input; it is one chip either way,
* and one that stays unambiguous however many agents read it.
*/
export function resolveAgentChatInputs(
modules: FlowModule[] | undefined,
additionalInputsSchema: Record<string, any> | undefined
): AgentChatInput[] {
const properties = additionalInputsSchema?.properties
if (!modules || !properties) return []
// One input per key, and only when every agent reading that key reads the same one:
// the composer writes a single flow input, so promoting one of two would feed one
// agent and leave the other with nothing — while hiding both from the modal, where
// the reader could at least have filled them in.
const namesPerKey = new Map<AgentChatInputKey, Set<string | undefined>>()
for (const module of chatFacingAgents(modules)) {
const transforms = (module.value as any).input_transforms ?? {}
for (const key of AGENT_CHAT_INPUT_KEYS) {
const transform = transforms[key]
// An agent that feeds the key from anything but a flow input — a literal, another
// step's result, or the empty placeholder every agent step carries for the keys of
// AI_AGENT_SCHEMA — is not reading an input, so it has no say in which one the
// composer drives.
if (transform?.type !== 'javascript' || !transform.expr.includes('flow_input')) continue
const name = flowInputRef(transform)
// A name the schema doesn't declare has no field to promote, and one expression
// reading two inputs names none: either way this agent reads something the
// composer cannot drive, which is what disagreement means here.
const usable = name && name in properties ? name : undefined
const names = namesPerKey.get(key) ?? new Set<string | undefined>()
names.add(usable)
namesPerKey.set(key, names)
}
}
const keyOf = new Map<string, AgentChatInputKey>()
for (const [key, names] of namesPerKey) {
if (names.size !== 1) continue
const name = [...names][0]
if (name === undefined || keyOf.has(name)) continue
keyOf.set(name, key)
}
return [...keyOf.entries()].map(([name, key]) => ({
name,
key,
property: properties[name]
}))
}
/**
* The run's inputs with a reasoning effort the chosen model cannot take removed.
*
* `effortPatch` reconciles the two when the reader switches model, which covers the only
* way the composer can put them out of step. It is not the only way they get out of step:
* a value stored from an earlier visit, a default the flow author wrote, or a model chosen
* before the effort was, all arrive already mismatched and the provider answers a
* mismatch with a 400 that names neither input ("adaptive thinking is not supported on this
* model"). Checked here, where the run's arguments are settled, so every route is covered.
*
* Only where the registry positively knows the model rejects it. An unknown family keeps
* whatever the author wrote: dropping a value on a guess would override their own default.
*/
export function withoutRejectedEffort(
wiring: AgentModelWiring | undefined,
values: Record<string, any>
): Record<string, any> {
if (!wiring) return values
// One input carrying the whole provider object: the three fields are read from it and
// the effort is cleared inside it, since that is where the agent will look for them.
if (wiring.whole) {
const provider = values[wiring.whole]
if (!provider || typeof provider !== 'object') return values
if (!rejectsEffort(provider.kind, provider.model, provider.reasoning_effort)) return values
return { ...values, [wiring.whole]: { ...provider, reasoning_effort: '' } }
}
const effortInput = wiring.fields.reasoning_effort
if (!effortInput) return values
const kindInput = wiring.fields.kind
const modelInput = wiring.fields.model
const rejected = rejectsEffort(
kindInput ? values[kindInput] : wiring.fixed.kind,
modelInput ? values[modelInput] : wiring.fixed.model,
values[effortInput]
)
return rejected ? { ...values, [effortInput]: '' } : values
}
/** Whether the registry positively says this model will not take this effort. */
function rejectsEffort(provider: unknown, model: unknown, effort: unknown): boolean {
if (typeof effort !== 'string' || effort === '') return false
if (typeof provider !== 'string' || typeof model !== 'string' || !provider || !model) {
return false
}
const capability = getReasoningCapability(provider as AIProvider, model)
return capability.known && !capability.supported
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { storedAttachmentName } from './attachmentNames'
/**
* The worker reads an attachment's media type from the object key and nothing else, so a
* key whose extension disagrees with the bytes reaches the provider mislabelled.
*/
describe('storedAttachmentName', () => {
// The composer re-encodes images, so the picked extension is the one that lies.
it('renames a re-encoded image to the type it was encoded as', () => {
expect(storedAttachmentName('photo.webp', 'image/png')).toBe('photo.png')
expect(storedAttachmentName('holiday.png', 'image/jpeg')).toBe('holiday.jpg')
})
it('gives an extension to a name that has none', () => {
expect(storedAttachmentName('attachment-1', 'image/png')).toBe('attachment-1.png')
expect(storedAttachmentName('contract', 'application/pdf')).toBe('contract.pdf')
})
it('replaces only the last extension', () => {
expect(storedAttachmentName('report.2026.final.webp', 'image/png')).toBe(
'report.2026.final.png'
)
})
// Blobs upload byte for byte, so a type we do not re-encode keeps the name as picked.
it('leaves a type it does not re-encode alone', () => {
expect(storedAttachmentName('notes.csv', 'text/csv')).toBe('notes.csv')
expect(storedAttachmentName('archive.zip', 'application/zip')).toBe('archive.zip')
})
})
@@ -0,0 +1,28 @@
/**
* What a chat attachment is stored under in object storage.
*
* The worker reads an attachment's media type from the object key and nothing else
* `mime_guess::from_path` in `windmill-ai/src/image_handler.rs`, falling back to
* `image/png` when it can read no extension and never from the content type stored
* beside it. So the key's extension is a claim about the bytes, and it has to be true.
*/
/** The extension each type the composer can send must be stored under. */
const EXTENSION_BY_MEDIA_TYPE: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'application/pdf': 'pdf'
}
/**
* The composer re-encodes every image to PNG or JPEG, so keeping the picked `photo.webp`
* would hand the provider PNG bytes labelled webp, which Anthropic rejects outright; and a
* PDF picked without an extension would be read back as the `image/png` fallback. A type
* not listed is left as picked blobs upload byte for byte, so their name is already true.
*/
export function storedAttachmentName(filename: string, mediaType: string): string {
const extension = EXTENSION_BY_MEDIA_TYPE[mediaType]
if (!extension) return filename
const stem = filename.replace(/\.[^./]+$/, '')
return `${stem || filename}.${extension}`
}
@@ -0,0 +1,702 @@
import type {
ChatSendRequestOptions,
ChatViewHost
} from '$lib/components/copilot/chat/chatViewHost'
import type { DisplayMessage } from '$lib/components/copilot/chat/shared'
import type { ChatMessage, FlowChatManager } from './FlowChatManager.svelte'
import { AIAutonomyMode } from '$lib/components/copilot/chat/AIChatManager.svelte'
import { isPlanCardTool } from '$lib/components/copilot/chat/planMode'
import { ToolCallStore, type ToolCallDetails } from './toolCallContext.svelte'
import { AttachedFilesStore } from '$lib/components/copilot/chat/files/attachedFiles.svelte'
import { SessionArtifactsStore } from '$lib/components/copilot/chat/artifacts/artifactsState.svelte'
import { dataUrlToBlob, type AttachedBlob } from '$lib/components/copilot/chat/blobUtils'
import { storedAttachmentName } from './attachmentNames'
import type { AttachedImage } from '$lib/components/copilot/chat/imageUtils'
import type { AttachedTextFile } from '$lib/components/copilot/chat/textFileUtils'
import { HelpersService, JobService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { randomUUID } from '$lib/utils/uuid'
import { turnFailed } from './turnTranscript'
import {
argsToMessageInputs,
attachmentsToMessageInputs,
MessageInputsStore,
type MessageInputs
} from './messageInputContext.svelte'
/** A row that needs no job behind it. */
const EMPTY_TOOL_CALL: ToolCallDetails = {}
/** What an AI agent step reads out of `user_attachments`. */
type S3Attachment = { s3: string; filename?: string }
/** The flow input the composer's attachments feed, and whether it holds a list. */
export type AttachmentsTarget = { name: string; multiple: boolean }
export type FlowChatViewHostOptions = {
additionalInputs?: () => Record<string, any> | undefined
attachmentsTarget?: () => AttachmentsTarget | undefined
workspace?: () => string | undefined
/** Why attaching is off despite the flow taking attachments no object storage, say.
* Undefined while the workspace has not answered: an explanation must not be a guess. */
attachmentsUnavailable?: () => string | undefined
/** Flow inputs the composer renders a control of its own for, so a message does not
* repeat them as context chips. */
inputsShownInComposer?: () => string[]
/** The flow's input schema, which says which of a run's arguments are secret. */
inputsSchema?: () => { properties?: Record<string, any> } | undefined
}
/**
* A tool's arguments and result reach us as strings: the provider's JSON for the call, and
* whatever the tool returned, which is often but not always JSON. Parsed where it parses so
* the card can fold it, kept verbatim where it does not.
*/
function parseToolPayload(raw: string | null | undefined): any {
if (raw === undefined || raw === null || raw === '') return undefined
try {
return JSON.parse(raw)
} catch {
return raw
}
}
function toDisplayMessage(
message: ChatMessage,
userIndex: number,
showStepNames: boolean,
inputs: MessageInputsStore,
toolCalls: ToolCallStore,
failed: boolean,
pendingInputs: MessageInputs | undefined
): DisplayMessage {
switch (message.message_type) {
case 'user': {
// What the turn ran with — the message row itself keeps only the text. Renders
// through the same lanes the copilot uses.
// What this tab sent wins wherever it has it, and the job answers for the rest:
// a row read back from the server on a later visit, a conversation reopened.
// The row is named with its job as soon as the run starts, so a send that kept
// nothing would switch lanes mid-run and fetch arguments it had just handed over.
const { images, contextElements } =
pendingInputs ??
(message.job_id ? inputs.get(message.job_id) : { images: [], contextElements: [] })
return {
role: 'user',
index: userIndex,
content: message.content,
// Drives the shared Retry button: the turn this message started failed.
error: failed || undefined,
images: images.length > 0 ? images : undefined,
contextElements: contextElements.length > 0 ? contextElements : undefined
}
}
case 'tool': {
const failed = message.success === false
// The same three details reach a row from one of two places, never both: the row
// itself for a tool with no job of its own, and for one still streaming; the
// tool's own job otherwise.
const fromRow: ToolCallDetails = {
toolName: message.tool_name,
parameters: parseToolPayload(message.tool_arguments),
result: parseToolPayload(message.tool_result)
}
// A row that carries its own call must not ask a job for it: an MCP tool runs
// inside the agent's job and names it, so the answer would be the agent's own
// arguments and result rather than the tool's. (It also saves a fetch per row
// when a conversation opens.)
const carriesItsOwnCall = fromRow.parameters !== undefined || fromRow.result !== undefined
const fromJob = carriesItsOwnCall ? EMPTY_TOOL_CALL : toolCalls.get(message.job_id)
const toolName = fromRow.toolName ?? fromJob.toolName
const parameters = fromRow.parameters ?? fromJob.parameters
const result = failed ? undefined : (fromRow.result ?? fromJob.result)
return {
role: 'tool',
tool_call_id: message.id,
content: message.content,
// Withheld for the copilot's two plan-mode names: `toolName` is what makes
// ToolExecutionDisplay render a plan card, and an agent tool that happened to
// share one would silently become one.
toolName: isPlanCardTool(toolName) ? undefined : toolName,
parameters,
result,
// The card's fold is opt-in (ToolExecutionDisplay reads showDetails), so it is
// offered only when there is a call or a result behind it to reveal.
showDetails: parameters !== undefined || result !== undefined,
error: failed ? message.content : undefined,
isLoading: message.loading
}
}
default:
return {
role: 'assistant',
content: message.content,
streaming: message.streaming,
reasoning: message.reasoning ?? undefined,
stepName: showStepNames ? (message.step_name ?? undefined) : undefined,
// The run behind the answer, so a reader can open what produced it. Absent on
// the temp message a stream builds, which has no job id until it settles.
jobId: message.job_id || undefined,
createdAt: message.created_at
}
}
}
/**
* Renders a flow run's conversation through the AI session chat components. The turn is a
* flow job rather than an LLM call this host makes, so what it can offer is whatever it can
* write back into the run's arguments each field below says for itself (see ChatViewHost).
*/
export class FlowChatViewHost implements ChatViewHost {
#manager: FlowChatManager
#options: FlowChatViewHostOptions
constructor(manager: FlowChatManager, options: FlowChatViewHostOptions = {}) {
this.#manager = manager
this.#options = options
// The queue belongs to the chat it was typed into, and that chat's run can finish
// while the reader is in another one — so the manager says which turn settled rather
// than the composer watching the open chat.
manager.onTurnSettled = (conversationId) => this.flushQueuedMessage(conversationId)
// The sidebar marks a chat with something waiting to go out; the queue lives here.
manager.hasQueuedMessage = (conversationId) => !!this.#queues[conversationId]?.text.trim()
}
// The step name says which AI agent step wrote a message, so it only tells the
// reader anything once a conversation holds more than one. Counted over the
// transcript rather than over the flow's current steps: a conversation outlives
// edits to the flow, so it can carry labels from a shape the flow no longer has.
#showStepNames = $derived.by(
() => new Set(this.#manager.messages.map((m) => m.step_name).filter(Boolean)).size > 1
)
// The inputs a turn was sent with, by the id of the row the composer added for it.
// The row is named with its job once the run starts, so the job could answer this too —
// but that is a fetch for arguments this tab just sent, and one that fails would blank
// the row for the rest of the session. Every send records, not only one carrying files:
// a row with no entry switches to the job lane the moment it is named, which renders it
// empty for the length of a round trip.
#sentInputs = $state<Record<string, MessageInputs>>({})
#messageInputs = new MessageInputsStore(
() => this.#options.workspace?.(),
() => this.#options.inputsSchema?.(),
() => new Set(this.#options.inputsShownInComposer?.() ?? [])
)
#toolCalls = new ToolCallStore(() => this.#options.workspace?.())
displayMessages = $derived.by(() => {
let userIndex = 0
const showStepNames = this.#showStepNames
const messages = this.#manager.messages
return messages.map((message, i) =>
toDisplayMessage(
message,
message.message_type === 'user' ? userIndex++ : -1,
showStepNames,
this.#messageInputs,
this.#toolCalls,
message.message_type === 'user' && turnFailed(messages, i),
this.#sentInputs[message.id]
)
)
})
/**
* Run the turn at this transcript position again, as it ran the first time: its own
* arguments, read back from its job, rather than whatever the composer holds now. The
* row shows the attachments and inputs it ran with, so a retry that quietly used today's
* settings would run something other than what the reader is looking at.
*
* The composer's own controls are the exception. What they edit the provider, the
* model, the thinking level is on screen beside the transcript rather than on the row,
* and a model that cannot answer is one of the likelier reasons a turn failed. Changing
* it and pressing Retry has to run the new one, or the retry fails the same way with no
* sign of why.
*
* A job that has been purged can no longer say what it ran with; the composer is then
* the only account left, and the row shows nothing either, so the two still agree.
*/
retryRequest = async (messageIndex: number) => {
const message = this.#manager.messages[messageIndex]
if (!message || message.message_type !== 'user' || this.loading || this.#readingReplayArgs)
return
// The turn belongs to the chat it was clicked in. Read before the fetch below, since
// the reader can select another conversation while it runs — the same reason the
// upload path pins it.
const conversationId = this.#manager.selectedConversationId
const workspace = this.#options.workspace?.()
let replayArgs: Record<string, any> | undefined
if (message.job_id && workspace) {
this.#readingReplayArgs = true
try {
const original = (await JobService.getJobArgs({
workspace,
id: message.job_id
})) as Record<string, any>
// `user_message` is the message itself, passed as the instructions below.
const { user_message: _sent, ...rest } = original ?? {}
const composerOwned = this.#options.inputsShownInComposer?.() ?? []
const current = this.#options.additionalInputs?.() ?? {}
for (const name of composerOwned) {
if (name in current) rest[name] = current[name]
else delete rest[name]
}
replayArgs = rest
} catch (error) {
// Only a job that is gone justifies running something else. Anything else —
// a network blip, a 500 — would substitute a different turn silently, which
// is the whole thing this guards against.
if ((error as { status?: number })?.status !== 404) {
sendUserToast('Could not read what that turn ran with. Try again.', true)
return
}
} finally {
this.#readingReplayArgs = false
}
}
// A send typed while the arguments were being read has already started a turn here,
// and `sendRequest` would refuse this one by handing its text to the composer as if
// the reader had typed it.
if (conversationId && this.#manager.isConversationBusy(conversationId)) {
sendUserToast('That chat started another turn. Retry once it finishes.', true)
return
}
void this.sendRequest({ instructions: message.content, conversationId }, replayArgs)
}
messages: readonly unknown[] = []
contextTokens = 0
operatingWorkspace = $derived.by(() => this.#options.workspace?.())
/** A retry reading back the turn it is about to replay. Read only by `retryRequest`, so a
* plain field: nothing renders from it. Deliberately not part of `loading`, which renders
* Stop there is no run yet to stop, and Stop would cancel the failed turn's own job and
* take the chat's queue back. A normal send in this window still gets ahead of the retry;
* the toast below is what says so. */
#readingReplayArgs = false
loading = $derived.by(
() =>
this.#manager.isLoading ||
this.#manager.isWaitingForResponse ||
this.#manager.isDispatchingTurn
)
// A flow run is followed from its job, so another tab holds nothing this one can't read.
runHeldElsewhere = false
loadingLabel = undefined
compacting = false
currentReply = ''
// The turn's thinking while it streams; it moves onto the answer once that starts.
currentReasoning = $derived.by(() => this.#manager.currentReasoning)
currentReasoningActive = $derived.by(() => this.#manager.isReasoningActive)
reasoningHiddenIndicatorLabel = undefined
#automaticScroll = $state(true)
get automaticScroll() {
return this.#automaticScroll
}
enableAutomaticScroll = () => {
this.#automaticScroll = true
}
disableAutomaticScroll = () => {
this.#automaticScroll = false
}
instructions = ''
// The flow run is the send: it is in flight for as long as the job is.
get sendInFlight() {
return this.#manager.isLoading
}
/**
* Chats whose in-flight send the reader stopped before it had a job to cancel. Read only
* by the send that set it aside, so a plain Set: nothing renders from this.
*/
#abortedSends = new Set<string>()
/**
* Give a spent draft back after a send that did not run. The composer took it before
* calling, so something has to.
*
* The composer belongs to whichever chat is on screen, so a turn that was not the open
* one's goes back to its own queue instead: a flush fires when *its* chat's run settles,
* which with turns running side by side can be while the reader is somewhere else, and
* dropping the text into the composer there would put it in the wrong conversation.
*/
#restoreToComposer(options: ChatSendRequestOptions) {
const conversationId = options.conversationId ?? this.#manager.selectedConversationId
// Back to the queue whenever that chat has one, open or not. The composer is the
// right home for a spent draft only while nothing is waiting behind it: anything
// queued was typed later, and that chat's next settled run sends its queue whole —
// so putting the older draft in the composer would run the two out of order.
const waiting = conversationId ? this.#queueOf(conversationId) : undefined
const queueHasMore =
!!waiting && (!!waiting.text.trim() || waiting.images.length > 0 || waiting.blobs.length > 0)
if (
conversationId &&
(queueHasMore || conversationId !== this.#manager.selectedConversationId)
) {
this.#enqueue(
conversationId,
options.instructions ?? '',
options.images ?? [],
options.blobs ?? [],
'front'
)
return
}
this.#aiChatInput?.prependText(
options.instructions ?? '',
options.images ?? [],
[],
options.blobs ?? []
)
}
/**
* `replayArgs` are a failed turn's own run arguments, read back from its job. They stand
* in for the composer's current inputs attachments included so a retry runs the turn
* that failed rather than a new one wearing its text.
*/
sendRequest = async (options: ChatSendRequestOptions = {}, replayArgs?: Record<string, any>) => {
const text = options.instructions?.trim() ?? ''
const args = { ...(replayArgs ?? this.#options.additionalInputs?.() ?? {}) }
const target = this.#options.attachmentsTarget?.()
// Where the paperclip is the input's editor, the stored settings have no say over it:
// a value saved while the modal owned it — before this workspace had object storage —
// would otherwise ride along on every later message. Attachments are set below or not
// at all. Where the modal still owns it, what the reader typed there stands. A replay
// is the exception: its attachments are the ones the failed turn ran with.
if (target && this.supportsMessageAttachments && !replayArgs) {
delete args[target.name]
}
let images = options.images ?? []
let blobs = options.blobs ?? []
// The composer refuses an attachment-only send (requiresMessageText), so this is
// the same rule at the other end: nothing runs without a message.
if (!text) return false
// And nothing runs into a conversation belonging to the other surface: the composer
// is shut for it, but a queued turn could have been written before it was opened.
// Reads the open conversation, which is the turn's own except for a flush into a
// background chat — reachable only if a surface ever both runs turns in parallel and
// lists both kinds of chat. No surface does today; one that did would need this to
// take `conversationId`.
const wrongKind = this.#manager.wrongKindReason
if (wrongKind) {
sendUserToast(wrongKind, true)
this.#restoreToComposer(options)
return false
}
// The per-turn cap again, at the place the truncation would happen: the composer
// enforces it as files are attached, but a queue built over several turns arrives
// here as one send, and a scalar input keeps `uploaded[0]` — uploading the rest
// would strand them in storage while the transcript claimed they went.
const cap = this.maxMessageAttachments
if (cap !== undefined && images.length + blobs.length > cap) {
const dropped = images.length + blobs.length - cap
images = images.slice(0, cap)
blobs = blobs.slice(0, Math.max(0, cap - images.length))
sendUserToast(
cap === 1
? `This chat sends one attachment per message; ${dropped} file(s) were not sent.`
: `This chat sends up to ${cap} attachments per message; ${dropped} file(s) were not sent.`,
true
)
}
const attachments = [...images, ...blobs]
// Settled before the upload below, and created here when there is none yet rather than
// left to `sendMessage` afterwards: everything that has to name this turn while it
// uploads — its busy state, a Stop, a message typed behind it — needs an id to name it
// by, and a send with none had no way to be marked, stopped or queued against.
// Read before the upload for the same reason: the reader can pick another chat while
// it runs, and the turn belongs to the one they sent it from.
const conversationId =
options.conversationId ??
this.#manager.selectedConversationId ??
(await this.#manager.createConversation({ clearMessages: false }))
// Whether the composer's own files are what this turn's attachment input holds. A
// replay's do not: its attachments are the ones its job already has.
let attachedLocally = false
if (target && attachments.length > 0) {
// Named, not "the open chat": the reader can switch while the upload runs, and
// where turns run in parallel the other chat has its own status to keep.
this.#manager.setDispatching(conversationId, true)
this.#abortedSends.delete(conversationId)
try {
const uploaded = await this.#uploadAttachments(attachments)
args[target.name] = target.multiple ? uploaded : uploaded[0]
} catch (e) {
sendUserToast(
`Could not upload the attachments: ${e instanceof Error ? e.message : String(e)}`,
true
)
// The composer already took the draft; without this the turn is simply lost. The
// id is the one captured before the upload, not whatever is open now: the reader
// can switch while it runs, and the draft belongs to the chat they sent it from.
this.#restoreToComposer({ ...options, conversationId })
return false
} finally {
this.#manager.setDispatching(conversationId, false)
}
// Stop pressed while the upload ran has no job to cancel yet, so it is honoured
// here — the run has not started, and starting it now would execute a message the
// reader already took back.
if (this.#abortedSends.delete(conversationId)) {
this.#restoreToComposer({ ...options, conversationId })
return false
}
attachedLocally = true
}
const sentInputs = this.#describeSentInputs(args, images, blobs, attachedLocally)
this.#manager.inputMessage = text
const started = await this.#manager.sendMessage(
Object.keys(args).length > 0 || replayArgs || this.#options.additionalInputs?.()
? args
: undefined,
(rowId) => {
if (!sentInputs) return
// Keep entries for rows some conversation still holds, not just the open one:
// a queued message flushes into the chat it was typed in, which by then need
// not be on screen, and pruning against the open chat would drop its chips.
const live = this.#manager.liveRowIds
const kept = Object.fromEntries(
Object.entries(this.#sentInputs).filter(([id]) => live.has(id))
)
this.#sentInputs = { ...kept, [rowId]: sentInputs }
},
conversationId
)
if (!started) {
// The upload succeeded and the run did not, so the composer's draft was spent on
// nothing. The uploaded objects stay where they are — a resend uploads its own,
// under its own prefix — but what the reader wrote comes back, to the chat it was
// written in rather than the one open by now.
this.#restoreToComposer({ ...options, conversationId })
return false
}
return true
}
/**
* The chips a row shows for the turn just sent, built from the arguments going out
* through the same split `argsToMessageInputs` makes of a job's arguments.
*
* Files the composer attached are the exception: the data URLs are still in hand and
* render with no network, where the args hold S3 references `argsToMessageInputs` would
* turn into `download_s3_file` links for bytes this tab already has. So that one input
* is described from what was attached and every other from the args. A replay attached
* nothing locally and is described entirely from the args, which are its own job's.
*/
#describeSentInputs(
args: Record<string, any>,
images: AttachedImage[],
blobs: AttachedBlob[],
attachedLocally: boolean
): MessageInputs | undefined {
const workspace = this.#options.workspace?.()
// Without one there is no way to build a file's link, so the job lane answers instead.
if (!workspace) return undefined
const target = this.#options.attachmentsTarget?.()
const shownElsewhere = new Set(this.#options.inputsShownInComposer?.() ?? [])
if (attachedLocally && target) shownElsewhere.add(target.name)
const fromArgs = argsToMessageInputs(
workspace,
args,
this.#options.inputsSchema?.(),
shownElsewhere
)
const attached = attachedLocally
? attachmentsToMessageInputs(images, blobs)
: { images: [], contextElements: [] }
return {
images: [...attached.images, ...fromArgs.images],
contextElements: [...attached.contextElements, ...fromArgs.contextElements]
}
}
/**
* Put each attachment in the workspace's object storage and hand back what the
* agent reads. The flow runs on a worker, so the bytes have to exist somewhere
* the worker can fetch unlike the copilot, which sends them from the browser.
*/
async #uploadAttachments(
attachments: { name?: string; dataUrl: string; mediaType?: string }[]
): Promise<S3Attachment[]> {
const workspace = this.#options.workspace?.()
if (!workspace) throw new Error('no workspace')
// One prefix per turn keeps a re-attached filename from overwriting the copy an
// earlier message still refers to.
const prefix = `windmill_chat_uploads/${randomUUID()}`
return Promise.all(
attachments.map(async (attachment, index) => {
const blob = dataUrlToBlob(attachment.dataUrl, attachment.mediaType)
const filename = storedAttachmentName(
attachment.name ?? `attachment-${index + 1}`,
blob.type
)
const { file_key } = await HelpersService.fileUpload({
workspace,
fileKey: `${prefix}/${filename}`,
contentType: blob.type,
requestBody: blob
})
return { s3: file_key, filename }
})
)
}
cancel = () => {
// Stop means stop: what was typed during the run goes back to the composer rather
// than waiting there to go out after some later turn settles.
this.dequeueMessage()
// A send still uploading has no job to cancel; it reads this once the upload lands.
const open = this.#manager.selectedConversationId
if (open) this.#abortedSends.add(open)
void this.#manager.cancelCurrentJob()
}
// Typed off the interface: a Svelte component's own type resolves differently
// across import specifiers, and the two would then not be assignable.
#aiChatInput: Parameters<ChatViewHost['setAiChatInput']>[0] = null
setAiChatInput: ChatViewHost['setAiChatInput'] = (aiChatInput) => {
this.#aiChatInput = aiChatInput
}
// A message typed while a chat is running waits here with its attachments and goes out
// whole when that chat's run finishes (see flushQueuedMessage). Held per conversation:
// turns run side by side, so a message typed into one must not ride out of another.
#queues = $state<
Record<string, { text: string; images: AttachedImage[]; blobs: AttachedBlob[] }>
>({})
queuedContext = undefined
queuedFiles: AttachedTextFile[] = []
#queueOf(conversationId: string | undefined) {
return (
(conversationId ? this.#queues[conversationId] : undefined) ?? {
text: '',
images: [] as AttachedImage[],
blobs: [] as AttachedBlob[]
}
)
}
get queuedMessage(): string {
return this.#queueOf(this.#manager.selectedConversationId).text
}
get queuedImages(): AttachedImage[] {
return this.#queueOf(this.#manager.selectedConversationId).images
}
get queuedBlobs(): AttachedBlob[] {
return this.#queueOf(this.#manager.selectedConversationId).blobs
}
queueMessage = (
text: string,
images: AttachedImage[] = [],
_context?: unknown,
_files?: unknown,
blobs: AttachedBlob[] = []
) => {
this.#enqueue(this.#manager.selectedConversationId, text, images, blobs)
}
/**
* Add to what a chat already has waiting, rather than replacing it: the reader can type
* again while a flush of the previous queue is still uploading, and that second message
* is in the queue by the time a failed flush hands the first one back.
*/
#enqueue(
conversationId: string | undefined,
text: string,
images: AttachedImage[],
blobs: AttachedBlob[],
/** Where this belongs in what is already waiting. A draft handed back by a send that
* did not run was typed before anything queued behind it, and goes back in front. */
at: 'end' | 'front' = 'end'
) {
if (!conversationId) return
const trimmed = text.trim()
if (!trimmed && images.length === 0 && blobs.length === 0) return
const queue = this.#queueOf(conversationId)
const joined = !trimmed
? queue.text
: !queue.text
? trimmed
: at === 'front'
? `${trimmed}\n${queue.text}`
: `${queue.text}\n${trimmed}`
this.#queues[conversationId] = {
text: joined,
images: at === 'front' ? [...images, ...queue.images] : [...queue.images, ...images],
blobs: at === 'front' ? [...blobs, ...queue.blobs] : [...queue.blobs, ...blobs]
}
}
/** Put the queued draft back in the composer, attachments included. */
dequeueMessage = () => {
const { text, images, blobs } = this.#takeQueue(this.#manager.selectedConversationId)
if (!text && images.length === 0 && blobs.length === 0) return
this.#aiChatInput?.prependText(text, images, [], blobs)
}
#takeQueue(conversationId: string | undefined) {
const taken = this.#queueOf(conversationId)
if (conversationId) delete this.#queues[conversationId]
return taken
}
/** Send whatever was typed during the run. Called once that chat's run settles. */
flushQueuedMessage = (conversationId = this.#manager.selectedConversationId) => {
// Same rule as sendRequest, read before the queue is drained: a turn with no message
// cannot run, and taking the queue for it would drop the attachments on the floor.
if (!this.#queueOf(conversationId).text.trim()) return
const { text, images, blobs } = this.#takeQueue(conversationId)
void this.sendRequest({ instructions: text, images, blobs, conversationId })
}
setComposerStaged = () => {}
clearComposerStaged = () => {}
attachmentBytesExcluding = () => 0
storedImages = () => undefined
restartGeneration = () => {}
handleUserQuestionAnswer = () => false
handleToolConfirmation = () => {}
// A flow's tools take their arguments from the model, never from a form the reader fills.
hasPendingRunForm = false
isRunFormPending = () => false
mode = undefined
isSessionChat = false
supportsModelSettings = false
supportsMessageEditing = false
// The turn is a flow run, and an AI agent step refuses one with neither a
// `user_message` nor manual memory (ai_executor.rs) — so files alone cannot be sent.
requiresMessageText = true
// Attachments go to object storage for the worker to read, so a linked folder —
// a live handle on the user's own disk — has no meaning here.
// The input's shape alone: whether this chat takes attachments at all is a fact about the
// flow, not about the workspace. Object storage decides whether it can right now, which is
// `attachmentsUnavailableReason` — a state on the control rather than a reason to move the
// input to the modal, where a file picker would be just as unable to upload.
get supportsMessageAttachments() {
return !!this.#options.attachmentsTarget?.()
}
get attachmentsUnavailableReason() {
return this.#options.attachmentsUnavailable?.()
}
supportsLinkedFolders = false
attachmentsAsBlobs = true
// A scalar flow input holds one file; sending more would upload every one and run with
// the first, leaving the rest orphaned in storage and the transcript claiming otherwise.
get maxMessageAttachments() {
return this.#options.attachmentsTarget?.()?.multiple === false ? 1 : undefined
}
// What a provider actually takes. Anthropic's document block accepts base64
// `application/pdf` and nothing else, so the wider set `is_document_mime`
// (windmill-ai/src/ai_types.rs) claims — csv, html, plain, docx, xlsx — is rejected with a
// 400 rather than read. Widen this only alongside a worker that inlines text as text.
attachmentAccept = 'image/*,application/pdf,.pdf'
tools = []
autonomyMode = AIAutonomyMode.DEFAULT
setAutonomyMode = () => {}
autoAcceptEditsActive = false
autoAcceptEditsAvailable = false
autoAcceptToolConfirmationsAvailable = false
planModeAvailable = false
attachedFiles = new AttachedFilesStore()
artifacts = new SessionArtifactsStore()
}
@@ -0,0 +1,380 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { HelpersService, JobService } from '$lib/gen'
import { FlowChatViewHost } from './flowChatViewHost.svelte'
vi.mock('$lib/gen', () => ({
HelpersService: { fileUpload: vi.fn() },
FlowConversationsService: { listConversationMessages: vi.fn(), deleteFlowConversation: vi.fn() },
JobService: { getJobArgs: vi.fn() },
FlowService: {}
}))
vi.mock('$lib/toast', () => ({ sendUserToast: vi.fn() }))
// The host imports it only for the AIAutonomyMode enum, and the real module pulls the
// editor in behind it.
vi.mock('$lib/components/copilot/chat/AIChatManager.svelte', () => ({
AIAutonomyMode: { DEFAULT: 'default' },
AIMode: { GLOBAL: 'global' }
}))
/** Just enough manager for the send protocol: the host only touches these. */
function stubManager(selectedConversationId: string | undefined) {
return {
selectedConversationId,
inputMessage: '',
messages: [] as unknown[],
isLoading: false,
isWaitingForResponse: false,
isDispatchingTurn: false,
currentReasoning: '',
isReasoningActive: false,
wrongKindReason: undefined,
liveRowIds: new Set<string>(),
dispatching: [] as { id: string; on: boolean }[],
setDispatching(id: string, on: boolean) {
this.dispatching.push({ id, on })
},
createConversation: vi.fn(async function (this: any) {
this.selectedConversationId = 'made-for-the-send'
return 'made-for-the-send'
}),
sendMessage: vi.fn(async () => true),
cancelCurrentJob: vi.fn(async () => {}),
conversationStatus: () => 'idle',
unreadCount: () => 0,
busy: false,
isConversationBusy(this: any) {
return this.busy
}
}
}
const host = (manager: ReturnType<typeof stubManager>) =>
new FlowChatViewHost(manager as any, {
workspace: () => 'ws',
attachmentsTarget: () => ({ name: 'files', multiple: true }),
additionalInputs: () => ({})
})
const anAttachment = {
name: 'shot.png',
dataUrl: 'data:image/png;base64,iVBORw0KGgo=',
mediaType: 'image/png'
}
/**
* The window between the composer taking a draft and the flow job existing. Everything that
* has to name the turn during it its busy state, a Stop, a message typed behind it needs
* a conversation to name it by, and a first message has none until one is made.
*/
describe('a send whose attachments are still uploading', () => {
beforeEach(() => {
vi.mocked(HelpersService.fileUpload).mockReset()
})
it('marks the conversation it was sent from, not whichever is open when the upload lands', async () => {
const manager = stubManager('a')
vi.mocked(HelpersService.fileUpload).mockImplementation(async () => {
manager.selectedConversationId = 'b'
return { file_key: 'k' } as any
})
await host(manager).sendRequest({ instructions: 'hi', blobs: [anAttachment] as any })
expect(manager.dispatching).toEqual([
{ id: 'a', on: true },
{ id: 'a', on: false }
])
expect(manager.sendMessage.mock.calls[0]?.[2]).toBe('a')
})
// Stop has no job to cancel yet, so it has to be honoured when the upload lands —
// otherwise the run starts and the reader watches a message they took back execute.
it('does not run after a Stop pressed while it uploaded', async () => {
const manager = stubManager('a')
const chatHost = host(manager)
vi.mocked(HelpersService.fileUpload).mockImplementation(async () => {
chatHost.cancel()
return { file_key: 'k' } as any
})
const started = await chatHost.sendRequest({
instructions: 'stop me',
blobs: [anAttachment] as any
})
expect(started).toBe(false)
expect(manager.sendMessage).not.toHaveBeenCalled()
})
// The first message of a chat has no conversation until one is made. Left to
// `sendMessage` afterwards, nothing in this window had an id to work with.
it('creates the conversation before uploading, so a first message can be stopped too', async () => {
const manager = stubManager(undefined)
const chatHost = host(manager)
vi.mocked(HelpersService.fileUpload).mockImplementation(async () => {
chatHost.cancel()
return { file_key: 'k' } as any
})
const started = await chatHost.sendRequest({
instructions: 'first message',
blobs: [anAttachment] as any
})
expect(manager.createConversation).toHaveBeenCalled()
expect(manager.dispatching[0]?.id).toBe('made-for-the-send')
expect(started).toBe(false)
expect(manager.sendMessage).not.toHaveBeenCalled()
})
})
/**
* Where a refused draft lands. The composer holds whichever chat is on screen, so it is the
* right home only when the turn was that chat's and nothing is queued behind it.
*/
describe('a send that did not run', () => {
beforeEach(() => {
vi.mocked(HelpersService.fileUpload).mockReset()
vi.mocked(HelpersService.fileUpload).mockResolvedValue({ file_key: 'k' } as any)
})
it('hands the draft back to the composer when it was the open chat with nothing waiting', async () => {
const manager = stubManager('a')
manager.sendMessage = vi.fn(async () => false)
const chatHost = host(manager)
const prepended: string[] = []
chatHost.setAiChatInput({ prependText: (text: string) => prepended.push(text) } as any)
await chatHost.sendRequest({ instructions: 'came back', blobs: [anAttachment] as any })
expect(prepended).toEqual(['came back'])
})
// The composer on screen belongs to another conversation by now, and anything queued was
// typed later — so the draft joins its own chat's queue, in front of what followed it.
it('queues it in front of its own chat when that chat is not the open one', async () => {
const manager = stubManager('a')
manager.sendMessage = vi.fn(async () => false)
const chatHost = host(manager)
const prepended: string[] = []
chatHost.setAiChatInput({ prependText: (text: string) => prepended.push(text) } as any)
chatHost.queueMessage('typed after')
manager.selectedConversationId = 'b'
await chatHost.sendRequest({
instructions: 'sent first',
blobs: [anAttachment] as any,
conversationId: 'a'
})
expect(prepended).toEqual([])
manager.selectedConversationId = 'a'
expect(chatHost.queuedMessage).toBe('sent first\ntyped after')
})
// The open chat with something already queued — reachable in the editor, where a turn
// running in another chat refuses this one. The composer would put the older draft
// beside the newer text, and the next settled run would send them inverted.
it('queues it in front even when it is the open chat, if something is waiting', async () => {
const manager = stubManager('a')
manager.sendMessage = vi.fn(async () => false)
const chatHost = host(manager)
const prepended: string[] = []
chatHost.setAiChatInput({ prependText: (text: string) => prepended.push(text) } as any)
chatHost.queueMessage('typed after')
await chatHost.sendRequest({ instructions: 'sent first', blobs: [anAttachment] as any })
expect(prepended).toEqual([])
expect(chatHost.queuedMessage).toBe('sent first\ntyped after')
})
})
/**
* Retry runs the turn that failed, not a new one wearing its text. The row shows the
* attachments and inputs it ran with read back from its job so a retry that quietly
* used the composer's current settings would run something other than what is on screen.
*/
describe('retrying a failed turn', () => {
const failedRow = {
id: 'row-1',
message_type: 'user',
content: 'summarise this',
job_id: 'job-that-failed'
}
beforeEach(() => {
vi.mocked(JobService.getJobArgs).mockReset()
})
it('runs with the arguments that turn ran with, not the ones on screen now', async () => {
const manager = stubManager('a')
manager.messages = [failedRow] as any
const chatHost = new FlowChatViewHost(manager as any, {
workspace: () => 'ws',
attachmentsTarget: () => ({ name: 'files', multiple: true }),
// What the composer holds now, which must not be what the retry runs with.
additionalInputs: () => ({ tone: 'breezy', files: [] })
})
vi.mocked(JobService.getJobArgs).mockResolvedValue({
user_message: 'summarise this',
tone: 'formal',
files: [{ s3: 'windmill_chat_uploads/abc/report.pdf' }]
} as any)
await chatHost.retryRequest(0)
expect(JobService.getJobArgs).toHaveBeenCalledWith({ workspace: 'ws', id: 'job-that-failed' })
// The original attachment and the original input; `user_message` goes as the text.
expect(manager.sendMessage.mock.calls[0]?.[0]).toEqual({
tone: 'formal',
files: [{ s3: 'windmill_chat_uploads/abc/report.pdf' }]
})
})
// A purged job can no longer say what it ran with, and refusing to retry would strand
// the reader on a failed turn. The composer is the only account left, and the row shows
// nothing either, so the two still agree.
it('falls back to the composer when the original run is gone', async () => {
const manager = stubManager('a')
manager.messages = [failedRow] as any
const chatHost = new FlowChatViewHost(manager as any, {
workspace: () => 'ws',
additionalInputs: () => ({ tone: 'breezy' })
})
vi.mocked(JobService.getJobArgs).mockRejectedValue({ status: 404 })
await chatHost.retryRequest(0)
expect(manager.sendMessage.mock.calls[0]?.[0]).toEqual({ tone: 'breezy' })
})
// Anything other than a purged job leaves the turn unknowable rather than gone, and
// running the text with today's settings would be the silent substitution this guards.
it('refuses rather than running something else when the read fails', async () => {
const manager = stubManager('a')
manager.messages = [failedRow] as any
const chatHost = new FlowChatViewHost(manager as any, {
workspace: () => 'ws',
additionalInputs: () => ({ tone: 'breezy' })
})
vi.mocked(JobService.getJobArgs).mockRejectedValue({ status: 500 })
await chatHost.retryRequest(0)
expect(manager.sendMessage).not.toHaveBeenCalled()
})
// A turn started in that chat while the arguments were being read: the retry must say so
// rather than let `sendRequest` refuse it by dropping its text into the composer.
it('says so when a send got ahead of it', async () => {
const manager = stubManager('a')
manager.messages = [failedRow] as any
const chatHost = new FlowChatViewHost(manager as any, {
workspace: () => 'ws',
additionalInputs: () => ({})
})
vi.mocked(JobService.getJobArgs).mockImplementation(async () => {
manager.busy = true
return { user_message: 'summarise this' } as any
})
await chatHost.retryRequest(0)
expect(manager.sendMessage).not.toHaveBeenCalled()
})
// The flag that refuses a second Retry is deliberately not part of `loading`, so nothing
// disables the button — this is what stops two fetches racing into two runs.
it('refuses a second Retry while the first is still reading', async () => {
const manager = stubManager('a')
manager.messages = [failedRow] as any
const chatHost = new FlowChatViewHost(manager as any, {
workspace: () => 'ws',
additionalInputs: () => ({})
})
let release: (v: any) => void = () => {}
vi.mocked(JobService.getJobArgs).mockReturnValue(
new Promise((resolve) => (release = resolve)) as any
)
const first = chatHost.retryRequest(0)
await chatHost.retryRequest(0)
release({ user_message: 'summarise this' })
await first
expect(JobService.getJobArgs).toHaveBeenCalledTimes(1)
expect(manager.sendMessage).toHaveBeenCalledTimes(1)
})
// The reader can pick another chat while the arguments are being read back, and the
// replay belongs to the one they clicked Retry in — the same window the upload path pins.
it('runs in the chat it was clicked in, not the one open when the read lands', async () => {
const manager = stubManager('a')
manager.messages = [failedRow] as any
const chatHost = new FlowChatViewHost(manager as any, {
workspace: () => 'ws',
additionalInputs: () => ({})
})
vi.mocked(JobService.getJobArgs).mockImplementation(async () => {
manager.selectedConversationId = 'b'
return { user_message: 'summarise this', tone: 'formal' } as any
})
await chatHost.retryRequest(0)
expect(manager.sendMessage.mock.calls[0]?.[2]).toBe('a')
})
// The provider and model are edited beside the transcript, not on the row — and a model
// that cannot answer is a likely reason the turn failed, so Retry must run the new one.
it('replays the turn but with the model the composer now holds', async () => {
const manager = stubManager('a')
manager.messages = [failedRow] as any
const chatHost = new FlowChatViewHost(manager as any, {
workspace: () => 'ws',
inputsShownInComposer: () => ['model'],
additionalInputs: () => ({ model: 'claude-sonnet-5', tone: 'breezy' })
})
vi.mocked(JobService.getJobArgs).mockResolvedValue({
user_message: 'summarise this',
model: 'a-model-that-failed',
tone: 'formal'
} as any)
await chatHost.retryRequest(0)
expect(manager.sendMessage.mock.calls[0]?.[0]).toEqual({
model: 'claude-sonnet-5',
tone: 'formal'
})
})
})
// The row is named with its job as soon as the run starts, so a send that kept nothing
// would swap to the job lane mid-run and render empty for the length of a round trip.
describe('the inputs a row shows for the turn just sent', () => {
it('come from what was sent, with no fetch, once the row has its job', async () => {
vi.mocked(JobService.getJobArgs).mockReset()
const manager = stubManager('a')
const row = { id: 'temp-1', message_type: 'user', content: 'bonjour', job_id: undefined }
manager.messages = [row]
manager.liveRowIds = new Set(['temp-1'])
manager.sendMessage = vi.fn(async (_args: any, nameRow: any) => {
nameRow?.('temp-1')
return true
}) as any
const chatHost = new FlowChatViewHost(manager as any, {
workspace: () => 'ws',
additionalInputs: () => ({ tone: 'formal' })
})
await chatHost.sendRequest({ instructions: 'bonjour' })
// What `#nameTurnJob` does once the flow job exists.
row.job_id = 'job-1' as any
expect(chatHost.displayMessages[0]?.contextElements).toEqual([
{ type: 'attached_file', title: 'tone', content: 'formal' }
])
expect(JobService.getJobArgs).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,81 @@
/**
* What a row can only learn from the job behind it, fetched once and kept while mounted.
*
* A conversation row stores the little it must; the rest a tool call's arguments and
* result, the attachments a message ran with already exists on that turn's job. Reading
* it back keeps one copy of the data instead of two, at the cost of a fetch per row, and
* of the same three rules wherever it is done: ask once, answer empty until it lands, and
* cache the empty answer when the job is gone so a purged run is not re-fetched forever.
*
* The rules live here; what to fetch and how to read it is the caller's.
*/
/**
* Fetches allowed out at once. A page of conversation rows asks for all of its jobs in the
* same render, and the answers only fill chips in below text that is already on screen.
*/
const MAX_CONCURRENT = 6
export class JobBackedStore<T> {
#workspace: () => string | undefined
#load: (workspace: string, jobId: string) => Promise<T>
#empty: T
#byJob = $state<Record<string, T>>({})
#inFlight = new Set<string>()
#waiting: string[] = []
#running = 0
constructor(
workspace: () => string | undefined,
empty: T,
load: (workspace: string, jobId: string) => Promise<T>
) {
this.#workspace = workspace
this.#empty = empty
this.#load = load
}
/** What the job holds, fetching on first ask. Empty until it lands. */
get(jobId: string | null | undefined): T {
if (!jobId) return this.#empty
const cached = this.#byJob[jobId]
if (cached) return cached
this.#enqueue(jobId)
return this.#empty
}
#enqueue(jobId: string) {
if (this.#inFlight.has(jobId)) return
this.#inFlight.add(jobId)
this.#waiting.push(jobId)
this.#pump()
}
#pump() {
while (this.#running < MAX_CONCURRENT && this.#waiting.length > 0) {
const jobId = this.#waiting.shift()!
this.#running++
void this.#fetch(jobId).finally(() => {
this.#running--
this.#pump()
})
}
}
async #fetch(jobId: string) {
const workspace = this.#workspace()
if (!workspace) {
// Neither cached nor in flight, so the row asks again once a workspace is known.
this.#inFlight.delete(jobId)
return
}
try {
this.#byJob = { ...this.#byJob, [jobId]: await this.#load(workspace, jobId) }
} catch {
// A purged job, or one this user cannot read: the row keeps what it stored.
this.#byJob = { ...this.#byJob, [jobId]: this.#empty }
} finally {
this.#inFlight.delete(jobId)
}
}
}
@@ -0,0 +1,142 @@
/**
* The inputs a chat message ran with, recovered from its job.
*
* A conversation message stores only its text, so what the user attached and which
* settings the turn used exist nowhere but the run's arguments. The user row carries
* the flow job id, whose args are the raw run arguments `user_message` plus every
* other flow input.
*
* Fetched lazily and kept in memory only: a purged job leaves a dangling id and the
* turn simply shows no inputs, which is honest the arguments are gone.
*/
import { JobService } from '$lib/gen'
import { JobBackedStore } from './jobBackedStore.svelte'
import { base } from '$lib/base'
import { redactFileArgs, redactSecretArgs } from '$lib/components/job_args'
import {
createAttachedFileContextElement,
type ContextElement
} from '$lib/components/copilot/chat/context'
import type { AttachedImage } from '$lib/components/copilot/chat/imageUtils'
type S3Ref = { s3: string; filename?: string; storage?: string }
const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.avif']
function isS3Ref(value: any): value is S3Ref {
return !!value && typeof value === 'object' && typeof value.s3 === 'string' && value.s3 !== ''
}
function s3Refs(value: any): S3Ref[] {
if (isS3Ref(value)) return [value]
if (Array.isArray(value)) return value.filter(isS3Ref)
return []
}
function displayName(ref: S3Ref): string {
return ref.filename ?? ref.s3.split('/').pop() ?? ref.s3
}
function looksLikeImage(ref: S3Ref): boolean {
const name = displayName(ref).toLowerCase()
return IMAGE_EXTENSIONS.some((ext) => name.endsWith(ext))
}
/** Same-origin, cookie-authed GET — usable directly as an <img src>, no blob fetch. */
function downloadUrl(workspace: string, ref: S3Ref): string {
const params = new URLSearchParams({ file_key: ref.s3 })
if (ref.storage) params.set('storage', ref.storage)
return `${base}/api/w/${workspace}/job_helpers/download_s3_file?${params.toString()}`
}
/** A scalar input, summarised for a chip. Objects are left to the file/JSON branches. */
function scalarSummary(value: any): string | undefined {
if (value === undefined || value === null || value === '') return undefined
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
return undefined
}
export type MessageInputs = { images: AttachedImage[]; contextElements: ContextElement[] }
const EMPTY: MessageInputs = { images: [], contextElements: [] }
/**
* Split a turn's run arguments into the lanes a user message renders: image
* thumbnails, and a chip per remaining input. `user_message` is the bubble itself.
*/
export function argsToMessageInputs(
workspace: string,
args: Record<string, any> | undefined,
schema: { properties?: Record<string, any> } | undefined,
shownElsewhere: ReadonlySet<string> = new Set()
): MessageInputs {
if (!args) return EMPTY
const images: AttachedImage[] = []
const contextElements: ContextElement[] = []
// A chip is text on screen, so it goes through the same redaction the run page and the
// copilot apply to a job's arguments: a password input must not be readable here, and a
// base64 file is unreadable anyway.
const shown = redactFileArgs(redactSecretArgs(args, schema), schema)
for (const [name, value] of Object.entries(shown)) {
if (name === 'user_message') continue
// An input the composer has its own control for — the model button's provider fields —
// is already on screen, and repeating it under every message is noise.
if (shownElsewhere.has(name)) continue
const files = s3Refs(value)
if (files.length > 0) {
for (const ref of files) {
if (looksLikeImage(ref)) {
images.push({
dataUrl: downloadUrl(workspace, ref),
mediaType: 'image/png',
name: displayName(ref)
})
} else {
contextElements.push(
createAttachedFileContextElement(displayName(ref), `Attached file · ${ref.s3}`)
)
}
}
continue
}
const summary = scalarSummary(value)
if (summary !== undefined) {
contextElements.push(createAttachedFileContextElement(name, summary))
}
}
return images.length > 0 || contextElements.length > 0 ? { images, contextElements } : EMPTY
}
/**
* The same lanes, built from what the composer just sent. The turn in flight has no
* job yet, so its row cannot read its inputs back from one; the data URLs are still
* in hand, so the thumbnails need no fetch.
*/
export function attachmentsToMessageInputs(
images: AttachedImage[],
blobs: { name: string }[]
): MessageInputs {
const contextElements = blobs.map((blob) =>
createAttachedFileContextElement(blob.name, `Attached file · ${blob.name}`)
)
return images.length > 0 || contextElements.length > 0 ? { images, contextElements } : EMPTY
}
/** The run arguments behind the transcript's user rows. One fetch per turn while mounted. */
export class MessageInputsStore extends JobBackedStore<MessageInputs> {
constructor(
workspace: () => string | undefined,
schema: () => { properties?: Record<string, any> } | undefined,
shownElsewhere: () => ReadonlySet<string>
) {
super(workspace, EMPTY, async (ws, jobId) =>
argsToMessageInputs(
ws,
(await JobService.getJobArgs({ workspace: ws, id: jobId })) as any,
schema(),
shownElsewhere()
)
)
}
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { argsToMessageInputs } from './messageInputContext.svelte'
const SCHEMA = {
properties: {
token: { type: 'string', password: true },
city: { type: 'string' },
report: { type: 'object', format: 'resource-s3_object' }
}
}
function summaries(elements: { title?: string; content?: string }[]) {
return elements.map((e) => e.content)
}
describe('argsToMessageInputs', () => {
it('never puts a secret input on screen', () => {
const { contextElements } = argsToMessageInputs(
'ws',
{ token: 'hunter2', city: 'Paris' },
SCHEMA
)
expect(summaries(contextElements as any)).toEqual(['<hidden>', 'Paris'])
})
// Only images get a thumbnail lane; every other s3 file is a chip naming its key.
it('splits attachments into thumbnails and file chips', () => {
const { images, contextElements } = argsToMessageInputs(
'ws',
{ report: [{ s3: 'a/shot.png' }, { s3: 'a/notes.pdf' }] },
SCHEMA
)
expect(images.map((i) => i.name)).toEqual(['shot.png'])
expect(images[0].dataUrl).toContain('file_key=a%2Fshot.png')
expect(contextElements).toHaveLength(1)
})
it('leaves out the message and anything the composer already shows', () => {
const { contextElements } = argsToMessageInputs(
'ws',
{ user_message: 'hi', city: 'Paris' },
SCHEMA,
new Set(['city'])
)
expect(contextElements).toEqual([])
})
})
@@ -0,0 +1,56 @@
/**
* What a tool call ran with and returned, recovered from its job.
*
* A conversation row stores only a summary sentence ("Used X tool"), but a Windmill tool
* runs as its own job, and that job already holds everything the card needs: `args` are
* the arguments the model supplied, `result` is what came back, and `script_path` names
* the tool. Reading them there keeps one copy of the data instead of two.
*
* Two kinds of tool are out of reach and keep the sentence: an MCP tool, and a
* provider-native one (web search). Neither gets a job of its own both run inside the
* agent's — so both rows name the agent's job, which retention needs to collect them. Its
* args are the agent's configuration and its result the agent's answer, so reading them
* would show confidently wrong details: an `aiagent` job is ignored here, and an MCP row
* stores its own call on the row instead (`tool_arguments` / `tool_result`).
*/
import { JobService } from '$lib/gen'
import { JobBackedStore } from './jobBackedStore.svelte'
export type ToolCallDetails = {
toolName?: string
parameters?: any
result?: any
}
const EMPTY: ToolCallDetails = {}
/** The tool's own name, which is the last segment of the job's path. */
function toolNameFromPath(path: string | undefined): string | undefined {
const name = path?.split('/').filter(Boolean).pop()
return name && name !== '' ? name : undefined
}
export function jobToToolCallDetails(job: any): ToolCallDetails {
// The agent's own job means this row is a tool that ran inside it — MCP or provider-native
// — and names that job so retention can collect it. Its args describe the agent and its
// result is the agent's answer, so reading either would show confidently wrong details.
if (!job || job.job_kind === 'aiagent') return EMPTY
const parameters =
job.args && typeof job.args === 'object' && Object.keys(job.args).length > 0
? job.args
: undefined
return {
toolName: toolNameFromPath(job.script_path),
parameters,
result: job.result
}
}
/** The tool jobs behind the transcript's tool rows. One fetch per row while mounted. */
export class ToolCallStore extends JobBackedStore<ToolCallDetails> {
constructor(workspace: () => string | undefined) {
super(workspace, EMPTY, async (ws, jobId) =>
jobToToolCallDetails(await JobService.getJob({ workspace: ws, id: jobId, noLogs: true }))
)
}
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { jobToToolCallDetails } from './toolCallContext.svelte'
/**
* Every conversation row names a job so retention can collect it, which means a tool that
* ran inside the agent MCP, or provider-native web search points at the agent's own
* job. Reading that job for the call would show the agent's configuration as the tool's
* arguments and the agent's answer as its result.
*/
describe('jobToToolCallDetails', () => {
it('reads nothing from the agent job a jobless tool points at', () => {
expect(
jobToToolCallDetails({
job_kind: 'aiagent',
script_path: 'f/chat/agent',
args: { user_message: 'hi', provider: { model: 'claude-sonnet-5' } },
result: 'the agent answer'
})
).toEqual({})
})
it('reads the call from a tool that has a job of its own', () => {
expect(
jobToToolCallDetails({
job_kind: 'script',
script_path: 'f/tools/search_docs',
args: { query: 'retention' },
result: ['a', 'b']
})
).toEqual({ toolName: 'search_docs', parameters: { query: 'retention' }, result: ['a', 'b'] })
})
it('treats an argument-less call as having none rather than an empty object', () => {
expect(
jobToToolCallDetails({ job_kind: 'script', script_path: 'f/t/now', args: {} }).parameters
).toBeUndefined()
})
})
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest'
import {
appendRevealed,
applyStreamEvent,
emptyTurnState,
turnFailed,
type TurnStep
} from './turnTranscript'
import type { ChatMessage } from './FlowChatManager.svelte'
import type { StreamEvent } from '$lib/components/chat/utils'
function start(): TurnStep {
return { rows: [], state: emptyTurnState('conv') }
}
let n = 0
const nextId = () => `row-${++n}`
function apply(step: TurnStep, events: StreamEvent[]): TurnStep {
return events.reduce((acc, event) => applyStreamEvent(acc, event, nextId), step)
}
describe('turn transcript', () => {
it('keeps thinking that arrives in the same chunk as the tool call it led to', () => {
// The regression: reasoning revealed and a tool call applied back to back, which is
// how one SSE chunk delivers "thought about it, then called the tool".
let step = appendRevealed(start(), 'reasoning', 'Checking the issue first.', nextId)
step = apply(step, [{ kind: 'tool_call', callId: 'c1', name: 'mcp_linear_get_issue' }])
expect(step.rows.map((r) => r.message_type)).toEqual(['assistant', 'tool'])
expect(step.rows[0].reasoning).toBe('Checking the issue first.')
expect(step.rows[0].streaming).toBe(false)
})
it('starts a new answer row after a tool call instead of extending the one before it', () => {
let step = appendRevealed(start(), 'answer', 'Let me look. ', nextId)
step = apply(step, [
{ kind: 'tool_call', callId: 'c1', name: 'get_time' },
{ kind: 'tool_result', callId: 'c1', name: 'get_time', result: '{}', success: true }
])
step = appendRevealed(step, 'answer', 'It is noon.', nextId)
expect(step.rows.map((r) => r.content)).toEqual([
'Let me look. ',
'Used get_time tool',
'It is noon.'
])
})
it('lands a tool call and its result on one row', () => {
const step = apply(start(), [
{ kind: 'tool_call', callId: 'c1', name: 'get_time' },
{ kind: 'tool_arguments', callId: 'c1', name: 'get_time', arguments: '{"tz":"UTC"}' },
{ kind: 'tool_execution', callId: 'c1', name: 'get_time' },
{ kind: 'tool_result', callId: 'c1', name: 'get_time', result: '{"now":1}', success: true }
])
const tools = step.rows.filter((r) => r.message_type === 'tool')
expect(tools).toHaveLength(1)
expect(tools[0].tool_arguments).toBe('{"tz":"UTC"}')
expect(tools[0].tool_result).toBe('{"now":1}')
expect(tools[0].loading).toBe(false)
})
it('marks a failed tool call on its row', () => {
const step = apply(start(), [
{ kind: 'tool_call', callId: 'c1', name: 'get_time' },
{ kind: 'tool_result', callId: 'c1', name: 'get_time', result: 'boom', success: false }
])
const tool = step.rows.find((r) => r.message_type === 'tool')
expect(tool?.success).toBe(false)
expect(tool?.content).toBe('Failed to use get_time tool')
})
it('grows one answer row as text is revealed', () => {
let step = appendRevealed(start(), 'answer', 'Hel', nextId)
step = appendRevealed(step, 'answer', 'lo', nextId)
expect(step.rows).toHaveLength(1)
expect(step.rows[0].content).toBe('Hello')
expect(step.rows[0].streaming).toBe(true)
})
})
/**
* Gates the Retry button, which on a flow re-runs the whole thing side effects included
* so it has to mean "this turn produced no answer", not "something inside it went wrong".
*/
describe('turnFailed', () => {
const row = (over: Partial<ChatMessage>): ChatMessage =>
({ id: 'x', message_type: 'assistant', content: '', ...over }) as ChatMessage
it('is false when a tool failed but the agent went on to answer', () => {
const messages = [
row({ message_type: 'user' }),
row({ message_type: 'tool', success: false }),
row({ message_type: 'assistant', success: true })
]
expect(turnFailed(messages, 0)).toBe(false)
})
it('is true when the turn ends on a failure', () => {
const messages = [
row({ message_type: 'user' }),
row({ message_type: 'tool', success: true }),
row({ message_type: 'assistant', success: false })
]
expect(turnFailed(messages, 0)).toBe(true)
})
it('reports nothing while the turn is still running', () => {
const messages = [
row({ message_type: 'user' }),
row({ message_type: 'tool', success: false }),
row({ message_type: 'assistant', streaming: true })
]
expect(turnFailed(messages, 0)).toBe(false)
})
// The window stops at the next user message, so a later turn's failure is not this one's.
it('does not read past the next user message', () => {
const messages = [
row({ message_type: 'user' }),
row({ message_type: 'assistant', success: true }),
row({ message_type: 'user' }),
row({ message_type: 'assistant', success: false })
]
expect(turnFailed(messages, 0)).toBe(false)
expect(turnFailed(messages, 2)).toBe(true)
})
it('is false for a turn that has produced nothing yet', () => {
expect(turnFailed([row({ message_type: 'user' })], 0)).toBe(false)
})
})

Some files were not shown because too many files have changed in this diff Show More