diff --git a/apps/emdash-desktop/drizzle/0016_acp_conversation_type.sql b/apps/emdash-desktop/drizzle/0016_acp_conversation_type.sql new file mode 100644 index 0000000000..a850f7365c --- /dev/null +++ b/apps/emdash-desktop/drizzle/0016_acp_conversation_type.sql @@ -0,0 +1 @@ +ALTER TABLE `conversations` ADD `type` text; diff --git a/apps/emdash-desktop/drizzle/0017_drop_messages_table.sql b/apps/emdash-desktop/drizzle/0017_drop_messages_table.sql new file mode 100644 index 0000000000..6136b9f5ea --- /dev/null +++ b/apps/emdash-desktop/drizzle/0017_drop_messages_table.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS `idx_messages_conversation_id`;--> statement-breakpoint +DROP INDEX IF EXISTS `idx_messages_timestamp`;--> statement-breakpoint +DROP TABLE IF EXISTS `messages`; diff --git a/apps/emdash-desktop/drizzle/meta/0016_snapshot.json b/apps/emdash-desktop/drizzle/meta/0016_snapshot.json new file mode 100644 index 0000000000..eaed75b3ab --- /dev/null +++ b/apps/emdash-desktop/drizzle/meta/0016_snapshot.json @@ -0,0 +1,1810 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0016_acp_conversation_type", + "prevId": "eb957caf-2d88-4c00-be64-e19f060c44c2", + "tables": { + "app_secrets": { + "name": "app_secrets", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_app_secrets_key": { + "name": "idx_app_secrets_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "app_settings": { + "name": "app_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_app_settings_key": { + "name": "idx_app_settings_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "automation_runs": { + "name": "automation_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deadline_at": { + "name": "deadline_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_created_at": { + "name": "task_created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "launched_at": { + "name": "launched_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger_kind": { + "name": "trigger_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_config_snapshot": { + "name": "trigger_config_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "conversation_config_snapshot": { + "name": "conversation_config_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "task_config_snapshot": { + "name": "task_config_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "generated_task_name": { + "name": "generated_task_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_runs_automation_started": { + "name": "idx_automation_runs_automation_started", + "columns": ["automation_id", "started_at"], + "isUnique": false + }, + "idx_automation_runs_automation_scheduled": { + "name": "idx_automation_runs_automation_scheduled", + "columns": ["automation_id", "scheduled_at"], + "isUnique": false + }, + "idx_automation_runs_automation_status": { + "name": "idx_automation_runs_automation_status", + "columns": ["automation_id", "status"], + "isUnique": false + }, + "idx_automation_runs_status": { + "name": "idx_automation_runs_status", + "columns": ["status"], + "isUnique": false + }, + "idx_automation_runs_status_scheduled": { + "name": "idx_automation_runs_status_scheduled", + "columns": ["status", "scheduled_at"], + "isUnique": false + } + }, + "foreignKeys": { + "automation_runs_automation_id_automations_id_fk": { + "name": "automation_runs_automation_id_automations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": ["automation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "automations": { + "name": "automations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger_config": { + "name": "trigger_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "conversation_config": { + "name": "conversation_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_config": { + "name": "task_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automations_project_id": { + "name": "idx_automations_project_id", + "columns": ["project_id"], + "isUnique": false + } + }, + "foreignKeys": { + "automations_project_id_projects_id_fk": { + "name": "automations_project_id_projects_id_fk", + "tableFrom": "automations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "conversations": { + "name": "conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_interacted_at": { + "name": "last_interacted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_initial_conversation": { + "name": "is_initial_conversation", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_status": { + "name": "agent_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_status_seen": { + "name": "agent_status_seen", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_conversations_task_id": { + "name": "idx_conversations_task_id", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_task_id_tasks_id_fk": { + "name": "conversations_task_id_tasks_id_fk", + "tableFrom": "conversations", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "editor_buffers": { + "name": "editor_buffers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_editor_buffers_workspace_file": { + "name": "idx_editor_buffers_workspace_file", + "columns": ["workspace_id", "file_path"], + "isUnique": false + } + }, + "foreignKeys": { + "editor_buffers_project_id_projects_id_fk": { + "name": "editor_buffers_project_id_projects_id_fk", + "tableFrom": "editor_buffers", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "kv": { + "name": "kv", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_kv_key": { + "name": "idx_kv_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender": { + "name": "sender", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_messages_conversation_id": { + "name": "idx_messages_conversation_id", + "columns": ["conversation_id"], + "isUnique": false + }, + "idx_messages_timestamp": { + "name": "idx_messages_timestamp", + "columns": ["timestamp"], + "isUnique": false + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "project_remotes": { + "name": "project_remotes", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_name": { + "name": "remote_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_remotes_project_id_projects_id_fk": { + "name": "project_remotes_project_id_projects_id_fk", + "tableFrom": "project_remotes", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_remotes_project_id_remote_name_pk": { + "columns": ["project_id", "remote_name"], + "name": "project_remotes_project_id_remote_name_pk" + } + }, + "uniqueConstraints": {} + }, + "project_settings": { + "name": "project_settings", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "base_project_settings_json": { + "name": "base_project_settings_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "shareable_project_settings_json": { + "name": "shareable_project_settings_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "legacy_config_migrated_at": { + "name": "legacy_config_migrated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "project_settings_project_id_projects_id_fk": { + "name": "project_settings_project_id_projects_id_fk", + "tableFrom": "project_settings", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_provider": { + "name": "workspace_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "base_ref": { + "name": "base_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_connection_id": { + "name": "ssh_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repository_workspace_id": { + "name": "repository_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_projects_path": { + "name": "idx_projects_path", + "columns": ["path"], + "isUnique": true + }, + "idx_projects_ssh_connection_id": { + "name": "idx_projects_ssh_connection_id", + "columns": ["ssh_connection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "projects_ssh_connection_id_ssh_connections_id_fk": { + "name": "projects_ssh_connection_id_ssh_connections_id_fk", + "tableFrom": "projects", + "tableTo": "ssh_connections", + "columnsFrom": ["ssh_connection_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "pull_request_assignees": { + "name": "pull_request_assignees", + "columns": { + "pull_request_url": { + "name": "pull_request_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_pra_pull_request_url": { + "name": "idx_pra_pull_request_url", + "columns": ["pull_request_url"], + "isUnique": false + }, + "idx_pra_user_id": { + "name": "idx_pra_user_id", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_assignees_pull_request_url_pull_requests_url_fk": { + "name": "pull_request_assignees_pull_request_url_pull_requests_url_fk", + "tableFrom": "pull_request_assignees", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_url"], + "columnsTo": ["url"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pull_request_assignees_user_id_pull_request_users_user_id_fk": { + "name": "pull_request_assignees_user_id_pull_request_users_user_id_fk", + "tableFrom": "pull_request_assignees", + "tableTo": "pull_request_users", + "columnsFrom": ["user_id"], + "columnsTo": ["user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pull_request_assignees_pull_request_url_user_id_pk": { + "columns": ["pull_request_url", "user_id"], + "name": "pull_request_assignees_pull_request_url_user_id_pk" + } + }, + "uniqueConstraints": {} + }, + "pull_request_checks": { + "name": "pull_request_checks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "pull_request_url": { + "name": "pull_request_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conclusion": { + "name": "conclusion", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "details_url": { + "name": "details_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workflow_name": { + "name": "workflow_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_name": { + "name": "app_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_logo_url": { + "name": "app_logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_prc_pull_request_url": { + "name": "idx_prc_pull_request_url", + "columns": ["pull_request_url"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_checks_pull_request_url_pull_requests_url_fk": { + "name": "pull_request_checks_pull_request_url_pull_requests_url_fk", + "tableFrom": "pull_request_checks", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_url"], + "columnsTo": ["url"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "pull_request_labels": { + "name": "pull_request_labels", + "columns": { + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_prl_name": { + "name": "idx_prl_name", + "columns": ["name"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_labels_pull_request_id_pull_requests_url_fk": { + "name": "pull_request_labels_pull_request_id_pull_requests_url_fk", + "tableFrom": "pull_request_labels", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_id"], + "columnsTo": ["url"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pull_request_labels_pull_request_id_name_pk": { + "columns": ["pull_request_id", "name"], + "name": "pull_request_labels_pull_request_id_name_pk" + } + }, + "uniqueConstraints": {} + }, + "pull_request_users": { + "name": "pull_request_users", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_updated_at": { + "name": "user_updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_created_at": { + "name": "user_created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "pull_requests": { + "name": "pull_requests", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'github'" + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_ref_name": { + "name": "base_ref_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_ref_oid": { + "name": "base_ref_oid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_repository_url": { + "name": "head_repository_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_ref_name": { + "name": "head_ref_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_ref_oid": { + "name": "head_ref_oid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "is_draft": { + "name": "is_draft", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_count": { + "name": "commit_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mergeable_status": { + "name": "mergeable_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merge_state_status": { + "name": "merge_state_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_request_created_at": { + "name": "pull_request_created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "pull_request_updated_at": { + "name": "pull_request_updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_pull_requests_url": { + "name": "idx_pull_requests_url", + "columns": ["url"], + "isUnique": true + }, + "idx_pull_requests_repository_url": { + "name": "idx_pull_requests_repository_url", + "columns": ["repository_url"], + "isUnique": false + }, + "idx_pull_requests_head_repository_url": { + "name": "idx_pull_requests_head_repository_url", + "columns": ["head_repository_url"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_requests_author_user_id_pull_request_users_user_id_fk": { + "name": "pull_requests_author_user_id_pull_request_users_user_id_fk", + "tableFrom": "pull_requests", + "tableTo": "pull_request_users", + "columnsFrom": ["author_user_id"], + "columnsTo": ["user_id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "ssh_connections": { + "name": "ssh_connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 22 + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "private_key_path": { + "name": "private_key_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_agent": { + "name": "use_agent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_connections_name": { + "name": "idx_ssh_connections_name", + "columns": ["name"], + "isUnique": true + }, + "idx_ssh_connections_host": { + "name": "idx_ssh_connections_host", + "columns": ["host"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_branch": { + "name": "source_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_branch": { + "name": "task_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_issue": { + "name": "linked_issue", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_interacted_at": { + "name": "last_interacted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "workspace_provider": { + "name": "workspace_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_provider_data": { + "name": "workspace_provider_data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_intent": { + "name": "workspace_intent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'task'" + }, + "automation_run_id": { + "name": "automation_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_tasks_project_id": { + "name": "idx_tasks_project_id", + "columns": ["project_id"], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "terminals": { + "name": "terminals", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh": { + "name": "ssh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shell_id": { + "name": "shell_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_terminals_task_id": { + "name": "idx_terminals_task_id", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "terminals_project_id_projects_id_fk": { + "name": "terminals_project_id_projects_id_fk", + "tableFrom": "terminals", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminals_task_id_tasks_id_fk": { + "name": "terminals_task_id_tasks_id_fk", + "tableFrom": "terminals", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_connection_id": { + "name": "ssh_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lines_added": { + "name": "lines_added", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lines_deleted": { + "name": "lines_deleted", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_workspaces_key": { + "name": "idx_workspaces_key", + "columns": ["key"], + "isUnique": true, + "where": "\"workspaces\".\"key\" is not null" + } + }, + "foreignKeys": { + "workspaces_ssh_connection_id_ssh_connections_id_fk": { + "name": "workspaces_ssh_connection_id_ssh_connections_id_fk", + "tableFrom": "workspaces", + "tableTo": "ssh_connections", + "columnsFrom": ["ssh_connection_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/emdash-desktop/drizzle/meta/0017_snapshot.json b/apps/emdash-desktop/drizzle/meta/0017_snapshot.json new file mode 100644 index 0000000000..516e3f92ce --- /dev/null +++ b/apps/emdash-desktop/drizzle/meta/0017_snapshot.json @@ -0,0 +1,1737 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "4c786772a47b8f15cd1d4f23af77e2a405e4", + "prevId": "0016", + "tables": { + "app_secrets": { + "name": "app_secrets", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_app_secrets_key": { + "name": "idx_app_secrets_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "app_settings": { + "name": "app_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_app_settings_key": { + "name": "idx_app_settings_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "automation_runs": { + "name": "automation_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deadline_at": { + "name": "deadline_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_created_at": { + "name": "task_created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "launched_at": { + "name": "launched_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger_kind": { + "name": "trigger_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_config_snapshot": { + "name": "trigger_config_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "conversation_config_snapshot": { + "name": "conversation_config_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "task_config_snapshot": { + "name": "task_config_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "generated_task_name": { + "name": "generated_task_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_runs_automation_started": { + "name": "idx_automation_runs_automation_started", + "columns": ["automation_id", "started_at"], + "isUnique": false + }, + "idx_automation_runs_automation_scheduled": { + "name": "idx_automation_runs_automation_scheduled", + "columns": ["automation_id", "scheduled_at"], + "isUnique": false + }, + "idx_automation_runs_automation_status": { + "name": "idx_automation_runs_automation_status", + "columns": ["automation_id", "status"], + "isUnique": false + }, + "idx_automation_runs_status": { + "name": "idx_automation_runs_status", + "columns": ["status"], + "isUnique": false + }, + "idx_automation_runs_status_scheduled": { + "name": "idx_automation_runs_status_scheduled", + "columns": ["status", "scheduled_at"], + "isUnique": false + } + }, + "foreignKeys": { + "automation_runs_automation_id_automations_id_fk": { + "name": "automation_runs_automation_id_automations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": ["automation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "automations": { + "name": "automations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger_config": { + "name": "trigger_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "conversation_config": { + "name": "conversation_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_config": { + "name": "task_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automations_project_id": { + "name": "idx_automations_project_id", + "columns": ["project_id"], + "isUnique": false + } + }, + "foreignKeys": { + "automations_project_id_projects_id_fk": { + "name": "automations_project_id_projects_id_fk", + "tableFrom": "automations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "conversations": { + "name": "conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_interacted_at": { + "name": "last_interacted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_initial_conversation": { + "name": "is_initial_conversation", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_status": { + "name": "agent_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_status_seen": { + "name": "agent_status_seen", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_conversations_task_id": { + "name": "idx_conversations_task_id", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_task_id_tasks_id_fk": { + "name": "conversations_task_id_tasks_id_fk", + "tableFrom": "conversations", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "editor_buffers": { + "name": "editor_buffers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_editor_buffers_workspace_file": { + "name": "idx_editor_buffers_workspace_file", + "columns": ["workspace_id", "file_path"], + "isUnique": false + } + }, + "foreignKeys": { + "editor_buffers_project_id_projects_id_fk": { + "name": "editor_buffers_project_id_projects_id_fk", + "tableFrom": "editor_buffers", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "kv": { + "name": "kv", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_kv_key": { + "name": "idx_kv_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "project_remotes": { + "name": "project_remotes", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_name": { + "name": "remote_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_remotes_project_id_projects_id_fk": { + "name": "project_remotes_project_id_projects_id_fk", + "tableFrom": "project_remotes", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_remotes_project_id_remote_name_pk": { + "columns": ["project_id", "remote_name"], + "name": "project_remotes_project_id_remote_name_pk" + } + }, + "uniqueConstraints": {} + }, + "project_settings": { + "name": "project_settings", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "base_project_settings_json": { + "name": "base_project_settings_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "shareable_project_settings_json": { + "name": "shareable_project_settings_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "legacy_config_migrated_at": { + "name": "legacy_config_migrated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "project_settings_project_id_projects_id_fk": { + "name": "project_settings_project_id_projects_id_fk", + "tableFrom": "project_settings", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_provider": { + "name": "workspace_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "base_ref": { + "name": "base_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_connection_id": { + "name": "ssh_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repository_workspace_id": { + "name": "repository_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_projects_path": { + "name": "idx_projects_path", + "columns": ["path"], + "isUnique": true + }, + "idx_projects_ssh_connection_id": { + "name": "idx_projects_ssh_connection_id", + "columns": ["ssh_connection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "projects_ssh_connection_id_ssh_connections_id_fk": { + "name": "projects_ssh_connection_id_ssh_connections_id_fk", + "tableFrom": "projects", + "tableTo": "ssh_connections", + "columnsFrom": ["ssh_connection_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "pull_request_assignees": { + "name": "pull_request_assignees", + "columns": { + "pull_request_url": { + "name": "pull_request_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_pra_pull_request_url": { + "name": "idx_pra_pull_request_url", + "columns": ["pull_request_url"], + "isUnique": false + }, + "idx_pra_user_id": { + "name": "idx_pra_user_id", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_assignees_pull_request_url_pull_requests_url_fk": { + "name": "pull_request_assignees_pull_request_url_pull_requests_url_fk", + "tableFrom": "pull_request_assignees", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_url"], + "columnsTo": ["url"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pull_request_assignees_user_id_pull_request_users_user_id_fk": { + "name": "pull_request_assignees_user_id_pull_request_users_user_id_fk", + "tableFrom": "pull_request_assignees", + "tableTo": "pull_request_users", + "columnsFrom": ["user_id"], + "columnsTo": ["user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pull_request_assignees_pull_request_url_user_id_pk": { + "columns": ["pull_request_url", "user_id"], + "name": "pull_request_assignees_pull_request_url_user_id_pk" + } + }, + "uniqueConstraints": {} + }, + "pull_request_checks": { + "name": "pull_request_checks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "pull_request_url": { + "name": "pull_request_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conclusion": { + "name": "conclusion", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "details_url": { + "name": "details_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workflow_name": { + "name": "workflow_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_name": { + "name": "app_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_logo_url": { + "name": "app_logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_prc_pull_request_url": { + "name": "idx_prc_pull_request_url", + "columns": ["pull_request_url"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_checks_pull_request_url_pull_requests_url_fk": { + "name": "pull_request_checks_pull_request_url_pull_requests_url_fk", + "tableFrom": "pull_request_checks", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_url"], + "columnsTo": ["url"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "pull_request_labels": { + "name": "pull_request_labels", + "columns": { + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_prl_name": { + "name": "idx_prl_name", + "columns": ["name"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_labels_pull_request_id_pull_requests_url_fk": { + "name": "pull_request_labels_pull_request_id_pull_requests_url_fk", + "tableFrom": "pull_request_labels", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_id"], + "columnsTo": ["url"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pull_request_labels_pull_request_id_name_pk": { + "columns": ["pull_request_id", "name"], + "name": "pull_request_labels_pull_request_id_name_pk" + } + }, + "uniqueConstraints": {} + }, + "pull_request_users": { + "name": "pull_request_users", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_updated_at": { + "name": "user_updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_created_at": { + "name": "user_created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "pull_requests": { + "name": "pull_requests", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'github'" + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_ref_name": { + "name": "base_ref_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_ref_oid": { + "name": "base_ref_oid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_repository_url": { + "name": "head_repository_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_ref_name": { + "name": "head_ref_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_ref_oid": { + "name": "head_ref_oid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "is_draft": { + "name": "is_draft", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_count": { + "name": "commit_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mergeable_status": { + "name": "mergeable_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merge_state_status": { + "name": "merge_state_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_request_created_at": { + "name": "pull_request_created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "pull_request_updated_at": { + "name": "pull_request_updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_pull_requests_url": { + "name": "idx_pull_requests_url", + "columns": ["url"], + "isUnique": true + }, + "idx_pull_requests_repository_url": { + "name": "idx_pull_requests_repository_url", + "columns": ["repository_url"], + "isUnique": false + }, + "idx_pull_requests_head_repository_url": { + "name": "idx_pull_requests_head_repository_url", + "columns": ["head_repository_url"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_requests_author_user_id_pull_request_users_user_id_fk": { + "name": "pull_requests_author_user_id_pull_request_users_user_id_fk", + "tableFrom": "pull_requests", + "tableTo": "pull_request_users", + "columnsFrom": ["author_user_id"], + "columnsTo": ["user_id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "ssh_connections": { + "name": "ssh_connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 22 + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "private_key_path": { + "name": "private_key_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_agent": { + "name": "use_agent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_connections_name": { + "name": "idx_ssh_connections_name", + "columns": ["name"], + "isUnique": true + }, + "idx_ssh_connections_host": { + "name": "idx_ssh_connections_host", + "columns": ["host"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_branch": { + "name": "source_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_branch": { + "name": "task_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_issue": { + "name": "linked_issue", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_interacted_at": { + "name": "last_interacted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "workspace_provider": { + "name": "workspace_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_provider_data": { + "name": "workspace_provider_data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_intent": { + "name": "workspace_intent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'task'" + }, + "automation_run_id": { + "name": "automation_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_tasks_project_id": { + "name": "idx_tasks_project_id", + "columns": ["project_id"], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "terminals": { + "name": "terminals", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh": { + "name": "ssh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shell_id": { + "name": "shell_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_terminals_task_id": { + "name": "idx_terminals_task_id", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "terminals_project_id_projects_id_fk": { + "name": "terminals_project_id_projects_id_fk", + "tableFrom": "terminals", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminals_task_id_tasks_id_fk": { + "name": "terminals_task_id_tasks_id_fk", + "tableFrom": "terminals", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_connection_id": { + "name": "ssh_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lines_added": { + "name": "lines_added", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lines_deleted": { + "name": "lines_deleted", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_workspaces_key": { + "name": "idx_workspaces_key", + "columns": ["key"], + "isUnique": true, + "where": "\"workspaces\".\"key\" is not null" + } + }, + "foreignKeys": { + "workspaces_ssh_connection_id_ssh_connections_id_fk": { + "name": "workspaces_ssh_connection_id_ssh_connections_id_fk", + "tableFrom": "workspaces", + "tableTo": "ssh_connections", + "columnsFrom": ["ssh_connection_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/emdash-desktop/drizzle/meta/_journal.json b/apps/emdash-desktop/drizzle/meta/_journal.json index 55f349e188..a25b7bc950 100644 --- a/apps/emdash-desktop/drizzle/meta/_journal.json +++ b/apps/emdash-desktop/drizzle/meta/_journal.json @@ -113,6 +113,20 @@ "when": 1780718295895, "tag": "0015_dapper_quasimodo", "breakpoints": true + }, + { + "idx": 16, + "version": "6", + "when": 1750008000000, + "tag": "0016_acp_conversation_type", + "breakpoints": true + }, + { + "idx": 17, + "version": "6", + "when": 1750028400000, + "tag": "0017_drop_messages_table", + "breakpoints": true } ] } diff --git a/apps/emdash-desktop/package.json b/apps/emdash-desktop/package.json index fb96cfd2de..3c04c0fd3c 100644 --- a/apps/emdash-desktop/package.json +++ b/apps/emdash-desktop/package.json @@ -19,7 +19,8 @@ "main": "./out/main/index.js", "scripts": { "d": "pnpm install && pnpm run dev", - "dev": "electron-vite dev", + "dev": "pnpm --filter @emdash/chat-ui build && LOG_LEVEL=debug VITE_LOG_LEVEL=debug electron-vite dev", + "dev:chat-ui": "pnpm --filter @emdash/chat-ui dev", "dev:debug": "VITE_LOG_LEVEL=debug electron-vite dev", "dev:main": "electron-vite dev --watch main", "dev:renderer": "electron-vite dev --watch renderer", @@ -51,6 +52,9 @@ "docs:build": "cd docs && pnpm run build" }, "dependencies": { + "@agentclientprotocol/claude-agent-acp": "^0.45.0", + "@agentclientprotocol/sdk": "^0.25.1", + "@emdash/chat-ui": "workspace:*", "@emdash/plugins": "workspace:*", "@emdash/shared": "workspace:*", "@emdash/ui": "workspace:*", @@ -74,6 +78,7 @@ "node-pty": "1.1.0", "pidusage": "^4.0.1", "smol-toml": "^1.6.0", + "solid-js": "^1.9.13", "ssh2": "^1.17.0", "zod": "^4.3.6" }, diff --git a/apps/emdash-desktop/src/main/core/acp/acp-session-manager.test.ts b/apps/emdash-desktop/src/main/core/acp/acp-session-manager.test.ts new file mode 100644 index 0000000000..6fa50c7d5a --- /dev/null +++ b/apps/emdash-desktop/src/main/core/acp/acp-session-manager.test.ts @@ -0,0 +1,608 @@ +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks (declared before imports) +// --------------------------------------------------------------------------- + +let capturedHandlerFactory: ((agent: unknown) => object) | null = null; + +// Per-test mutable connection reference (reset each test via property mutation). +const mockConnection: { + initialize: Mock; + newSession: Mock; + loadSession: Mock; + closeSession: Mock; + cancel: Mock; + prompt: Mock; + setSessionConfigOption: Mock; + closed: Promise; +} = { + initialize: vi.fn().mockResolvedValue({ protocolVersion: 1 }), + newSession: vi.fn().mockResolvedValue({ sessionId: 'session-1' }), + loadSession: vi.fn().mockResolvedValue({}), + closeSession: vi.fn().mockResolvedValue({}), + cancel: vi.fn().mockResolvedValue({}), + prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }), + setSessionConfigOption: vi.fn().mockResolvedValue({}), + closed: new Promise(() => {}), +}; + +vi.mock('@agentclientprotocol/sdk', () => ({ + // Use a regular function (not arrow) so it works as a constructor. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ClientSideConnection: function (handlerFactory: (a: unknown) => object): any { + capturedHandlerFactory = handlerFactory; + // Returning a non-primitive from a constructor uses that object as the instance. + return mockConnection; + }, + ndJsonStream: vi.fn().mockReturnValue({}), +})); + +vi.mock('@agentclientprotocol/claude-agent-acp/dist/utils.js', () => ({ + nodeToWebReadable: vi.fn().mockReturnValue({}), + nodeToWebWritable: vi.fn().mockReturnValue({}), +})); + +const mockChild = { + stdin: { write: vi.fn() }, + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + exitCode: null as number | null, + kill: vi.fn(), + on: vi.fn(), +}; + +vi.mock('node:child_process', () => ({ + spawn: vi.fn(() => mockChild), +})); + +vi.mock('@main/core/pty/pty-env', () => ({ + buildAgentEnv: vi.fn(() => ({})), +})); + +vi.mock('@main/core/agents/plugin-registry', () => ({ + getPlugin: vi.fn(() => ({ + capabilities: { acp: { kind: 'supported' } }, + behavior: { + acp: { + buildSpawn: () => ({ + command: '/usr/bin/node', + args: ['agent.js'], + env: {}, + }), + }, + }, + })), +})); + +vi.mock('@main/core/agent-hooks/agent-hook-service', () => ({ + agentHookService: { emitAgentEvent: vi.fn() }, +})); + +vi.mock('@main/core/agent-hooks/notification', () => ({ + isAppFocused: vi.fn(() => false), +})); + +vi.mock('@main/core/conversations/set-provider-session-id', () => ({ + setProviderSessionId: vi.fn().mockResolvedValue(true), +})); + +vi.mock('@main/core/conversations/updateConversationModel', () => ({ + updateConversationModel: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@main/lib/events', () => ({ + events: { emit: vi.fn() }, +})); + +vi.mock('@main/lib/logger', () => ({ + log: { + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + }, +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +import { spawn } from 'node:child_process'; +import type { Conversation } from '@shared/core/conversations/conversations'; +import { AcpSessionManager } from './acp-session-manager'; + +const makeConversation = (overrides: { conversationId?: string } = {}): Conversation => ({ + id: overrides.conversationId ?? 'conv-1', + providerId: 'claude', + projectId: 'proj-1', + taskId: 'task-1', + providerSessionId: undefined, + model: undefined, + type: 'acp', + title: 'Test Chat', + lastInteractedAt: null, + isInitialConversation: false, +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('AcpSessionManager – pooling', () => { + beforeEach(() => { + capturedHandlerFactory = null; + mockConnection.initialize = vi.fn().mockResolvedValue({ protocolVersion: 1 }); + mockConnection.newSession = vi.fn().mockResolvedValue({ sessionId: 'session-1' }); + mockConnection.loadSession = vi.fn().mockResolvedValue({}); + mockConnection.closeSession = vi.fn().mockResolvedValue({}); + mockConnection.cancel = vi.fn().mockResolvedValue({}); + mockConnection.prompt = vi.fn().mockResolvedValue({ stopReason: 'end_turn' }); + mockConnection.setSessionConfigOption = vi.fn().mockResolvedValue({}); + mockConnection.closed = new Promise(() => {}); + (spawn as Mock).mockClear(); + (spawn as Mock).mockReturnValue(mockChild); + mockChild.kill.mockReset(); + mockChild.on.mockReset(); + }); + + it('two conversations in the same workspace share one child process', async () => { + const manager = new AcpSessionManager(); + + const convA = makeConversation({ conversationId: 'conv-a' }); + const convB = makeConversation({ conversationId: 'conv-b' }); + + (mockConnection.newSession as Mock) + .mockResolvedValueOnce({ sessionId: 'session-a' }) + .mockResolvedValueOnce({ sessionId: 'session-b' }); + + await manager.start(convA, 'ws-1', '/tmp/workspace'); + await manager.start(convB, 'ws-1', '/tmp/workspace'); + + expect(spawn).toHaveBeenCalledTimes(1); + expect(mockConnection.newSession).toHaveBeenCalledTimes(2); + }); + + it('a third conversation in a different workspace spawns a second child', async () => { + const manager = new AcpSessionManager(); + + (mockConnection.newSession as Mock).mockResolvedValue({ sessionId: 'session-x' }); + + const convA = makeConversation({ conversationId: 'conv-a' }); + const convC = makeConversation({ conversationId: 'conv-c' }); + + await manager.start(convA, 'ws-1', '/ws1'); + await manager.start(convC, 'ws-2', '/ws2'); + + expect(spawn).toHaveBeenCalledTimes(2); + }); + + it('stop on one of two pooled conversations keeps the child alive', async () => { + const manager = new AcpSessionManager(); + + (mockConnection.newSession as Mock) + .mockResolvedValueOnce({ sessionId: 'session-a' }) + .mockResolvedValueOnce({ sessionId: 'session-b' }); + + await manager.start(makeConversation({ conversationId: 'conv-a' }), 'ws-1', '/tmp/workspace'); + await manager.start(makeConversation({ conversationId: 'conv-b' }), 'ws-1', '/tmp/workspace'); + + manager.stop('conv-a'); + + expect(mockChild.kill).not.toHaveBeenCalled(); + expect(manager.isRunning('conv-b')).toBe(true); + }); + + it('stop on the last conversation kills the child', async () => { + const manager = new AcpSessionManager(); + + (mockConnection.newSession as Mock) + .mockResolvedValueOnce({ sessionId: 'session-a' }) + .mockResolvedValueOnce({ sessionId: 'session-b' }); + + await manager.start(makeConversation({ conversationId: 'conv-a' }), 'ws-1', '/tmp/workspace'); + await manager.start(makeConversation({ conversationId: 'conv-b' }), 'ws-1', '/tmp/workspace'); + + manager.stop('conv-a'); + manager.stop('conv-b'); + + expect(mockChild.kill).toHaveBeenCalledWith('SIGTERM'); + }); +}); + +describe('AcpSessionManager – routing', () => { + beforeEach(() => { + capturedHandlerFactory = null; + mockConnection.initialize = vi.fn().mockResolvedValue({ protocolVersion: 1 }); + mockConnection.newSession = vi.fn().mockResolvedValue({ sessionId: 'session-1' }); + mockConnection.loadSession = vi.fn().mockResolvedValue({}); + mockConnection.closeSession = vi.fn().mockResolvedValue({}); + mockConnection.cancel = vi.fn().mockResolvedValue({}); + mockConnection.prompt = vi.fn().mockResolvedValue({ stopReason: 'end_turn' }); + mockConnection.setSessionConfigOption = vi.fn().mockResolvedValue({}); + mockConnection.closed = new Promise(() => {}); + (spawn as Mock).mockReturnValue(mockChild); + mockChild.kill.mockReset(); + mockChild.on.mockReset(); + }); + + it('sessionUpdate routes to the correct conversationId and emits turnId + seq', async () => { + const { events } = await import('@main/lib/events'); + const { acpSessionUpdateChannel } = await import('@shared/core/acp/acpEvents'); + + (events.emit as Mock).mockReset(); + + const manager = new AcpSessionManager(); + + (mockConnection.newSession as Mock) + .mockResolvedValueOnce({ sessionId: 'session-a' }) + .mockResolvedValueOnce({ sessionId: 'session-b' }); + + await manager.start(makeConversation({ conversationId: 'conv-a' }), 'ws-1', '/tmp/workspace'); + await manager.start(makeConversation({ conversationId: 'conv-b' }), 'ws-1', '/tmp/workspace'); + + expect(capturedHandlerFactory).not.toBeNull(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handler = capturedHandlerFactory!(null) as any; + + (events.emit as Mock).mockReset(); + + await handler.sessionUpdate({ + sessionId: 'session-b', + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hello' } }, + }); + + expect(events.emit).toHaveBeenCalledWith(acpSessionUpdateChannel, { + conversationId: 'conv-b', + turnId: expect.any(String), + update: expect.objectContaining({ sessionUpdate: 'agent_message_chunk' }), + seq: expect.any(Number), + }); + }); + + it('reconnecting with a providerSessionId calls loadSession and emits a replay turn', async () => { + const { events } = await import('@main/lib/events'); + const { acpSessionStateChannel, acpTurnCommittedChannel } = + await import('@shared/core/acp/acpEvents'); + + (events.emit as Mock).mockReset(); + + const manager = new AcpSessionManager(); + + const conv = { + ...makeConversation({ conversationId: 'conv-replay' }), + providerSessionId: 'existing-session-42', + }; + + await manager.start(conv, 'ws-1', '/tmp/workspace'); + + expect(mockConnection.loadSession).toHaveBeenCalledWith({ + sessionId: 'existing-session-42', + cwd: '/tmp/workspace', + mcpServers: [], + }); + expect(mockConnection.newSession).not.toHaveBeenCalled(); + + // Should emit acpSessionStateChannel with lifecycle 'replaying' (openTurn) + // then 'ready' (closeTurn + final ready). + const stateCalls = (events.emit as Mock).mock.calls.filter( + (args: unknown[]) => args[0] === acpSessionStateChannel + ); + const lifecycles = stateCalls.map( + (args: unknown[]) => (args[1] as { lifecycle: string }).lifecycle + ); + expect(lifecycles).toContain('replaying'); + expect(lifecycles).toContain('ready'); + + // The replay turn should be committed. + const committedCalls = (events.emit as Mock).mock.calls.filter( + (args: unknown[]) => args[0] === acpTurnCommittedChannel + ); + expect(committedCalls).toHaveLength(1); + expect(committedCalls[0][1]).toMatchObject({ + conversationId: 'conv-replay', + turn: expect.objectContaining({ source: 'replay', status: 'complete' }), + }); + }); + + it('falls back to newSession when loadSession throws; replay turn is committed as complete', async () => { + const { events } = await import('@main/lib/events'); + const { acpTurnCommittedChannel } = await import('@shared/core/acp/acpEvents'); + + (events.emit as Mock).mockReset(); + + const manager = new AcpSessionManager(); + + mockConnection.loadSession = vi.fn().mockRejectedValue(new Error('load failed')); + mockConnection.newSession = vi.fn().mockResolvedValue({ sessionId: 'fallback-session' }); + + const conv = { + ...makeConversation({ conversationId: 'conv-fallback' }), + providerSessionId: 'bad-session', + }; + + await manager.start(conv, 'ws-1', '/tmp/workspace'); + + expect(mockConnection.loadSession).toHaveBeenCalled(); + expect(mockConnection.newSession).toHaveBeenCalled(); + expect(manager.isRunning('conv-fallback')).toBe(true); + + // Replay turn is still committed even on failure. + const committedCalls = (events.emit as Mock).mock.calls.filter( + (args: unknown[]) => args[0] === acpTurnCommittedChannel + ); + expect(committedCalls).toHaveLength(1); + expect(committedCalls[0][1]).toMatchObject({ + conversationId: 'conv-fallback', + turn: expect.objectContaining({ source: 'replay', status: 'complete' }), + }); + }); + + it('loadSession replay: agent-assigned session ID is dynamically registered and routed', async () => { + const { events } = await import('@main/lib/events'); + const { acpSessionUpdateChannel } = await import('@shared/core/acp/acpEvents'); + const { setProviderSessionId } = + await import('@main/core/conversations/set-provider-session-id'); + + (events.emit as Mock).mockReset(); + (setProviderSessionId as Mock).mockClear(); + + const manager = new AcpSessionManager(); + + mockConnection.loadSession = vi.fn().mockImplementation(async () => { + expect(capturedHandlerFactory).not.toBeNull(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handler = capturedHandlerFactory!(null) as any; + await handler.sessionUpdate({ + sessionId: 'agent-assigned-session-99', + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }, + }); + return {}; + }); + mockConnection.newSession = vi.fn(); + + const conv = { + ...makeConversation({ conversationId: 'conv-dynamic' }), + providerSessionId: 'stored-session-id', + }; + + await manager.start(conv, 'ws-1', '/tmp/workspace'); + + const updateEmits = (events.emit as Mock).mock.calls.filter( + (args: unknown[]) => args[0] === acpSessionUpdateChannel + ); + expect(updateEmits).toHaveLength(1); + expect(updateEmits[0][1]).toMatchObject({ + conversationId: 'conv-dynamic', + turnId: expect.any(String), + update: expect.objectContaining({ sessionUpdate: 'agent_message_chunk' }), + }); + + expect(mockConnection.newSession).not.toHaveBeenCalled(); + expect(setProviderSessionId).toHaveBeenCalledWith('conv-dynamic', 'agent-assigned-session-99'); + expect(manager.isRunning('conv-dynamic')).toBe(true); + }); + + it('sessionUpdate for unknown sessionId does not emit an event', async () => { + const { events } = await import('@main/lib/events'); + const { acpSessionUpdateChannel } = await import('@shared/core/acp/acpEvents'); + + (events.emit as Mock).mockReset(); + + const manager = new AcpSessionManager(); + + (mockConnection.newSession as Mock).mockResolvedValue({ sessionId: 'session-a' }); + await manager.start(makeConversation({ conversationId: 'conv-a' }), 'ws-1', '/tmp/workspace'); + + expect(capturedHandlerFactory).not.toBeNull(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handler = capturedHandlerFactory!(null) as any; + + (events.emit as Mock).mockReset(); + + await handler.sessionUpdate({ + sessionId: 'unknown-session-xyz', + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'ghost' } }, + }); + + const acpUpdateEmits = (events.emit as Mock).mock.calls.filter( + (args: unknown[]) => args[0] === acpSessionUpdateChannel + ); + expect(acpUpdateEmits).toHaveLength(0); + }); +}); + +describe('AcpSessionManager – turn model', () => { + beforeEach(() => { + capturedHandlerFactory = null; + mockConnection.initialize = vi.fn().mockResolvedValue({ protocolVersion: 1 }); + mockConnection.newSession = vi.fn().mockResolvedValue({ sessionId: 'session-1' }); + mockConnection.loadSession = vi.fn().mockResolvedValue({}); + mockConnection.closeSession = vi.fn().mockResolvedValue({}); + mockConnection.cancel = vi.fn().mockResolvedValue({}); + mockConnection.prompt = vi.fn().mockResolvedValue({ stopReason: 'end_turn' }); + mockConnection.setSessionConfigOption = vi.fn().mockResolvedValue({}); + mockConnection.closed = new Promise(() => {}); + (spawn as Mock).mockReturnValue(mockChild); + mockChild.kill.mockReset(); + mockChild.on.mockReset(); + }); + + it('prompt() opens a live turn, commits it as complete on end_turn stopReason', async () => { + const { events } = await import('@main/lib/events'); + const { acpSessionStateChannel, acpTurnCommittedChannel } = + await import('@shared/core/acp/acpEvents'); + + const manager = new AcpSessionManager(); + (mockConnection.newSession as Mock).mockResolvedValue({ sessionId: 'session-1' }); + await manager.start(makeConversation({ conversationId: 'conv-1' }), 'ws-1', '/tmp'); + + (events.emit as Mock).mockReset(); + mockConnection.prompt = vi.fn().mockResolvedValue({ stopReason: 'end_turn' }); + + await manager.prompt('conv-1', 'hello'); + + const stateCalls = (events.emit as Mock).mock.calls.filter( + (args: unknown[]) => args[0] === acpSessionStateChannel + ); + const lifecycles = stateCalls.map((a: unknown[]) => (a[1] as { lifecycle: string }).lifecycle); + expect(lifecycles).toContain('working'); + expect(lifecycles).toContain('ready'); + + const committedCalls = (events.emit as Mock).mock.calls.filter( + (args: unknown[]) => args[0] === acpTurnCommittedChannel + ); + expect(committedCalls).toHaveLength(1); + expect(committedCalls[0][1]).toMatchObject({ + conversationId: 'conv-1', + turn: expect.objectContaining({ source: 'live', status: 'complete' }), + }); + }); + + it('prompt() commits turn as cancelled when stopReason is cancelled', async () => { + const { events } = await import('@main/lib/events'); + const { acpTurnCommittedChannel } = await import('@shared/core/acp/acpEvents'); + + const manager = new AcpSessionManager(); + (mockConnection.newSession as Mock).mockResolvedValue({ sessionId: 'session-1' }); + await manager.start(makeConversation({ conversationId: 'conv-1' }), 'ws-1', '/tmp'); + + (events.emit as Mock).mockReset(); + mockConnection.prompt = vi.fn().mockResolvedValue({ stopReason: 'cancelled' }); + + await manager.prompt('conv-1', 'hi'); + + const committedCalls = (events.emit as Mock).mock.calls.filter( + (args: unknown[]) => args[0] === acpTurnCommittedChannel + ); + expect(committedCalls).toHaveLength(1); + expect(committedCalls[0][1]).toMatchObject({ + turn: expect.objectContaining({ status: 'cancelled' }), + }); + }); + + it('prompt() commits turn as error when prompt() rejects', async () => { + const { events } = await import('@main/lib/events'); + const { acpTurnCommittedChannel } = await import('@shared/core/acp/acpEvents'); + + const manager = new AcpSessionManager(); + (mockConnection.newSession as Mock).mockResolvedValue({ sessionId: 'session-1' }); + await manager.start(makeConversation({ conversationId: 'conv-1' }), 'ws-1', '/tmp'); + + (events.emit as Mock).mockReset(); + mockConnection.prompt = vi.fn().mockRejectedValue(new Error('boom')); + + await manager.prompt('conv-1', 'fail'); + + const committedCalls = (events.emit as Mock).mock.calls.filter( + (args: unknown[]) => args[0] === acpTurnCommittedChannel + ); + expect(committedCalls).toHaveLength(1); + expect(committedCalls[0][1]).toMatchObject({ + turn: expect.objectContaining({ status: 'error' }), + }); + }); + + it('getChatHistory returns only committed turns and correct complete flag', async () => { + const manager = new AcpSessionManager(); + (mockConnection.newSession as Mock).mockResolvedValue({ sessionId: 'session-hist' }); + + // Before session started: empty, complete=true (nothing in-flight). + expect(manager.getChatHistory('no-such-conv')).toEqual({ turns: [], complete: true }); + + await manager.start(makeConversation({ conversationId: 'conv-hist' }), 'ws-1', '/tmp'); + + // After start (no replay, no turns yet): empty turns, complete=true (ready). + let history = manager.getChatHistory('conv-hist'); + expect(history.turns).toHaveLength(0); + expect(history.complete).toBe(true); + + // Run a prompt to create + commit a turn. + mockConnection.prompt = vi.fn().mockResolvedValue({ stopReason: 'end_turn' }); + await manager.prompt('conv-hist', 'hello'); + + history = manager.getChatHistory('conv-hist'); + expect(history.turns).toHaveLength(1); + expect(history.turns[0].status).toBe('complete'); + expect(history.complete).toBe(true); + }); + + it('getSessionState returns active turn mid-prompt', async () => { + const manager = new AcpSessionManager(); + (mockConnection.newSession as Mock).mockResolvedValue({ sessionId: 'session-state' }); + await manager.start(makeConversation({ conversationId: 'conv-state' }), 'ws-1', '/tmp'); + + let promptResolved = false; + let resolvePrompt!: (val: { stopReason: string }) => void; + mockConnection.prompt = vi.fn().mockReturnValue( + new Promise((res) => { + resolvePrompt = res; + }) + ); + + const promptPromise = manager.prompt('conv-state', 'hi'); + + // Mid-flight: getSessionState should show working + activeTurn. + const stateMidFlight = manager.getSessionState('conv-state'); + expect(stateMidFlight.lifecycle).toBe('working'); + expect(stateMidFlight.activeTurn).not.toBeNull(); + expect(stateMidFlight.activeTurn?.status).toBe('active'); + + resolvePrompt({ stopReason: 'end_turn' }); + await promptPromise; + promptResolved = true; + + // After prompt: no active turn. + const stateAfter = manager.getSessionState('conv-state'); + expect(stateAfter.lifecycle).toBe('ready'); + expect(stateAfter.activeTurn).toBeNull(); + expect(promptResolved).toBe(true); + }); + + it('sessionUpdate attributes to active turn with incrementing seq and emits turnId', async () => { + const { events } = await import('@main/lib/events'); + const { acpSessionUpdateChannel } = await import('@shared/core/acp/acpEvents'); + + (events.emit as Mock).mockReset(); + + const manager = new AcpSessionManager(); + (mockConnection.newSession as Mock).mockResolvedValue({ sessionId: 'session-buf' }); + await manager.start(makeConversation({ conversationId: 'conv-buf' }), 'ws-1', '/tmp/workspace'); + + expect(capturedHandlerFactory).not.toBeNull(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handler = capturedHandlerFactory!(null) as any; + + const update1 = { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'a' } }; + const update2 = { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'b' } }; + + await handler.sessionUpdate({ sessionId: 'session-buf', update: update1 }); + await handler.sessionUpdate({ sessionId: 'session-buf', update: update2 }); + + const updateEmits = (events.emit as Mock).mock.calls.filter( + (args: unknown[]) => args[0] === acpSessionUpdateChannel + ); + expect(updateEmits).toHaveLength(2); + + const [e1, e2] = updateEmits.map( + (args: unknown[]) => args[1] as { seq: number; turnId: string } + ); + expect(e1.seq).toBe(0); + expect(e2.seq).toBe(1); + expect(e1.turnId).toBe(e2.turnId); // Same turn. + expect(typeof e1.turnId).toBe('string'); + + // Updates should be stored in the active turn via getChatHistory after commit. + // (Turn is still active here since no prompt() resolved it.) + const sessionState = manager.getSessionState('conv-buf'); + expect(sessionState.activeTurn?.updates).toHaveLength(2); + expect(sessionState.activeTurn?.updates[0].seq).toBe(0); + expect(sessionState.activeTurn?.updates[1].seq).toBe(1); + }); + + it('getChatHistory returns empty complete result for unknown conversation', () => { + const manager = new AcpSessionManager(); + const history = manager.getChatHistory('no-such-conversation'); + expect(history).toEqual({ turns: [], complete: true }); + }); +}); diff --git a/apps/emdash-desktop/src/main/core/acp/acp-session-manager.ts b/apps/emdash-desktop/src/main/core/acp/acp-session-manager.ts new file mode 100644 index 0000000000..e6afa42cd8 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/acp/acp-session-manager.ts @@ -0,0 +1,759 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { + nodeToWebReadable, + nodeToWebWritable, +} from '@agentclientprotocol/claude-agent-acp/dist/utils.js'; +import { ClientSideConnection, ndJsonStream } from '@agentclientprotocol/sdk'; +import type { + Client, + InitializeRequest, + InitializeResponse, + LoadSessionRequest, + NewSessionRequest, + PromptResponse, + RequestPermissionRequest, + RequestPermissionResponse, + SessionNotification, + SetSessionConfigOptionRequest, + StopReason, + WriteTextFileRequest, + WriteTextFileResponse, + ReadTextFileRequest, + ReadTextFileResponse, +} from '@agentclientprotocol/sdk'; +import { agentHookService } from '@main/core/agent-hooks/agent-hook-service'; +import { isAppFocused } from '@main/core/agent-hooks/notification'; +import { getPlugin } from '@main/core/agents/plugin-registry'; +import { setProviderSessionId } from '@main/core/conversations/set-provider-session-id'; +import { updateConversationModel } from '@main/core/conversations/updateConversationModel'; +import { events } from '@main/lib/events'; +import { log } from '@main/lib/logger'; +import { + acpSessionClosedChannel, + acpSessionStateChannel, + acpSessionUpdateChannel, + acpTurnCommittedChannel, +} from '@shared/core/acp/acpEvents'; +import type { + AcpTurn, + ChatHistory, + SessionLifecycle, + SessionState, + TurnSource, + TurnStatus, +} from '@shared/core/acp/acpTurns'; +import type { AgentEvent } from '@shared/core/agents/agentEvents'; +import { agentSessionExitedChannel } from '@shared/core/agents/agentEvents'; +import type { Conversation } from '@shared/core/conversations/conversations'; +import { buildAgentEnv } from '../pty/pty-env'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Per-conversation state held inside a pool. */ +interface AcpConversation { + conversationId: string; + projectId: string; + taskId: string; + providerId: string; + /** ACP-native session id assigned by newSession / loadSession. */ + acpSessionId: string | null; + /** Model to apply after session is established (from persisted config). */ + pendingModel: string | null; + /** All turns for this conversation (committed + at most one active). */ + turns: AcpTurn[]; + /** Id of the currently-active turn, or null when idle. */ + activeTurnId: string | null; + /** Monotonic per-conversation sequence counter for cross-reload dedup. */ + nextSeq: number; + /** Coarse session lifecycle state. */ + lifecycle: SessionLifecycle; +} + +/** + * One child process + connection shared by all conversations in a + * (provider, workspace) pair. poolKey = `${providerId}:${workspaceId}`. + */ +interface AcpPool { + child: ChildProcess; + connection: ClientSideConnection; + providerId: string; + workspaceId: string; + path: string; + /** All conversations currently multiplexed on this connection. */ + conversations: Map; + /** Maps ACP sessionId → conversationId for routing incoming events. */ + sessionToConversation: Map; + /** Conversations currently awaiting loadSession so unknown-sessionId notifications can be routed. */ + loadingConversations: Set; + /** Promise that resolves once the pool's `initialize` call completes. */ + initialized: Promise | null; + stopped: boolean; +} + +// --------------------------------------------------------------------------- +// AcpSessionManager +// --------------------------------------------------------------------------- + +class AcpSessionManager { + /** Pools keyed by `${providerId}:${workspaceId}`. */ + private pools = new Map(); + + /** + * Secondary index: conversationId → { poolKey, acpSessionId } for fast + * lookup from the public controller API without scanning pools. + */ + private conversationIndex = new Map(); + + // ------------------------------------------------------------------------- + // Public API (keyed by conversationId — unchanged from callers' perspective) + // ------------------------------------------------------------------------- + + async start( + conversation: Conversation, + workspaceId: string, + path: string, + initialPrompt?: string + ): Promise { + const { id: conversationId, providerId } = conversation; + + if (this.conversationIndex.has(conversationId)) { + log.debug('AcpSessionManager: conversation already running', { conversationId }); + // Session already exists — emit current state so any late subscriber syncs. + const conv = this.resolveConv(conversationId); + if (conv) this.emitState(conv); + return; + } + + const plugin = getPlugin(providerId); + if (!plugin || plugin.capabilities.acp.kind !== 'supported' || !plugin.behavior?.acp) { + throw new Error(`AcpSessionManager: provider '${providerId}' does not support ACP transport`); + } + + const poolKey = `${providerId}:${workspaceId}`; + const pool = await this.getOrCreatePool(poolKey, providerId, workspaceId, path, plugin); + + // Wait for the pool's initialize handshake to finish before adding sessions. + if (pool.initialized) { + await pool.initialized; + } + + // Build the per-conversation record. + const conv: AcpConversation = { + conversationId, + projectId: conversation.projectId, + taskId: conversation.taskId, + providerId, + acpSessionId: conversation.providerSessionId ?? null, + pendingModel: conversation.model ?? null, + turns: [], + activeTurnId: null, + nextSeq: 0, + lifecycle: 'starting', + }; + + pool.conversations.set(conversationId, conv); + this.conversationIndex.set(conversationId, { poolKey, acpSessionId: conv.acpSessionId }); + + this.emitState(conv); + + try { + let acpSessionId: string; + + if (conv.acpSessionId) { + // Pre-register the stored session ID so notifications that use it are routed + // immediately. Agents may also assign a new session ID during replay — the + // buildClientHandler fallback will register that mapping dynamically via + // loadingConversations. + const originalSessionId = conv.acpSessionId; + pool.sessionToConversation.set(originalSessionId, conversationId); + pool.loadingConversations.add(conversationId); + this.openTurn(conv, 'replay'); + try { + await pool.connection.loadSession(this.buildLoadSessionRequest(path, originalSessionId)); + this.closeTurn(conv, 'complete'); + // conv.acpSessionId may have been updated to a new ID by the dynamic routing + // fallback in buildClientHandler. Use that updated value. + acpSessionId = conv.acpSessionId; + // Remove the old mapping when the agent adopted a different session ID. + if (acpSessionId !== originalSessionId) { + pool.sessionToConversation.delete(originalSessionId); + } + } catch { + log.warn('AcpSessionManager: loadSession failed, starting new session', { + conversationId, + }); + this.closeTurn(conv, 'complete'); + // Clean up both the original pre-registration and any dynamically registered ID. + pool.sessionToConversation.delete(originalSessionId); + if (conv.acpSessionId !== originalSessionId) { + pool.sessionToConversation.delete(conv.acpSessionId); + } + const newResp = await pool.connection.newSession(this.buildNewSessionRequest(path)); + acpSessionId = newResp.sessionId; + } finally { + pool.loadingConversations.delete(conversationId); + } + } else { + const newResp = await pool.connection.newSession(this.buildNewSessionRequest(path)); + acpSessionId = newResp.sessionId; + } + + conv.acpSessionId = acpSessionId; + pool.sessionToConversation.set(acpSessionId, conversationId); + this.conversationIndex.set(conversationId, { poolKey, acpSessionId }); + + // Persist the ACP session id so it survives pool teardown / app restart. + void setProviderSessionId(conversationId, acpSessionId).catch(() => { + // Non-fatal: worst case we lose resume on restart. + }); + + if (conv.pendingModel) { + await this.applyModelInternal(pool.connection, acpSessionId, conv.pendingModel, conv); + } + + // Session is ready — the agent can now accept prompts. + conv.lifecycle = 'ready'; + this.emitState(conv); + + // Do not emit 'start' here — session setup alone is not agent activity. + // start/stop are emitted in sendPromptInternal so status reflects actual + // prompt execution, trusting the hook events as the source of truth. + if (initialPrompt?.trim()) { + await this.sendPromptInternal(pool, conv, initialPrompt); + } + } catch (err) { + log.error('AcpSessionManager: failed to initialize ACP conversation', { + conversationId, + error: err instanceof Error ? err.message : String(err), + }); + // Clean up the half-initialized conversation entry. Use a reverse lookup to + // remove all session ID mappings for this conversation (covers both the original + // pre-registered ID and any dynamically registered ID from loadSession replay). + pool.conversations.delete(conversationId); + this.conversationIndex.delete(conversationId); + for (const [sid, cid] of pool.sessionToConversation) { + if (cid === conversationId) pool.sessionToConversation.delete(sid); + } + if (pool.conversations.size === 0) { + this.destroyPool(pool); + } + throw err; + } + } + + async prompt(conversationId: string, text: string): Promise { + const { pool, conv } = this.resolveConversation(conversationId); + await this.sendPromptInternal(pool, conv, text); + } + + async cancel(conversationId: string): Promise { + const entry = this.conversationIndex.get(conversationId); + if (!entry?.acpSessionId) return; + const pool = this.pools.get(entry.poolKey); + if (!pool) return; + try { + await pool.connection.cancel({ sessionId: entry.acpSessionId }); + // The in-flight prompt() resolves with stopReason:'cancelled', which + // will call closeTurn('cancelled') via sendPromptInternal. + } catch (err) { + log.warn('AcpSessionManager: cancel failed', { + conversationId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + stop(conversationId: string): void { + const entry = this.conversationIndex.get(conversationId); + if (!entry) return; + const pool = this.pools.get(entry.poolKey); + if (!pool) { + this.conversationIndex.delete(conversationId); + return; + } + const conv = pool.conversations.get(conversationId); + + // Best-effort closeSession so the agent can clean up state. + if (conv?.acpSessionId) { + void pool.connection.closeSession({ sessionId: conv.acpSessionId }).catch(() => { + // agent-side closeSession is optional; ignore failures. + }); + pool.sessionToConversation.delete(conv.acpSessionId); + } + + pool.conversations.delete(conversationId); + this.conversationIndex.delete(conversationId); + + // Tearing down this conversation's session is a session exit; mirror the PTY + // path so a stuck 'working' status (e.g. tab closed mid-turn) is reset to idle. + if (conv) { + events.emit(agentSessionExitedChannel, { + conversationId: conv.conversationId, + taskId: conv.taskId, + }); + } + + if (pool.conversations.size === 0) { + this.destroyPool(pool); + } + } + + async setModel(conversationId: string, model: string): Promise { + const entry = this.conversationIndex.get(conversationId); + const pool = entry ? this.pools.get(entry.poolKey) : undefined; + const conv = pool ? pool.conversations.get(conversationId) : undefined; + + if (pool && conv && entry?.acpSessionId) { + await this.applyModelInternal(pool.connection, entry.acpSessionId, model, conv); + } + + void updateConversationModel(conversationId, model).catch((err) => { + log.warn('AcpSessionManager: failed to persist model selection', { + conversationId, + model, + error: err instanceof Error ? err.message : String(err), + }); + }); + } + + isRunning(conversationId: string): boolean { + return this.conversationIndex.has(conversationId); + } + + /** + * Returns committed (non-active) turns for the conversation. + * `complete` is false while the session is still starting or a loadSession + * replay is in progress — the renderer can show a loading indicator. + * Returns an empty, complete result for unknown/stopped conversations. + */ + getChatHistory(conversationId: string): ChatHistory { + const conv = this.resolveConv(conversationId); + if (!conv) return { turns: [], complete: true }; + return { + turns: conv.turns.filter((t) => t.status !== 'active').map((t) => structuredClone(t)), + complete: conv.lifecycle !== 'starting' && conv.lifecycle !== 'replaying', + }; + } + + /** + * Returns the current session lifecycle + active turn (if any). + * The active turn snapshot includes all updates received so far, so a + * reloaded renderer can resume streaming from where it left off. + */ + getSessionState(conversationId: string): SessionState { + const conv = this.resolveConv(conversationId); + if (!conv) return { lifecycle: 'closed', activeTurn: null, model: null }; + const activeTurn = conv.activeTurnId + ? (conv.turns.find((t) => t.id === conv.activeTurnId) ?? null) + : null; + return { + lifecycle: conv.lifecycle, + activeTurn: activeTurn ? structuredClone(activeTurn) : null, + model: conv.pendingModel, + }; + } + + // ------------------------------------------------------------------------- + // Pool management + // ------------------------------------------------------------------------- + + private async getOrCreatePool( + poolKey: string, + providerId: string, + workspaceId: string, + path: string, + plugin: ReturnType & object + ): Promise { + const existing = this.pools.get(poolKey); + if (existing) return existing; + + const agentEnv = buildAgentEnv({ agentApiVars: true }); + const { command, args, env } = ( + plugin as { + behavior: { + acp: { + buildSpawn: (ctx: { cwd: string; env: NodeJS.ProcessEnv }) => { + command: string; + args: string[]; + env?: NodeJS.ProcessEnv; + }; + }; + }; + } + ).behavior.acp.buildSpawn({ cwd: path, env: agentEnv }); + + const child = spawn(command, args, { + cwd: path, + env: { ...agentEnv, ...env }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + if (child.stdin === null || child.stdout === null) { + throw new Error('AcpSessionManager: failed to spawn ACP child process (no stdio)'); + } + + if (child.stderr) { + child.stderr.on('data', (data: Buffer) => { + log.debug('AcpSessionManager: agent stderr', { + poolKey, + text: data.toString().trim(), + }); + }); + } + + const stream = ndJsonStream( + nodeToWebWritable(child.stdin), + nodeToWebReadable(child.stdout) as unknown as ReadableStream + ); + + const pool: AcpPool = { + child, + connection: null as unknown as ClientSideConnection, + providerId, + workspaceId, + path, + conversations: new Map(), + sessionToConversation: new Map(), + loadingConversations: new Set(), + initialized: null, + stopped: false, + }; + + const connection = new ClientSideConnection((_agent) => this.buildClientHandler(pool), stream); + pool.connection = connection; + + // Register pool before awaiting initialize so concurrent start() calls see it. + this.pools.set(poolKey, pool); + + void connection.closed.then(() => { + this.handlePoolClosed(pool); + }); + + child.on('error', (err) => { + log.error('AcpSessionManager: child process error', { poolKey, error: err.message }); + this.handlePoolClosed(pool); + }); + + const initReq: InitializeRequest = { + protocolVersion: 1, + clientInfo: { name: 'emdash', version: '1' }, + }; + + pool.initialized = connection + .initialize(initReq) + .then((_resp: InitializeResponse) => { + log.debug('AcpSessionManager: pool initialized', { poolKey }); + }) + .catch((err) => { + log.error('AcpSessionManager: pool initialize failed', { + poolKey, + error: err instanceof Error ? err.message : String(err), + }); + this.handlePoolClosed(pool); + throw err; + }); + + return pool; + } + + private destroyPool(pool: AcpPool): void { + if (pool.stopped) return; + pool.stopped = true; + this.pools.delete(`${pool.providerId}:${pool.workspaceId}`); + try { + pool.child.kill('SIGTERM'); + } catch { + // ignore + } + } + + private handlePoolClosed(pool: AcpPool): void { + if (pool.stopped) { + // Already torn down by destroyPool — only clean up the index. + for (const conv of pool.conversations.values()) { + this.conversationIndex.delete(conv.conversationId); + } + return; + } + pool.stopped = true; + this.pools.delete(`${pool.providerId}:${pool.workspaceId}`); + + const exitCode = pool.child.exitCode; + + // Fan out close events to every conversation that was live in this pool. + for (const conv of pool.conversations.values()) { + this.conversationIndex.delete(conv.conversationId); + + // Close any active turn so history stays consistent. + if (conv.activeTurnId) { + this.closeTurnInternal(conv, 'error'); + } + conv.lifecycle = 'closed'; + this.emitState(conv); + + events.emit(acpSessionClosedChannel, { + conversationId: conv.conversationId, + exitCode, + }); + events.emit(agentSessionExitedChannel, { + conversationId: conv.conversationId, + taskId: conv.taskId, + }); + } + + log.debug('AcpSessionManager: pool closed', { + poolKey: `${pool.providerId}:${pool.workspaceId}`, + exitCode, + conversationCount: pool.conversations.size, + }); + + pool.conversations.clear(); + pool.sessionToConversation.clear(); + } + + // ------------------------------------------------------------------------- + // Client handler (built once per pool, routes by sessionId) + // ------------------------------------------------------------------------- + + private buildClientHandler(pool: AcpPool): Client { + return { + sessionUpdate: async (params: SessionNotification): Promise => { + const update = params.update; + let conversationId = pool.sessionToConversation.get(params.sessionId); + if (!conversationId && pool.loadingConversations.size > 0) { + // The agent used a different session ID during loadSession replay than the + // one provided in the request. Route to the pending conversation and register + // the new mapping so subsequent notifications are routed without this fallback. + const pendingId = pool.loadingConversations.values().next().value; + if (pendingId) { + conversationId = pendingId; + pool.sessionToConversation.set(params.sessionId, pendingId); + const conv = pool.conversations.get(pendingId); + if (conv) conv.acpSessionId = params.sessionId; + } + } + if (!conversationId) { + log.warn('AcpSessionManager: sessionUpdate for unknown sessionId', { + sessionId: params.sessionId, + }); + return; + } + const conv = pool.conversations.get(conversationId); + if (!conv) return; + + // Lazy-open a turn if an update arrives with no active turn (defensive; + // in practice prompt() always opens one first for live turns). + if (!conv.activeTurnId) { + this.openTurn(conv, 'live'); + } + const turn = conv.turns.find((t) => t.id === conv.activeTurnId)!; + + const seq = conv.nextSeq++; + turn.updates.push({ seq, update }); + events.emit(acpSessionUpdateChannel, { conversationId, turnId: turn.id, update, seq }); + }, + + requestPermission: async ( + params: RequestPermissionRequest + ): Promise => { + const conversationId = pool.sessionToConversation.get(params.sessionId); + const allowOption = + params.options.find((o) => o.kind === 'allow_once' || o.kind === 'allow_always') ?? + params.options[0]; + + log.debug('AcpSessionManager: auto-approving permission request', { + conversationId, + toolCallId: params.toolCall?.toolCallId, + chosen: allowOption?.name, + }); + + return { + outcome: { + outcome: 'selected', + optionId: allowOption?.optionId ?? params.options[0]?.optionId ?? '', + }, + }; + }, + + readTextFile: async (params: ReadTextFileRequest): Promise => { + const { readFile } = await import('node:fs/promises'); + try { + const content = await readFile(params.path, 'utf8'); + return { content }; + } catch (err) { + throw new Error( + `readTextFile failed for ${params.path}: ${err instanceof Error ? err.message : String(err)}` + ); + } + }, + + writeTextFile: async (params: WriteTextFileRequest): Promise => { + const { writeFile, mkdir } = await import('node:fs/promises'); + const { dirname } = await import('node:path'); + await mkdir(dirname(params.path), { recursive: true }); + await writeFile(params.path, params.content, 'utf8'); + return {}; + }, + }; + } + + // ------------------------------------------------------------------------- + // Turn lifecycle helpers + // ------------------------------------------------------------------------- + + private openTurn(conv: AcpConversation, source: TurnSource): AcpTurn { + const turn: AcpTurn = { + id: `turn-${conv.conversationId}-${conv.turns.length}`, + status: 'active', + source, + startSeq: conv.nextSeq, + endSeq: null, + updates: [], + }; + conv.turns.push(turn); + conv.activeTurnId = turn.id; + conv.lifecycle = source === 'replay' ? 'replaying' : 'working'; + this.emitState(conv); + return turn; + } + + /** Mutate the active turn to committed status and emit events. */ + private closeTurnInternal(conv: AcpConversation, status: Exclude): void { + const turn = conv.turns.find((t) => t.id === conv.activeTurnId); + if (!turn) return; + turn.status = status; + turn.endSeq = conv.nextSeq; + conv.activeTurnId = null; + events.emit(acpTurnCommittedChannel, { + conversationId: conv.conversationId, + turn: structuredClone(turn), + }); + } + + /** + * Close the active turn, set lifecycle back to ready, and emit state. + * Callers that set a different lifecycle afterwards (e.g. 'closed') can + * mutate conv.lifecycle directly before calling emitState. + */ + private closeTurn(conv: AcpConversation, status: Exclude): void { + this.closeTurnInternal(conv, status); + // Only transition to 'ready' if we're not already 'closed'. + if (conv.lifecycle !== 'closed') { + conv.lifecycle = 'ready'; + } + this.emitState(conv); + } + + private emitState(conv: AcpConversation): void { + events.emit(acpSessionStateChannel, { + conversationId: conv.conversationId, + lifecycle: conv.lifecycle, + activeTurnId: conv.activeTurnId, + }); + } + + private statusFromStopReason(r: StopReason): Exclude { + return r === 'cancelled' ? 'cancelled' : 'complete'; + } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + /** Look up an AcpConversation without throwing — returns null if not found. */ + private resolveConv(conversationId: string): AcpConversation | null { + const entry = this.conversationIndex.get(conversationId); + if (!entry) return null; + const pool = this.pools.get(entry.poolKey); + return pool?.conversations.get(conversationId) ?? null; + } + + private resolveConversation(conversationId: string): { pool: AcpPool; conv: AcpConversation } { + const entry = this.conversationIndex.get(conversationId); + if (!entry?.acpSessionId) { + throw new Error(`AcpSessionManager: no active session for conversation ${conversationId}`); + } + const pool = this.pools.get(entry.poolKey); + const conv = pool?.conversations.get(conversationId); + if (!pool || !conv) { + throw new Error(`AcpSessionManager: pool not found for conversation ${conversationId}`); + } + return { pool, conv }; + } + + private async sendPromptInternal( + pool: AcpPool, + conv: AcpConversation, + text: string + ): Promise { + if (!conv.acpSessionId) return; + this.openTurn(conv, 'live'); + this.emitAgentEvent(conv, 'start'); + + try { + const res: PromptResponse = await pool.connection.prompt({ + sessionId: conv.acpSessionId, + prompt: [{ type: 'text', text }], + }); + this.closeTurn(conv, this.statusFromStopReason(res.stopReason)); + this.emitAgentEvent(conv, 'stop'); + } catch (err) { + log.error('AcpSessionManager: prompt error', { + conversationId: conv.conversationId, + error: err instanceof Error ? err.message : String(err), + }); + this.closeTurn(conv, 'error'); + this.emitAgentEvent(conv, 'error'); + } + } + + private async applyModelInternal( + connection: ClientSideConnection, + acpSessionId: string, + model: string, + conv: AcpConversation + ): Promise { + try { + const req: SetSessionConfigOptionRequest = { + sessionId: acpSessionId, + configId: 'model', + value: model, + }; + await connection.setSessionConfigOption(req); + conv.pendingModel = model; + } catch (err) { + log.warn('AcpSessionManager: failed to apply model selection', { + conversationId: conv.conversationId, + model, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + private buildNewSessionRequest(cwd: string): NewSessionRequest { + return { cwd, mcpServers: [] }; + } + + private buildLoadSessionRequest(cwd: string, sessionId: string): LoadSessionRequest { + return { sessionId, cwd, mcpServers: [] }; + } + + private emitAgentEvent(conv: AcpConversation, type: AgentEvent['type']): void { + const event: AgentEvent = { + type, + source: 'hook', + providerId: conv.providerId, + projectId: conv.projectId, + taskId: conv.taskId, + conversationId: conv.conversationId, + timestamp: Date.now(), + payload: {}, + }; + agentHookService.emitAgentEvent(event, isAppFocused()); + } +} + +export { AcpSessionManager }; +export const acpSessionManager = new AcpSessionManager(); diff --git a/apps/emdash-desktop/src/main/core/acp/controller.ts b/apps/emdash-desktop/src/main/core/acp/controller.ts new file mode 100644 index 0000000000..9e10573c51 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/acp/controller.ts @@ -0,0 +1,31 @@ +import type { ChatHistory, SessionState } from '@shared/core/acp/acpTurns'; +import { createRPCController } from '@shared/lib/ipc/rpc'; +import { acpSessionManager } from './acp-session-manager'; + +async function prompt(conversationId: string, text: string): Promise { + await acpSessionManager.prompt(conversationId, text); +} + +async function cancel(conversationId: string): Promise { + await acpSessionManager.cancel(conversationId); +} + +async function setModel(conversationId: string, model: string): Promise { + await acpSessionManager.setModel(conversationId, model); +} + +function getChatHistory(conversationId: string): Promise { + return Promise.resolve(acpSessionManager.getChatHistory(conversationId)); +} + +function getSessionState(conversationId: string): Promise { + return Promise.resolve(acpSessionManager.getSessionState(conversationId)); +} + +export const acpController = createRPCController({ + prompt, + cancel, + setModel, + getChatHistory, + getSessionState, +}); diff --git a/apps/emdash-desktop/src/main/core/conversations/createConversation.ts b/apps/emdash-desktop/src/main/core/conversations/createConversation.ts index b65b9812d8..33c4b9af96 100644 --- a/apps/emdash-desktop/src/main/core/conversations/createConversation.ts +++ b/apps/emdash-desktop/src/main/core/conversations/createConversation.ts @@ -61,43 +61,50 @@ export async function createConversation( title: params.title, provider: params.provider, config, - sessionId: id, + // PTY conversations use sessionId as an idempotency guard (set to conversationId). + // ACP conversations write the agent-assigned session ID here after the first newSession call. + sessionId: params.type === 'acp' ? null : id, isInitialConversation: params.isInitialConversation ?? false, createdAt: sql`CURRENT_TIMESTAMP`, updatedAt: sql`CURRENT_TIMESTAMP`, lastInteractedAt: new Date().toISOString(), + type: params.type ?? 'pty', }) .returning(); - const task = resolveTask(params.projectId, params.taskId); - if (!task) { - throw new Error('Task not found'); - } - const conversation = mapConversationRowToConversation(row); - await withCompensation({ - action: () => - task.conversations.startSession( - conversation, - params.initialSize, - false, - params.initialPrompt - ), - compensate: async () => { - await database.delete(conversations).where(eq(conversations.id, row.id)).execute(); - }, - onCompensationError: (error) => { - log.error('createConversation: failed to roll back conversation row after spawn failure', { - conversationId: id, - error: error instanceof Error ? error.message : String(error), - }); - }, - }); + // ACP conversations are started lazily via hydrateConversation when the tab opens. + if (conversation.type !== 'acp') { + const task = resolveTask(params.projectId, params.taskId); + if (!task) { + throw new Error('Task not found'); + } + + await withCompensation({ + action: () => + task.conversations.startSession( + conversation, + params.initialSize, + false, + params.initialPrompt + ), + compensate: async () => { + await database.delete(conversations).where(eq(conversations.id, row.id)).execute(); + }, + onCompensationError: (error) => { + log.error('createConversation: failed to roll back conversation row after spawn failure', { + conversationId: id, + error: error instanceof Error ? error.message : String(error), + }); + }, + }); + + emitInitialPromptStarted(conversation, params); + } conversationEvents._emit('conversation:created', conversation); events.emit(conversationCreatedChannel, { conversation }); - emitInitialPromptStarted(conversation, params); telemetryService.capture('conversation_created', { provider: params.provider, is_first_in_task: existingConversation === undefined, diff --git a/apps/emdash-desktop/src/main/core/conversations/dehydrateConversation.ts b/apps/emdash-desktop/src/main/core/conversations/dehydrateConversation.ts index 07699db5e8..9d2fece9a8 100644 --- a/apps/emdash-desktop/src/main/core/conversations/dehydrateConversation.ts +++ b/apps/emdash-desktop/src/main/core/conversations/dehydrateConversation.ts @@ -1,10 +1,35 @@ +import { and, eq } from 'drizzle-orm'; +import { acpSessionManager } from '@main/core/acp/acp-session-manager'; +import { db } from '@main/db/client'; +import { conversations } from '@main/db/schema'; import { resolveTask } from '../projects/utils'; +import { mapConversationRowToConversation } from './utils'; export async function dehydrateConversation( projectId: string, taskId: string, conversationId: string ): Promise { + const [row] = await db + .select() + .from(conversations) + .where( + and( + eq(conversations.id, conversationId), + eq(conversations.projectId, projectId), + eq(conversations.taskId, taskId) + ) + ) + .limit(1); + + if (row) { + const conversation = mapConversationRowToConversation(row); + if (conversation.type === 'acp') { + acpSessionManager.stop(conversationId); + return; + } + } + const task = resolveTask(projectId, taskId); await task?.conversations.detachSession(conversationId); } diff --git a/apps/emdash-desktop/src/main/core/conversations/deleteConversation.ts b/apps/emdash-desktop/src/main/core/conversations/deleteConversation.ts index f5ca63aec3..2a6fb2114f 100644 --- a/apps/emdash-desktop/src/main/core/conversations/deleteConversation.ts +++ b/apps/emdash-desktop/src/main/core/conversations/deleteConversation.ts @@ -1,4 +1,5 @@ import { and, eq } from 'drizzle-orm'; +import { acpSessionManager } from '@main/core/acp/acp-session-manager'; import { projectManager } from '@main/core/projects/project-manager'; import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; import { db } from '@main/db/client'; @@ -7,12 +8,25 @@ import { telemetryService } from '@main/lib/telemetry'; import { makePtySessionId } from '@shared/core/pty/ptySessionId'; import { resolveTask } from '../projects/utils'; import { conversationEvents } from './conversation-events'; +import { mapConversationRowToConversation } from './utils'; export async function deleteConversation( projectId: string, taskId: string, conversationId: string ): Promise { + const [row] = await db + .select() + .from(conversations) + .where( + and( + eq(conversations.id, conversationId), + eq(conversations.projectId, projectId), + eq(conversations.taskId, taskId) + ) + ) + .limit(1); + await db .delete(conversations) .where( @@ -25,6 +39,19 @@ export async function deleteConversation( conversationEvents._emit('conversation:deleted', conversationId); + if (row) { + const conversation = mapConversationRowToConversation(row); + if (conversation.type === 'acp') { + acpSessionManager.stop(conversationId); + telemetryService.capture('conversation_deleted', { + project_id: projectId, + task_id: taskId, + conversation_id: conversationId, + }); + return; + } + } + const task = resolveTask(projectId, taskId); if (task) { await task.conversations.stopSession(conversationId); diff --git a/apps/emdash-desktop/src/main/core/conversations/hydrateConversation.ts b/apps/emdash-desktop/src/main/core/conversations/hydrateConversation.ts index 152701833b..098f0bc45e 100644 --- a/apps/emdash-desktop/src/main/core/conversations/hydrateConversation.ts +++ b/apps/emdash-desktop/src/main/core/conversations/hydrateConversation.ts @@ -1,4 +1,7 @@ import { and, eq } from 'drizzle-orm'; +import { acpSessionManager } from '@main/core/acp/acp-session-manager'; +import { taskSessionManager } from '@main/core/tasks/task-session-manager'; +import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; import { db } from '@main/db/client'; import { conversations } from '@main/db/schema'; import { resolveTask } from '../projects/utils'; @@ -9,9 +12,6 @@ export async function hydrateConversation( taskId: string, conversationId: string ): Promise { - const task = resolveTask(projectId, taskId); - if (!task) throw new Error('Task not found'); - const [row] = await db .select() .from(conversations) @@ -25,6 +25,21 @@ export async function hydrateConversation( .limit(1); if (!row) throw new Error('Conversation not found'); + const conversation = mapConversationRowToConversation(row); + + if (conversation.type === 'acp') { + if (acpSessionManager.isRunning(conversationId)) return; + const workspaceId = taskSessionManager.getWorkspaceId(taskId); + const workspace = workspaceId ? workspaceRegistry.get(workspaceId) : undefined; + if (!workspace || !workspaceId) throw new Error('Workspace not found for ACP conversation'); + const config = row.config ?? {}; + await acpSessionManager.start(conversation, workspaceId, workspace.path, config.initialPrompt); + return; + } + + const task = resolveTask(projectId, taskId); + if (!task) throw new Error('Task not found'); + const isFirstSpawn = row.sessionId === null; if (isFirstSpawn) { diff --git a/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts b/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts index af86a12a1f..5c62d52acb 100644 --- a/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts +++ b/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts @@ -217,6 +217,7 @@ function conversation(): Conversation { lastInteractedAt: null, providerSessionId: 'provider-session-1', isInitialConversation: false, + type: 'pty', }; } diff --git a/apps/emdash-desktop/src/main/core/conversations/impl/keystroke-injection.test.ts b/apps/emdash-desktop/src/main/core/conversations/impl/keystroke-injection.test.ts index 4c2677fae3..885fe38213 100644 --- a/apps/emdash-desktop/src/main/core/conversations/impl/keystroke-injection.test.ts +++ b/apps/emdash-desktop/src/main/core/conversations/impl/keystroke-injection.test.ts @@ -13,6 +13,7 @@ function makeConversation(providerId: Conversation['providerId']): Conversation autoApprove: false, lastInteractedAt: null, isInitialConversation: false, + type: 'pty', }; } diff --git a/apps/emdash-desktop/src/main/core/conversations/resetStuckWorkingStatuses.ts b/apps/emdash-desktop/src/main/core/conversations/resetStuckWorkingStatuses.ts new file mode 100644 index 0000000000..34a6d813cb --- /dev/null +++ b/apps/emdash-desktop/src/main/core/conversations/resetStuckWorkingStatuses.ts @@ -0,0 +1,23 @@ +import { eq } from 'drizzle-orm'; +import { db } from '@main/db/client'; +import { conversations } from '@main/db/schema'; + +/** + * At startup no agent is actually running yet — PTY sessions and ACP pools are + * spawned lazily when a conversation is opened. Any conversation persisted as + * 'working' is therefore stale: a crash, force-quit, or (for ACP) a turn whose + * 'stop'/'error' event never landed before the app closed. Reset those rows to + * 'idle' so their tab and sidebar indicators don't show a perpetual spinner. + * + * Runs once during boot, before the renderer loads its conversation list, so the + * cleaned-up status is what the renderer reads — no IPC notification required. + * + * @returns the number of conversations reset. + */ +export async function resetStuckWorkingStatuses(): Promise { + const result = await db + .update(conversations) + .set({ agentStatus: 'idle', agentStatusSeen: 1 }) + .where(eq(conversations.agentStatus, 'working')); + return result.changes ?? 0; +} diff --git a/apps/emdash-desktop/src/main/core/conversations/resolve-agent-session-command.test.ts b/apps/emdash-desktop/src/main/core/conversations/resolve-agent-session-command.test.ts index 3d49134c36..903d82a36c 100644 --- a/apps/emdash-desktop/src/main/core/conversations/resolve-agent-session-command.test.ts +++ b/apps/emdash-desktop/src/main/core/conversations/resolve-agent-session-command.test.ts @@ -12,6 +12,7 @@ function makeConversation(overrides: Partial = {}): Conversation { title: 'Test', lastInteractedAt: null, isInitialConversation: false, + type: 'pty', ...overrides, }; } diff --git a/apps/emdash-desktop/src/main/core/conversations/save-provider-session-id.ts b/apps/emdash-desktop/src/main/core/conversations/save-provider-session-id.ts index 0cac11d6eb..0cfbbd7593 100644 --- a/apps/emdash-desktop/src/main/core/conversations/save-provider-session-id.ts +++ b/apps/emdash-desktop/src/main/core/conversations/save-provider-session-id.ts @@ -20,7 +20,7 @@ export async function saveProviderSessionId( const [row] = await db .select({ - config: conversations.config, + sessionId: conversations.sessionId, projectId: conversations.projectId, taskId: conversations.taskId, }) @@ -29,13 +29,11 @@ export async function saveProviderSessionId( .limit(1); if (!row) return; - - const config = row.config ?? {}; - if (config.providerSessionId === providerSessionId) return; + if (row.sessionId === providerSessionId) return; await db .update(conversations) - .set({ config: { ...config, providerSessionId }, updatedAt: new Date().toISOString() }) + .set({ sessionId: providerSessionId, updatedAt: new Date().toISOString() }) .where(eq(conversations.id, conversationId)); events.emit(conversationChangedChannel, { diff --git a/apps/emdash-desktop/src/main/core/conversations/set-provider-session-id.ts b/apps/emdash-desktop/src/main/core/conversations/set-provider-session-id.ts index 919a84f6d8..23683e4c6a 100644 --- a/apps/emdash-desktop/src/main/core/conversations/set-provider-session-id.ts +++ b/apps/emdash-desktop/src/main/core/conversations/set-provider-session-id.ts @@ -10,19 +10,17 @@ export async function setProviderSessionId( if (!trimmed) return false; const [row] = await db - .select({ config: conversations.config }) + .select({ sessionId: conversations.sessionId }) .from(conversations) .where(eq(conversations.id, conversationId)) .limit(1); if (!row) return false; - - const config = row.config ?? {}; - if (config.providerSessionId === trimmed) return false; + if (row.sessionId === trimmed) return false; await db .update(conversations) - .set({ config: { ...config, providerSessionId: trimmed } }) + .set({ sessionId: trimmed }) .where(eq(conversations.id, conversationId)); return true; diff --git a/apps/emdash-desktop/src/main/core/conversations/updateConversationModel.ts b/apps/emdash-desktop/src/main/core/conversations/updateConversationModel.ts new file mode 100644 index 0000000000..64a1af0a82 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/conversations/updateConversationModel.ts @@ -0,0 +1,37 @@ +import { eq } from 'drizzle-orm'; +import { db } from '@main/db/client'; +import { conversations } from '@main/db/schema'; +import { events } from '@main/lib/events'; +import { conversationChangedChannel } from '@shared/core/conversations/conversationEvents'; + +export async function updateConversationModel( + conversationId: string, + model: string +): Promise { + const [row] = await db + .select({ + config: conversations.config, + projectId: conversations.projectId, + taskId: conversations.taskId, + }) + .from(conversations) + .where(eq(conversations.id, conversationId)) + .limit(1); + + if (!row) return; + + const config = row.config ?? {}; + if (config.model === model) return; + + await db + .update(conversations) + .set({ config: { ...config, model }, updatedAt: new Date().toISOString() }) + .where(eq(conversations.id, conversationId)); + + events.emit(conversationChangedChannel, { + conversationId, + taskId: row.taskId, + projectId: row.projectId, + changes: { model }, + }); +} diff --git a/apps/emdash-desktop/src/main/core/conversations/utils.ts b/apps/emdash-desktop/src/main/core/conversations/utils.ts index 8c693cecde..83636c0c85 100644 --- a/apps/emdash-desktop/src/main/core/conversations/utils.ts +++ b/apps/emdash-desktop/src/main/core/conversations/utils.ts @@ -1,7 +1,7 @@ import { type ConversationRow } from '@main/db/schema'; import { type AgentProviderId } from '@shared/core/agents/agent-provider-registry'; import { type AgentStatus } from '@shared/core/agents/agentEvents'; -import { type Conversation } from '@shared/core/conversations/conversations'; +import { type Conversation, type ConversationType } from '@shared/core/conversations/conversations'; export function mapConversationRowToConversation( row: ConversationRow, @@ -15,11 +15,20 @@ export function mapConversationRowToConversation( projectId: row.projectId, providerId: row.provider as AgentProviderId, autoApprove: config.autoApprove, - providerSessionId: config.providerSessionId, + // All provider session IDs now live in the sessionId column. + // For PTY conversations, sessionId === id is the idempotency guard (not a real session ID). + providerSessionId: + row.type === 'acp' + ? (row.sessionId ?? undefined) + : row.sessionId !== null && row.sessionId !== row.id + ? row.sessionId + : undefined, resume: resume, lastInteractedAt: row.lastInteractedAt ?? null, isInitialConversation: row.isInitialConversation, agentStatus: (row.agentStatus as AgentStatus | null) ?? null, agentStatusSeen: row.agentStatusSeen === 1, + type: (row.type as ConversationType) ?? 'pty', + model: config.model, }; } diff --git a/apps/emdash-desktop/src/main/db/legacy-port/beta-import.ts b/apps/emdash-desktop/src/main/db/legacy-port/beta-import.ts index 5a25f3f5e0..ce063e17d6 100644 --- a/apps/emdash-desktop/src/main/db/legacy-port/beta-import.ts +++ b/apps/emdash-desktop/src/main/db/legacy-port/beta-import.ts @@ -21,7 +21,6 @@ const COPY_TABLE_ORDER = [ 'tasks', 'conversations', 'terminals', - 'messages', 'editor_buffers', ] as const; diff --git a/apps/emdash-desktop/src/main/db/legacy-port/destination-cleanup.ts b/apps/emdash-desktop/src/main/db/legacy-port/destination-cleanup.ts index ff0e3f80e1..872aa8861b 100644 --- a/apps/emdash-desktop/src/main/db/legacy-port/destination-cleanup.ts +++ b/apps/emdash-desktop/src/main/db/legacy-port/destination-cleanup.ts @@ -16,17 +16,6 @@ export function deleteProjectsById( const ids = [...projectIds]; const placeholders = ids.map(() => '?').join(', '); - if (tableExists(sqlite, 'conversations')) { - runDelete( - sqlite, - 'messages', - `DELETE FROM messages WHERE conversation_id IN ( - SELECT id FROM conversations WHERE project_id IN (${placeholders}) - )`, - ids - ); - } - runDelete( sqlite, 'terminals', diff --git a/apps/emdash-desktop/src/main/db/legacy-port/manual-port-check.test.ts b/apps/emdash-desktop/src/main/db/legacy-port/manual-port-check.test.ts index 3f26c082eb..98a1e1777d 100644 --- a/apps/emdash-desktop/src/main/db/legacy-port/manual-port-check.test.ts +++ b/apps/emdash-desktop/src/main/db/legacy-port/manual-port-check.test.ts @@ -53,7 +53,6 @@ describe('manual legacy port verification', () => { projects: count(legacyDb, 'projects'), tasks: count(legacyDb, 'tasks'), conversations: count(legacyDb, 'conversations'), - messages: count(legacyDb, 'messages'), }; const stateStore = new OneShotStateStore(); diff --git a/apps/emdash-desktop/src/main/db/schema.ts b/apps/emdash-desktop/src/main/db/schema.ts index 972f9df630..52249bb7f4 100644 --- a/apps/emdash-desktop/src/main/db/schema.ts +++ b/apps/emdash-desktop/src/main/db/schema.ts @@ -388,6 +388,7 @@ export const conversations = sqliteTable( sessionId: text('session_id'), agentStatus: text('agent_status'), agentStatusSeen: integer('agent_status_seen').default(1), + type: text('type'), }, (table) => ({ taskIdIdx: index('idx_conversations_task_id').on(table.taskId), @@ -419,26 +420,6 @@ export const terminals = sqliteTable( }) ); -export const messages = sqliteTable( - 'messages', - { - id: text('id').primaryKey(), - conversationId: text('conversation_id') - .notNull() - .references(() => conversations.id, { onDelete: 'cascade' }), - content: text('content').notNull(), - sender: text('sender').notNull(), - timestamp: text('timestamp') - .notNull() - .default(sql`CURRENT_TIMESTAMP`), - metadata: text('metadata'), - }, - (table) => ({ - conversationIdIdx: index('idx_messages_conversation_id').on(table.conversationId), - timestampIdx: index('idx_messages_timestamp').on(table.timestamp), - }) -); - export const editorBuffers = sqliteTable( 'editor_buffers', { @@ -494,7 +475,6 @@ export type ProjectSettingsInsert = typeof projectSettings.$inferInsert; export type TaskRow = typeof tasks.$inferSelect; export type ConversationRow = typeof conversations.$inferSelect; export type TerminalRow = typeof terminals.$inferSelect; -export type MessageRow = typeof messages.$inferSelect; export type EditorBufferRow = typeof editorBuffers.$inferSelect; export type EditorBufferInsert = typeof editorBuffers.$inferInsert; export type KvRow = typeof kv.$inferSelect; diff --git a/apps/emdash-desktop/src/main/index.ts b/apps/emdash-desktop/src/main/index.ts index 61ba2cd126..11a1e4f1e8 100644 --- a/apps/emdash-desktop/src/main/index.ts +++ b/apps/emdash-desktop/src/main/index.ts @@ -16,6 +16,7 @@ import { appService } from './core/app/service'; import { automationsService } from './core/automations/automations-service'; import { cleanupLegacyBrowserPartitions } from './core/browser/browser-partition-cleanup'; import { browserWebContentsRegistry } from './core/browser/browser-webcontents-registry'; +import { resetStuckWorkingStatuses } from './core/conversations/resetStuckWorkingStatuses'; import { localDependencyManager } from './core/dependencies/dependency-managers'; import { editorBufferService } from './core/editor/editor-buffer-service'; import { gitWatcherRegistry } from './core/git/git-watcher-registry'; @@ -97,6 +98,14 @@ void app.whenReady().then(async () => { try { await initializeDatabase(); + try { + const reset = await resetStuckWorkingStatuses(); + if (reset > 0) { + log.info(`Reset ${reset} stale 'working' conversation status(es) on startup`); + } + } catch (e: unknown) { + log.warn("conversations: failed to reset stale 'working' statuses", { error: e }); + } searchService.initialize(); workspaceFileIndexService.initialize(); void editorBufferService.pruneStale(); diff --git a/apps/emdash-desktop/src/main/rpc.ts b/apps/emdash-desktop/src/main/rpc.ts index a499e3bc27..470663e91d 100644 --- a/apps/emdash-desktop/src/main/rpc.ts +++ b/apps/emdash-desktop/src/main/rpc.ts @@ -1,5 +1,6 @@ import { createRPCNamespace, createRPCRouter } from '../shared/lib/ipc/rpc'; import { accountController } from './core/account/controller'; +import { acpController } from './core/acp/controller'; import { agentsController } from './core/agents/controller'; import { appController } from './core/app/controller'; import { asanaController } from './core/asana/controller'; @@ -70,6 +71,7 @@ export const rpcRouter = createRPCRouter({ ssh: sshController, projects: projectController, tasks: taskController, + acp: acpController, conversations: conversationController, terminals: terminalsController, mcp: mcpController, diff --git a/apps/emdash-desktop/src/renderer/features/tasks/conversations/chat/chat-empty-state.tsx b/apps/emdash-desktop/src/renderer/features/tasks/conversations/chat/chat-empty-state.tsx new file mode 100644 index 0000000000..2162dd3eb3 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/conversations/chat/chat-empty-state.tsx @@ -0,0 +1,31 @@ +import { Loader2, MessageSquare } from 'lucide-react'; + +export type ChatEmptyStateVariant = 'loading' | 'empty'; + +/** + * Overlay shown inside the transcript area when there is nothing to render. + * `loading` — the ACP session is cold-starting or replaying history. + * `empty` — the session is ready but no messages have been sent yet. + */ +export function ChatEmptyState({ variant }: { variant: ChatEmptyStateVariant }) { + return ( +
+ {variant === 'loading' ? ( + <> + +

Connecting to agent…

+ + ) : ( + <> + +
+

Start a conversation

+

+ Type a message below and press Enter or Send. +

+
+ + )} +
+ ); +} diff --git a/apps/emdash-desktop/src/renderer/features/tasks/conversations/chat/chat-panel.tsx b/apps/emdash-desktop/src/renderer/features/tasks/conversations/chat/chat-panel.tsx new file mode 100644 index 0000000000..223cf1edf6 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/conversations/chat/chat-panel.tsx @@ -0,0 +1,99 @@ +import type { ChatHandle } from '@emdash/chat-ui'; +import { ChatTranscript } from '@emdash/chat-ui/react'; +import { ChatComposer } from '@emdash/ui/components'; +import { observer } from 'mobx-react-lite'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useAgents } from '@renderer/lib/stores/use-agents'; +import type { ModelOption } from '@shared/core/agents/agent-payload'; +import { useConversations } from '../../task-view-context'; +import { ChatEmptyState } from './chat-empty-state'; + +const PAD_TOP = 16; +const PAD_BOTTOM_MARGIN = 12; + +export const ChatPanel = observer(function ChatPanel({ + conversationId, +}: { + conversationId: string; +}) { + const conversations = useConversations(); + const store = conversations.getOrCreateChatStore(conversationId); + const convStore = conversations.conversations.get(conversationId); + const providerId = convStore?.data.providerId; + + const { data: agents } = useAgents(); + const agentPayload = agents?.find((a) => a.id === providerId); + const modelOptions = + agentPayload?.capabilities?.models?.kind === 'selectable' + ? ( + agentPayload.capabilities.models as { + kind: 'selectable'; + modelOptions: Record; + } + ).modelOptions + : null; + + const handleReady = useCallback( + ({ transcript }: ChatHandle) => { + store.bindTranscript(transcript); + }, + [store] + ); + + const handleSubmit = (text: string) => { + store.setInput(text); + store.sendPrompt(); + }; + + // Measure the floating composer height so we can reserve matching canvas space. + const composerRef = useRef(null); + const [composerH, setComposerH] = useState(0); + + useEffect(() => { + const el = composerRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + const h = entries[0]?.borderBoxSize[0]?.blockSize ?? entries[0]?.contentRect.height ?? 0; + setComposerH(Math.round(h)); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + return ( +
+ {/* Full-bleed transcript — reserves canvas space for the floating composer */} + + + {/* Empty / loading states overlay the transcript */} + {!store.isReady && !store.isClosed ? ( + + ) : !store.hasItems && !store.isWorking ? ( + + ) : null} + + {/* Floating composer — measured via ResizeObserver above */} +
+ store.setModel(id)} + onSubmit={handleSubmit} + onStop={() => store.cancel()} + /> +
+
+ ); +}); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/conversations/chat/chat-store.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/conversations/chat/chat-store.test.ts new file mode 100644 index 0000000000..1cf5a69695 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/conversations/chat/chat-store.test.ts @@ -0,0 +1,382 @@ +/** + * Unit tests for ChatStore pull-based model. + * + * Validates: + * - History rebuild from getChatHistory commits turns deterministically (no stuck thinking). + * - Active turn from getSessionState streams without finalizing. + * - Live updates racing the initial query are deduplicated by seq. + * - acpTurnCommittedChannel finalizes streaming state. + * - acpSessionStateChannel drives isWorking/isReady/isClosed. + */ +import { type Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ChatHistory, SessionState } from '@shared/core/acp/acpTurns'; +import type { AcpTurn } from '@shared/core/acp/acpTurns'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +// Listener registry: channel name → handler function. +const listeners = new Map void>(); + +vi.mock('@renderer/lib/ipc', () => ({ + events: { + on: vi.fn((channel: { name: string }, handler: (payload: unknown) => void) => { + listeners.set(channel.name, handler); + return () => listeners.delete(channel.name); + }), + }, + rpc: { + acp: { + getSessionState: vi.fn(), + getChatHistory: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + cancel: vi.fn().mockResolvedValue(undefined), + setModel: vi.fn().mockResolvedValue(undefined), + }, + }, +})); + +vi.mock('@renderer/utils/logger', () => ({ + log: { warn: vi.fn(), debug: vi.fn(), error: vi.fn() }, +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +import { + acpSessionStateChannel, + acpSessionUpdateChannel, + acpTurnCommittedChannel, +} from '@shared/core/acp/acpEvents'; +import { ChatStore } from './chat-store'; +import type { ChatMessageItem } from './chat-store'; + +/** Emit a channel event and run all pending microtasks. */ +async function emit(channel: { name: string }, payload: unknown): Promise { + const handler = listeners.get(channel.name); + if (!handler) throw new Error(`No listener registered for channel: ${channel.name}`); + handler(payload); + await Promise.resolve(); +} + +/** Flush all microtasks (resolves async bootstrap queries etc.). */ +async function flushAsync(ticks = 5): Promise { + for (let i = 0; i < ticks; i++) await Promise.resolve(); +} + +function makeActiveTurn(overrides: Partial = {}): AcpTurn { + return { + id: 'turn-1', + status: 'active', + source: 'live', + startSeq: 0, + endSeq: null, + updates: [], + ...overrides, + }; +} + +function makeCompleteTurn( + updates: AcpTurn['updates'] = [], + overrides: Partial = {} +): AcpTurn { + return { + id: 'turn-hist-1', + status: 'complete', + source: 'live', + startSeq: 0, + endSeq: updates.length, + updates, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('ChatStore – bootstrap via getChatHistory + getSessionState', () => { + let rpcMock: { acp: { getSessionState: Mock; getChatHistory: Mock } }; + + beforeEach(async () => { + listeners.clear(); + const { rpc } = await import('@renderer/lib/ipc'); + rpcMock = rpc as unknown as typeof rpcMock; + }); + + afterEach(() => { + vi.clearAllMocks(); + listeners.clear(); + }); + + it('rebuilds items from committed history without stuck thinking', async () => { + const msgUpdate = { + sessionUpdate: 'agent_message_chunk' as const, + content: { type: 'text' as const, text: 'hello' }, + messageId: 'msg-1', + }; + const thoughtUpdate = { + sessionUpdate: 'agent_thought_chunk' as const, + content: { type: 'text' as const, text: 'thinking...' }, + messageId: 'thought-1', + }; + + const history: ChatHistory = { + turns: [ + makeCompleteTurn( + [ + { seq: 0, update: thoughtUpdate }, + { seq: 1, update: msgUpdate }, + ], + { id: 'turn-1' } + ), + ], + complete: true, + }; + const state: SessionState = { lifecycle: 'ready', activeTurn: null, model: null }; + + rpcMock.acp.getChatHistory.mockResolvedValue(history); + rpcMock.acp.getSessionState.mockResolvedValue(state); + + const store = new ChatStore('conv-1', 'proj-1', 'task-1'); + await flushAsync(10); + + // Should have thought + message items. + expect(store.items.some((it) => it.kind === 'message' && it.role === 'thought')).toBe(true); + expect(store.items.some((it) => it.kind === 'message' && it.role === 'assistant')).toBe(true); + + // No item should be stuck streaming (regression test for stuck thinking). + const streaming = store.items.filter((it) => it.kind === 'message' && it.streaming); + expect(streaming).toHaveLength(0); + + expect(store.isReady).toBe(true); + expect(store.isWorking).toBe(false); + }); + + it('rebuilds active turn updates without finalizing them', async () => { + const msgUpdate = { + sessionUpdate: 'agent_message_chunk' as const, + content: { type: 'text' as const, text: 'streaming...' }, + messageId: 'msg-1', + }; + + const history: ChatHistory = { turns: [], complete: true }; + const state: SessionState = { + lifecycle: 'working', + activeTurn: makeActiveTurn({ + updates: [{ seq: 0, update: msgUpdate }], + }), + model: null, + }; + + rpcMock.acp.getChatHistory.mockResolvedValue(history); + rpcMock.acp.getSessionState.mockResolvedValue(state); + + const store = new ChatStore('conv-1', 'proj-1', 'task-1'); + await flushAsync(10); + + // Item should be streaming (not finalized). + const assistantItems = store.items.filter( + (it): it is ChatMessageItem => it.kind === 'message' && it.role === 'assistant' + ); + expect(assistantItems).toHaveLength(1); + expect(assistantItems[0].streaming).toBe(true); + + expect(store.isWorking).toBe(true); + expect(store.isReady).toBe(true); + }); + + it('deduplicates live updates that race the initial query by seq', async () => { + const msgUpdate = { + sessionUpdate: 'agent_message_chunk' as const, + content: { type: 'text' as const, text: 'hi' }, + messageId: 'msg-dedup', + }; + + let resolveHistory!: (h: ChatHistory) => void; + const historyPromise = new Promise((res) => { + resolveHistory = res; + }); + + const history: ChatHistory = { turns: [], complete: true }; + const state: SessionState = { + lifecycle: 'working', + activeTurn: makeActiveTurn({ updates: [{ seq: 0, update: msgUpdate }] }), + model: null, + }; + + rpcMock.acp.getChatHistory.mockReturnValue(historyPromise); + rpcMock.acp.getSessionState.mockResolvedValue(state); + + const store = new ChatStore('conv-1', 'proj-1', 'task-1'); + // Flush enough to register listeners but not resolve the history query. + await Promise.resolve(); + + // Emit seq=0 live update before history resolves. + await emit(acpSessionUpdateChannel, { + conversationId: 'conv-1', + turnId: 'turn-1', + update: msgUpdate, + seq: 0, + }); + + // Resolve the history query (active turn has seq=0 already). + resolveHistory(history); + await flushAsync(10); + + // 'hi' should appear only once. + const assistantItems = store.items.filter( + (it): it is ChatMessageItem => it.kind === 'message' && it.role === 'assistant' + ); + expect(assistantItems.map((it) => it.text).join('')).toBe('hi'); + }); + + it('applies live updates with seq > lastSeq after history is loaded', async () => { + const history: ChatHistory = { turns: [], complete: true }; + const state: SessionState = { lifecycle: 'ready', activeTurn: null, model: null }; + + rpcMock.acp.getChatHistory.mockResolvedValue(history); + rpcMock.acp.getSessionState.mockResolvedValue(state); + + const store = new ChatStore('conv-1', 'proj-1', 'task-1'); + await flushAsync(10); // bootstrap complete + + await emit(acpSessionStateChannel, { + conversationId: 'conv-1', + lifecycle: 'working', + activeTurnId: 'turn-live', + }); + + await emit(acpSessionUpdateChannel, { + conversationId: 'conv-1', + turnId: 'turn-live', + update: { + sessionUpdate: 'agent_message_chunk' as const, + content: { type: 'text' as const, text: 'new' }, + messageId: 'msg-live', + }, + seq: 0, + }); + + const assistantItems = store.items.filter( + (it): it is ChatMessageItem => it.kind === 'message' && it.role === 'assistant' + ); + expect(assistantItems).toHaveLength(1); + expect(assistantItems[0].text).toBe('new'); + expect(store.isWorking).toBe(true); + }); +}); + +describe('ChatStore – event channel subscriptions', () => { + beforeEach(() => { + listeners.clear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + listeners.clear(); + }); + + it('acpSessionStateChannel drives isWorking / isReady / isClosed', async () => { + const { rpc } = await import('@renderer/lib/ipc'); + const rpc_ = rpc as unknown as { acp: { getSessionState: Mock; getChatHistory: Mock } }; + rpc_.acp.getChatHistory.mockResolvedValue({ turns: [], complete: true }); + rpc_.acp.getSessionState.mockResolvedValue({ + lifecycle: 'ready', + activeTurn: null, + model: null, + }); + + const store = new ChatStore('conv-1', 'proj-1', 'task-1'); + await flushAsync(10); + + await emit(acpSessionStateChannel, { + conversationId: 'conv-1', + lifecycle: 'working', + activeTurnId: 'turn-x', + }); + expect(store.isWorking).toBe(true); + expect(store.isReady).toBe(true); + expect(store.isClosed).toBe(false); + + await emit(acpSessionStateChannel, { + conversationId: 'conv-1', + lifecycle: 'ready', + activeTurnId: null, + }); + expect(store.isWorking).toBe(false); + expect(store.isReady).toBe(true); + + await emit(acpSessionStateChannel, { + conversationId: 'conv-1', + lifecycle: 'closed', + activeTurnId: null, + }); + expect(store.isClosed).toBe(true); + expect(store.isWorking).toBe(false); + }); + + it('acpTurnCommittedChannel finalizes streaming items (turn_done)', async () => { + const { rpc } = await import('@renderer/lib/ipc'); + const rpc_ = rpc as unknown as { acp: { getSessionState: Mock; getChatHistory: Mock } }; + + const msgUpdate = { + sessionUpdate: 'agent_message_chunk' as const, + content: { type: 'text' as const, text: 'hi' }, + messageId: 'msg-commit', + }; + + const history: ChatHistory = { turns: [], complete: true }; + const state: SessionState = { + lifecycle: 'working', + activeTurn: makeActiveTurn({ + updates: [{ seq: 0, update: msgUpdate }], + }), + model: null, + }; + + rpc_.acp.getChatHistory.mockResolvedValue(history); + rpc_.acp.getSessionState.mockResolvedValue(state); + + const store = new ChatStore('conv-1', 'proj-1', 'task-1'); + await flushAsync(10); + + // Should be streaming. + expect(store.items.some((it) => it.kind === 'message' && it.streaming)).toBe(true); + + // Commit the turn. + await emit(acpTurnCommittedChannel, { + conversationId: 'conv-1', + turn: makeCompleteTurn([{ seq: 0, update: msgUpdate }]), + }); + + // No more streaming items. + const streaming = store.items.filter((it) => it.kind === 'message' && it.streaming); + expect(streaming).toHaveLength(0); + }); + + it('ignores events for other conversation ids', async () => { + const { rpc } = await import('@renderer/lib/ipc'); + const rpc_ = rpc as unknown as { acp: { getSessionState: Mock; getChatHistory: Mock } }; + rpc_.acp.getChatHistory.mockResolvedValue({ turns: [], complete: true }); + rpc_.acp.getSessionState.mockResolvedValue({ + lifecycle: 'ready', + activeTurn: null, + model: null, + }); + + const store = new ChatStore('conv-1', 'proj-1', 'task-1'); + await flushAsync(10); + + // Emit for a different conversation. + await emit(acpSessionStateChannel, { + conversationId: 'other-conv', + lifecycle: 'working', + activeTurnId: 'turn-x', + }); + + expect(store.isWorking).toBe(false); + }); +}); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/conversations/chat/chat-store.ts b/apps/emdash-desktop/src/renderer/features/tasks/conversations/chat/chat-store.ts new file mode 100644 index 0000000000..344c6c179a --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/conversations/chat/chat-store.ts @@ -0,0 +1,769 @@ +import type { SessionUpdate } from '@agentclientprotocol/sdk'; +import type { + ChatDiff, + ChatExecute, + ChatFileOpToolCall, + ChatItem as UiChatItem, + FileOpKind, + ToolStatus, + TranscriptApi, +} from '@emdash/chat-ui'; +import { action, makeObservable, observable } from 'mobx'; +import { events, rpc } from '@renderer/lib/ipc'; +import { log } from '@renderer/utils/logger'; +import { + acpSessionClosedChannel, + acpSessionStateChannel, + acpSessionUpdateChannel, + acpTurnCommittedChannel, +} from '@shared/core/acp/acpEvents'; + +export type ChatMessageRole = 'user' | 'assistant' | 'thought'; + +export type ToolStatusKind = 'running' | 'done' | 'error'; + +/** A rendered message bubble (user / assistant / thought). */ +export type ChatMessageItem = { + kind: 'message'; + id: string; + role: ChatMessageRole; + text: string; + streaming: boolean; +}; + +/** A generic tool call line. */ +export type ChatToolItem = { + kind: 'tool'; + id: string; + toolCallId: string; + toolName: string; + status: ToolStatusKind; + inputSummary?: string; +}; + +/** A file-operation tool call line (read / edit / delete / move). */ +export type ChatFileOpItem = { + kind: 'file-op'; + id: string; + op: FileOpKind; + status: ToolStatusKind; + ops: { path: string }[]; +}; + +/** An execute tool call line (shell commands). */ +export type ChatExecuteItem = { + kind: 'execute'; + id: string; + command: string; + status: ToolStatusKind; + startedAt: number; + durationMs?: number; +}; + +/** A diff preview row — one per changed file in an ACP edit tool call. */ +export type ChatDiffItem = { + kind: 'diff'; + /** `${toolCallId}:${path}` */ + id: string; + path: string; + oldText: string | null; + newText: string; + status: ToolStatusKind; +}; + +/** A single ordered entry in the chat transcript. */ +export type ChatItem = + | ChatMessageItem + | ChatToolItem + | ChatFileOpItem + | ChatExecuteItem + | ChatDiffItem; + +/** Convert desktop ChatItem[] to chat-ui ChatItem[] for TranscriptApi.seed(). */ +function toChatUiItems(items: ChatItem[]): UiChatItem[] { + return items.flatMap((item): UiChatItem[] => { + if (item.kind === 'tool') { + return [ + { + kind: 'tool', + id: item.toolCallId, + name: item.toolName, + status: item.status, + inputSummary: item.inputSummary, + }, + ]; + } + if (item.kind === 'file-op') { + return [ + { + kind: 'file-op', + id: item.id, + op: item.op, + status: item.status, + ops: item.ops, + } satisfies ChatFileOpToolCall, + ]; + } + if (item.kind === 'execute') { + return [ + { + kind: 'execute', + id: item.id, + command: item.command, + status: item.status, + startedAt: item.startedAt, + durationMs: item.durationMs, + } satisfies ChatExecute, + ]; + } + if (item.kind === 'diff') { + return [ + { + kind: 'diff', + id: item.id, + path: item.path, + oldText: item.oldText, + newText: item.newText, + status: item.status, + } satisfies ChatDiff, + ]; + } + if (item.role === 'thought') { + return [ + { + kind: 'thinking', + id: item.id, + // Committed history turns always have finalized thought rows. + status: 'done', + text: item.text, + startedAt: 0, + }, + ]; + } + return [ + { + kind: 'message', + id: item.id, + role: item.role, + text: item.text, + streaming: item.streaming, + }, + ]; + }); +} + +const FILE_OP_KINDS = new Set(['read', 'delete', 'move']); + +/** + * Extract unique file paths from an ACP tool call notification. + * Prefers locations[]; falls back to diff content[] paths for edit calls. + */ +function extractPaths( + update: Extract +): { path: string }[] { + const paths = new Set(); + + if (update.locations) { + for (const loc of update.locations) { + if (loc.path) paths.add(loc.path); + } + } + + if (paths.size === 0 && update.content) { + for (const c of update.content) { + if (c.type === 'diff' && c.path) paths.add(c.path); + } + } + + return Array.from(paths, (path) => ({ path })); +} + +export class ChatStore { + /** + * Ordered transcript kept as source-of-truth for hydrating the chat-ui + * TranscriptApi when it binds after mount (via bindTranscript). + */ + items: ChatItem[] = []; + isWorking = false; + isClosed = false; + /** True once the ACP session is ready to accept prompts. */ + isReady = false; + input = ''; + selectedModel = 'default'; + + private readonly _conversationId: string; + private readonly _projectId: string; + private readonly _taskId: string; + private readonly _unsubs: (() => void)[] = []; + + /** + * Maps a streaming message key (`${role}:${messageId}`) to its item, so chunks + * for the same role+message merge while different roles stay separate bubbles. + */ + private _streamKeyMap = new Map(); + + /** Bound chat-ui transcript — set when the ChatTranscript component mounts. */ + private _transcript: TranscriptApi | null = null; + + /** + * False while the initial getChatHistory/getSessionState fetch is in-flight. + * Live updates that arrive during this window are held in _pendingLive. + */ + private _historyLoaded = false; + private _pendingLive: { turnId: string; seq: number; update: SessionUpdate }[] = []; + + /** + * Last sequence number applied from either the history buffer or the live + * stream. Used to dedup live updates that race the initial query. + */ + private _lastSeq = -1; + + constructor(conversationId: string, projectId: string, taskId: string, initialModel?: string) { + this._conversationId = conversationId; + this._projectId = projectId; + this._taskId = taskId; + if (initialModel) this.selectedModel = initialModel; + + makeObservable(this, { + items: observable, + isWorking: observable, + isClosed: observable, + isReady: observable, + input: observable, + selectedModel: observable, + setInput: action, + sendPrompt: action, + cancel: action, + setModel: action, + }); + + // Live update deltas — only the active turn streams. + this._unsubs.push( + events.on( + acpSessionUpdateChannel, + action((payload) => { + if (payload.conversationId !== this._conversationId) return; + if (!this._historyLoaded) { + // Hold updates that race in while the initial query is in-flight. + this._pendingLive.push({ + turnId: payload.turnId, + seq: payload.seq, + update: payload.update, + }); + } else if (payload.seq > this._lastSeq) { + this._applyUpdate(payload.update); + this._lastSeq = payload.seq; + } + }) + ) + ); + + // Session lifecycle transitions (ready/working/closed). + this._unsubs.push( + events.on( + acpSessionStateChannel, + action((payload) => { + if (payload.conversationId !== this._conversationId) return; + this.isReady = payload.lifecycle === 'ready' || payload.lifecycle === 'working'; + this.isWorking = payload.lifecycle === 'working'; + this.isClosed = payload.lifecycle === 'closed'; + }) + ) + ); + + // Active turn committed — finalize streaming items and dispatch turn_done. + this._unsubs.push( + events.on( + acpTurnCommittedChannel, + action((payload) => { + if (payload.conversationId !== this._conversationId) return; + this._finalizeStreaming(); + }) + ) + ); + + // Session closed — also finalise any open streaming state. + this._unsubs.push( + events.on( + acpSessionClosedChannel, + action((payload) => { + if (payload.conversationId !== this._conversationId) return; + this.isWorking = false; + this.isClosed = true; + this._finalizeStreaming(); + }) + ) + ); + + // Bootstrap: pull history (committed turns) and current session state. + // History turns are known-complete so each one is finalized deterministically, + // fixing the stuck-thinking race that existed in the old event-stream model. + void Promise.all([ + rpc.acp.getSessionState(this._conversationId), + rpc.acp.getChatHistory(this._conversationId), + ]) + .then( + action(([state, history]) => { + // 1. Committed history: reduce each turn then finalize. + for (const turn of history.turns) { + for (const { seq, update } of turn.updates) { + this._applyUpdate(update); + this._lastSeq = Math.max(this._lastSeq, seq); + } + // Each committed turn is known-complete: finalize unconditionally. + this._finalizeStreaming(); + } + + // 2. Active turn (mid-flight on reload): reduce without finalizing. + if (state.activeTurn) { + for (const { seq, update } of state.activeTurn.updates) { + this._applyUpdate(update); + this._lastSeq = Math.max(this._lastSeq, seq); + } + } + + // 3. Sync lifecycle state. + this.isWorking = state.lifecycle === 'working'; + this.isReady = state.lifecycle === 'ready' || state.lifecycle === 'working'; + this.isClosed = state.lifecycle === 'closed'; + if (state.model && state.model !== 'default') { + this.selectedModel = state.model; + } + }) + ) + .catch((err) => { + log.warn('ChatStore: initial query failed', { + conversationId: this._conversationId, + error: err instanceof Error ? err.message : String(err), + }); + }) + .finally( + action(() => { + // Flush live updates that raced the async query (seq-guarded). + for (const { seq, update } of this._pendingLive) { + if (seq > this._lastSeq) { + this._applyUpdate(update); + this._lastSeq = seq; + } + } + this._pendingLive = []; + this._historyLoaded = true; + }) + ); + } + + get hasItems(): boolean { + return this.items.length > 0; + } + + /** + * Called by ChatPanel via onReady once the chat-ui ChatTranscript mounts. + * Seeds the transcript with any items already accumulated before mount. + */ + bindTranscript(api: TranscriptApi): void { + this._transcript = api; + if (this.items.length > 0) { + api.seed(toChatUiItems(this.items)); + } + } + + setInput(value: string): void { + this.input = value; + } + + sendPrompt(): void { + const text = this.input.trim(); + if (!text || this.isWorking) return; + this.input = ''; + this.isWorking = true; + + // Optimistically add user message + const userId = `user-${Date.now()}`; + this.items.push({ + kind: 'message', + id: userId, + role: 'user', + text, + streaming: false, + }); + this._transcript?.dispatch({ type: 'message_chunk', role: 'user', id: userId, text }); + this._transcript?.dispatch({ type: 'turn_done' }); + + void rpc.acp + .prompt(this._conversationId, text) + .then( + action(() => { + // isWorking is now driven by acpSessionStateChannel; this is just a fallback + // in case the state event hasn't arrived yet. + if (this.isWorking) this.isWorking = false; + }) + ) + .catch( + action(() => { + if (this.isWorking) this.isWorking = false; + this._finalizeStreaming(); + }) + ); + } + + cancel(): void { + void rpc.acp.cancel(this._conversationId); + } + + setModel(modelId: string): void { + this.selectedModel = modelId; + void rpc.acp.setModel(this._conversationId, modelId); + } + + dispose(): void { + for (const unsub of this._unsubs) unsub(); + } + + private _applyUpdate(update: SessionUpdate): void { + const kind = update.sessionUpdate; + + if (kind === 'agent_message_chunk') { + this._appendChunk('assistant', update); + } else if (kind === 'agent_thought_chunk') { + this._appendThinkingChunk(update); + } else if (kind === 'user_message_chunk') { + this._appendChunk('user', update); + } else if (kind === 'tool_call') { + const acpKind = update.kind ?? undefined; + if (acpKind && FILE_OP_KINDS.has(acpKind)) { + this._upsertFileOp({ + id: update.toolCallId, + op: acpKind as FileOpKind, + status: 'running', + ops: extractPaths(update), + }); + } else if (acpKind === 'edit') { + this._upsertDiffsFromUpdate(update); + } else if (acpKind === 'execute') { + const rawInput = update.rawInput as Record | null | undefined; + this._upsertExecute({ + id: update.toolCallId, + command: typeof rawInput?.command === 'string' ? rawInput.command : '', + }); + } else { + this._upsertTool({ + toolCallId: update.toolCallId, + toolName: update.title, + status: 'running', + inputSummary: this._summarizeInput(update.rawInput), + }); + } + } else if (kind === 'tool_call_update') { + const existingFileOp = this.items.find( + (it): it is ChatFileOpItem => it.kind === 'file-op' && it.id === update.toolCallId + ); + const existingExecute = this.items.find( + (it): it is ChatExecuteItem => it.kind === 'execute' && it.id === update.toolCallId + ); + const existingDiffs = this.items.filter( + (it): it is ChatDiffItem => it.kind === 'diff' && it.id.startsWith(`${update.toolCallId}:`) + ); + if (existingFileOp) { + const newOps = update.locations + ? update.locations + .filter((l): l is { path: string } => typeof l.path === 'string') + .map((l) => ({ path: l.path })) + : undefined; + const newStatus = + update.status === 'completed' ? 'done' : update.status === 'failed' ? 'error' : undefined; + this._upsertFileOp({ id: update.toolCallId, status: newStatus, ops: newOps }); + } else if (existingExecute) { + const rawInput = update.rawInput as Record | null | undefined; + const newCommand = typeof rawInput?.command === 'string' ? rawInput.command : undefined; + const newStatus = + update.status === 'completed' ? 'done' : update.status === 'failed' ? 'error' : undefined; + this._upsertExecute({ + id: update.toolCallId, + command: newCommand, + status: newStatus, + }); + } else if (existingDiffs.length > 0) { + const newStatus: ToolStatus | undefined = + update.status === 'completed' ? 'done' : update.status === 'failed' ? 'error' : undefined; + if (update.content) { + // Re-apply content diffs (updated oldText/newText) and status + this._upsertDiffsFromUpdate(update, newStatus); + } else if (newStatus) { + for (const d of existingDiffs) { + this._upsertDiff({ id: d.id, path: d.path, status: newStatus }); + } + } + } else if (update.status === 'completed') { + this._upsertTool({ toolCallId: update.toolCallId, status: 'done' }); + } else if (update.status === 'failed') { + this._upsertTool({ toolCallId: update.toolCallId, status: 'error' }); + } + } + } + + private _appendChunk( + role: 'user' | 'assistant', + chunk: { messageId?: string | null; content: { type: string; text?: string } } + ): void { + const text = + chunk.content.type === 'text' ? ((chunk.content as { text?: string }).text ?? '') : ''; + const messageId = chunk.messageId ?? undefined; + + // Merge by role + messageId so chunks of the same message coalesce while a + // thought and an assistant message sharing a messageId stay distinct bubbles. + if (messageId) { + const streamKey = `${role}:${messageId}`; + const existing = this._streamKeyMap.get(streamKey); + if (existing) { + existing.text += text; + } else { + const created = this._pushMessage(role, text); + this._streamKeyMap.set(streamKey, created); + } + this._transcript?.dispatch({ type: 'message_chunk', role, id: messageId, text }); + return; + } + + // No messageId: append to the trailing bubble if it's the same role and still + // streaming; otherwise start a new bubble. + const last = this.items.at(-1); + if (last && last.kind === 'message' && last.role === role && last.streaming) { + last.text += text; + this._transcript?.dispatch({ type: 'message_chunk', role, id: last.id, text }); + return; + } + const created = this._pushMessage(role, text); + this._transcript?.dispatch({ type: 'message_chunk', role, id: created.id, text }); + } + + private _appendThinkingChunk(chunk: { + messageId?: string | null; + content: { type: string; text?: string }; + }): void { + const text = + chunk.content.type === 'text' ? ((chunk.content as { text?: string }).text ?? '') : ''; + const thinkingId = chunk.messageId ?? `thought-${Date.now()}`; + + // Mirror into items[] as a legacy thought message for seed() hydration. + const streamKey = `thought:${thinkingId}`; + const existing = this._streamKeyMap.get(streamKey); + if (existing) { + existing.text += text; + } else { + const created = this._pushMessage('thought', text); + this._streamKeyMap.set(streamKey, created); + } + + // The transcript reducer owns startedAt and text accumulation — just dispatch the delta. + this._transcript?.dispatch({ type: 'thinking_chunk', id: thinkingId, text }); + } + + private _pushMessage(role: ChatMessageRole, text: string, streaming = true): ChatMessageItem { + const item: ChatMessageItem = { + kind: 'message', + id: `${role}-${Date.now()}-${Math.random()}`, + role, + text, + streaming, + }; + this.items.push(item); + // Return the array-resident element: MobX wraps pushed plain objects in an + // observable proxy, so mutating the original `item` would not be reactive. + return this.items[this.items.length - 1] as ChatMessageItem; + } + + private _upsertTool(patch: { + toolCallId: string; + toolName?: string; + status?: ToolStatusKind; + inputSummary?: string; + }): void { + const existing = this.items.find( + (it): it is ChatToolItem => it.kind === 'tool' && it.toolCallId === patch.toolCallId + ); + if (existing) { + if (patch.toolName !== undefined) existing.toolName = patch.toolName; + if (patch.status !== undefined) existing.status = patch.status; + if (patch.inputSummary !== undefined) existing.inputSummary = patch.inputSummary; + this._transcript?.dispatch({ + type: 'tool_update', + id: patch.toolCallId, + status: patch.status, + name: patch.toolName, + inputSummary: patch.inputSummary, + }); + } else { + this.items.push({ + kind: 'tool', + id: patch.toolCallId, + toolCallId: patch.toolCallId, + toolName: patch.toolName ?? '(tool)', + status: patch.status ?? 'running', + inputSummary: patch.inputSummary, + }); + this._transcript?.dispatch({ + type: 'tool_start', + id: patch.toolCallId, + name: patch.toolName ?? '(tool)', + inputSummary: patch.inputSummary, + }); + } + } + + private _upsertFileOp(patch: { + id: string; + op?: FileOpKind; + status?: ToolStatusKind; + ops?: { path: string }[]; + }): void { + const existing = this.items.find( + (it): it is ChatFileOpItem => it.kind === 'file-op' && it.id === patch.id + ); + if (existing) { + if (patch.status !== undefined) existing.status = patch.status; + if (patch.ops !== undefined) existing.ops = patch.ops; + this._transcript?.dispatch({ + type: 'file_op_update', + id: patch.id, + status: patch.status, + ops: patch.ops, + }); + } else { + this.items.push({ + kind: 'file-op', + id: patch.id, + op: patch.op ?? 'read', + status: patch.status ?? 'running', + ops: patch.ops ?? [], + }); + this._transcript?.dispatch({ + type: 'file_op_start', + id: patch.id, + op: patch.op ?? 'read', + ops: patch.ops ?? [], + }); + } + } + + private _upsertExecute(patch: { id: string; command?: string; status?: ToolStatusKind }): void { + const existing = this.items.find( + (it): it is ChatExecuteItem => it.kind === 'execute' && it.id === patch.id + ); + if (existing) { + if (patch.command !== undefined) existing.command = patch.command; + if (patch.status !== undefined) { + existing.status = patch.status; + if (patch.status === 'done' && existing.durationMs === undefined) { + existing.durationMs = Date.now() - existing.startedAt; + } + } + this._transcript?.dispatch({ + type: 'execute_update', + id: patch.id, + command: patch.command, + status: patch.status, + }); + } else { + const startedAt = Date.now(); + this.items.push({ + kind: 'execute', + id: patch.id, + command: patch.command ?? '', + status: patch.status ?? 'running', + startedAt, + }); + this._transcript?.dispatch({ + type: 'execute_start', + id: patch.id, + command: patch.command ?? '', + startedAt, + }); + } + } + + /** + * Parse diff content from an ACP `tool_call` (or `tool_call_update`) payload + * and upsert one diff row per changed file. + * + * Groups by path and uses only the first diff block per path to avoid + * duplicate rows. Ignores entries without valid path/newText. + */ + private _upsertDiffsFromUpdate( + update: { + toolCallId: string; + content?: { type: string; path?: string; newText?: string; oldText?: string | null }[] | null; + }, + status?: ToolStatus + ): void { + if (!update.content) return; + + const seenPaths = new Set(); + for (const entry of update.content) { + if (entry.type !== 'diff') continue; + const { path, newText, oldText: entryOldText } = entry; + if (!path || !newText) continue; + if (seenPaths.has(path)) continue; + seenPaths.add(path); + + const oldText = entryOldText ?? null; + const id = `${update.toolCallId}:${path}`; + this._upsertDiff({ id, path, oldText, newText, status }); + } + } + + private _upsertDiff(patch: { + id: string; + path: string; + oldText?: string | null; + newText?: string; + status?: ToolStatus; + }): void { + const existing = this.items.find( + (it): it is ChatDiffItem => it.kind === 'diff' && it.id === patch.id + ); + if (existing) { + if (patch.status !== undefined) existing.status = patch.status; + if (patch.oldText !== undefined) existing.oldText = patch.oldText; + if (patch.newText !== undefined) existing.newText = patch.newText; + this._transcript?.dispatch({ + type: 'diff_update', + id: patch.id, + status: patch.status, + oldText: patch.oldText, + newText: patch.newText, + }); + } else { + this.items.push({ + kind: 'diff', + id: patch.id, + path: patch.path, + oldText: patch.oldText ?? null, + newText: patch.newText ?? '', + status: patch.status ?? 'running', + }); + this._transcript?.dispatch({ + type: 'diff_start', + id: patch.id, + path: patch.path, + oldText: patch.oldText ?? null, + newText: patch.newText ?? '', + }); + } + } + + /** Marks all streaming message bubbles as settled and commits the active turn. */ + private _finalizeStreaming(): void { + for (const item of this.items) { + if (item.kind === 'message') item.streaming = false; + } + this._streamKeyMap.clear(); + this._transcript?.dispatch({ type: 'turn_done' }); + } + + private _summarizeInput(input: unknown): string | undefined { + if (!input || typeof input !== 'object') return undefined; + const keys = Object.keys(input as Record); + if (keys.length === 0) return undefined; + return keys.slice(0, 3).join(', '); + } +} diff --git a/apps/emdash-desktop/src/renderer/features/tasks/conversations/conversation-manager.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/conversations/conversation-manager.test.ts index e357b891fb..e67a984057 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/conversations/conversation-manager.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/conversations/conversation-manager.test.ts @@ -55,6 +55,7 @@ describe('ConversationManagerStore session hydration', () => { title: 'Conversation 1', lastInteractedAt: null, isInitialConversation: false, + type: 'pty' as const, }, ]); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/conversations/conversation-manager.ts b/apps/emdash-desktop/src/renderer/features/tasks/conversations/conversation-manager.ts index 6bb048e809..4ae91cde27 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/conversations/conversation-manager.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/conversations/conversation-manager.ts @@ -21,6 +21,7 @@ import { type CreateConversationParams, } from '@shared/core/conversations/conversations'; import { makePtySessionId } from '@shared/core/pty/ptySessionId'; +import { ChatStore } from './chat/chat-store'; export class ConversationManagerStore implements IDisposable { private offAgentStatusChanged: (() => void) | null = null; @@ -35,6 +36,8 @@ export class ConversationManagerStore implements IDisposable { conversations = observable.map(); /** Session layer keyed by conversation id — created alongside data, connected lazily. */ sessions = observable.map(); + /** Chat UI stores keyed by conversation id — created lazily for ACP conversations. */ + chatStores = observable.map(); constructor( private readonly projectId: string, @@ -44,6 +47,7 @@ export class ConversationManagerStore implements IDisposable { makeObservable(this, { conversations: observable, sessions: observable, + chatStores: observable, taskStatus: computed, }); @@ -172,6 +176,17 @@ export class ConversationManagerStore implements IDisposable { return null; } + /** Returns an existing ChatStore for the conversation or lazily creates one. */ + getOrCreateChatStore(conversationId: string): ChatStore { + const existing = this.chatStores.get(conversationId); + if (existing) return existing; + const convStore = this.conversations.get(conversationId); + const initialModel = convStore?.data.model; + const store = new ChatStore(conversationId, this.projectId, this.taskId, initialModel); + this.chatStores.set(conversationId, store); + return store; + } + async createConversation(params: CreateConversationParams): Promise { const conversation = await rpc.conversations.createConversation(params); runInAction(() => { @@ -266,6 +281,9 @@ export class ConversationManagerStore implements IDisposable { for (const session of this.sessions.values()) { session.destroy(); } + for (const store of this.chatStores.values()) { + store.dispose(); + } } private createSession(conversation: Conversation): PtySession { diff --git a/apps/emdash-desktop/src/renderer/features/tasks/conversations/create-conversation-modal.tsx b/apps/emdash-desktop/src/renderer/features/tasks/conversations/create-conversation-modal.tsx index 7cc3b69edf..6a26abc620 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/conversations/create-conversation-modal.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/conversations/create-conversation-modal.tsx @@ -1,3 +1,4 @@ +import { MessageSquare, Terminal } from 'lucide-react'; import { observer } from 'mobx-react-lite'; import { useCallback, useState } from 'react'; import { getProjectSshConnectionId } from '@renderer/features/projects/stores/project-selectors'; @@ -14,14 +15,23 @@ import { } from '@renderer/lib/ui/dialog'; import { Field, FieldGroup, FieldLabel } from '@renderer/lib/ui/field'; import { Switch } from '@renderer/lib/ui/switch'; +import { + ACP_CAPABLE_PROVIDER_IDS, + type ConversationType, +} from '@shared/core/conversations/conversations'; import { nextDefaultConversationTitle } from './conversation-title-utils'; import { useEffectiveProvider } from './use-effective-provider'; +export type CreateConversationResult = { + conversationId: string; + type: ConversationType; +}; + export const CreateConversationModal = observer(function CreateConversationModal({ onSuccess, projectId, taskId, -}: BaseModalProps<{ conversationId: string }> & { +}: BaseModalProps & { projectId: string; taskId: string; }) { @@ -31,7 +41,18 @@ export const CreateConversationModal = observer(function CreateConversationModal const autoApproveDefaults = useAgentAutoApproveDefaults(); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); - const skipPermissions = providerId ? autoApproveDefaults.getDefault(providerId) : false; + const [conversationType, setConversationType] = useState('pty'); + + const supportsAcp = providerId ? ACP_CAPABLE_PROVIDER_IDS.has(providerId) : false; + const effectiveType: ConversationType = supportsAcp ? conversationType : 'pty'; + + const skipPermissions = + effectiveType === 'acp' + ? true // ACP auto-approves internally; skip the toggle for ACP mode + : providerId + ? autoApproveDefaults.getDefault(providerId) + : false; + const titleProviderId = providerId ?? 'claude'; const title = nextDefaultConversationTitle( titleProviderId, @@ -48,11 +69,12 @@ export const CreateConversationModal = observer(function CreateConversationModal projectId, taskId, id, - autoApprove: skipPermissions, + autoApprove: effectiveType === 'pty' ? skipPermissions : undefined, provider: providerId, title, + type: effectiveType, }); - onSuccess({ conversationId: id }); + onSuccess({ conversationId: id, type: effectiveType }); } catch { setError('Failed to create conversation'); setIsSubmitting(false); @@ -60,6 +82,7 @@ export const CreateConversationModal = observer(function CreateConversationModal }, [ conversationMgr, createDisabled, + effectiveType, isSubmitting, providerId, title, @@ -85,18 +108,53 @@ export const CreateConversationModal = observer(function CreateConversationModal connectionId={connectionId} /> - -
- { - if (providerId) autoApproveDefaults.setDefault(providerId, checked); - }} - /> - Auto-approve permissions -
-
+ {supportsAcp && ( + + Mode +
+ + +
+
+ )} + {effectiveType === 'pty' && ( + +
+ { + if (providerId) autoApproveDefaults.setDefault(providerId, checked); + }} + /> + Auto-approve permissions +
+
+ )} {error &&

{error}

} diff --git a/apps/emdash-desktop/src/renderer/features/tasks/conversations/sidebar-conversations-list.tsx b/apps/emdash-desktop/src/renderer/features/tasks/conversations/sidebar-conversations-list.tsx index 63e0e66a58..87ddd6eab3 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/conversations/sidebar-conversations-list.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/conversations/sidebar-conversations-list.tsx @@ -74,7 +74,7 @@ const ConversationRow = observer(function ConversationRow({ }; const handleDoubleClick = () => { - tabGroupManager.openConversation(conversationId); + tabGroupManager.openConversationAuto(conversationId); handleRename(); }; @@ -96,12 +96,12 @@ const ConversationRow = observer(function ConversationRow({
tabGroupManager.openConversationPreview(conversationId)} + onClick={() => tabGroupManager.openConversationAutoPreview(conversationId)} onDoubleClick={handleDoubleClick} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - tabGroupManager.openConversationPreview(conversationId); + tabGroupManager.openConversationAutoPreview(conversationId); } }} className={cn( @@ -187,7 +187,7 @@ export const SidebarConversationsList = observer(function SidebarConversationsLi projectId, taskId, onSuccess: ({ conversationId }) => { - tabGroupManager.openConversation(conversationId); + tabGroupManager.openConversationAuto(conversationId); }, }); }; diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/task-manager.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/task-manager.ts index 7d7bc5759f..c84c175d3a 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/task-manager.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/task-manager.ts @@ -343,6 +343,7 @@ export class TaskManagerStore { lastInteractedAt: null, autoApprove: ic.autoApprove ?? false, isInitialConversation: true, + type: 'pty', }; const conversationManager = conversationRegistry.acquire(params.id, this.projectId, [ optimistic, diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.test.ts index d227ddc7c8..d954ebeada 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.test.ts @@ -131,6 +131,7 @@ function makeConversation(overrides: Partial = {}): Conversation { title: 'Conversation 1', lastInteractedAt: null, isInitialConversation: false, + type: 'pty', ...overrides, }; } diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.tsx b/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.tsx index eb634f105a..0b18d97f61 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.tsx @@ -500,7 +500,9 @@ export class WorkspaceViewModel implements ILifecycle { for (const { tabManager } of this.tabGroupManager.groups) { for (const tabId of tabManager.tabOrder) { const entry = tabManager.entries.get(tabId); - if (entry?.kind === 'conversation') ids.add(entry.conversationId); + if (entry?.kind === 'conversation' || entry?.kind === 'chat') { + ids.add(entry.conversationId); + } } } return [...ids].sort(); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/tabs/close-tab-with-confirm.ts b/apps/emdash-desktop/src/renderer/features/tasks/tabs/close-tab-with-confirm.ts index a1be6833b1..ee0326b78e 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/tabs/close-tab-with-confirm.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/tabs/close-tab-with-confirm.ts @@ -7,6 +7,9 @@ function getTabDisplayTitle(tab: ResolvedTab): string { if (tab.kind === 'conversation') { return formatConversationTitleForDisplay(tab.store.data.providerId, tab.store.data.title); } + if (tab.kind === 'chat') { + return formatConversationTitleForDisplay(tab.store.data.providerId, tab.store.data.title); + } if (tab.kind === 'browser') { return tab.session.title || tab.session.currentUrl || 'Browser'; } diff --git a/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-group-manager-store.ts b/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-group-manager-store.ts index 9406165305..820f6027a4 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-group-manager-store.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-group-manager-store.ts @@ -67,6 +67,10 @@ export class TabGroupManagerStore { restoreSnapshot: action, openConversation: action, openConversationPreview: action, + openChat: action, + openChatPreview: action, + openConversationAuto: action, + openConversationAutoPreview: action, openBrowser: action, }); } @@ -248,6 +252,42 @@ export class TabGroupManagerStore { this.focusedGroup.openConversationPreview(conversationId); } + openChat(conversationId: string): void { + for (const { groupId, tabManager } of this.groups) { + if (tabManager.hasChatTab(conversationId)) { + this.activeGroupId = groupId; + tabManager.openChat(conversationId); + return; + } + } + this.focusedGroup.openChat(conversationId); + } + + openChatPreview(conversationId: string): void { + for (const { groupId, tabManager } of this.groups) { + if (tabManager.hasChatTab(conversationId)) { + this.activeGroupId = groupId; + tabManager.openChatPreview(conversationId); + return; + } + } + this.focusedGroup.openChatPreview(conversationId); + } + + /** Opens the correct tab kind (chat or PTY terminal) based on conversation type. */ + openConversationAuto(conversationId: string): void { + const type = this._getConversations()?.conversations.get(conversationId)?.data.type; + if (type === 'acp') this.openChat(conversationId); + else this.openConversation(conversationId); + } + + /** Opens a preview tab for the correct kind based on conversation type. */ + openConversationAutoPreview(conversationId: string): void { + const type = this._getConversations()?.conversations.get(conversationId)?.data.type; + if (type === 'acp') this.openChatPreview(conversationId); + else this.openConversationPreview(conversationId); + } + openBrowser(initialUrl?: string): void { this.focusedGroup.openBrowser(initialUrl); } diff --git a/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-manager-store.ts b/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-manager-store.ts index c9a89183ff..c4687b7420 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-manager-store.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-manager-store.ts @@ -23,6 +23,7 @@ import { } from '@renderer/lib/stores/tab-utils'; import { setTelemetryConversationScope } from '@renderer/utils/telemetry-scope'; import { normalizeBrowserProfileSelection, type BrowserSessionSnapshot } from '@shared/browser'; +import type { ConversationType } from '@shared/core/conversations/conversations'; import { refsEqual, type GitChangeStatus, type GitObjectRef } from '@shared/core/git/git'; import { browserOpenInNewTabChannel } from '@shared/events/browserEvents'; import type { ActiveFile, TabDescriptor, TabManagerSnapshot } from '@shared/view-state'; @@ -74,7 +75,34 @@ export class BrowserTabEntry { } } -export type TabEntry = FileTabStore | DiffTabStore | ConversationTabEntry | BrowserTabEntry; +export class ChatTabEntry { + readonly kind = 'chat' as const; + readonly tabId: string; + conversationId: string; + isPreview: boolean; + + constructor(conversationId: string, isPreview: boolean, tabId?: string) { + this.tabId = tabId ?? crypto.randomUUID(); + this.conversationId = conversationId; + this.isPreview = isPreview; + makeObservable(this, { + conversationId: observable, + isPreview: observable, + pin: action, + }); + } + + pin(): void { + this.isPreview = false; + } +} + +export type TabEntry = + | FileTabStore + | DiffTabStore + | ConversationTabEntry + | BrowserTabEntry + | ChatTabEntry; function optionalRefsEqual(left: GitObjectRef | undefined, right: GitObjectRef | undefined) { if (left === undefined || right === undefined) return left === right; @@ -131,8 +159,18 @@ export type ResolvedDiffTab = { isActive: boolean; }; +export type ResolvedChatTab = { + kind: 'chat'; + tabId: string; + conversationId: string; + store: ConversationStore; + isPreview: boolean; + isActive: boolean; +}; + export type ResolvedTab = | ResolvedConversationTab + | ResolvedChatTab | ResolvedFileTab | ResolvedBrowserTab | ResolvedDiffTab; @@ -198,6 +236,10 @@ export class TabManagerStore implements Snapshottable { snapshot: computed, openConversation: action, openConversationPreview: action, + openChat: action, + openChatPreview: action, + openConversationAuto: action, + openConversationAutoPreview: action, openFile: action, openExternalFile: action, openFilePreview: action, @@ -231,7 +273,10 @@ export class TabManagerStore implements Snapshottable { const idSet = new Set(ids); const toRemove: string[] = []; for (const [tabId, entry] of this.entries) { - if (entry.kind === 'conversation' && !idSet.has(entry.conversationId)) { + if ( + (entry.kind === 'conversation' || entry.kind === 'chat') && + !idSet.has(entry.conversationId) + ) { toRemove.push(tabId); } } @@ -243,9 +288,10 @@ export class TabManagerStore implements Snapshottable { ); // Mark conversation as seen when it becomes the active tab in the focused pane. + // Uses activeConversationStore so both pty-agent and chat tabs clear their indicator. this.disposers.push( autorun(() => { - const conv = this.activeConversation; + const conv = this.activeConversationStore; if (this.isFocused && conv && !conv.seen) { conv.markSeen(); } @@ -307,6 +353,17 @@ export class TabManagerStore implements Snapshottable { return desc?.kind === 'conversation' ? desc.conversationId : undefined; } + /** + * The ConversationStore for the active tab, regardless of whether it is a + * pty-agent `'conversation'` tab or a `'chat'` tab. Used for notification + * mark-seen so both tab kinds clear their indicator on activation. + */ + get activeConversationStore(): ConversationStore | undefined { + const desc = this.activeDescriptor; + if (!desc || (desc.kind !== 'conversation' && desc.kind !== 'chat')) return undefined; + return this._getConversations()?.conversations.get(desc.conversationId); + } + get activeFileEntry(): FileTabStore | undefined { const desc = this.activeDescriptor; return desc?.kind === 'file' ? desc : undefined; @@ -359,7 +416,18 @@ export class TabManagerStore implements Snapshottable { const entry = this.entries.get(id); if (!entry) continue; - if (entry.kind === 'conversation') { + if (entry.kind === 'chat') { + const store = this._getConversations()?.conversations.get(entry.conversationId); + if (!store) continue; + result.push({ + kind: 'chat', + tabId: entry.tabId, + conversationId: entry.conversationId, + store, + isPreview: entry.isPreview, + isActive: effectiveActiveId === entry.tabId, + }); + } else if (entry.kind === 'conversation') { const store = this._getConversations()?.conversations.get(entry.conversationId); if (!store) continue; result.push({ @@ -422,9 +490,20 @@ export class TabManagerStore implements Snapshottable { for (const id of this.tabOrder) { const entry = this.entries.get(id); if (!entry) continue; - if (entry.kind === 'conversation') { + if (entry.kind === 'chat') { tabs.push({ - kind: 'conversation', + kind: 'chat', + tabId: entry.tabId, + conversationId: entry.conversationId, + isPreview: entry.isPreview, + }); + } else if (entry.kind === 'conversation') { + // Migrate stale PTY entries that are actually ACP conversations so the + // next restore picks them up as chat tabs without the user having to + // manually close and reopen each tab. + const conversationType = this._conversationType(entry.conversationId); + tabs.push({ + kind: conversationType === 'acp' ? 'chat' : 'conversation', tabId: entry.tabId, conversationId: entry.conversationId, isPreview: entry.isPreview, @@ -485,6 +564,49 @@ export class TabManagerStore implements Snapshottable { this.activeTabId = entry.tabId; } + openChat(conversationId: string): void { + const existing = this._findChatEntry(conversationId); + if (existing) { + existing.isPreview = false; + this.activeTabId = existing.tabId; + return; + } + const entry = new ChatTabEntry(conversationId, false); + this.entries.set(entry.tabId, entry); + addTabId(this, entry.tabId); + this.activeTabId = entry.tabId; + } + + openChatPreview(conversationId: string): void { + const existing = this._findChatEntry(conversationId); + if (existing) { + this.activeTabId = existing.tabId; + return; + } + const previewEntry = this._findChatPreviewEntry(); + if (previewEntry) { + previewEntry.conversationId = conversationId; + this.activeTabId = previewEntry.tabId; + return; + } + const entry = new ChatTabEntry(conversationId, true); + this.entries.set(entry.tabId, entry); + addTabId(this, entry.tabId); + this.activeTabId = entry.tabId; + } + + /** Opens the correct tab kind (chat or PTY terminal) based on conversation type. */ + openConversationAuto(conversationId: string): void { + if (this._conversationType(conversationId) === 'acp') this.openChat(conversationId); + else this.openConversation(conversationId); + } + + /** Opens a preview tab for the correct kind based on conversation type. */ + openConversationAutoPreview(conversationId: string): void { + if (this._conversationType(conversationId) === 'acp') this.openChatPreview(conversationId); + else this.openConversationPreview(conversationId); + } + openConversationPreview(conversationId: string): void { const existing = this._findConversationEntry(conversationId); if (existing) { @@ -773,6 +895,10 @@ export class TabManagerStore implements Snapshottable { return this._findConversationEntry(conversationId) !== undefined; } + hasChatTab(conversationId: string): boolean { + return this._findChatEntry(conversationId) !== undefined; + } + // --------------------------------------------------------------------------- // Snapshot // --------------------------------------------------------------------------- @@ -783,10 +909,25 @@ export class TabManagerStore implements Snapshottable { this.entries.clear(); this.tabOrder = []; for (const t of snapshot.tabs) { - if (t.kind === 'conversation') { - const entry = new ConversationTabEntry(t.conversationId, t.isPreview, t.tabId); + if (t.kind === 'chat') { + const entry = new ChatTabEntry(t.conversationId, t.isPreview, t.tabId); this.entries.set(entry.tabId, entry); this.tabOrder.push(entry.tabId); + } else if (t.kind === 'conversation') { + // Migration: old snapshots saved ACP conversations as 'conversation'. + // Preloaded conversation data is available at restore time, so upgrade + // any ACP entries to ChatTabEntry so ChatPanel mounts instead of the + // PTY ConversationsPanel. + const conversationType = this._conversationType(t.conversationId); + if (conversationType === 'acp') { + const entry = new ChatTabEntry(t.conversationId, t.isPreview, t.tabId); + this.entries.set(entry.tabId, entry); + this.tabOrder.push(entry.tabId); + } else { + const entry = new ConversationTabEntry(t.conversationId, t.isPreview, t.tabId); + this.entries.set(entry.tabId, entry); + this.tabOrder.push(entry.tabId); + } } else if (t.kind === 'browser') { browserSessionStore.restoreSession( t.session, @@ -834,7 +975,7 @@ export class TabManagerStore implements Snapshottable { if (!conversations) return; for (const [id, store] of conversations.conversations) { if (store.isInitialConversation) { - this.openConversation(id); + this.openConversationAuto(id); return; } } @@ -877,6 +1018,16 @@ export class TabManagerStore implements Snapshottable { return undefined; } + private _findChatEntry(conversationId: string): ChatTabEntry | undefined { + for (const id of this.tabOrder) { + const entry = this.entries.get(id); + if (entry?.kind === 'chat' && entry.conversationId === conversationId) { + return entry; + } + } + return undefined; + } + private _findConversationPreviewEntry(): ConversationTabEntry | undefined { for (const id of this.tabOrder) { const entry = this.entries.get(id); @@ -885,6 +1036,18 @@ export class TabManagerStore implements Snapshottable { return undefined; } + private _findChatPreviewEntry(): ChatTabEntry | undefined { + for (const id of this.tabOrder) { + const entry = this.entries.get(id); + if (entry?.kind === 'chat' && entry.isPreview) return entry; + } + return undefined; + } + + private _conversationType(conversationId: string): ConversationType | undefined { + return this._getConversations()?.conversations.get(conversationId)?.data.type; + } + private _findFileEntryByPath(path: string): FileTabStore | undefined { for (const id of this.tabOrder) { const entry = this.entries.get(id); @@ -954,7 +1117,8 @@ export class TabManagerStore implements Snapshottable { private _getConversationIdForTab(id: string): string | undefined { const entry = this.entries.get(id); - return entry?.kind === 'conversation' ? entry.conversationId : undefined; + if (entry?.kind === 'conversation' || entry?.kind === 'chat') return entry.conversationId; + return undefined; } private _markConversationSeen(conversationId: string): void { diff --git a/apps/emdash-desktop/src/renderer/features/tasks/view/pane-content.tsx b/apps/emdash-desktop/src/renderer/features/tasks/view/pane-content.tsx index 9ceedeadb6..6f028c78f0 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/view/pane-content.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/view/pane-content.tsx @@ -1,7 +1,9 @@ import { useDroppable } from '@dnd-kit/core'; import { observer } from 'mobx-react-lite'; +import { useRef } from 'react'; import { BrowserPane } from '@renderer/features/browser/browser-pane'; import { ShowHide } from '@renderer/lib/ui/show-hide'; +import { ChatPanel } from '../conversations/chat/chat-panel'; import { ConversationsPanel } from '../conversations/conversations-panel'; import { DiffView } from '../diff-view/main-panel/diff-view'; import { useTabGroupContext } from '../tabs/tab-group-context'; @@ -19,6 +21,19 @@ export const PaneContent = observer(function PaneContent() { const paneRenderer = resolvePaneRenderer(paneTabManager); + // Chat tabs are kept alive using visibility-based stacking so that: + // 1. Each conversation gets its own ChatPanel instance, keyed by conversationId, + // ensuring the transcript is bound to the correct ChatStore on mount. + // 2. Switching tabs toggles visibility (not display), preserving scroll position + // and the virtualizer's scroll signal — no desync, no flicker, no re-seed. + // activatedSet tracks which chats have been viewed at least once (lazy mount). + // Must be called unconditionally (Rules of Hooks) before any early return. + const chatTabs = paneTabManager.resolvedTabs.filter((t) => t.kind === 'chat'); + const activatedSetRef = useRef>(new Set()); + for (const t of chatTabs) { + if (t.isActive) activatedSetRef.current.add(t.conversationId); + } + if (!paneRenderer) { return ; } @@ -33,6 +48,23 @@ export const PaneContent = observer(function PaneContent() { + + {/* Chat pane pool — one ChatPanel per opened chat tab, visibility-toggled */} + {chatTabs + .filter((t) => activatedSetRef.current.has(t.conversationId)) + .map((t) => ( +
+ +
+ ))} + {paneRenderer.kind === 'browser' && } {paneRenderer.kind === 'file' && } {paneRenderer.kind === 'file-diff' && } diff --git a/apps/emdash-desktop/src/renderer/features/tasks/view/pane-empty-state.tsx b/apps/emdash-desktop/src/renderer/features/tasks/view/pane-empty-state.tsx index 66153212cf..6df6ace3a6 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/view/pane-empty-state.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/view/pane-empty-state.tsx @@ -20,7 +20,7 @@ export function PaneEmptyState() { showCreateConversationModal({ projectId, taskId, - onSuccess: ({ conversationId }) => tabManager.openConversation(conversationId), + onSuccess: ({ conversationId }) => tabManager.openConversationAuto(conversationId), }), () => showCommandPalette({ projectId, taskId, workspaceId: workspaceId ?? undefined }), ]; diff --git a/apps/emdash-desktop/src/renderer/features/tasks/view/pane-renderer.ts b/apps/emdash-desktop/src/renderer/features/tasks/view/pane-renderer.ts index e44abcb72a..a7e4146192 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/view/pane-renderer.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/view/pane-renderer.ts @@ -5,6 +5,7 @@ import type { TabManagerStore } from '@renderer/features/tasks/tabs/tab-manager- /** The top-level rendering mode for a single pane. */ export type PaneRenderer = | { kind: 'pty-agent' } + | { kind: 'chat'; conversationId: string } | { kind: 'browser'; browserId: string } | { kind: 'file'; tab: FileTabStore } | { kind: 'file-diff'; tab: DiffTabStore }; @@ -36,5 +37,8 @@ export function resolvePaneRenderer(tabManager: TabManagerStore): PaneRenderer | if (activeTab.kind === 'browser') { return { kind: 'browser', browserId: activeTab.browserId }; } + if (activeTab.kind === 'chat') { + return { kind: 'chat', conversationId: activeTab.conversationId }; + } return { kind: 'pty-agent' }; } diff --git a/apps/emdash-desktop/src/renderer/features/tasks/view/tab-bar.tsx b/apps/emdash-desktop/src/renderer/features/tasks/view/tab-bar.tsx index 2c6159969a..d0c5337b9a 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/view/tab-bar.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/view/tab-bar.tsx @@ -13,12 +13,14 @@ import { useTabShortcuts } from '@renderer/lib/hooks/useTabShortcuts'; import type { ConversationManagerStore } from '../conversations/conversation-manager'; import type { ResolvedBrowserTab, + ResolvedChatTab, ResolvedConversationTab, ResolvedDiffTab, ResolvedFileTab, ResolvedTab, } from '../tabs/tab-manager-store'; import { BrowserTabItem } from './tab-bar/browser-tab-item'; +import { ChatTabItem } from './tab-bar/chat-tab-item'; import { ConversationTabItem } from './tab-bar/conversation-tab-item'; import { DiffTabItem } from './tab-bar/diff-tab-item'; import { PaneDropZone } from './tab-bar/draggable-tab'; @@ -30,6 +32,15 @@ function makeTabRenderers( conversations: ConversationManagerStore ) { return { + chat: (tab: ResolvedChatTab): ReactNode => ( + tabManager.setActiveTab(tab.tabId)} + onPin={() => tabManager.openChat(tab.conversationId)} + onClose={() => closeTabWithConfirm(tabManager, tab.tabId)} + /> + ), conversation: (tab: ResolvedConversationTab): ReactNode => ( void; + onPin: () => void; + onClose: () => void; +}) { + const title = formatConversationTitleForDisplay(tab.store.data.providerId, tab.store.data.title); + + return ( + + + + {title} + + + + + } + /> + + ); +}); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/view/tab-bar/tab-bar-actions.tsx b/apps/emdash-desktop/src/renderer/features/tasks/view/tab-bar/tab-bar-actions.tsx index 6a0802b378..cbbdf1733b 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/view/tab-bar/tab-bar-actions.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/view/tab-bar/tab-bar-actions.tsx @@ -49,7 +49,7 @@ export const TabBarActions = observer(function TabBarActions() { showCreateConversationModal({ projectId, taskId, - onSuccess: ({ conversationId }) => tabManager.openConversation(conversationId), + onSuccess: ({ conversationId }) => tabManager.openConversationAuto(conversationId), }) } > diff --git a/apps/emdash-desktop/src/renderer/features/tasks/view/tab-bar/tab-drag-preview.tsx b/apps/emdash-desktop/src/renderer/features/tasks/view/tab-bar/tab-drag-preview.tsx index 3a9ca7267d..f2440c8944 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/view/tab-bar/tab-drag-preview.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/view/tab-bar/tab-drag-preview.tsx @@ -1,7 +1,9 @@ +import { MessageSquare } from 'lucide-react'; import { observer } from 'mobx-react-lite'; import type { ReactNode } from 'react'; import type { ResolvedBrowserTab, + ResolvedChatTab, ResolvedConversationTab, ResolvedDiffTab, ResolvedFileTab, @@ -12,8 +14,19 @@ import { BrowserTabDragPreview } from './browser-tab-item'; import { ConversationTabDragPreview } from './conversation-tab-item'; import { DiffTabDragPreview } from './diff-tab-item'; import { FileTabDragPreview } from './file-tab-item'; +import { TabDragPreviewShell } from './tab-item-shell'; + +function ChatTabDragPreview({ tab }: { tab: ResolvedChatTab }) { + return ( + + + {tab.store.data.title} + + ); +} const dragPreviewRenderers = { + chat: (tab: ResolvedChatTab): ReactNode => , browser: (tab: ResolvedBrowserTab): ReactNode => , conversation: (tab: ResolvedConversationTab): ReactNode => ( diff --git a/apps/emdash-desktop/src/renderer/main.tsx b/apps/emdash-desktop/src/renderer/main.tsx index 0ff6507de9..ab53d750b9 100644 --- a/apps/emdash-desktop/src/renderer/main.tsx +++ b/apps/emdash-desktop/src/renderer/main.tsx @@ -1,7 +1,10 @@ import ReactDOM from 'react-dom/client'; import { setupNavigationGuards } from '@renderer/app/view-registry'; import { prefetchAppSettingsKey } from '@renderer/features/settings/use-app-settings-key'; +import '@emdash/ui/theme/theme.css'; import './index.css'; +import '@emdash/chat-ui/style.css'; +import '@emdash/chat-ui/chat-theme.css'; import 'devicon/devicon.min.css'; import 'katex/dist/katex.min.css'; import { setupAppCommandProvider } from '@renderer/lib/commands/app-commands'; diff --git a/apps/emdash-desktop/src/shared/core/acp/acpEvents.ts b/apps/emdash-desktop/src/shared/core/acp/acpEvents.ts new file mode 100644 index 0000000000..ae5259182b --- /dev/null +++ b/apps/emdash-desktop/src/shared/core/acp/acpEvents.ts @@ -0,0 +1,44 @@ +import type { SessionUpdate } from '@agentclientprotocol/sdk'; +import { defineEvent } from '@shared/lib/ipc/events'; +import type { AcpTurn, SessionLifecycle } from './acpTurns'; + +/** + * Forwarded from the main process whenever the ACP agent emits a + * session/update notification. Only emitted for the currently-active turn; + * history is served via the getChatHistory RPC. + */ +export const acpSessionUpdateChannel = defineEvent<{ + conversationId: string; + /** Turn this update belongs to (matches the current activeTurnId). */ + turnId: string; + update: SessionUpdate; + /** Monotonic per-conversation sequence number for cross-reload dedup. */ + seq: number; +}>('acp:session-update'); + +/** Emitted when the ACP agent subprocess exits or the connection closes. */ +export const acpSessionClosedChannel = defineEvent<{ + conversationId: string; + exitCode: number | null; +}>('acp:session-closed'); + +/** + * Emitted when the session lifecycle changes (starting → replaying → ready → + * working → closed) so the renderer can update isReady / isWorking without + * polling. + */ +export const acpSessionStateChannel = defineEvent<{ + conversationId: string; + lifecycle: SessionLifecycle; + /** Non-null when lifecycle === 'working'. */ + activeTurnId: string | null; +}>('acp:session-state'); + +/** + * Emitted when the active turn is committed to history (prompt() resolves or + * rejects). The renderer uses this to finalise streaming state. + */ +export const acpTurnCommittedChannel = defineEvent<{ + conversationId: string; + turn: AcpTurn; +}>('acp:turn-committed'); diff --git a/apps/emdash-desktop/src/shared/core/acp/acpTurns.ts b/apps/emdash-desktop/src/shared/core/acp/acpTurns.ts new file mode 100644 index 0000000000..5b08d7aab7 --- /dev/null +++ b/apps/emdash-desktop/src/shared/core/acp/acpTurns.ts @@ -0,0 +1,63 @@ +/** + * Shared turn model for ACP conversation history. + * + * AcpSessionManager is the single authority on turn state: it owns history + * (committed turns) and the at-most-one active turn, committing turns via + * prompt() stopReason. The renderer queries this data on mount rather than + * reconstructing it from a replay event stream. + */ + +import type { SessionUpdate } from '@agentclientprotocol/sdk'; + +/** Final state of a turn once it leaves the active slot. */ +export type TurnStatus = 'active' | 'complete' | 'error' | 'cancelled'; + +/** + * How the turn was driven. + * - `live` – originated from a prompt() call initiated by the user. + * - `replay` – originated from a loadSession replay (app restart / cold start). + */ +export type TurnSource = 'live' | 'replay'; + +/** A single prompt–response exchange, with its ordered SessionUpdate stream. */ +export interface AcpTurn { + id: string; + status: TurnStatus; + source: TurnSource; + /** Conversation-global seq of the first update in this turn. */ + startSeq: number; + /** Conversation-global seq after the last update (null while active). */ + endSeq: number | null; + updates: { seq: number; update: SessionUpdate }[]; +} + +/** + * Coarse lifecycle of a conversation's ACP session. + * + * Transitions: + * starting → replaying | ready + * replaying → ready + * ready → working | closed + * working → ready | closed + */ +export type SessionLifecycle = 'starting' | 'replaying' | 'ready' | 'working' | 'closed'; + +/** The committed history snapshot returned by getChatHistory(). */ +export interface ChatHistory { + /** Committed turns only (status !== 'active'). */ + turns: AcpTurn[]; + /** + * False while the session is still starting or a loadSession replay is in + * flight — the renderer can show a loading state in this window. + */ + complete: boolean; +} + +/** Current session state returned by getSessionState(). */ +export interface SessionState { + lifecycle: SessionLifecycle; + /** The in-flight turn, if any. */ + activeTurn: AcpTurn | null; + /** Currently selected model (null if none configured). */ + model: string | null; +} diff --git a/apps/emdash-desktop/src/shared/core/agents/agent-payload.ts b/apps/emdash-desktop/src/shared/core/agents/agent-payload.ts index 31302556fb..80f97b8c63 100644 --- a/apps/emdash-desktop/src/shared/core/agents/agent-payload.ts +++ b/apps/emdash-desktop/src/shared/core/agents/agent-payload.ts @@ -158,9 +158,19 @@ export type AgentHostDependencyInfo = { uninstall?: AgentUninstallStrategy; }; +export type ModelOption = { + name: string; + description?: string; + modelFeatures?: { + contextWindowSize?: number; + speed?: number; + intelligence?: number; + }; +}; + export type AgentCapabilities = { hostDependency: AgentHostDependencyInfo; - models: { kind: string }; + models: { kind: 'none' } | { kind: 'selectable'; modelOptions: Record }; effort: { kind: string }; prompt: { kind: string }; sessions: { kind: string }; diff --git a/apps/emdash-desktop/src/shared/core/conversations/conversation-config.test.ts b/apps/emdash-desktop/src/shared/core/conversations/conversation-config.test.ts index b54ef3a38a..b23bee284b 100644 --- a/apps/emdash-desktop/src/shared/core/conversations/conversation-config.test.ts +++ b/apps/emdash-desktop/src/shared/core/conversations/conversation-config.test.ts @@ -2,17 +2,11 @@ import { describe, expect, it } from 'vitest'; import { conversationConfig, isDroidProviderSessionId } from './conversation-config'; describe('conversation-config', () => { - it('parses autoApprove and providerSessionId', () => { - const result = conversationConfig.safeParse({ - autoApprove: true, - providerSessionId: '31477a03-961a-4451-82d4-efded56947fc', - }); + it('parses autoApprove', () => { + const result = conversationConfig.safeParse({ autoApprove: true }); expect(result.status).toBe('ok'); if (result.status === 'ok') { - expect(result.data).toEqual({ - autoApprove: true, - providerSessionId: '31477a03-961a-4451-82d4-efded56947fc', - }); + expect(result.data).toEqual({ autoApprove: true }); } }); @@ -22,7 +16,7 @@ describe('conversation-config', () => { }); it('round-trips through parseJson and serialize', () => { - const config = { autoApprove: false, providerSessionId: 'abc' }; + const config = { autoApprove: false }; const json = conversationConfig.serialize(config); expect(conversationConfig.parseJson(json)).toEqual(config); }); diff --git a/apps/emdash-desktop/src/shared/core/conversations/conversation-config.ts b/apps/emdash-desktop/src/shared/core/conversations/conversation-config.ts index 0abb5c28c9..9e338a9d2f 100644 --- a/apps/emdash-desktop/src/shared/core/conversations/conversation-config.ts +++ b/apps/emdash-desktop/src/shared/core/conversations/conversation-config.ts @@ -9,10 +9,10 @@ export function isDroidProviderSessionId(value: string): boolean { const conversationConfigV0Schema = z.object({ autoApprove: z.boolean().optional(), - /** Provider-native session id (e.g. Droid UUID) for resuming the correct chat. */ - providerSessionId: z.string().optional(), /** Initial prompt to deliver on the first spawn; cleared from config after the session starts. */ initialPrompt: z.string().optional(), + /** Selected model id for ACP-based chat conversations. */ + model: z.string().optional(), }); export const conversationConfig = defineVersionedSchema() diff --git a/apps/emdash-desktop/src/shared/core/conversations/conversationEvents.ts b/apps/emdash-desktop/src/shared/core/conversations/conversationEvents.ts index eb6b4ae330..8ddab06f64 100644 --- a/apps/emdash-desktop/src/shared/core/conversations/conversationEvents.ts +++ b/apps/emdash-desktop/src/shared/core/conversations/conversationEvents.ts @@ -6,7 +6,9 @@ export const conversationChangedChannel = defineEvent<{ conversationId: string; taskId: string; projectId: string; - changes: Partial>; + changes: Partial< + Pick + >; }>('conversation:changed'); export const conversationCreatedChannel = defineEvent<{ diff --git a/apps/emdash-desktop/src/shared/core/conversations/conversations.ts b/apps/emdash-desktop/src/shared/core/conversations/conversations.ts index d34a8021b5..50c8f4300d 100644 --- a/apps/emdash-desktop/src/shared/core/conversations/conversations.ts +++ b/apps/emdash-desktop/src/shared/core/conversations/conversations.ts @@ -3,6 +3,11 @@ import type { AgentStatus } from '@shared/core/agents/agentEvents'; export const MAX_CONVERSATION_TITLE_LENGTH = 100; +/** Provider IDs that support the ACP (Agent Client Protocol) chat transport. */ +export const ACP_CAPABLE_PROVIDER_IDS: ReadonlySet = new Set(['claude']); + +export type ConversationType = 'pty' | 'acp'; + export type Conversation = { id: string; projectId: string; @@ -17,6 +22,9 @@ export type Conversation = { isInitialConversation: boolean | null; agentStatus?: AgentStatus | null; agentStatusSeen?: boolean; + type: ConversationType; + /** Persisted model selection for ACP chat conversations. */ + model?: string; }; export type RenameConversationParams = { @@ -34,4 +42,5 @@ export type CreateConversationParams = { isInitialConversation?: boolean; initialSize?: { cols: number; rows: number }; initialPrompt?: string; + type?: ConversationType; }; diff --git a/apps/emdash-desktop/src/shared/view-state.ts b/apps/emdash-desktop/src/shared/view-state.ts index 6dbae69e28..f1f408d9e8 100644 --- a/apps/emdash-desktop/src/shared/view-state.ts +++ b/apps/emdash-desktop/src/shared/view-state.ts @@ -8,6 +8,7 @@ export type TabViewSnapshot = { export type TabDescriptor = | { kind: 'conversation'; tabId: string; conversationId: string; isPreview: boolean } + | { kind: 'chat'; tabId: string; conversationId: string; isPreview: boolean } | { kind: 'file'; tabId: string; path: string; isPreview: boolean; isExternal?: boolean } | { kind: 'browser'; diff --git a/packages/chat-ui/.oxlintrc.json b/packages/chat-ui/.oxlintrc.json new file mode 100644 index 0000000000..2501b9a4ee --- /dev/null +++ b/packages/chat-ui/.oxlintrc.json @@ -0,0 +1,7 @@ +{ + "extends": ["../../apps/emdash-desktop/.oxlintrc.json"], + "rules": { + "react-hooks/rules-of-hooks": "off", + "react-hooks/exhaustive-deps": "off" + } +} diff --git a/packages/chat-ui/.storybook/main.ts b/packages/chat-ui/.storybook/main.ts new file mode 100644 index 0000000000..67d692a1dc --- /dev/null +++ b/packages/chat-ui/.storybook/main.ts @@ -0,0 +1,12 @@ +import tailwindcss from '@tailwindcss/vite'; +import type { StorybookConfig } from 'storybook-solidjs-vite'; +import { mergeConfig } from 'vite'; + +const config: StorybookConfig = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(ts|tsx)'], + addons: ['@storybook/addon-docs'], + framework: 'storybook-solidjs-vite', + viteFinal: (config) => mergeConfig(config, { plugins: [tailwindcss()] }), +}; + +export default config; diff --git a/packages/chat-ui/.storybook/preview.css b/packages/chat-ui/.storybook/preview.css new file mode 100644 index 0000000000..4e9535e000 --- /dev/null +++ b/packages/chat-ui/.storybook/preview.css @@ -0,0 +1,64 @@ +/* Devicon icon font — required for FileIcon in diff header stories */ +@import 'devicon/devicon.min.css'; + +/* Emdash design tokens + semantic color aliases */ +@import '@emdash/ui/theme/theme.css'; +@import '@emdash/ui/theme/semantic.css'; + +/* Chat-specific color bridge (--chat-* vars mapped from semantic tokens) */ +@import '../src/chat-theme.css'; + +/* + * Full Tailwind (including preflight/base) — acceptable here because Storybook + * is a standalone app, not the host desktop renderer. The host renderer has its + * own Tailwind + preflight; the lib build ships utilities only (no preflight). + */ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +/* + * Shared @theme inline bridge + @custom-variant definitions. + * These are the single source of truth consumed by both Storybook and the lib. + */ +@import '../src/tailwind.css'; + +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } + + ::selection { + background-color: color-mix(in srgb, var(--selection) 35%, transparent); + color: var(--selection-foreground); + } + + ::-moz-selection { + background-color: color-mix(in srgb, var(--selection) 35%, transparent); + color: var(--selection-foreground); + } + + * { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; + } + + *::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + *::-webkit-scrollbar-track { + background: transparent; + } + + *::-webkit-scrollbar-thumb { + background-color: var(--border); + border-radius: 4px; + border: 2px solid transparent; + background-clip: content-box; + } +} diff --git a/packages/chat-ui/.storybook/preview.tsx b/packages/chat-ui/.storybook/preview.tsx new file mode 100644 index 0000000000..962d6ff062 --- /dev/null +++ b/packages/chat-ui/.storybook/preview.tsx @@ -0,0 +1,57 @@ +import type { Preview } from 'storybook-solidjs-vite'; +import { DebugContext } from '../src/components/debug-context'; +import './preview.css'; + +const preview: Preview = { + parameters: { + layout: 'centered', + }, + decorators: [ + (Story, context) => { + // storybook-solidjs makes `context.globals` a reactive Solid store and does + // NOT remount the story when a toolbar global changes — it reconciles the + // store in place. So globals must be read lazily inside the reactive tree + // (accessors / JSX), never snapshotted here, or theme/debug won't update. + const globals = context.globals as Record; + const theme = () => globals['theme'] ?? 'emlight'; + const debugOn = () => globals['debug'] === 'on'; + return ( + +
+ +
+
+ ) as unknown as ReturnType; + }, + ], + globalTypes: { + theme: { + description: 'Color theme', + defaultValue: 'emlight', + toolbar: { + title: 'Theme', + icon: 'circlehollow', + items: [ + { value: 'emlight', title: 'Light' }, + { value: 'emdark', title: 'Dark' }, + ], + dynamicTitle: true, + }, + }, + debug: { + description: 'Layout boundary debug overlay', + defaultValue: 'off', + toolbar: { + title: 'Debug', + icon: 'ruler', + items: [ + { value: 'off', title: 'Debug off' }, + { value: 'on', title: 'Debug on' }, + ], + dynamicTitle: true, + }, + }, + }, +}; + +export default preview; diff --git a/packages/chat-ui/.storybook/vite-env.d.ts b/packages/chat-ui/.storybook/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/packages/chat-ui/.storybook/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/chat-ui/API.md b/packages/chat-ui/API.md new file mode 100644 index 0000000000..8c59d1736a --- /dev/null +++ b/packages/chat-ui/API.md @@ -0,0 +1,422 @@ +# @emdash/chat-ui — Public API + +A Solid-based chat transcript renderer with a pretext layout engine. It +virtualizes long conversations, renders markdown/code/diffs/tool calls, and +exposes a small imperative handle for streaming and scroll control. + +The package ships two entry points: + +- `@emdash/chat-ui` — the Solid mount function `mountChat`. +- `@emdash/chat-ui/react` — a thin React wrapper component `ChatTranscript`. + +Everything below is the supported surface. Internals (components, layout +engine, stores) are not exported and may change without notice. + +--- + +## Installation & styles + +```ts +import { mountChat } from '@emdash/chat-ui'; +import '@emdash/chat-ui/style.css'; +``` + +Optionally map the renderer's colors onto the host design-system tokens by +importing the theme bridge and applying an `.emlight` / `.emdark` class on an +ancestor element: + +```ts +import '@emdash/chat-ui/chat-theme.css'; +``` + +The mount container must have a **fixed height** — the virtualizer measures the +scroll viewport, so a zero-height or auto-height container renders nothing. + +--- + +## Quick start (Solid / vanilla) + +```ts +import { mountChat } from '@emdash/chat-ui'; +import '@emdash/chat-ui/style.css'; + +const handle = mountChat(document.getElementById('chat')!, { + stickToBottom: true, + commands: { + onOpenFile: ({ path }) => openInEditor(path), + }, +}); + +// Seed historical items. +handle.transcript.seed([ + { kind: 'message', id: 'u1', role: 'user', text: 'Hello!' }, +]); + +// Stream a turn. +handle.transcript.dispatch({ type: 'message_chunk', id: 'a1', role: 'assistant', text: 'Hi ' }); +handle.transcript.dispatch({ type: 'message_chunk', id: 'a1', role: 'assistant', text: 'there.' }); +handle.transcript.dispatch({ type: 'turn_done' }); + +// Tear down when done. +handle.dispose(); +``` + +## Quick start (React) + +```tsx +import { ChatTranscript, type ChatHandle } from '@emdash/chat-ui/react'; +import '@emdash/chat-ui/style.css'; + +function Chat() { + const handleRef = useRef(null); + return ( +
+ openInEditor(path) }} + onReady={(h) => { + handleRef.current = h; + h.transcript.seed(initialItems); + }} + /> +
+ ); +} +``` + +--- + +## `mountChat(container, options?)` + +```ts +function mountChat(container: HTMLElement, opts?: MountChatOptions): ChatHandle; +``` + +Mounts the Solid renderer into `container` and returns a `ChatHandle`. Call +`handle.dispose()` to unmount and release all DOM and caches. + +### `MountChatOptions` + +| Option | Type | Description | +| --- | --- | --- | +| `theme` | `ChatTheme` | Typography + density. Defaults to `DEFAULT_THEME`. | +| `stickToBottom` | `boolean` | Auto-scroll to bottom as content streams in. | +| `transcript` | `TranscriptApi` | Reuse an existing transcript store; otherwise one is created. | +| `viewState` | `ViewState` | Reuse an existing collapse store; otherwise one is created. | +| `class` | `string` | Extra class on the full-width scroll container. | +| `contentClass` | `string` | Class for the centered content column (defaults to a max-width column). | +| `padTop` | `number` | Top padding (px) baked into the virtualizer coordinate space. | +| `padBottom` | `number` | Bottom padding (px) — use to clear a floating composer. | +| `commands` | `ChatCommands` | User-interaction callbacks (see below). | +| `onReachStart` | `() => void` | Fires when scrolled near the top and history is exhausted. | +| `onAtBottomChange` | `(atBottom: boolean) => void` | Fires when the "at bottom" sticky state changes. | + +### `ChatHandle` + +| Member | Signature | Description | +| --- | --- | --- | +| `transcript` | `TranscriptApi` | Seed/stream data into the transcript. | +| `viewState` | `ViewState` | Manage per-row collapse state. | +| `setContentPadding` | `(p: { top?: number; bottom?: number }) => void` | Update canvas padding without remounting. | +| `scrollToBottom` | `(opts?: { behavior?: ScrollBehavior }) => void` | Scroll to the latest row. | +| `scrollToItem` | `(id: string, opts?: ScrollToItemOptions) => void` | Scroll to a row by item id. | +| `loadOlder` | `(items: ChatItem[]) => void` | Prepend history while preserving scroll position. | +| `setCommands` | `(commands: ChatCommands) => void` | Swap command callbacks without remounting. | +| `dispose` | `() => void` | Unmount the Solid root and remove all DOM. | + +### `ChatCommands` + +```ts +type ChatCommands = { + onOpenFile?: (arg: { path: string; itemId: string; source: 'diff' | 'file-op' }) => void; +}; +``` + +Invoked when the user clicks a file path in a diff header or a file-op row. + +### `ScrollToItemOptions` + +```ts +type ScrollToItemOptions = { + align?: 'start' | 'center' | 'end'; // default: 'start' + offset?: number; // default: 0 + behavior?: ScrollBehavior; // default: 'auto' +}; +``` + +`scrollToItem` is best-effort precise: if the target was off-screen (its height +was estimated, not measured), it settles within a frame or two. + +--- + +## React adapter — `@emdash/chat-ui/react` + +```tsx +function ChatTranscript(props: ChatTranscriptProps): React.ReactElement; +``` + +`ChatTranscriptProps` is `MountChatOptions` minus the imperative bits, plus: + +| Prop | Type | Description | +| --- | --- | --- | +| `onReady` | `(handle: ChatHandle) => void` | Called once after mount with the handle. | +| `style` | `React.CSSProperties` | Style for the wrapper `div`. | +| `className` | `string` | Class for the wrapper `div`. | +| `padTop` / `padBottom` | `number` | Pushed reactively via `setContentPadding` (no remount). | +| `commands` | `ChatCommands` | Pushed reactively so inline callbacks never go stale. | +| `onReachStart` | `() => void` | Pushed reactively. | +| `onAtBottomChange` | `(atBottom: boolean) => void` | Pushed reactively. | + +The component mounts the Solid root on mount, exposes the handle via `onReady`, +and disposes on unmount. `commands`, padding, and callbacks are forwarded +reactively so React re-renders never produce stale closures. + +Also re-exported from this entry point: `ChatHandle`, `ChatCommands`, +`ScrollToItemOptions`, and `LoadOlderFn = (items: ChatItem[]) => void`. + +--- + +## Data model + +A transcript is an array of `ChatItem`s. Each item is a discriminated union on +`kind`. Every item has a stable, unique `id` (used as the React/Solid key and +the layout memo key — keep references stable across updates). + +```ts +type ChatItem = + | ChatMessage + | ChatToolCall + | ChatThinking + | ChatFileOpToolCall + | ChatExecute + | ChatDiff; + +type ChatRole = 'user' | 'assistant' | 'thought'; +type ToolStatus = 'running' | 'done' | 'error'; +type ThinkingStatus = 'thinking' | 'done'; +type FileOpKind = 'read' | 'edit' | 'delete' | 'move'; +type FileOp = { path: string }; +``` + +### `ChatMessage` + +```ts +type ChatMessage = { + kind: 'message'; + id: string; + role: ChatRole; + text: string; // markdown source + streaming?: boolean; // true while the agent is still writing +}; +``` + +### `ChatToolCall` + +```ts +type ChatToolCall = { + kind: 'tool'; + id: string; + name: string; + status: ToolStatus; + inputSummary?: string; // one-line synopsis shown next to the name +}; +``` + +### `ChatThinking` + +```ts +type ChatThinking = { + kind: 'thinking'; + id: string; + status: ThinkingStatus; + text: string; // accumulating reasoning text + startedAt: number; // epoch ms; drives the live duration + durationMs?: number; // frozen once status flips to 'done' +}; +``` + +While `thinking`, the row shows a fixed-height streaming window with the tail +faded at the top. When `done`, it collapses to a "Thought for {duration}s" +header that expands to the full text. + +### `ChatFileOpToolCall` + +```ts +type ChatFileOpToolCall = { + kind: 'file-op'; + id: string; + op: FileOpKind; + status: ToolStatus; + ops: FileOp[]; // replaced (not appended) on each update +}; +``` + +A single file renders as a one-liner ("Read foo.tsx"); multiple files render as +a collapsible "Read 2 files ›" header. Collapse semantics are **inverted**: the +stored "collapsed" flag means "expanded". + +### `ChatExecute` + +```ts +type ChatExecute = { + kind: 'execute'; + id: string; + command: string; // e.g. "ls -a"; empty until the command arrives + status: ToolStatus; + startedAt: number; // epoch ms + durationMs?: number; // frozen once done; omitted when unavailable +}; +``` + +### `ChatDiff` + +```ts +type ChatDiff = { + kind: 'diff'; + id: string; // `${toolCallId}:${path}` + path: string; + oldText: string | null; // null => new file (all newText lines are adds) + newText: string; + status: ToolStatus; +}; +``` + +Renders a compact preview of the first changed region (≤12 lines, ±1 context) +with syntax + diff highlighting. One `ChatDiff` per changed file. + +--- + +## `TranscriptApi` + +The store you reach through `handle.transcript`. It keeps a two-tier state: +`committed` items (immutable) and an `activeTurn` that accumulates streaming +updates until `turn_done` moves it into `committed`. + +```ts +type TranscriptApi = { + readonly state: TranscriptState; + seed(history: ChatItem[]): void; + dispatch(event: TranscriptEvent): void; + reset(): void; + prependHistory(items: ChatItem[]): void; + findIndexById(id: string): number; +}; +``` + +| Method | Description | +| --- | --- | +| `seed(history)` | Replace the entire transcript (e.g. session replay). | +| `dispatch(event)` | Feed one streaming event (see `TranscriptEvent`). | +| `reset()` | Clear all state. | +| `prependHistory(items)` | Prepend older items above current ones; keep stable references. | +| `findIndexById(id)` | Absolute index (committed-first), or `-1`. | + +> For pagination, pair `onReachStart` with `handle.loadOlder(items)` rather than +> calling `prependHistory` directly — `loadOlder` also preserves scroll position. + +### `TranscriptEvent` + +A discriminated union on `type`. Deltas (text/reasoning chunks) are appended; +`ops`/`command`/`status` updates patch the matching item by `id`. + +| `type` | Payload | Effect | +| --- | --- | --- | +| `message_chunk` | `{ id, role, text }` | Append a text chunk to a message (creates it on first chunk). | +| `tool_start` | `{ id, name, inputSummary? }` | Begin a tool call. | +| `tool_update` | `{ id, status?, name?, inputSummary? }` | Patch a tool call. | +| `thinking_chunk` | `{ id, text, startedAt? }` | Append reasoning text (creates the row on first chunk). | +| `thinking_done` | `{ id, durationMs? }` | Freeze reasoning to `done`. | +| `file_op_start` | `{ id, op, ops }` | Begin a file-operation call. | +| `file_op_update` | `{ id, status?, ops? }` | Patch a file-op (full `ops` replacement). | +| `execute_start` | `{ id, command, startedAt? }` | Begin an execute call. | +| `execute_update` | `{ id, command?, status? }` | Patch an execute call. | +| `diff_start` | `{ id, path, oldText, newText }` | Begin a diff preview. | +| `diff_update` | `{ id, status?, oldText?, newText? }` | Patch a diff row. | +| `turn_done` | `{}` | Finalize the active turn into `committed`. | + +Dispatching a content event after reasoning auto-finalizes any still-open +`thinking` row, so you rarely need an explicit `thinking_done`. + +--- + +## `ViewState` + +Per-row collapse state, reached through `handle.viewState`. + +```ts +type ViewState = { + isCollapsed(id: string): boolean; + toggleCollapsed(id: string): void; + setCollapsed(id: string, value: boolean): void; + expandAll(): void; +}; +``` + +Note the **inverted** semantics for `thinking` and `file-op` rows: a stored +"collapsed" flag is treated as "expanded". + +--- + +## Theming + +```ts +type DensityScale = { + blockGap: number; // gap between blocks of different tiers + proseGap: number; // tighter gap between two prose blocks + inlineCodePadX: number; // inline-code chip horizontal padding (px) + inlineCodePadY: number; // inline-code chip vertical padding (px) +}; + +type ChatTheme = { + version: number; // bump on any change so the layout memo invalidates + fonts: FontConfig; + density: DensityScale; +}; + +function buildTheme(fonts?: FontConfig): ChatTheme; +const DEFAULT_THEME: ChatTheme; +``` + +`ChatTheme` is the single injection point for measurement inputs (typography + +shared density). Build a custom theme with `buildTheme(fonts)` and pass it via +`MountChatOptions.theme`. Always bump `version` when cloning a theme with +changed values so the renderer's identity-based layout cache invalidates. + +Colors are CSS-driven (the `--chat-*` variables in `chat-theme.css`), not part +of `ChatTheme`. + +--- + +## Test / story helper + +```ts +function generateMockTranscript(count?: number, seed?: number): ChatItem[]; +``` + +Returns a deterministic mock transcript (default `count = 6000`, `seed = 1`) +mixing messages, tool calls, thinking, file-ops, execute rows, and markdown +blocks. Useful for stories, performance testing, and demos. + +--- + +## Exports at a glance + +**`@emdash/chat-ui`** + +- Values: `mountChat`, `buildTheme`, `DEFAULT_THEME`, `generateMockTranscript` +- Types: `MountChatOptions`, `ChatHandle`, `ChatCommands`, `ScrollToItemOptions`, + `ChatTheme`, `DensityScale`, `TranscriptApi`, `TranscriptEvent`, `ViewState`, + `ChatItem`, `ChatMessage`, `ChatToolCall`, `ChatThinking`, + `ChatFileOpToolCall`, `ChatExecute`, `ChatDiff`, `ChatRole`, `FileOpKind`, + `FileOp`, `ToolStatus` + +**`@emdash/chat-ui/react`** + +- Values: `ChatTranscript` +- Types: `ChatTranscriptProps`, `ChatHandle`, `ChatCommands`, + `ScrollToItemOptions`, `LoadOlderFn` + +**Styles** + +- `@emdash/chat-ui/style.css` — required renderer styles +- `@emdash/chat-ui/chat-theme.css` — optional color bridge to `.emlight` / `.emdark` diff --git a/packages/chat-ui/ARCHITECTURE.md b/packages/chat-ui/ARCHITECTURE.md new file mode 100644 index 0000000000..dd846fe1ec --- /dev/null +++ b/packages/chat-ui/ARCHITECTURE.md @@ -0,0 +1,746 @@ +# chat-ui — Technical Architecture + +`@emdash/chat-ui` is a framework-agnostic, high-performance chat transcript +renderer built on **SolidJS**. It is designed to render very long, continuously +streaming AI conversations (10k+ rows) at 60fps without layout thrash. + +It achieves this by separating three concerns that most chat UIs entangle: + +1. **Measurement** — computing the exact pixel height/geometry of every row in + pure JavaScript _before_ touching the DOM (using [`pretext`](#3-pretext--off-dom-text-measurement) for text shaping). +2. **Virtualization** — only mounting the handful of rows currently on screen, + using a [Fenwick tree](#4-the-fenwick-tree-virtualizer) for O(log n) scroll math. +3. **Projection** — rendering each visible row by walking a precomputed + [layout tree](#5-the-measurecomposeproject-rendering-pipeline) and applying + geometry via inline styles (no CSS-driven reflow). + +The result is a renderer where adding a token to a streaming message, or +scrolling through thousands of rows, costs `O(log n)` rather than `O(n)`, and +where the browser never re-flows content it has already laid out. + +--- + +## Table of contents + +- [1. High-level architecture](#1-high-level-architecture) +- [2. SolidJS — the reactive substrate](#2-solidjs--the-reactive-substrate) +- [3. pretext — off-DOM text measurement](#3-pretext--off-dom-text-measurement) +- [4. The Fenwick tree virtualizer](#4-the-fenwick-tree-virtualizer) +- [5. The measure/compose/project rendering pipeline](#5-the-measurecomposeproject-rendering-pipeline) +- [6. The data model & transcript store](#6-the-data-model--transcript-store) +- [7. End-to-end flow: a streaming token](#7-end-to-end-flow-a-streaming-token) +- [8. Scroll virtualization in motion](#8-scroll-virtualization-in-motion) +- [9. Caching strategy](#9-caching-strategy) +- [10. How the concepts interact](#10-how-the-concepts-interact) + +--- + +## 1. High-level architecture + +The package exposes a single imperative entry point, `mountChat(container, opts)` +(`src/index.tsx`), which renders a Solid root (`ChatRoot`) and returns a +`ChatHandle` for feeding data and controlling scroll. Everything else is internal. + +```mermaid +flowchart TD + Host["Host app"] -->|mountChat| Handle["ChatHandle
(transcript, viewState, scroll API)"] + Host -->|dispatch events| Store["Transcript store
(Solid createStore)"] + + subgraph Engine["ChatRoot (Solid root)"] + Store --> CountEffect["count-sync effect
virt.setCount(estimate)"] + CountEffect --> Virt["Virtualizer
(Fenwick tree)"] + Scroll["scroll / resize signals"] --> VisRange["visibleRange memo"] + Virt --> VisRange + VisRange --> For["For each visible index"] + For --> Row["Row"] + Row -->|measure| Measure["ComponentDef.measure()"] + Measure -->|"Measured tree"| Project["Project (tree walker)"] + Measure -->|"exact height"| Virt + Project --> DOM["Positioned DOM"] + end + + subgraph Support["Cross-cutting services"] + Pretext["pretext
(glyph measurement)"] + Caches["ChatCaches
(per-instance)"] + Theme["ChatTheme
(fonts + density)"] + end + + Measure -.uses.-> Pretext + Measure -.uses.-> Caches + Measure -.reads.-> Theme +``` + +The key architectural inversion: **layout is computed first, in JS, and the DOM +is a pure projection of that layout.** The browser is never asked to measure or +wrap text — `pretext` does that off-DOM, and every element is positioned with an +explicit `top`/`left`/`height`. + +--- + +## 2. SolidJS — the reactive substrate + +The renderer is built on Solid because Solid's **fine-grained reactivity** maps +perfectly onto "only re-run the computation whose specific input changed." + +Unlike React's re-render-the-component-tree model, Solid components run **once**; +afterward, only the individual reactive computations (`createMemo`, +`createEffect`, JSX expressions) that read a changed signal re-execute. + +### Primitives in use + +| Primitive | Where | Purpose | +| --- | --- | --- | +| `createSignal` | `ChatRoot` (`scrollTop`, `viewHeight`, `totalHeight`, `containerWidth`) | Scalar reactive state driving the visible range. | +| `createStore` | `state/transcript.ts`, `state/view-state.ts` | Fine-grained nested reactivity: mutating one item's `text` only notifies readers of that path. | +| `createMemo` | `ChatRoot` (`visibleRange`, `visibleIndexes`), `Row` (`layout`) | Cached derived values; recompute only when dependencies change. | +| `createEffect` | `Row` (height bridge), `ChatRoot` (count-sync, width flush) | Side effects synchronizing JS state into the virtualizer / DOM. | +| `createContext` / `useContext` | `ThemeContext`, `CachesContext`, `CommandsContext` | Dependency injection without prop drilling. | +| `` | `ChatRoot` (visible rows), `Project` (placed children) | Keyed list rendering — reuses DOM nodes by key. | +| `/` | `Project` | Dispatch a layout node to the right sub-renderer by `layout.kind`. | + +### The "Lane A / Lane B" state split + +A core discipline (documented in `src/core/define.ts`) divides state into two lanes: + +- **Lane A — layout-affecting state.** Only `width`, `theme.version`, and + resolved `expanded` state may flow into `measure()`/`estimate()`. These are the + _only_ inputs allowed in the memo fingerprint and the _only_ things that can + trigger `virt.setSize`. +- **Lane B — presentational/ephemeral state.** Copy-button "copied" flags, hover, + shimmer, timer ticks — these live as local signals inside `Render` components + and **never** enter measurement. + +This separation is what guarantees that a hover or a copy-click can never +invalidate a height and cause a scroll jump. + +```mermaid +flowchart LR + subgraph LaneA["Lane A — affects height"] + W[width] --> Measure + TV[theme.version] --> Measure + EX["expanded(id)"] --> Measure + Measure["measure() / estimate()"] --> Fingerprint["memo fingerprint"] + Fingerprint --> Virt["virt.setSize"] + end + subgraph LaneB["Lane B — never affects height"] + Hover[hover] --> Render + Copied[copied] --> Render + Tick[timer tick] --> Render + Render["Render component (local signals)"] + end +``` + +--- + +## 3. pretext — off-DOM text measurement + +**The problem:** to virtualize, you must know each row's height _before_ you +render it. For text, height depends on line-wrapping, which depends on glyph +widths — normally only the browser knows these, and only after layout. + +**The solution:** `@chenglou/pretext` measures glyph widths and computes +line-breaking entirely off-DOM (via `OffscreenCanvas` text metrics), so the +engine can compute exact prose height in pure JS. + +### The measurement contract + +Because measurement happens off-DOM, the rendered DOM must reproduce pretext's +metrics _exactly_. This is enforced by: + +- **Font shorthands** in `FontConfig` (`src/core/measure/fonts.ts`) that exactly + match the CSS `font` applied to each fragment variant (body/bold/italic/code/…). +- **Geometry-coupled CSS** in `prose.module.css` (`font-size`, `font-family`, + `white-space: pre`, `line-height: 1`, inline-code chip padding) that pretext + also accounts for via `extraWidth`. +- **Browser contract tests** (`*.contract.test.tsx`) that mount the real + component and assert `def.measure(...).height === element.offsetHeight`. + +### The pipeline for one prose block + +```mermaid +flowchart LR + MD["markdown string"] -->|"remark parse"| Blocks["Block[]
(document model)"] + Blocks -->|"ProseBlock.runs"| R2R["runsToRichItems()"] + R2R -->|"RichInlineItem[]
(text + font + extraWidth)"| Prep["prepareRichInline()"] + Prep -->|"PreparedRichInline"| Walk["walkRichInlineLineRanges(width)"] + Walk -->|"per-line ranges"| Mat["materializeRichInlineLineRange()"] + Mat -->|"fragments + x offsets"| Laid["ProseLaidOut
(lines, fragments, height)"] +``` + +`layoutProse` (`src/components/prose/layout.ts`) drives this: + +1. Splits `runs` at `{ kind: 'break' }` markers into independently-shaped segments. +2. For each segment, converts runs → `RichInlineItem[]` (mapping bold/italic/ + code/mention to the matching font shorthand + extra width). +3. Calls `prepareRichInline` (memoized — see [§9](#9-caching-strategy)). +4. `walkRichInlineLineRanges(prepared, effectiveWidth, …)` yields each wrapped + line; `materializeRichInlineLineRange` gives the fragments and their x offsets. +5. Produces a `ProseLaidOut` with absolute per-line `top` and per-fragment `x` — + pure geometry, no DOM. + +`measureProseNaturalWidth` runs the same shaping with an unbounded width to get +the intrinsic content width — used by the user-bubble hug algorithm. + +The `Prose` renderer (`src/components/prose/Prose.tsx`) then just emits absolutely +positioned ``s at the precomputed `(x, top)` — no wrapping, no reflow. + +--- + +## 4. The Fenwick tree virtualizer + +`src/core/virtualizer.ts` is the heart of scroll performance. It maintains every +row's pixel height in a **Fenwick tree** (Binary Indexed Tree) so the operations +the scroll loop needs are all `O(log n)`: + +| Operation | Meaning | Complexity | +| --- | --- | --- | +| `setSize(i, h)` | a row was measured; update its height | `O(log n)` | +| `top(i)` | prefix sum — pixel offset of row `i` | `O(log n)` | +| `total()` | total canvas height | `O(1)` (running sum) | +| `findIndex(offset)` | binary-lift: which row is at pixel `offset` | `O(log n)` | +| `range(scrollTop, viewH)` | inclusive visible `{start, end}` | `O(log n)` | + +### Why a Fenwick tree? + +A naive virtualizer recomputes cumulative offsets in `O(n)` whenever any row's +height changes — catastrophic when a streaming row's height changes every token. +The BIT makes both the update (`setSize`) and the reverse lookup (`findIndex` via +**binary lifting** over the tree) logarithmic. + +```mermaid +flowchart TD + subgraph BIT["Fenwick tree (1-indexed)"] + direction TB + Sizes["sizes[]: per-row truth (Float64Array)"] + Tree["bit[]: range partial sums"] + Total["totalSize: running O(1) sum"] + end + + setSize["setSize(i, h)"] -->|"delta = h - old"| Tree + setSize --> Total + top["top(i)"] -->|"prefix sum [0,i)"| Tree + findIndex["findIndex(offset)"] -->|"binary lift"| Tree + Total --> total["total()"] +``` + +### Growth strategy + +Streaming appends one message per turn, so `setCount` is tuned for the +append-at-tail case: growing keeps existing BIT entries valid and builds only the +new high-index nodes from their children — `O(log n)` per appended row. +`prepend` (history pagination) shifts sizes and rebuilds the BIT in one `O(n)` +pass, which is acceptable for user-paced "load older" actions. + +### Scroll-anchor correction + +`setSize` returns the signed pixel delta `(newH - oldH)`. When a row _above_ the +viewport changes height (e.g. a collapsed thinking row expands off-screen, or an +estimated row settles to its real height), `ChatRoot.onHeightChanged` adds that +delta to `scrollTop` so the content the user is looking at doesn't jump. + +--- + +## 5. The measure/compose/project rendering pipeline + +Every row kind (message, thinking, diff, tool, file-op, execute) and every block +tier (prose, code, table) is described by a **`ComponentDef`** (`src/core/define.ts`): + +```ts +type ComponentDef = { + kind: string; + padY?: number; + collapse?: CollapseDecl; + estimate(node, ctx: MeasureCtx): number; // O(1) heuristic + measure(node, ctx: MeasureCtx): Measured; // exact geometry + Render: Component<{ item; layout: Measured; ctx }>; +}; +``` + +All defs are gathered in a single `REGISTRY` (`src/components/registry.ts`) keyed +by node kind. `Row.tsx` dispatches through it; composite rows dispatch their +block children through the same map. + +### Step 1 — `estimate`: cheap height for every row + +When the item count changes, `ChatRoot` seeds the virtualizer with an `O(1)` +character-count heuristic for **every** row (visible or not). This gives a +plausible total canvas height instantly without measuring off-screen content. + +### Step 2 — `measure`: exact geometry for visible rows only + +`measure` runs only for rows in the visible range. It returns a `Measured`: + +```ts +type Measured = { height: number; width: number; layout: L }; +``` + +Rather than each def hand-rolling its geometry, layout is assembled from **pure +combinators** in `src/core/compose.ts`. Each returns a `Measured` whose +`layout.kind` discriminates its payload: + +| Combinator | Builds | +| --- | --- | +| `stack(children, {padY, gap})` | vertical sequence; accumulates child heights | +| `pad(child, {padX, padY, border})` | uniform padding + border | +| `bubble(child, {padX, variantClass, width})` | hug-width user message bubble | +| `collapsible({headerH, headerSlot, expanded, body})` | header + optional body | +| `scrollWindow(child, maxH, {overlay, autoScrollBottom})` | clip tall content to a viewport | +| `slot(name, height)` | named placeholder for non-generic chrome (headers/footers) | + +> **Width flows down, height flows up.** Callers narrow the width budget before +> calling `measure`; combinators sum child heights upward. This is the +> single invariant that keeps the whole tree consistent. + +For example, an assistant message's `measure` produces: + +```mermaid +flowchart TD + msg["messageDef.measure()"] --> stack["stack"] + stack --> bs["layoutBlockStack(blocks)"] + stack --> footer["slot('message:footer')"] + bs --> p1["prose leaf"] + bs --> c1["code leaf"] + bs --> p2["prose leaf"] +``` + +### Step 3 — `Project`: render the tree + +`src/components/Project.tsx` is a generic tree-walker. Given the `Measured` tree, +it recurses by `layout.kind` through a Solid ``: + +- **Combinator nodes** (stack/pad/bubble/collapsible/window) → positioned `
`s + with explicit geometry, recursing into children. +- **Slot nodes** → resolved from the `slots` map the Render shell supplies + (this is how non-generic chrome like a diff header or message footer is injected + into the otherwise-generic walk). +- **Block leaves** (prose/code/table) → `renderBlockLeaf`, which uses the `raw` + back-reference on each leaf to construct `Prose`/`Code`/`Table` without a + second lookup. + +So a `Render` component is a thin shell: it sets the row height, supplies slots, +and delegates the entire body to ``. + +```mermaid +flowchart TD + Render["ComponentDef.Render (shell)"] --> Project["Project(node, slots)"] + Project -->|"kind === 'stack'"| PStack["ProjectStack"] + Project -->|"kind === 'collapsible'"| PColl["ProjectCollapsible"] + Project -->|"kind === 'window'"| PWin["ProjectWindow (auto-scroll)"] + Project -->|"kind === 'slot'"| Slots["slots[name] → header/footer chrome"] + Project -->|"block leaf"| Leaf["renderBlockLeaf → Prose / Code / Table"] + PStack --> Project + PColl --> Project + PWin --> Project +``` + +### The height bridge (`Row.tsx`) + +`Row` ties measurement to the virtualizer: + +```ts +const layout = createMemo(() => cachedMeasure(item, isActiveTurn, measureCtx())); +const reserved = () => layout().height + 2 * padY(); +createEffect(() => { + const delta = virt.setSize(index, reserved()); + if (delta !== 0) onHeightChanged(index, delta); +}); +``` + +When measurement yields a new height, the effect writes it into the Fenwick tree +and triggers scroll-anchor correction if needed — closing the loop. + +--- + +## 6. The data model & transcript store + +`src/state/transcript.ts` is a Solid `createStore` with a **two-tier** structure: + +- `committed: readonly ChatItem[]` — finalized rows; never mutated. +- `activeTurn: ChatItem[] | null` — the in-flight turn; accumulates streaming + deltas. + +Writes go through a single `dispatch(event)` reducer. Events are deltas +(`message_chunk`, `thinking_chunk`, `diff_update`, …); `turn_done` migrates the +active turn into `committed`. + +```mermaid +flowchart LR + Ev["dispatch(event)"] --> Reducer["produce() reducer"] + Reducer -->|"streaming deltas"| AT["activeTurn[]"] + Reducer -->|"turn_done"| Commit["committed[]"] + AT -->|"item.text += chunk"| Path["fine-grained path-set"] + Path --> Solid["Solid notifies only
readers of that path"] +``` + +The two-tier split is a **performance boundary**: committed items are stable +object references, which lets the identity-based memo (`nodeMemo`, a `WeakMap` +keyed by the item object) skip re-measuring them entirely. Only `activeTurn` rows +— the ones actually changing — bypass that cache and re-measure each tick. + +`ViewState` (`src/state/view-state.ts`) is a separate per-key store holding +collapse flags, so toggling one row's collapse only notifies that row. + +--- + +## 7. End-to-end flow: a streaming token + +This sequence shows what happens when the host appends one token to a streaming +assistant message — the hot path that must stay cheap. + +```mermaid +sequenceDiagram + participant Host + participant Store as Transcript store + participant Root as ChatRoot + participant Row + participant Def as messageDef + participant Pretext + participant Virt as Virtualizer + participant DOM + + Host->>Store: dispatch(message_chunk) + Store->>Store: activeTurn item.text += chunk (path-set) + Store-->>Row: reactive: item.text changed + Row->>Def: measure(item, ctx) (activeTurn → bypass nodeMemo) + Def->>Def: parseBlocks(id, text) (cached; reuses old Block refs) + Def->>Pretext: prepareRichInline(last block only) + Note over Def,Pretext: earlier blocks hit blockMemo (WeakMap by Block identity) + Pretext-->>Def: line geometry + Def-->>Row: Measured tree (new height) + Row->>Virt: setSize(index, height) → delta + alt row above viewport + Virt-->>Root: onHeightChanged → adjust scrollTop + end + Row->>DOM: Project walks tree → positioned spans + alt stuck to bottom + Root->>DOM: scroll to bottom + end +``` + +The reason this is cheap despite firing on every token: + +- **`parseBlocks`** reuses Block object references for unchanged content, so only + the last (growing) block is new. +- **`blockMemo`** (a `WeakMap` keyed by Block identity) means every earlier block + is a measurement cache hit; only the last block is reshaped by pretext. +- **`setSize`** is `O(log n)` regardless of transcript length. + +--- + +## 8. Scroll virtualization in motion + +When the user scrolls, only signals change — no data is touched. + +```mermaid +sequenceDiagram + participant User + participant Scroll as scroll listener (rAF) + participant Root as ChatRoot + participant Virt as Virtualizer + participant For as For (visible rows) + participant Row + + User->>Scroll: scroll event + Scroll->>Root: setScrollTop / setScrollVelocity (rAF-batched) + Root->>Virt: range(scrollTop, viewH, before, after) + Note over Root,Virt: direction-aware overscan
(more buffer ahead of travel) + Virt-->>Root: { start, end } (binary lift, O(log n)) + Root->>For: visibleIndexes memo updates + For->>Row: mount entering rows / unmount leaving rows + Row->>Row: measure on first appearance + Row->>Virt: setSize (estimate → exact); settle scroll +``` + +Key details: + +- The scroll handler is **rAF-batched** and writes only signals; the visible + range is a `createMemo` so it recomputes only when `scrollTop`/`velocity`/ + `totalHeight` change. +- **Direction-aware overscan**: a larger leading buffer in the direction of + travel (`OVERSCAN_LEADING = 12`) and a small trailing one (`OVERSCAN_TRAILING = 3`) + pre-mount rows just before they enter, avoiding blank flashes. +- Rows are positioned with `transform: translateY(top)` against an absolutely + sized canvas (`totalHeight + padTop + padBottom`), and carry + `contain: layout paint style` to isolate their reflow. + +--- + +## 9. Caching strategy + +Caching is layered, and **all mutable data caches are per-instance** (owned by a +`ChatCaches` bundle created in `ChatRoot`, `src/core/caches.ts`) so two mounted +chats never share state and teardown is a single `caches.clear()`. + +```mermaid +flowchart TD + subgraph PerInstance["ChatCaches (per ChatRoot)"] + PB["parseBlocks — markdown→Block[] by id"] + RI["prepareRichInline — pretext shaping by content"] + HL["highlight — Shiki tokens (LRU 200)"] + DF["computeDiff — Myers rows (LRU 100)"] + end + + subgraph Identity["Identity memos (WeakMap, GC'd)"] + NM["nodeMemo — by ChatItem (Row.tsx)"] + BM["blockMemo — by Block (block-stack.ts)"] + end + + subgraph Global["Shared global (stateless / immutable)"] + Engine["Shiki highlighter engine"] + Consts["fonts, lang tables, metrics"] + end + + Measure["measure path"] -->|ctx.caches| PerInstance + Render["render leaves (Code, Diff)"] -->|useCaches()| PerInstance + Row --> NM + BlockStack["layoutBlockStack"] --> BM + PerInstance -.token engine.-> Engine +``` + +| Layer | Key | Bound | Reach | +| --- | --- | --- | --- | +| `nodeMemo` | `ChatItem` identity | `WeakMap` (auto-GC) | skips whole-row re-measure for committed rows | +| `blockMemo` | `Block` identity | `WeakMap` (auto-GC) | skips per-block re-measure inside streaming rows | +| `parseBlocks` | `messageId` + text | per-instance Map | identity-stable Block refs across re-renders | +| `prepareRichInline` | shaped content | per-instance Map | reuse pretext shaping; flushed on width/font change | +| `highlight` | `lang + code` | per-instance LRU(200) | Shiki tokenisation | +| `computeDiff` | `oldText + newText` | per-instance LRU(100) | Myers diff rows | + +Two reach channels exist because caches are touched in two execution contexts: + +- **Measure path** has a `MeasureCtx` → reached via `ctx.caches`. +- **Render leaves** (`Code`, `Diff`) live deep in `Project` with no `MeasureCtx` + → reached via a Solid `CachesContext` + `useCaches()`. + +**Invalidation:** on container-width or font-load change, `caches.clearTextMeasure()` +drops the rich-inline cache _and_ flushes pretext's internal global metrics (which +are keyed only by font string and would otherwise hold stale fallback-font widths +from before the webfont loaded). The identity memos self-invalidate via their +fingerprint (`theme.version | width | collapsed | expanded`). + +The **Shiki highlighter engine** stays a global singleton — it is stateless and +expensive to initialize (grammar/theme construction); only its token _results_ +are cached per-instance. + +--- + +## 10. How the concepts interact + +Putting it together, the four pillars compose into one tight loop: + +```mermaid +flowchart TB + subgraph Data["Data layer (SolidJS stores)"] + TS["Transcript store (committed + activeTurn)"] + VS["ViewState (collapse flags)"] + end + + subgraph MeasureLayer["Measurement (pure JS, off-DOM)"] + Parse["markdown → Block[]"] + PT["pretext: glyph metrics + line-breaking"] + Comp["compose combinators → Measured tree"] + end + + subgraph VirtLayer["Virtualization"] + FT["Fenwick tree: heights, offsets, visible range"] + end + + subgraph RenderLayer["Projection (SolidJS DOM)"] + Pr["Project: walk tree, position via inline styles"] + end + + TS -->|"item count"| FT + TS -->|"visible item text"| Parse + Parse --> PT + PT --> Comp + Comp -->|"exact height"| FT + Comp -->|"Measured tree"| Pr + VS -->|"expanded/collapsed"| Comp + FT -->|"which rows + offsets"| Pr + Pr --> Screen["Screen"] + Screen -->|"scroll / resize"| FT +``` + +1. **SolidJS** stores hold the conversation and view state, and notify exactly the + computations that read changed paths. +2. **pretext** turns text into exact geometry _before_ the DOM exists, satisfying + the precondition for virtualization. +3. The **Fenwick tree** uses those heights to answer "what's on screen?" and + "where does row _i_ sit?" in `O(log n)`, and absorbs height changes without + `O(n)` recomputation. +4. **Projection** renders only the visible rows by walking the precomputed + `Measured` tree and applying geometry as inline styles — so the browser never + re-wraps or re-flows content the engine already laid out. + +The discipline that makes it hold together is the **Lane A / Lane B** split and +the **width-down / height-up** invariant: measurement depends only on layout +inputs, geometry is computed purely, and the DOM is a faithful projection of that +geometry — never a source of truth. + +--- + +## Adding a new row kind + +This recipe walks through adding a hypothetical `'status'` row kind. Replace `status` / `ChatStatus` / `StatusLayout` with your actual names throughout. + +### 1. Define the model type + +Add your item shape to `src/model.ts`: + +```ts +export type ChatStatus = { + kind: 'status'; + id: string; + text: string; +}; + +export type ChatItem = ChatMessage | ChatThinking | ChatDiff | ChatFileOpToolCall | ChatStatus; +``` + +### 2. Implement the `ComponentDef` + +Create `src/components/status/status.def.tsx`. A `ComponentDef` requires: + +| Member | Role | +| --- | --- | +| `kind` | String literal matching `ChatItem.kind` | +| `padY` | Symmetric vertical padding (px) around the row | +| `estimate(item, ctx)` | O(1) height heuristic; used before measure for scroll-thumb sizing | +| `measure(item, ctx)` | Exact geometry; returns `Measured` (may use compose combinators) | +| `Render` | SolidJS component `(props: { item, layout, ctx }) => JSX.Element` | + +Example skeleton: + +```tsx +import { defineComponent, type Measured, type MeasureCtx, type RenderCtx } from '../../core/define'; +import type { ChatStatus } from '../../model'; + +type StatusLayout = { kind: 'status'; height: number }; + +export const statusDef = defineComponent({ + kind: 'status', + padY: 4, + + estimate(_item, ctx: MeasureCtx): number { + return ctx.theme.fonts.body.lineHeight; + }, + + measure(item, ctx: MeasureCtx): Measured { + const height = ctx.theme.fonts.body.lineHeight; + return { height, width: ctx.width, layout: { kind: 'status', height } }; + }, + + Render(props: { item: ChatStatus; layout: Measured; ctx: RenderCtx }) { + return ( +
+ {props.item.text} +
+ ); + }, +}); +``` + +#### Using slots + +If your row needs injected chrome (a header, footer, or side-panel): + +1. Add a new entry to `SLOT_NAMES` in `src/core/compose.ts`: + + ```ts + STATUS_HEADER: 'status:header', + ``` + +2. Use it in `measure()`: + + ```ts + import { SLOT_NAMES, slot, stack } from '../../core/compose'; + + const headerSlot = SLOT_NAMES.STATUS_HEADER; + const tree = stack([ + { id: `${item.id}:header`, measured: slot(headerSlot, headerH) }, + { id: `${item.id}:body`, measured: body }, + ], { gap: 0 }); + ``` + +3. Supply the slot renderer in `Render`: + + ```tsx + }} + /> + ``` + +### 3. Register the def + +Add one line to `src/components/registry.ts`: + +```ts +import { statusDef } from './status/status.def'; + +export const REGISTRY: Record> = { + // … existing entries … + status: statusDef, +}; +``` + +### 4. Add fixtures + +Add representative `ChatStatus` items to the mock transcript fixture +(`src/mock-transcript.ts` or the fixture generator) so that the benchmark +and visual snapshot tests cover the new kind: + +```ts +{ kind: 'status', id: 'status-1', text: 'Indexing…' }, +``` + +### 5. Add a height contract test + +Create `src/tests/status.contract.test.tsx`. The contract test verifies that +`measure()` returns an exact height, protecting against slot-height drift: + +```tsx +import { describe, expect, it } from 'vitest'; +import { DEFAULT_THEME } from '../core/theme'; +import { createChatCaches } from '../core/caches'; +import { makeContractCtx, renderAndMeasure } from './contract'; +import { statusDef } from '../components/status/status.def'; + +const ctx = makeContractCtx({ width: 640 }); +const item: ChatStatus = { kind: 'status', id: 's1', text: 'Indexing…' }; + +describe('statusDef height contract', () => { + it('measure height matches rendered height', async () => { + const layout = statusDef.measure(item, ctx); + const renderedH = await renderAndMeasure( + , + layout.height + ); + expect(renderedH).toBe(layout.height); + }); +}); +``` + +The `renderAndMeasure` helper (see `src/tests/contract.tsx`) mounts the +component in a headless browser, reads the actual DOM height, and compares +it to the engine's prediction. A failing test here means a slot height +constant or padding value is wrong. + +--- + +## File map + +| Concern | Files | +| --- | --- | +| Entry / mount | `src/index.tsx`, `src/ChatRoot.tsx` | +| Data model | `src/model.ts`, `src/state/transcript.ts`, `src/state/view-state.ts` | +| Markdown | `src/core/markdown/{document,parse,plain-text}.ts` | +| Measurement | `src/core/measure/*`, `src/components/prose/layout.ts` (pretext) | +| Virtualization | `src/core/virtualizer.ts`, `src/core/stick-to-bottom.ts` | +| Layout system | `src/core/define.ts`, `src/core/compose.ts`, `src/core/layout/*` | +| Rendering | `src/components/Project.tsx`, `src/components/Row.tsx`, `src/components/*/` | +| Registry | `src/components/registry.ts` | +| Slot names | `src/core/compose.ts` (`SLOT_NAMES`, `SlotName`) | +| Caching | `src/core/caches.ts`, `src/components/CachesContext.ts` | +| Theme | `src/core/theme.ts`, `src/core/metrics.ts`, `src/core/measure/fonts.ts` | +| Contexts | `src/components/{ThemeContext,CachesContext,CommandsContext}.ts` | diff --git a/packages/chat-ui/package.json b/packages/chat-ui/package.json new file mode 100644 index 0000000000..6bbae42147 --- /dev/null +++ b/packages/chat-ui/package.json @@ -0,0 +1,85 @@ +{ + "name": "@emdash/chat-ui", + "version": "1.0.0", + "private": true, + "description": "Solid-based chat transcript renderer with pretext layout engine", + "license": "Apache-2.0", + "author": { + "name": "Emdash Contributors", + "email": "support@emdash.sh" + }, + "type": "module", + "sideEffects": [ + "./dist/style.css", + "./src/chat-theme.css", + "./src/tailwind.css" + ], + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/src/index.d.ts" + }, + "./react": { + "import": "./dist/react.js", + "types": "./dist/src/react/index.d.ts" + }, + "./style.css": "./dist/style.css", + "./chat-theme.css": "./src/chat-theme.css" + }, + "scripts": { + "build": "vite build --config vite.lib.config.ts", + "dev": "vite build --config vite.lib.config.ts --watch", + "storybook": "storybook dev -p 6007", + "build-storybook": "storybook build", + "lint": "sh -c 'root=$(cd ../.. && pwd); cd \"$root/apps/emdash-desktop\" && pnpm exec oxlint --config .oxlintrc.json \"$root/packages/chat-ui/src\"'", + "format": "oxfmt", + "format:check": "oxfmt --check", + "typecheck": "tsc --noEmit", + "test": "vitest run --project node --project browser", + "test:perf": "vitest run --project perf", + "test:bench": "vitest bench --project browser" + }, + "dependencies": { + "@chenglou/pretext": "^0.0.8", + "@emdash/ui": "workspace:*", + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "5.2.8", + "@shikijs/langs": "^4.2.0", + "@shikijs/themes": "^4.2.0", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", + "shiki": "^4.2.0", + "solid-js": "^1.9.13", + "unified": "^11.0.5" + }, + "devDependencies": { + "@solidjs/web": "2.0.0-beta.14", + "@storybook/addon-docs": "^10.4.5", + "@tailwindcss/vite": "^4.2.1", + "@types/mdast": "^4.0.4", + "@types/node": "^20.10.0", + "@types/react": "^19.0.0", + "@vitest/browser": "^4.1.8", + "@vitest/browser-playwright": "^4.1.8", + "devicon": "^2.17.0", + "oxfmt": "^0.51.0", + "oxlint": "^1.69.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "storybook": "^10.4.5", + "storybook-solidjs-vite": "10.4.0", + "tailwindcss": "^4.2.1", + "tw-animate-css": "^1.4.0", + "typescript": "^6.0.2", + "vite": "7.1.11", + "vite-plugin-dts": "5.0.2", + "vite-plugin-solid": "2.11.12", + "vitest": "^4.1.8" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "packageManager": "pnpm@10.28.2" +} diff --git a/packages/chat-ui/src/ChatRoot.tsx b/packages/chat-ui/src/ChatRoot.tsx new file mode 100644 index 0000000000..03f185d48f --- /dev/null +++ b/packages/chat-ui/src/ChatRoot.tsx @@ -0,0 +1,686 @@ +/** + * ChatRoot — the single Solid root for the chat transcript renderer. + * + * Reactive flow: + * transcriptStore.state → itemCount/getItem → visibleIndexes → → Row + * ↑ + * scrollTop signal + viewHeight signal ──────────────────┤ + * ↓ + * Row → createMemo(measure) → createEffect(virt.setSize) → setTotalHeight + * totalHeight signal → canvas height + row top positions + * + * Changes from the old architecture: + * - Accepts `theme?: ChatTheme` instead of `fonts?: FontConfig`. + * - Provides ThemeContext so all descendant Render components can access + * geometry constants without CSS var lookups. + * - Drops `chatCssVars()` writes — all geometry is projected via inline styles. + * - Drops `clearMessageLayoutCache` — invalidation is via node memo fingerprint. + * - Drops `ROW_REGISTRY` import in favour of the unified `REGISTRY`. + * - Accepts `commands` / `onReachStart` / `onAtBottomChange` / `controls` + * for command callbacks, pagination events, and imperative scroll. + */ + +import { + For, + Show, + createEffect, + createMemo, + createSignal, + onCleanup, + onMount, + untrack, + useContext, +} from 'solid-js'; +import { CachesContext } from './components/CachesContext'; +import { CommandsContext } from './components/CommandsContext'; +import { DebugContext } from './components/debug-context'; +import { REGISTRY } from './components/registry'; +import { Row } from './components/Row'; +import { cachedMeasure, makeResolveExpanded } from './components/row-measure'; +import { ThemeContext } from './components/ThemeContext'; +import { createChatCaches } from './core/caches'; +import { genericEstimate } from './core/layout/generic-estimate'; +import { registerFontsReadyClear } from './core/measure/pretext-cache'; +import { StickToBottom } from './core/stick-to-bottom'; +import type { ChatTheme } from './core/theme'; +import { DEFAULT_THEME } from './core/theme'; +import { Virtualizer } from './core/virtualizer'; +import type { ChatCommands, ScrollToItemOptions } from './index'; +import type { ChatItem } from './model'; +import { getItem, itemCount } from './state/transcript'; +import type { TranscriptApi } from './state/transcript'; +import type { ViewState } from './state/view-state'; +import './chat.module.css'; + +// Centered content column. The scroll container stays full width (so the +// scrollbar sits at the viewport edge) while rows are measured and laid out +// against this capped, centered canvas — matching the desktop composer width. +const DEFAULT_CONTENT_CLASS = 'mx-auto w-full max-w-2xl'; + +// Symmetric overscan used when idle or velocity unknown +const OVERSCAN_BASE = 4; +// Leading buffer in the direction of scroll; trailing buffer behind it +const OVERSCAN_LEADING = 12; +const OVERSCAN_TRAILING = 3; + +// Idle-time prefetch: how many rows beyond the overscan window to pre-measure +// during requestIdleCallback slices. Rows ahead in scroll direction get a +// larger budget; behind get a smaller one. +const PREFETCH_AHEAD = 30; +const PREFETCH_BEHIND = 10; +// Stop the current idle slice if less than this many ms remain (leaves headroom +// for the browser's own idle tasks). +const PREFETCH_MIN_REMAINING_MS = 3; + +// onReachStart fires when the top row is visible and scrollTop is within this +// threshold of the canvas top. Debounced: only fires once until reset. +const REACH_START_THRESHOLD_PX = 200; + +// ── EngineControls ──────────────────────────────────────────────────────────── + +/** + * Mutable holder populated by ChatRoot.onMount. mountChat creates an instance + * and passes it to ChatRoot; the handle methods delegate to it so callers + * never hold stale closures. + */ +export type EngineControls = { + scrollToBottom(opts?: { behavior?: ScrollBehavior }): void; + scrollToItem(id: string, opts?: ScrollToItemOptions): void; + loadOlder(items: ChatItem[]): void; +}; + +// ── ChatRootProps ───────────────────────────────────────────────────────────── + +export type ChatRootProps = { + transcript: TranscriptApi; + viewState: ViewState; + /** Full theme (fonts + geometry). Replaces the old `fonts` option. */ + theme?: ChatTheme; + stickToBottom?: boolean; + /** Extra classes for the full-width scroll container. */ + class?: string; + /** + * Classes for the centered content column. Defaults to a max-width column. + * Rows are measured against this element's width, not the scroll container. + */ + contentClass?: string; + /** + * Enable the layout-boundary debug overlay on every block and row. When + * omitted, an ambient DebugContext (e.g. the Storybook toolbar) is inherited. + */ + debug?: boolean; + /** + * Vertical padding reserved at the top of the canvas (px). Baked into the + * virtualizer coordinate space — not CSS padding — so scroll math stays exact. + * Accepts a static number or a reactive accessor so mountChat can pass a signal. + */ + padTop?: number | (() => number); + /** + * Vertical padding reserved at the bottom of the canvas (px). The last row + * rests above this space, keeping content clear of a floating composer. + * Accepts a static number or a reactive accessor so mountChat can pass a signal. + */ + padBottom?: number | (() => number); + /** + * Reactive accessor returning the current ChatCommands. Provided by mountChat + * via a signal so setCommands can update them without remounting. + */ + commands?: () => ChatCommands; + /** Fired when the user scrolls near the top and the engine has run out of history. */ + onReachStart?: () => void; + /** Fired when the "at bottom" sticky state changes. */ + onAtBottomChange?: (atBottom: boolean) => void; + /** + * Mutable holder that ChatRoot.onMount populates with imperative scroll + * methods. mountChat passes its own holder so handle methods delegate here. + */ + controls?: EngineControls; +}; + +// ── ChatRoot ────────────────────────────────────────────────────────────────── + +export function ChatRoot(props: ChatRootProps) { + const caches = createChatCaches(); + const theme = () => props.theme ?? DEFAULT_THEME; + const contentClass = () => props.contentClass ?? DEFAULT_CONTENT_CLASS; + const commands = () => props.commands?.() ?? {}; + + const padTop = () => { + const v = props.padTop; + return v === undefined ? 0 : typeof v === 'function' ? v() : v; + }; + const padBottom = () => { + const v = props.padBottom; + return v === undefined ? 0 : typeof v === 'function' ? v() : v; + }; + + const inheritedDebug = useContext(DebugContext); + const debugValue = () => props.debug ?? inheritedDebug(); + + let scrollEl: HTMLDivElement | undefined; + let canvasEl: HTMLDivElement | undefined; + const virt = new Virtualizer(); + let sticky: StickToBottom | null = null; + + const [totalHeight, setTotalHeight] = createSignal(0); + const [scrollTop, setScrollTop] = createSignal(0); + const [scrollVelocity, setScrollVelocity] = createSignal(0); + const [viewHeight, setViewHeight] = createSignal(600); + const [containerWidth, setContainerWidth] = createSignal(0); + + const refreshTotal = () => { + setTotalHeight(virt.total()); + }; + + // ── Count sync effect ───────────────────────────────────────────────────── + + createEffect(() => { + const n = itemCount(props.transcript.state); + const t = theme(); + untrack(() => { + const estimateCtx = { + theme: t, + width: 0, + isCollapsed: () => false, + expanded: () => false, + caches, + }; + virt.setCount(n, (i) => { + const item = getItem(props.transcript.state, i); + if (!item) return 60; + const def = REGISTRY[item.kind as keyof typeof REGISTRY]; + return ( + (def.estimate?.(item, estimateCtx) ?? genericEstimate(item, estimateCtx)) + + 2 * (def.padY ?? 0) + ); + }); + refreshTotal(); + if (props.stickToBottom !== false) sticky?.schedule(); + }); + }); + + // ── Width change: flush text-measurement cache ─────────────────────────── + + createEffect(() => { + const w = containerWidth(); + if (w <= 0) return; + caches.clearTextMeasure(); + // Node memo in registry is fingerprint-keyed (includes width) so it + // self-invalidates on width change — no explicit cache clear needed. + }); + + // ── Visible range — direction-aware asymmetric overscan ─────────────────── + + const visibleRange = createMemo(() => { + totalHeight(); + const v = scrollVelocity(); + let before: number; + let after: number; + if (v > 0) { + before = OVERSCAN_TRAILING; + after = OVERSCAN_LEADING; + } else if (v < 0) { + before = OVERSCAN_LEADING; + after = OVERSCAN_TRAILING; + } else { + before = OVERSCAN_BASE; + after = OVERSCAN_BASE; + } + return virt.range(Math.max(0, scrollTop() - padTop()), viewHeight(), before, after); + }); + + const visibleIndexes = createMemo(() => { + totalHeight(); + const n = itemCount(props.transcript.state); + const { start, end } = visibleRange(); + const visEnd = Math.min(end, n - 1); + const arr: number[] = []; + for (let i = start; i <= visEnd; i++) { + arr.push(i); + } + return arr; + }); + + // ── Row top positions ───────────────────────────────────────────────────── + + let programmaticScroll = false; + + const onHeightChanged = (index: number, delta: number) => { + refreshTotal(); + if (delta === 0) return; + + if (props.stickToBottom !== false && sticky?.isStuck()) { + sticky.schedule(); + return; + } + + if (virt.top(index) + padTop() < scrollEl!.scrollTop) { + const next = scrollEl!.scrollTop + delta; + programmaticScroll = true; + scrollEl!.scrollTop = next; + setScrollTop(next); + } + }; + + // ── Scroll helpers ──────────────────────────────────────────────────────── + + const doScrollToBottom = (opts?: { behavior?: ScrollBehavior }) => { + const el = scrollEl; + if (!el) return; + if (opts?.behavior === 'smooth') { + el.scrollTo({ top: el.scrollHeight - el.clientHeight, behavior: 'smooth' }); + } else if (sticky) { + sticky.scrollToBottom(); + } else { + el.scrollTo({ top: el.scrollHeight - el.clientHeight }); + } + }; + + const doScrollToItem = (id: string, opts?: ScrollToItemOptions) => { + const el = scrollEl; + if (!el) return; + const idx = props.transcript.findIndexById(id); + if (idx < 0) return; + + const align = opts?.align ?? 'start'; + const extraOffset = opts?.offset ?? 0; + const behavior = opts?.behavior ?? 'auto'; + + const computeTarget = () => { + const rowTop = virt.top(idx) + padTop(); + const rowH = virt.size(idx); + const vh = el.clientHeight; + let target: number; + if (align === 'center') { + target = rowTop - (vh - rowH) / 2; + } else if (align === 'end') { + target = rowTop - vh + rowH; + } else { + target = rowTop; + } + return Math.max(0, target + extraOffset); + }; + + el.scrollTo({ top: computeTarget(), behavior }); + + // Settle pass: after one rAF the row may have measured; re-read its top. + if (behavior !== 'smooth') { + programmaticScroll = true; + requestAnimationFrame(() => { + programmaticScroll = false; + el.scrollTo({ top: computeTarget(), behavior: 'auto' }); + }); + } + }; + + const doLoadOlder = (items: ChatItem[]) => { + const el = scrollEl; + if (!el || items.length === 0) return; + + const t = theme(); + + // Capture anchor: the first fully-visible row and its offset from scrollTop. + const anchorIdx = virt.findIndex(Math.max(0, el.scrollTop - padTop())); + const anchorId = getItem(props.transcript.state, anchorIdx)?.id; + const anchorOffset = el.scrollTop - (virt.top(anchorIdx) + padTop()); + + // Grow the virtualizer at the front with estimated heights. + const loadEstimateCtx = { + theme: t, + width: containerWidth(), + isCollapsed: () => false, + expanded: () => false, + caches, + }; + const count = items.length; + virt.prepend(count, (i) => { + const item = items[i]; + if (!item) return 60; + const def = REGISTRY[item.kind as keyof typeof REGISTRY]; + return ( + (def.estimate?.(item, loadEstimateCtx) ?? genericEstimate(item, loadEstimateCtx)) + + 2 * (def.padY ?? 0) + ); + }); + + // Update the transcript store (triggers the count-sync effect). + props.transcript.prependHistory(items); + refreshTotal(); + + // Restore scroll position so the previously-visible row stays in view. + if (anchorId !== undefined) { + const newIdx = props.transcript.findIndexById(anchorId); + if (newIdx >= 0) { + const newTop = virt.top(newIdx) + padTop() + anchorOffset; + programmaticScroll = true; + el.scrollTop = newTop; + setScrollTop(newTop); + } + } + }; + + // ── DOM setup ───────────────────────────────────────────────────────────── + + onMount(() => { + const el = scrollEl!; + + sticky = new StickToBottom(el); + + // Populate the controls holder so handle delegates resolve immediately. + if (props.controls) { + props.controls.scrollToBottom = doScrollToBottom; + props.controls.scrollToItem = doScrollToItem; + props.controls.loadOlder = doLoadOlder; + } + + // CSS vars required by CSS modules. Set once on mount (theme is not reactive + // in the current architecture; if theme changes, a full unmount/remount is + // expected). Groups: + // Typography — feed pretext measurement; keep until CSS modules migrate to --type-* vars. + // Inline code / mention chrome — feed pretext extra-width accounting. + // Code block geometry — feed Code.tsx visual chrome (padding, border). + // Island geometry — caps the max-height of fixed-size island blocks. + const t = theme(); + const d = t.density; + // Extract font-size px value from CSS shorthand "weight size family". + const codeSizePx = t.fonts.code.font.match(/(\d+(?:\.\d+)?)px/)?.[1] ?? '13'; + const cssVars: Record = { + // Typography (--type-code-* equivalents that CSS modules reference as --chat-code-*) + '--chat-code-size': `${codeSizePx}px`, + '--chat-code-lh': `${t.fonts.code.lineHeight}px`, + '--chat-code-weight': '400', + // Inline code / mention chrome (feed pretext extra-width accounting) + '--chat-ic-pad-x': `${d.inlineCodePadX}px`, + '--chat-ic-pad-y': `${d.inlineCodePadY}px`, + '--chat-mention-pad-x': `${Math.round(t.fonts.mentionExtraWidth / 2)}px`, + // Code block geometry (visual chrome — border, padding; matches code.def.tsx constants) + '--chat-code-border': '1px', + '--chat-code-pad-x': '8px', + '--chat-code-pad-y': '8px', + }; + for (const [k, v] of Object.entries(cssVars)) { + el.style.setProperty(k, v); + } + + let rafId: number | null = null; + let lastScrollTop = 0; + let atBottom = sticky.isStuck(); + let reachStartFired = false; + + const flushScroll = () => { + rafId = null; + const st = el.scrollTop; + setScrollVelocity(st - lastScrollTop); + lastScrollTop = st; + setScrollTop(st); + + // onAtBottomChange + const nowAtBottom = sticky!.isStuck(); + if (nowAtBottom !== atBottom) { + atBottom = nowAtBottom; + props.onAtBottomChange?.(atBottom); + } + + // onReachStart — fire once when near the top; reset when user scrolls away + if (st <= REACH_START_THRESHOLD_PX) { + if (!reachStartFired) { + reachStartFired = true; + props.onReachStart?.(); + } + } else { + reachStartFired = false; + } + + // Arm idle prefetch after scroll settles. + schedulePrefetch(); + }; + + // ── Idle-time prefetch scheduler ──────────────────────────────────────────── + // + // After each scroll settle (no new scroll event fires before the rAF callback + // flushes) we schedule a requestIdleCallback to pre-measure rows just beyond + // the overscan window. This populates the shared nodeMemo WeakMap so those + // rows are cache hits when they later scroll into view, converting ~1500x + // cold-vs-warm measure cost into background idle work. + // + // Cancellation / rescheduling: + // - Cancelled immediately when a new scroll event fires (so it never + // competes with active scrolling). + // - Re-scheduled after each flushScroll (settle). + // - Per-slice budget: stop if deadline.timeRemaining() < PREFETCH_MIN_REMAINING_MS. + // - Reschedules itself if work remains within the current window. + // + // Falls back to setTimeout(fn, 0) when requestIdleCallback is unavailable. + + let prefetchIdleId: ReturnType | null = null; + + // Index cursor: prefetcher tracks where it left off so each slice continues + // from the previous boundary without re-scanning already-warm rows. + let prefetchStart = -1; + let prefetchEnd = -1; + + const schedulePrefetch = () => { + if (prefetchIdleId !== null) return; + prefetchIdleId = requestIdleCallback(runPrefetchSlice, { timeout: 500 }); + }; + + const cancelPrefetch = () => { + if (prefetchIdleId !== null) { + cancelIdleCallback(prefetchIdleId); + prefetchIdleId = null; + } + }; + + const runPrefetchSlice = (deadline: IdleDeadline) => { + prefetchIdleId = null; + + const { start: visStart, end: visEnd } = visibleRange(); + const n = itemCount(props.transcript.state); + if (n === 0) return; + + // Determine the window to prefetch: rows beyond the current visible+overscan + // range, ahead in scroll direction + a shorter tail behind. + const ahead = Math.min(visEnd + PREFETCH_AHEAD, n - 1); + const behind = Math.max(visStart - PREFETCH_BEHIND, 0); + + // Initialise cursor on first call after a settle. + if (prefetchStart < 0 || prefetchEnd < 0) { + prefetchStart = visEnd + 1; + prefetchEnd = ahead; + } + + const w = containerWidth(); + const t = theme(); + + let measured = 0; + + // Forward pass: visEnd+1 .. ahead + while ( + prefetchStart <= prefetchEnd && + deadline.timeRemaining() >= PREFETCH_MIN_REMAINING_MS + ) { + const item = getItem(props.transcript.state, prefetchStart); + if (item) { + const resolveExpanded = makeResolveExpanded(item, props.viewState); + const ctx = { + theme: t, + width: w, + isCollapsed: (id: string) => props.viewState.isCollapsed(id), + expanded: resolveExpanded, + caches, + }; + const measuredLayout = cachedMeasure(item, false, ctx); + const def = REGISTRY[item.kind as keyof typeof REGISTRY]; + const h = measuredLayout.height + 2 * (def.padY ?? 0); + const delta = virt.setSize(prefetchStart, h); + if (delta !== 0) onHeightChanged(prefetchStart, delta); + measured++; + } + prefetchStart++; + } + + // Backward pass: behind .. visStart-1 (only when forward pass exhausted) + if (prefetchStart > prefetchEnd) { + let backCursor = visStart - 1; + while ( + backCursor >= behind && + deadline.timeRemaining() >= PREFETCH_MIN_REMAINING_MS + ) { + const item = getItem(props.transcript.state, backCursor); + if (item) { + const resolveExpanded = makeResolveExpanded(item, props.viewState); + const ctx = { + theme: t, + width: w, + isCollapsed: (id: string) => props.viewState.isCollapsed(id), + expanded: resolveExpanded, + caches, + }; + const measuredLayout = cachedMeasure(item, false, ctx); + const def = REGISTRY[item.kind as keyof typeof REGISTRY]; + const h = measuredLayout.height + 2 * (def.padY ?? 0); + const delta = virt.setSize(backCursor, h); + if (delta !== 0) onHeightChanged(backCursor, delta); + measured++; + } + backCursor--; + } + } + + // Reschedule if work remains in the forward window. + if (measured > 0 && prefetchStart <= prefetchEnd) { + schedulePrefetch(); + } + }; + + const onScroll = () => { + if (el.offsetParent === null) return; + if (programmaticScroll) { + programmaticScroll = false; + return; + } + // Cancel any in-flight prefetch so it never competes with active scrolling. + cancelPrefetch(); + // Reset cursor so the next settle re-targets the new viewport position. + prefetchStart = -1; + prefetchEnd = -1; + if (rafId === null) { + rafId = requestAnimationFrame(flushScroll); + } + }; + el.addEventListener('scroll', onScroll, { passive: true }); + onCleanup(() => { + el.removeEventListener('scroll', onScroll); + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + cancelPrefetch(); + }); + + const roHeight = new ResizeObserver((entries) => { + const h = entries[0]?.contentRect.height; + if (h && h > 0) setViewHeight(h); + }); + roHeight.observe(el); + onCleanup(() => roHeight.disconnect()); + + if (canvasEl) { + const roWidth = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect.width; + if (w && w > 0) setContainerWidth(w); + }); + roWidth.observe(canvasEl); + onCleanup(() => roWidth.disconnect()); + } + + const onClick = (e: Event) => { + const target = (e.target as HTMLElement).closest('[data-collapse-id]') as HTMLElement | null; + if (target?.dataset.collapseId) { + props.viewState.toggleCollapsed(target.dataset.collapseId); + } + }; + el.addEventListener('click', onClick); + onCleanup(() => el.removeEventListener('click', onClick)); + + registerFontsReadyClear(() => { + caches.clearTextMeasure(); + refreshTotal(); + }); + + if (props.stickToBottom !== false) { + sticky?.scrollToBottom(); + } + + onCleanup(() => { + sticky?.dispose(); + sticky = null; + }); + + onCleanup(() => caches.clear()); + }); + + // ── Render ──────────────────────────────────────────────────────────────── + + return ( + + + + +
{ + scrollEl = el; + }} + data-chat-scroll + class={`relative h-full w-full overflow-x-hidden overflow-y-auto${props.class ? ` ${props.class}` : ''}`} + > +
{ + canvasEl = el; + }} + data-chat-canvas + class={`relative ${contentClass()}`} + style={{ height: `${totalHeight() + padTop() + padBottom()}px` }} + > + + {(rowIndex) => { + const rowTop = createMemo(() => { + totalHeight(); + return virt.top(rowIndex) + padTop(); + }); + + const item = createMemo(() => getItem(props.transcript.state, rowIndex)); + const committedCount = () => props.transcript.state.committed.length; + const isActiveTurn = () => rowIndex >= committedCount(); + + return ( + +
+ +
+
+ ); + }} +
+
+
+
+
+
+
+ ); +} diff --git a/packages/chat-ui/src/chat-theme.css b/packages/chat-ui/src/chat-theme.css new file mode 100644 index 0000000000..5441f2c43e --- /dev/null +++ b/packages/chat-ui/src/chat-theme.css @@ -0,0 +1,34 @@ +/* + * chat-theme.css — maps @emdash/ui semantic tokens to --chat-* color vars. + * + * Import this alongside @emdash/ui/theme/theme.css and semantic.css so the + * chat renderer's color system adapts to the active .emlight / .emdark class. + * Fallback values keep components legible when the theme CSS is not loaded + * (e.g. standalone embedding without the full design system). + */ + +/* Shared mappings — semantic vars already resolve correctly per mode. */ +.emlight, +.emdark { + --chat-fg: var(--foreground); + --chat-muted: var(--foreground-muted); + --chat-border: var(--border); + --chat-link: var(--blue-11); + --chat-bubble-user: var(--background-3); + --chat-bubble-user-fg: var(--foreground); + --chat-mention-bg: var(--blue-3); + --chat-mention-fg: var(--blue-11); + /* --neutral-1 is lightest gray in light mode and near-black in dark mode — + * an ideal code-block background in both themes. */ + --chat-code-bg: var(--neutral-1); + --chat-table-header-bg: var(--background-1); +} + +/* Inline code chip: semi-transparent tint that reads well on both surfaces. */ +.emlight { + --chat-code-inline-bg: rgba(0, 0, 0, 0.06); +} + +.emdark { + --chat-code-inline-bg: rgba(255, 255, 255, 0.08); +} diff --git a/packages/chat-ui/src/chat.module.css b/packages/chat-ui/src/chat.module.css new file mode 100644 index 0000000000..99f107b965 --- /dev/null +++ b/packages/chat-ui/src/chat.module.css @@ -0,0 +1,11 @@ +/* + * Chat engine base styles. + * + * Font imports must be here so they are requested before any measurement runs. + * + * The scroll container, centered content canvas, and absolutely-positioned rows + * are styled with Tailwind utilities directly in ChatRoot.tsx. + */ + +@import '@fontsource-variable/inter/index.css'; +@import '@fontsource-variable/jetbrains-mono/index.css'; diff --git a/packages/chat-ui/src/components/CachesContext.ts b/packages/chat-ui/src/components/CachesContext.ts new file mode 100644 index 0000000000..c656348933 --- /dev/null +++ b/packages/chat-ui/src/components/CachesContext.ts @@ -0,0 +1,40 @@ +/** + * CachesContext — Solid context that provides the per-instance ChatCaches bundle + * to all descendant Render components in the chat tree. + * + * ChatRoot (or any test harness) provides the bundle via: + * + * + * Render leaf components that need caches (Code.tsx, Diff.tsx, etc.) call + * `useCaches()` to read the bundle rather than accessing module-level state. + * + * The context default is the module-level fallback bundle so that direct-mount + * tests and stories that don't wrap in a Provider still work. The fallback is + * shared across those call sites, which is acceptable for isolated test runs. + */ + +import { createContext, useContext } from 'solid-js'; +import { type ChatCaches, getFallbackCaches } from '../core/caches'; + +let _warnedFallback = false; + +export const CachesContext = createContext( + // Proxy to the lazily-created fallback so the heavy createChatCaches() call + // is deferred until the first actual usage rather than at module-load time. + new Proxy({} as ChatCaches, { + get(_target, prop: keyof ChatCaches) { + if (import.meta.env.DEV && !_warnedFallback) { + _warnedFallback = true; + console.warn( + '[chat-ui] useCaches() called without a CachesContext.Provider. ' + + 'Wrap your component tree in ' + + 'to use an isolated per-instance cache.' + ); + } + return getFallbackCaches()[prop]; + }, + }) +); + +/** Returns the current ChatCaches bundle from the nearest CachesContext. */ +export const useCaches = (): ChatCaches => useContext(CachesContext); diff --git a/packages/chat-ui/src/components/CommandsContext.ts b/packages/chat-ui/src/components/CommandsContext.ts new file mode 100644 index 0000000000..b6273d9ef1 --- /dev/null +++ b/packages/chat-ui/src/components/CommandsContext.ts @@ -0,0 +1,20 @@ +/** + * CommandsContext — Solid context that provides the current ChatCommands to all + * descendant components in the chat tree. + * + * ChatRoot provides a reactive accessor `() => ChatCommands`; component renderers + * that need to fire user-action callbacks (open-file, etc.) call `useCommands()` + * rather than receiving callbacks as props. + * + * Mirrors the pattern established by ThemeContext. + */ + +import { createContext, useContext } from 'solid-js'; +import type { ChatCommands } from '../index'; + +const EMPTY_COMMANDS: ChatCommands = {}; + +export const CommandsContext = createContext<() => ChatCommands>(() => EMPTY_COMMANDS); + +/** Returns the current ChatCommands reactive accessor from the nearest CommandsContext. */ +export const useCommands = (): (() => ChatCommands) => useContext(CommandsContext); diff --git a/packages/chat-ui/src/components/Project.tsx b/packages/chat-ui/src/components/Project.tsx new file mode 100644 index 0000000000..5ce99220cb --- /dev/null +++ b/packages/chat-ui/src/components/Project.tsx @@ -0,0 +1,333 @@ +/** + * Project — generic layout tree renderer. + * + * Walks a `Measured` tree by `layout.kind` and renders each node using full + * projection (all geometry from the layout, no CSS-driven geometry). + * + * Combinator nodes (stack / pad / bubble / collapsible / window) are handled + * recursively. Slot nodes are dispatched to the `slots` map supplied by the + * calling Render shell. All other `layout.kind` values are treated as generic + * block leaves and dispatched to `children`. + * + * Leaf layouts produced by `core/layout/block-stack.ts` carry a `raw` + * back-reference to the source `Block`; `renderBlockLeaf` uses this to + * construct `Prose`/`Code`/`Table` without a separate block lookup. + * + * Performance design: + * - Dispatch: one `createMemo` over `layout.kind` feeds ``. + * This replaces the previous `+6` (which created many + * reactive scopes per node) and an eagerly-evaluated `fallback`. + * The leaf/slot fast-path also skips all 6 condition checks. + * - Styles: each sub-renderer batches all `l()`-derived style properties + * into a single `createMemo` so Solid tracks one computation rather than + * N per-property reactive bindings. + * - Reactivity for correctness: `l() = () => props.node.layout` ensures that + * when a parent passes a new `Measured` node (e.g. collapsible toggling + * expanded/collapsed), all JSX expressions re-evaluate correctly. + */ + +import { For, Show, createEffect, createMemo, onCleanup, type JSX } from 'solid-js'; +import { Dynamic } from 'solid-js/web'; +import type { + BubbleLayout, + CollapsibleLayout, + PadLayout, + SlotLayout, + SlotName, + StackLayout, + WindowLayout, +} from '../core/compose'; +import type { Measured } from '../core/define'; +import type { CodeLaidOut, ProseLaidOut, TableLaidOut } from '../core/layout/layout-types'; +import type { Block, CodeBlock, ProseBlock } from '../core/markdown/document'; +import { Code } from './code/Code'; +import { Prose } from './prose/Prose'; +import { Table } from './table/Table'; + +// ── Type discriminator ──────────────────────────────────────────────────────── + +type AnyLayout = { kind: string }; + +// ── Uniform sub-renderer prop type ──────────────────────────────────────────── + +/** + * Prop shape shared by every sub-renderer component so they can all be stored + * in a single `Record` and dispatched via ``. + * Sub-renderers that do not use `slots` / `renderLeaf` simply ignore them. + */ +/** Public API type for the slots map: keys must be known SlotName values. */ +export type SlotMap = Partial) => JSX.Element>>; + +/** + * Internal dispatch type: uses `string` keys so runtime slot name lookups + * (from `CollapsibleLayout.headerSlot` and `SlotLayout.name`) typecheck without casts. + * Production callers always pass a `SlotMap` (narrowed at the public boundary). + */ +type InternalSlotMap = Partial) => JSX.Element>>; + +type SubProps = { + // oxlint-disable-next-line typescript/no-explicit-any -- dispatch boundary; each renderer narrows at its own call site + node: Measured; + slots: InternalSlotMap; + // oxlint-disable-next-line typescript/no-explicit-any + render: (child: Measured) => JSX.Element; + renderLeaf: (node: Measured) => JSX.Element; +}; + +// ── Block leaf layout (produced by block-stack.ts) ──────────────────────────── + +/** + * Extended leaf layout types that carry a back-reference to the source Block. + * Produced by `measureBlockCached` in `core/layout/block-stack.ts`. + */ +export type ProseLeafLayout = ProseLaidOut & { raw: ProseBlock }; +export type CodeLeafLayout = CodeLaidOut & { raw: CodeBlock }; +export type TableLeafLayout = TableLaidOut & { raw: Block }; + +export type BlockLeafLayout = ProseLeafLayout | CodeLeafLayout | TableLeafLayout; + +// ── Sub-renderers ───────────────────────────────────────────────────────────── + +function ProjectStack(props: SubProps) { + const placed = () => (props.node as Measured).layout.placed; + return ( +
+ + {(p) => ( +
+ {props.render(p.child)} +
+ )} +
+
+ ); +} + +function ProjectPad(props: SubProps) { + const l = () => (props.node as Measured).layout; + const style = createMemo(() => ({ + position: 'relative' as const, + height: `${props.node.height}px`, + 'box-sizing': 'border-box' as const, + 'border-width': `${l().border}px`, + 'padding-top': `${l().padY}px`, + 'padding-bottom': `${l().padY}px`, + 'padding-left': `${l().padX}px`, + 'padding-right': `${l().padX}px`, + })); + return
{props.render(l().child)}
; +} + +function ProjectBubble(props: SubProps) { + const l = () => (props.node as Measured).layout; + const style = createMemo(() => ({ + position: 'relative' as const, + height: `${props.node.height}px`, + 'padding-left': `${l().padX}px`, + 'padding-right': `${l().padX}px`, + ...(l().width !== undefined ? { width: `${l().width}px` } : {}), + })); + return ( +
+ {props.render(l().child)} +
+ ); +} + +function ProjectCollapsible(props: SubProps) { + const l = () => (props.node as Measured).layout; + const headerSlotNode = (): Measured => ({ + height: l().headerH, + width: props.node.width, + layout: { kind: 'slot', name: l().headerSlot, height: l().headerH }, + }); + const outerStyle = createMemo(() => ({ + position: 'relative' as const, + height: `${props.node.height}px`, + })); + const headerStyle = createMemo(() => ({ + position: 'absolute' as const, + top: '0', + left: '0', + right: '0', + height: `${l().headerH}px`, + })); + const bodyStyle = createMemo(() => ({ + position: 'absolute' as const, + top: `${l().bodyTop}px`, + left: '0', + right: '0', + })); + return ( +
+
{props.slots[l().headerSlot]?.(headerSlotNode())}
+ + {(c) => ( +
{props.render(c())}
+ )} +
+
+ ); +} + +function ProjectWindow(props: SubProps) { + const l = () => (props.node as Measured).layout; + let scrollEl: HTMLDivElement | undefined; + + // Auto-scroll to bottom when autoScrollBottom is enabled. + // Runs unconditionally so Solid tracks l().child.height reactively as + // content grows during active thinking/file-op preview streaming. + createEffect(() => { + if (!l().autoScrollBottom) return; + const _h = l().child.height; + if (scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight; + return _h; + }); + onCleanup(() => { + scrollEl = undefined; + }); + + const outerStyle = createMemo(() => ({ + height: `${props.node.height}px`, + 'max-height': `${l().maxH}px`, + overflow: 'hidden' as const, + position: 'relative' as const, + })); + const scrollStyle = createMemo(() => ({ + 'max-height': `${l().maxH}px`, + overflow: l().autoScrollBottom ? 'auto' : ('hidden' as const), + 'scrollbar-gutter': l().autoScrollBottom ? ('stable' as const) : undefined, + })); + + return ( +
+ + + ); +} + +function ProjectSlot(props: SubProps) { + const name = () => (props.node.layout as SlotLayout).name; + return <>{props.slots[name()]?.(props.node as Measured) ?? null}; +} + +function ProjectLeaf(props: SubProps) { + return <>{props.renderLeaf(props.node) ?? null}; +} + +// ── Dispatch table ──────────────────────────────────────────────────────────── + +const RENDERERS: Record JSX.Element> = { + stack: ProjectStack, + pad: ProjectPad, + bubble: ProjectBubble, + collapsible: ProjectCollapsible, + window: ProjectWindow, + slot: ProjectSlot, +}; + +// ── Block leaf renderer ─────────────────────────────────────────────────────── + +/** + * Render a generic block leaf node produced by `layoutBlockStack`. + * + * The layout carries a `raw` back-reference to the source `Block` so + * Prose/Code/Table renderers can access `runs`, `variant`, and code text + * without a separate lookup. This replaces `BlockStack.tsx`. + */ +export function renderBlockLeaf(node: Measured): JSX.Element { + const layout = node.layout as AnyLayout; + if (layout.kind === 'prose') { + const l = node.layout as ProseLeafLayout; + return ; + } + if (layout.kind === 'code') { + const l = node.layout as CodeLeafLayout; + return ; + } + if (layout.kind === 'table') { + const l = node.layout as TableLeafLayout; + return ; + } + return null; +} + +// ── Main dispatcher ─────────────────────────────────────────────────────────── + +export type ProjectProps = { + // oxlint-disable-next-line typescript/no-explicit-any -- accepts any layout tree node + node: Measured; + /** + * Map from slot name to render function. Consulted for `slot` nodes and + * for `collapsible` header slots. + * + * Production callers should use keys from `SLOT_NAMES` (i.e. `SlotMap`) to get + * autocomplete and compile-time typo protection. The prop accepts the wider + * `InternalSlotMap` so that test helpers can pass ad-hoc slot names without casts. + */ + slots?: InternalSlotMap; + /** + * Renderer for non-combinator leaf nodes (prose/code/table block leaves). + * Defaults to `renderBlockLeaf`. Callers may override for custom leaves. + */ + children?: (child: Measured) => JSX.Element; +}; + +/** + * Render a `Measured` layout tree by `layout.kind`. + * + * A single `createMemo` over `layout.kind` feeds ``, + * replacing a `+6` structure. This cuts per-node reactive + * overhead from ~7 constructs to 1 memo + 1 Dynamic, while keeping full + * reactivity when `kind` changes across re-measures. + */ +export function Project(props: ProjectProps): JSX.Element { + const slots: InternalSlotMap = props.slots ?? {}; + const renderLeaf = props.children ?? renderBlockLeaf; + + // oxlint-disable-next-line typescript/no-explicit-any -- tree walk; each branch narrows the type + const renderChild = (child: Measured): JSX.Element => ( + + {renderLeaf} + + ); + + const layout = () => props.node.layout as AnyLayout; + const comp = createMemo(() => RENDERERS[layout().kind] ?? ProjectLeaf); + + return ( + + ); +} diff --git a/packages/chat-ui/src/components/Row.tsx b/packages/chat-ui/src/components/Row.tsx new file mode 100644 index 0000000000..6c64041ab4 --- /dev/null +++ b/packages/chat-ui/src/components/Row.tsx @@ -0,0 +1,176 @@ +/** + * Row — generic row dispatcher driven by REGISTRY. + * + * Owns the virtualizer height bridge: computes exact layout via the registry + * def's `measure()`, writes the height into the Fenwick tree via `virt.setSize`, + * and delegates rendering to the def's `Render` component via . + * + * Per-row state: + * measured — DOM-measured heights for islands and thinking bodies, written + * back by the Render component through ctx.setMeasured. + * + * Rendered via keyed by row index, so each Row instance owns a fixed row + * index for its lifetime — no slot recycling, no cross-row state contamination. + * + * Debug overlay: when DebugContext is enabled, a dashed boundary is drawn over + * the full row at the virtualizer-reserved height. A red boundary + label means + * the rendered height differs from what the engine reserved. + */ + +import { Show, createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; +import { Dynamic } from 'solid-js/web'; +import type { ChatCaches } from '../core/caches'; +import type { MeasureCtx, RenderCtx } from '../core/define'; +import type { ChatTheme } from '../core/theme'; +import type { Virtualizer } from '../core/virtualizer'; +import type { ChatItem } from '../model'; +import type { ViewState } from '../state/view-state'; +import { useDebug } from './debug-context'; +import { REGISTRY } from './registry'; +import { cachedMeasure } from './row-measure'; + +// ── Two-level identity-based memo ───────────────────────────────────────────── +// +// Level 1 (nodeMemo, components/row-measure.ts): WeakMap keyed by the ChatItem +// object. Shared with the idle-time prefetch scheduler so prefetch cache +// hits are visible to Row on mount. +// activeTurn rows bypass this level (streaming — content changes every tick), +// but they benefit from Level 2. +// +// Level 2 (blockMemo, core/layout/block-stack.ts): WeakMap keyed by Block object. +// Skips individual block re-measures for unchanged blocks within a streaming +// row. Since ctx.caches.parseBlocks reuses the same Block object for +// unchanged content, only the last growing block is a miss each tick. +// +// Fingerprint: theme.version + rowWidth + isCollapsed(item.id) + expanded(item.id) +// (covers all Lane A inputs — see core/define.ts state split docs) + +export type RowProps = { + item: ChatItem; + index: number; + rowWidth: number; + theme: ChatTheme; + viewState: ViewState; + virt: Virtualizer; + onHeightChanged: (index: number, delta: number) => void; + /** True when this row is in the activeTurn (currently streaming). */ + isActiveTurn?: boolean; + /** Per-instance cache bundle from ChatRoot. */ + caches: ChatCaches; +}; + +// ── Row-level debug overlay ──────────────────────────────────────────────────── + +function RowDebugOverlay(props: { reserved: number; rowEl: () => HTMLElement | undefined }) { + const [mismatch, setMismatch] = createSignal(false); + const [actualH, setActualH] = createSignal(0); + + onMount(() => { + const el = props.rowEl(); + if (!el) return; + + const check = () => { + const h = el.offsetHeight; + setActualH(h); + setMismatch(Math.abs(h - props.reserved) > 0.5); + }; + const ro = new ResizeObserver(check); + ro.observe(el); + onCleanup(() => ro.disconnect()); + requestAnimationFrame(check); + }); + + return ( +
+ + row · reserved={props.reserved} + + {' '} + + ⚠ actual={actualH()} (+{actualH() - props.reserved}) + + + +
+ ); +} + +// ── Row ─────────────────────────────────────────────────────────────────────── + +export function Row(props: RowProps) { + const debug = useDebug(); + + let rowEl: HTMLElement | undefined; + + // ── Contexts ───────────────────────────────────────────────────────────────── + + const resolveExpanded = (id: string): boolean => { + const collapseDecl = def().collapse; + if (!collapseDecl) return false; + const flag = props.viewState.isCollapsed(id); + if (collapseDecl.mode === 'inverted') { + // Inverted: stored "collapsed" flag means "expanded"; absence means not expanded. + return flag; + } + // Normal: expanded when the view-state "collapsed" flag is NOT set. + return !flag; + }; + + const measureCtx = (): MeasureCtx => ({ + theme: props.theme, + width: props.rowWidth, + isCollapsed: (id) => props.viewState.isCollapsed(id), + expanded: resolveExpanded, + caches: props.caches, + }); + + const renderCtx: RenderCtx = { + viewState: { isCollapsed: (id) => props.viewState.isCollapsed(id) }, + }; + + // ── Def + layout ──────────────────────────────────────────────────────────── + + const def = createMemo(() => REGISTRY[props.item.kind as keyof typeof REGISTRY]); + + // Per-kind symmetric wrapper padding declared in each ComponentDef. + const padY = () => def().padY ?? 0; + + // ── Layout + height bridge ──────────────────────────────────────────────────── + + const layout = createMemo(() => cachedMeasure(props.item, !!props.isActiveTurn, measureCtx())); + + // Virtualizer height = content height + both padding sides. + const reserved = () => layout().height + 2 * padY(); + + createEffect(() => { + const delta = props.virt.setSize(props.index, reserved()); + if (delta !== 0) props.onHeightChanged(props.index, delta); + }); + + // ── Render ──────────────────────────────────────────────────────────────────── + + return ( +
{ + rowEl = e; + }} + style={{ + position: 'relative', + 'padding-top': `${padY()}px`, + 'padding-bottom': `${padY()}px`, + }} + > + + + rowEl} /> + +
+ ); +} diff --git a/packages/chat-ui/src/components/ThemeContext.ts b/packages/chat-ui/src/components/ThemeContext.ts new file mode 100644 index 0000000000..c277f7c963 --- /dev/null +++ b/packages/chat-ui/src/components/ThemeContext.ts @@ -0,0 +1,21 @@ +/** + * ThemeContext — Solid context that provides the current ChatTheme to all + * descendant components in the chat tree. + * + * ChatRoot (or any test harness) provides the theme; component renderers that + * need geometry constants (heights, paddings) call `useTheme()` to read them + * rather than importing from per-component metrics files. + * + * The value is a reactive accessor `() => ChatTheme` so that a parent can + * swap the theme without remounting the whole subtree — the accessor is + * re-evaluated by any reactive computation that reads it. + */ + +import { createContext, useContext } from 'solid-js'; +import type { ChatTheme } from '../core/theme'; +import { DEFAULT_THEME } from '../core/theme'; + +export const ThemeContext = createContext<() => ChatTheme>(() => DEFAULT_THEME); + +/** Returns the current ChatTheme reactive accessor from the nearest ThemeContext. */ +export const useTheme = (): (() => ChatTheme) => useContext(ThemeContext); diff --git a/packages/chat-ui/src/components/block-frame.module.css b/packages/chat-ui/src/components/block-frame.module.css new file mode 100644 index 0000000000..9576d583b6 --- /dev/null +++ b/packages/chat-ui/src/components/block-frame.module.css @@ -0,0 +1,16 @@ +/* + * block-frame.module.css — base positioning for all block-level content. + * + * BlockFrame and MeasuredBlockFrame render a div with this class. + * Block-kind-specific visual styles (background, border, radius, etc.) are + * applied via a second class passed through the `class` prop. + */ + +.pblock { + position: absolute; + left: 0; + width: 100%; + overflow: visible; + /* Ensure border-box sizing regardless of host preflight (lib ships utilities-only). */ + box-sizing: border-box; +} diff --git a/packages/chat-ui/src/components/block-frame.tsx b/packages/chat-ui/src/components/block-frame.tsx new file mode 100644 index 0000000000..544ab04aa9 --- /dev/null +++ b/packages/chat-ui/src/components/block-frame.tsx @@ -0,0 +1,116 @@ +/** + * BlockFrame — reusable absolute-position wrapper for block-level content + * inside a message bubble. + * + * Block components (Code, Prose, Table) use this instead of hand-writing + * `position: absolute; top; height; left: 0; right: 0` inline styles. + * Placement lives here; components only describe content. + * + * Debug overlay: when the DebugContext is enabled, a dashed blue boundary is + * drawn over the engine-reserved box. If the real DOM offsetHeight diverges from + * the reserved height by more than 0.5px the outline turns red and shows both + * values. Uses offsetHeight (border-box) — not scrollHeight — to match the + * border-box height formula used by reserveHeight() in the layout engine. + * + * data-block-id is set on each frame div so the measurement contract tests + * can query specific blocks without relying on fragile positional selectors. + */ + +import { Show, createSignal, onMount, type JSX, onCleanup } from 'solid-js'; +import { useDebug } from './debug-context'; +import styles from './block-frame.module.css'; + +// ── Debug overlay ───────────────────────────────────────────────────────────── + +type DebugOverlayProps = { + id?: string; + reservedHeight: number; + elRef: () => HTMLElement | undefined; +}; + +function DebugOverlay(props: DebugOverlayProps) { + const [mismatch, setMismatch] = createSignal(false); + const [actualH, setActualH] = createSignal(0); + + onMount(() => { + const el = props.elRef(); + if (!el) return; + const check = () => { + // offsetHeight is the correct probe here: it reads the border-box height + // of the positioned frame, matching the reserveHeight() arithmetic that + // produced props.reservedHeight. scrollHeight is unreliable for frames + // whose children are absolutely positioned (it excludes some padding). + const h = el.offsetHeight; + setActualH(h); + setMismatch(Math.abs(h - props.reservedHeight) > 0.5); + }; + const ro = new ResizeObserver(check); + ro.observe(el); + onCleanup(() => ro.disconnect()); + requestAnimationFrame(check); + }); + + return ( +
+ + {props.id ? `${props.id} · ` : ''}h={props.reservedHeight} + + {' '} + + ⚠ actual={actualH()} (+{actualH() - props.reservedHeight}) + + + +
+ ); +} + +// ── BlockFrame ──────────────────────────────────────────────────────────────── + +export type BlockFrameProps = { + layout: { top: number; height: number; id?: string }; + class?: string; + ref?: (el: HTMLElement) => void; + children: JSX.Element; +}; + +/** + * Pure positioning wrapper. Renders a `position: absolute` div sized and + * placed by the pre-computed layout geometry. The `.pblock` base class (from + * the block-frame module) provides `position: absolute; left: 0; width: 100%; + * box-sizing: border-box`. + * Pass an additional `class` for block-kind-specific visual styling. + */ +export function BlockFrame(props: BlockFrameProps) { + const debug = useDebug(); // () => boolean — reactive accessor + let el: HTMLElement | undefined; + const blockId = (props.layout as { id?: string }).id; + + return ( +
{ + el = e; + props.ref?.(e); + }} + data-block-id={blockId} + class={`${styles.pblock}${props.class ? ` ${props.class}` : ''}`} + style={{ + top: `${props.layout.top}px`, + height: `${props.layout.height}px`, + left: '0', + right: '0', + }} + > + {props.children} + + el} /> + +
+ ); +} diff --git a/packages/chat-ui/src/components/code/Code.tsx b/packages/chat-ui/src/components/code/Code.tsx new file mode 100644 index 0000000000..9917fa8c81 --- /dev/null +++ b/packages/chat-ui/src/components/code/Code.tsx @@ -0,0 +1,106 @@ +/** + * Code — Solid component rendering a CodeLaidOut block. + * + * Renders plain text first (synchronous), then applies Shiki highlighting + * asynchronously on an idle callback. + * + * Positioning is handled by BlockFrame; this component only describes content. + */ + +import { For, createEffect, onCleanup } from 'solid-js'; +import { applyTokenLines } from '../../core/highlight/apply-tokens'; +import type { CodeLaidOut } from '../../core/layout/layout-types'; +import type { CodeBlock } from '../../core/markdown/document'; +import { BlockFrame } from '../block-frame'; +import { useCaches } from '../CachesContext'; +import { cancelIdle, scheduleIdle } from '../dom-utils'; +import { CopyButton } from '../primitives/CopyButton'; +import styles from './code.module.css'; + +export type CodeProps = { + block: CodeLaidOut; + rawBlock: CodeBlock; +}; + +export function Code(props: CodeProps) { + const caches = useCaches(); + const lineElsMap: Map = new Map(); + let wrapperEl: HTMLElement | undefined; + + createEffect(() => { + if (!wrapperEl) return; + const lang = props.block.lang; + if (!lang) return; + + const code = props.rawBlock.code; + + // Synchronous fast-path on cache hit + const cached = caches.peekHighlight(code, lang); + if (cached) { + const lineEls = Array.from(lineElsMap.values()); + if (cached.rootStyle) { + for (const decl of cached.rootStyle.split(';')) { + const colon = decl.indexOf(':'); + if (colon === -1) continue; + const prop = decl.slice(0, colon).trim(); + const val = decl.slice(colon + 1).trim(); + if (prop) wrapperEl!.style.setProperty(prop, val); + } + } + applyTokenLines(lineEls, cached.lines); + return; + } + + // Deferred path + let cancelled = false; + const handle = scheduleIdle(() => { + if (cancelled) return; + const hl = caches.highlight(code, lang); + if (!hl || cancelled) return; + const el = wrapperEl; + if (!el) return; + if (hl.rootStyle) { + for (const decl of hl.rootStyle.split(';')) { + const colon = decl.indexOf(':'); + if (colon === -1) continue; + const prop = decl.slice(0, colon).trim(); + const val = decl.slice(colon + 1).trim(); + if (prop) el.style.setProperty(prop, val); + } + } + const lineEls = Array.from(lineElsMap.values()); + applyTokenLines(lineEls, hl.lines); + }); + + onCleanup(() => { + cancelled = true; + cancelIdle(handle); + }); + }); + + return ( + { + wrapperEl = el; + }} + > + + + {(line, i) => ( +
{ + lineElsMap.set(i(), el); + onCleanup(() => lineElsMap.delete(i())); + }} + class={`${styles['pcode-line']} text-foreground`} + style={{ top: `${line.top}px` }} + > + {line.text} +
+ )} +
+
+ ); +} diff --git a/packages/chat-ui/src/components/code/code.def.tsx b/packages/chat-ui/src/components/code/code.def.tsx new file mode 100644 index 0000000000..45c2768030 --- /dev/null +++ b/packages/chat-ui/src/components/code/code.def.tsx @@ -0,0 +1,62 @@ +/** + * codeDef — ComponentDef for CodeBlock / CodeLaidOut (block kind). + * + * Height = lines.length * code.lineHeight + 2 * CODE_PAD_Y + 2 * CODE_BORDER + * `measure()` always passes `blockTop: 0`; the parent stack combinator + * (layoutBlocks in messageDef) supplies the actual vertical offset. + */ + +import { defineComponent, type Measured, type RenderCtx } from '../../core/define'; +import type { CodeLaidOut } from '../../core/layout/layout-types'; +import { reserveHeight } from '../../core/layout/reserve-height'; +import type { CodeBlock } from '../../core/markdown/document'; +import { Code } from './Code'; + +/** Vertical padding on each side of the code block (px). */ +const CODE_PAD_Y = 8; +/** Border width (px) on each side of the code block. */ +const CODE_BORDER = 1; + +export type CodeDefLayout = CodeLaidOut; + +function CodeRender(props: { item: CodeBlock; layout: Measured; ctx: RenderCtx }) { + return ; +} + +export const codeDef = defineComponent({ + kind: 'code', + + measure(item, ctx): Measured { + const codeLineHeight = ctx.theme.fonts.code.lineHeight; + const rawLines = item.code.split('\n'); + + const lines = rawLines.map((text, i) => ({ + top: CODE_PAD_Y + i * codeLineHeight, + text, + })); + + const height = reserveHeight({ + content: rawLines.length * codeLineHeight, + padY: CODE_PAD_Y, + border: CODE_BORDER, + }); + + const laid: CodeLaidOut = { + kind: 'code', + id: item.id, + top: 0, + height, + contentWidth: ctx.width, + lines, + lang: item.lang, + }; + + return { + height, + width: ctx.width, + layout: laid, + }; + }, + + Render: CodeRender, +}); diff --git a/packages/chat-ui/src/components/code/code.module.css b/packages/chat-ui/src/components/code/code.module.css new file mode 100644 index 0000000000..cd5099a954 --- /dev/null +++ b/packages/chat-ui/src/components/code/code.module.css @@ -0,0 +1,53 @@ +/* + * render-code retained styles — geometry-coupled and Shiki-specific rules only. + * + * Visual rules (border-radius, overflow, line text-color) have been moved to + * Tailwind classes in Code.tsx. Only rules that the layout engine accounts for + * in its arithmetic remain here: padding, border, and Shiki background overrides + * (set as inline custom props by the highlighter, not expressible in Tailwind). + * + * Syntax highlighting theme + * When Shiki is active, `renderCode` writes --shiki-light-bg / --shiki-dark-bg + * onto .pcode-block as inline custom properties. Token colors come from + * --shiki-light / --shiki-dark inline properties set on each . + */ + +/* ── Code block ───────────────────────────────────────────────────────────── */ + +.pcode-block { + position: absolute; + left: 0; + /* Shiki bg vars override --chat-code-bg. Must stay here (not Tailwind) because + the value is a runtime CSS custom property set by the highlighter engine. */ + background: var(--shiki-light-bg, var(--chat-code-bg, #f8fafc)); + border: var(--chat-code-border) solid var(--chat-border, #e2e8f0); + padding: var(--chat-code-pad-y) var(--chat-code-pad-x); + /* border-radius, overflow — moved to Tailwind in Code.tsx */ +} + +/* Dark mode Shiki background override */ +:global(.emdark) .pcode-block { + background: var(--shiki-dark-bg, var(--chat-code-bg, #1e1e2e)); +} + +/* ── Code lines ───────────────────────────────────────────────────────────── */ + +.pcode-line { + position: absolute; + white-space: pre; + /* font metrics drive layout height — keep var-driven, not Tailwind */ + font-size: var(--chat-code-size); + font-weight: var(--chat-code-weight); + font-family: var(--type-code-font-family); + line-height: var(--chat-code-lh); + /* text color — moved to Tailwind in Code.tsx */ +} + +/* Highlighted token spans — inline CSS vars set by Shiki */ +.pcode-line span { + color: var(--shiki-light); +} + +:global(.emdark) .pcode-line span { + color: var(--shiki-dark); +} diff --git a/packages/chat-ui/src/components/code/layout.ts b/packages/chat-ui/src/components/code/layout.ts new file mode 100644 index 0000000000..1c4630daa1 --- /dev/null +++ b/packages/chat-ui/src/components/code/layout.ts @@ -0,0 +1,48 @@ +/** + * layoutCode — pure geometry for a CodeBlock. + * + * Moved here from core/layout/layout-code.ts so that layout constants, + * CSS vars, and the renderer live in the same folder. + * + * Constants come from ./metrics (single source of truth). + * Typography (line height) comes from the FontConfig passed in. + */ + +import type { CodeLaidOut } from '../../core/layout/layout-types'; +import { reserveHeight } from '../../core/layout/reserve-height'; +import type { CodeBlock } from '../../core/markdown/document'; +import type { FontConfig } from '../../core/measure/fonts'; + +const CODE_BLOCK_PAD_Y = 8; +const CODE_BLOCK_BORDER = 1; + +export function layoutCode( + block: CodeBlock, + fonts: FontConfig, + blockTop: number, + effectiveWidth: number +): CodeLaidOut { + const codeLineHeight = fonts.code.lineHeight; + const rawLines = block.code.split('\n'); + + const lines = rawLines.map((text, i) => ({ + top: CODE_BLOCK_PAD_Y + i * codeLineHeight, + text, + })); + + const height = reserveHeight({ + content: rawLines.length * codeLineHeight, + padY: CODE_BLOCK_PAD_Y, + border: CODE_BLOCK_BORDER, + }); + + return { + kind: 'code', + id: block.id, + top: blockTop, + height, + contentWidth: effectiveWidth, + lines, + lang: block.lang, + }; +} diff --git a/packages/chat-ui/src/components/debug-context.ts b/packages/chat-ui/src/components/debug-context.ts new file mode 100644 index 0000000000..40b651694e --- /dev/null +++ b/packages/chat-ui/src/components/debug-context.ts @@ -0,0 +1,28 @@ +/** + * DebugContext — controls the layout-boundary overlay in BlockFrame and Row. + * + * When debug is true, each block renders a dashed boundary at the exact height + * the layout engine reserved. If the real DOM height diverges from the reserved + * height the outline turns red — that signals a Tailwind class (or other CSS) + * that added geometry the engine didn't account for. + * + * Usage: + * 1. Set `debug` on ChatRoot (or pass the provider directly in tests/stories). + * 2. Toggle the "Debug" toolbar item in Storybook to see overlays on all rows. + */ + +import { createContext, useContext } from 'solid-js'; + +export const DebugContext = createContext<() => boolean>(() => false); + +/** + * Returns the reactive debug accessor. Call it inside JSX — `` — + * or capture as `const debug = useDebug(); ... ` so Solid's + * tracking picks up changes when the Storybook toolbar toggles. + * + * Do NOT call it immediately (i.e. `useDebug()()`) at component init — that + * captures a static snapshot that won't react to context changes. + */ +export function useDebug(): () => boolean { + return useContext(DebugContext); +} diff --git a/packages/chat-ui/src/components/diff/Diff.tsx b/packages/chat-ui/src/components/diff/Diff.tsx new file mode 100644 index 0000000000..6815b4f582 --- /dev/null +++ b/packages/chat-ui/src/components/diff/Diff.tsx @@ -0,0 +1,184 @@ +/** + * Diff — slot components for ChatDiff rows. + * + * DiffHeader — clickable file header (rendered in the 'diff:header' slot). + * DiffLines — diff line body with Shiki syntax highlighting + * (rendered in the 'diff:body' slot inside ProjectWindow). + * + * Both components are pure content; outer geometry is handled by the compose + * tree built in diffDef (stack + scrollWindow + slot nodes rendered by Project). + */ + +import { resolveFileIconClass } from '@emdash/ui/primitives'; +import { For, createEffect, onCleanup } from 'solid-js'; +import { applyTokensToElement } from '../../core/highlight/apply-tokens'; +import type { CodeToken } from '../../core/highlight/highlighter'; +import { basename } from '../../lib/path'; +import type { ChatDiff } from '../../model'; +import { useCaches } from '../CachesContext'; +import { useCommands } from '../CommandsContext'; +import { cancelIdle, scheduleIdle } from '../dom-utils'; +import { GenericFileIcon } from '../primitives/icons'; +import type { DiffRow } from './diff-lines'; +import type { DiffLayout } from './diff.def'; +import styles from './diff.module.css'; + +// ── Row style map ────────────────────────────────────────────────────────────── + +const ROW_CLASS: Record = { + add: 'bg-foreground-diff-added/10 border-l-[3px] border-foreground-diff-added', + remove: 'bg-foreground-diff-deleted/10 border-l-[3px] border-foreground-diff-deleted', + context: 'border-l-[3px] border-transparent', +}; + +// ── DiffHeader ──────────────────────────────────────────────────────────────── + +export type DiffHeaderProps = { + item: ChatDiff; + adds: number; + dels: number; + headerH: number; +}; + +export function DiffHeader(props: DiffHeaderProps) { + const name = () => basename(props.item.path); + const iconClass = () => resolveFileIconClass(name()); + const commands = useCommands(); + + const handleClick = () => { + commands().onOpenFile?.({ path: props.item.path, itemId: props.item.id, source: 'diff' }); + }; + + return ( +
+ {iconClass() ? ( +
+ {cell} +
+ {cell} +