diff --git a/drizzle.config.ts b/drizzle.config.ts index bc8a736..027513a 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,12 +1,23 @@ -import { defineConfig } from 'drizzle-kit'; +import { defineConfig } from "drizzle-kit"; export default defineConfig({ - schema: './src/db/schema.ts', - dialect: 'postgresql', - dbCredentials: { - url: process.env.DATABASE_URL!, - }, - verbose: true, - // Only manage our app tables — Better Auth manages its own tables - tablesFilter: ['workspaces', 'chat_sessions', 'skills', 'sources', 'user_settings', 'sandbox_snapshots'], + schema: "./src/db/schema.ts", + dialect: "postgresql", + dbCredentials: { + url: process.env.DATABASE_URL!, + }, + verbose: true, + // Only manage our app tables — Better Auth manages its own tables + tablesFilter: [ + "workspaces", + "chat_sessions", + "skills", + "sources", + "user_settings", + "sandbox_snapshots", + "archived_sessions", + "archived_messages", + "archived_parts", + "archived_todos", + ], }); diff --git a/drizzle/0002_nervous_wither.sql b/drizzle/0002_nervous_wither.sql index 273dd1d..38ee3f3 100644 --- a/drizzle/0002_nervous_wither.sql +++ b/drizzle/0002_nervous_wither.sql @@ -1,6 +1,30 @@ -ALTER TABLE "user_settings" ADD COLUMN "voice_enabled" boolean DEFAULT false;--> statement-breakpoint -ALTER TABLE "user_settings" ADD COLUMN "voice_model" text DEFAULT 'gpt-4o-mini-tts';--> statement-breakpoint -ALTER TABLE "user_settings" ADD COLUMN "voice_name" text DEFAULT 'alloy';--> statement-breakpoint -ALTER TABLE "user_settings" ADD COLUMN "voice_auto_speak" boolean DEFAULT true;--> statement-breakpoint -ALTER TABLE "user_settings" ADD COLUMN "voice_speed" text DEFAULT '1.0';--> statement-breakpoint -ALTER TABLE "user_settings" ADD COLUMN "preferred_mic" text; \ No newline at end of file +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='user_settings' AND column_name='voice_enabled') THEN + ALTER TABLE "user_settings" ADD COLUMN "voice_enabled" boolean DEFAULT false; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='user_settings' AND column_name='voice_model') THEN + ALTER TABLE "user_settings" ADD COLUMN "voice_model" text DEFAULT 'gpt-4o-mini-tts'; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='user_settings' AND column_name='voice_name') THEN + ALTER TABLE "user_settings" ADD COLUMN "voice_name" text DEFAULT 'alloy'; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='user_settings' AND column_name='voice_auto_speak') THEN + ALTER TABLE "user_settings" ADD COLUMN "voice_auto_speak" boolean DEFAULT true; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='user_settings' AND column_name='voice_speed') THEN + ALTER TABLE "user_settings" ADD COLUMN "voice_speed" text DEFAULT '1.0'; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='user_settings' AND column_name='preferred_mic') THEN + ALTER TABLE "user_settings" ADD COLUMN "preferred_mic" text; + END IF; +END $$; diff --git a/drizzle/0003_open_titania.sql b/drizzle/0003_open_titania.sql index 21d1e9e..4d5831c 100644 --- a/drizzle/0003_open_titania.sql +++ b/drizzle/0003_open_titania.sql @@ -1,2 +1,6 @@ ALTER TABLE "user_settings" ALTER COLUMN "voice_name" SET DEFAULT 'coral';--> statement-breakpoint -ALTER TABLE "chat_sessions" ADD COLUMN "forked_from_session_id" uuid; \ No newline at end of file +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='chat_sessions' AND column_name='forked_from_session_id') THEN + ALTER TABLE "chat_sessions" ADD COLUMN "forked_from_session_id" uuid; + END IF; +END $$; diff --git a/drizzle/0004_wonderful_silver_surfer.sql b/drizzle/0004_wonderful_silver_surfer.sql index d3e6434..c976d9b 100644 --- a/drizzle/0004_wonderful_silver_surfer.sql +++ b/drizzle/0004_wonderful_silver_surfer.sql @@ -1,4 +1,4 @@ -CREATE TABLE "sandbox_snapshots" ( +CREATE TABLE IF NOT EXISTS "sandbox_snapshots" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "workspace_id" uuid NOT NULL, "created_by" text NOT NULL, @@ -10,4 +10,8 @@ CREATE TABLE "sandbox_snapshots" ( "created_at" timestamp with time zone DEFAULT now() ); --> statement-breakpoint -ALTER TABLE "sandbox_snapshots" ADD CONSTRAINT "sandbox_snapshots_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'sandbox_snapshots_workspace_id_workspaces_id_fk') THEN + ALTER TABLE "sandbox_snapshots" ADD CONSTRAINT "sandbox_snapshots_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$; diff --git a/drizzle/0005_mixed_alex_wilder.sql b/drizzle/0005_mixed_alex_wilder.sql index 1598c3f..978cf94 100644 --- a/drizzle/0005_mixed_alex_wilder.sql +++ b/drizzle/0005_mixed_alex_wilder.sql @@ -1 +1,5 @@ -ALTER TABLE "user_settings" ADD COLUMN "default_command" text DEFAULT ''; \ No newline at end of file +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='user_settings' AND column_name='default_command') THEN + ALTER TABLE "user_settings" ADD COLUMN "default_command" text DEFAULT ''; + END IF; +END $$; diff --git a/drizzle/0006_fancy_sally_floyd.sql b/drizzle/0006_fancy_sally_floyd.sql new file mode 100644 index 0000000..9a1d67c --- /dev/null +++ b/drizzle/0006_fancy_sally_floyd.sql @@ -0,0 +1,86 @@ +CREATE TABLE IF NOT EXISTS "archived_messages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "archived_session_id" uuid NOT NULL, + "opencode_message_id" text NOT NULL, + "role" text NOT NULL, + "agent" text, + "model" text, + "cost" double precision, + "tokens" jsonb, + "error" text, + "data" jsonb, + "time_created" timestamp with time zone, + "time_updated" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "archived_parts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "archived_message_id" uuid NOT NULL, + "archived_session_id" uuid NOT NULL, + "opencode_part_id" text NOT NULL, + "type" text NOT NULL, + "data" jsonb NOT NULL, + "time_created" timestamp with time zone, + "time_updated" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "archived_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "chat_session_id" uuid NOT NULL, + "opencode_session_id" text NOT NULL, + "parent_session_id" text, + "title" text, + "project_id" text, + "total_cost" double precision DEFAULT 0, + "total_tokens" integer DEFAULT 0, + "input_tokens" integer DEFAULT 0, + "output_tokens" integer DEFAULT 0, + "reasoning_tokens" integer DEFAULT 0, + "cache_read" integer DEFAULT 0, + "cache_write" integer DEFAULT 0, + "message_count" integer DEFAULT 0, + "time_created" timestamp with time zone, + "time_updated" timestamp with time zone, + "metadata" jsonb DEFAULT '{}'::jsonb, + "created_at" timestamp with time zone DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "archived_todos" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "archived_session_id" uuid NOT NULL, + "content" text NOT NULL, + "status" text NOT NULL, + "priority" text NOT NULL, + "position" integer NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='chat_sessions' AND column_name='archive_status') THEN + ALTER TABLE "chat_sessions" ADD COLUMN "archive_status" text DEFAULT 'none' NOT NULL; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'archived_messages_archived_session_id_archived_sessions_id_fk') THEN + ALTER TABLE "archived_messages" ADD CONSTRAINT "archived_messages_archived_session_id_archived_sessions_id_fk" FOREIGN KEY ("archived_session_id") REFERENCES "public"."archived_sessions"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'archived_parts_archived_message_id_archived_messages_id_fk') THEN + ALTER TABLE "archived_parts" ADD CONSTRAINT "archived_parts_archived_message_id_archived_messages_id_fk" FOREIGN KEY ("archived_message_id") REFERENCES "public"."archived_messages"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'archived_parts_archived_session_id_archived_sessions_id_fk') THEN + ALTER TABLE "archived_parts" ADD CONSTRAINT "archived_parts_archived_session_id_archived_sessions_id_fk" FOREIGN KEY ("archived_session_id") REFERENCES "public"."archived_sessions"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'archived_sessions_chat_session_id_chat_sessions_id_fk') THEN + ALTER TABLE "archived_sessions" ADD CONSTRAINT "archived_sessions_chat_session_id_chat_sessions_id_fk" FOREIGN KEY ("chat_session_id") REFERENCES "public"."chat_sessions"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'archived_todos_archived_session_id_archived_sessions_id_fk') THEN + ALTER TABLE "archived_todos" ADD CONSTRAINT "archived_todos_archived_session_id_archived_sessions_id_fk" FOREIGN KEY ("archived_session_id") REFERENCES "public"."archived_sessions"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$; diff --git a/drizzle/0007_ancient_grandmaster.sql b/drizzle/0007_ancient_grandmaster.sql new file mode 100644 index 0000000..086092b --- /dev/null +++ b/drizzle/0007_ancient_grandmaster.sql @@ -0,0 +1,5 @@ +CREATE INDEX IF NOT EXISTS "idx_archived_messages_session_id" ON "archived_messages" USING btree ("archived_session_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_archived_parts_session_id" ON "archived_parts" USING btree ("archived_session_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_archived_parts_message_id" ON "archived_parts" USING btree ("archived_message_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_archived_sessions_chat_session_id" ON "archived_sessions" USING btree ("chat_session_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_archived_todos_session_id" ON "archived_todos" USING btree ("archived_session_id"); diff --git a/drizzle/0008_proactive_archive_sync.sql b/drizzle/0008_proactive_archive_sync.sql new file mode 100644 index 0000000..aa28a65 --- /dev/null +++ b/drizzle/0008_proactive_archive_sync.sql @@ -0,0 +1,5 @@ +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='chat_sessions' AND column_name='last_archived_at') THEN + ALTER TABLE "chat_sessions" ADD COLUMN "last_archived_at" TIMESTAMP WITH TIME ZONE; + END IF; +END $$; diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..c22c49f --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,965 @@ +{ + "id": "8ebd2135-46f5-436f-abe9-1311c222c64f", + "prevId": "ca56a172-adb3-4a23-9e5c-581369343c96", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.archived_messages": { + "name": "archived_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "archived_session_id": { + "name": "archived_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "opencode_message_id": { + "name": "opencode_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "archived_messages_archived_session_id_archived_sessions_id_fk": { + "name": "archived_messages_archived_session_id_archived_sessions_id_fk", + "tableFrom": "archived_messages", + "tableTo": "archived_sessions", + "columnsFrom": [ + "archived_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.archived_parts": { + "name": "archived_parts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "archived_message_id": { + "name": "archived_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "archived_session_id": { + "name": "archived_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "opencode_part_id": { + "name": "opencode_part_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "archived_parts_archived_message_id_archived_messages_id_fk": { + "name": "archived_parts_archived_message_id_archived_messages_id_fk", + "tableFrom": "archived_parts", + "tableTo": "archived_messages", + "columnsFrom": [ + "archived_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "archived_parts_archived_session_id_archived_sessions_id_fk": { + "name": "archived_parts_archived_session_id_archived_sessions_id_fk", + "tableFrom": "archived_parts", + "tableTo": "archived_sessions", + "columnsFrom": [ + "archived_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.archived_sessions": { + "name": "archived_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_session_id": { + "name": "chat_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "opencode_session_id": { + "name": "opencode_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_session_id": { + "name": "parent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_cost": { + "name": "total_cost", + "type": "double precision", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cache_read": { + "name": "cache_read", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cache_write": { + "name": "cache_write", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "archived_sessions_chat_session_id_chat_sessions_id_fk": { + "name": "archived_sessions_chat_session_id_chat_sessions_id_fk", + "tableFrom": "archived_sessions", + "tableTo": "chat_sessions", + "columnsFrom": [ + "chat_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.archived_todos": { + "name": "archived_todos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "archived_session_id": { + "name": "archived_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "archived_todos_archived_session_id_archived_sessions_id_fk": { + "name": "archived_todos_archived_session_id_archived_sessions_id_fk", + "tableFrom": "archived_todos", + "tableTo": "archived_sessions", + "columnsFrom": [ + "archived_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_sessions": { + "name": "chat_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "archive_status": { + "name": "archive_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "sandbox_id": { + "name": "sandbox_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_url": { + "name": "sandbox_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "opencode_session_id": { + "name": "opencode_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forked_from_session_id": { + "name": "forked_from_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "flagged": { + "name": "flagged", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_sessions_workspace_id_workspaces_id_fk": { + "name": "chat_sessions_workspace_id_workspaces_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_snapshots": { + "name": "sandbox_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_session_id": { + "name": "source_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sandbox_snapshots_workspace_id_workspaces_id_fk": { + "name": "sandbox_snapshots_workspace_id_workspaces_id_fk", + "tableFrom": "sandbox_snapshots", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "skills_workspace_id_workspaces_id_fk": { + "name": "skills_workspace_id_workspaces_id_fk", + "tableFrom": "skills", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sources": { + "name": "sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sources_workspace_id_workspaces_id_fk": { + "name": "sources_workspace_id_workspaces_id_fk", + "tableFrom": "sources", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_pat": { + "name": "github_pat", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "voice_enabled": { + "name": "voice_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "voice_model": { + "name": "voice_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'gpt-4o-mini-tts'" + }, + "voice_name": { + "name": "voice_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'coral'" + }, + "voice_auto_speak": { + "name": "voice_auto_speak", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "voice_speed": { + "name": "voice_speed", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "preferred_mic": { + "name": "preferred_mic", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_command": { + "name": "default_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_settings_user_id_unique": { + "name": "user_settings_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..b90223b --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,1044 @@ +{ + "id": "6ad68277-3895-41b2-8768-f6c351b53e06", + "prevId": "8ebd2135-46f5-436f-abe9-1311c222c64f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.archived_messages": { + "name": "archived_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "archived_session_id": { + "name": "archived_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "opencode_message_id": { + "name": "opencode_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_archived_messages_session_id": { + "name": "idx_archived_messages_session_id", + "columns": [ + { + "expression": "archived_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archived_messages_archived_session_id_archived_sessions_id_fk": { + "name": "archived_messages_archived_session_id_archived_sessions_id_fk", + "tableFrom": "archived_messages", + "tableTo": "archived_sessions", + "columnsFrom": [ + "archived_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.archived_parts": { + "name": "archived_parts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "archived_message_id": { + "name": "archived_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "archived_session_id": { + "name": "archived_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "opencode_part_id": { + "name": "opencode_part_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_archived_parts_session_id": { + "name": "idx_archived_parts_session_id", + "columns": [ + { + "expression": "archived_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_archived_parts_message_id": { + "name": "idx_archived_parts_message_id", + "columns": [ + { + "expression": "archived_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archived_parts_archived_message_id_archived_messages_id_fk": { + "name": "archived_parts_archived_message_id_archived_messages_id_fk", + "tableFrom": "archived_parts", + "tableTo": "archived_messages", + "columnsFrom": [ + "archived_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "archived_parts_archived_session_id_archived_sessions_id_fk": { + "name": "archived_parts_archived_session_id_archived_sessions_id_fk", + "tableFrom": "archived_parts", + "tableTo": "archived_sessions", + "columnsFrom": [ + "archived_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.archived_sessions": { + "name": "archived_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_session_id": { + "name": "chat_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "opencode_session_id": { + "name": "opencode_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_session_id": { + "name": "parent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_cost": { + "name": "total_cost", + "type": "double precision", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cache_read": { + "name": "cache_read", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cache_write": { + "name": "cache_write", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_archived_sessions_chat_session_id": { + "name": "idx_archived_sessions_chat_session_id", + "columns": [ + { + "expression": "chat_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archived_sessions_chat_session_id_chat_sessions_id_fk": { + "name": "archived_sessions_chat_session_id_chat_sessions_id_fk", + "tableFrom": "archived_sessions", + "tableTo": "chat_sessions", + "columnsFrom": [ + "chat_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.archived_todos": { + "name": "archived_todos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "archived_session_id": { + "name": "archived_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_archived_todos_session_id": { + "name": "idx_archived_todos_session_id", + "columns": [ + { + "expression": "archived_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archived_todos_archived_session_id_archived_sessions_id_fk": { + "name": "archived_todos_archived_session_id_archived_sessions_id_fk", + "tableFrom": "archived_todos", + "tableTo": "archived_sessions", + "columnsFrom": [ + "archived_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_sessions": { + "name": "chat_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "archive_status": { + "name": "archive_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "sandbox_id": { + "name": "sandbox_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_url": { + "name": "sandbox_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "opencode_session_id": { + "name": "opencode_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forked_from_session_id": { + "name": "forked_from_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "flagged": { + "name": "flagged", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_sessions_workspace_id_workspaces_id_fk": { + "name": "chat_sessions_workspace_id_workspaces_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_snapshots": { + "name": "sandbox_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_session_id": { + "name": "source_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sandbox_snapshots_workspace_id_workspaces_id_fk": { + "name": "sandbox_snapshots_workspace_id_workspaces_id_fk", + "tableFrom": "sandbox_snapshots", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "skills_workspace_id_workspaces_id_fk": { + "name": "skills_workspace_id_workspaces_id_fk", + "tableFrom": "skills", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sources": { + "name": "sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sources_workspace_id_workspaces_id_fk": { + "name": "sources_workspace_id_workspaces_id_fk", + "tableFrom": "sources", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_pat": { + "name": "github_pat", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "voice_enabled": { + "name": "voice_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "voice_model": { + "name": "voice_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'gpt-4o-mini-tts'" + }, + "voice_name": { + "name": "voice_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'coral'" + }, + "voice_auto_speak": { + "name": "voice_auto_speak", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "voice_speed": { + "name": "voice_speed", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "preferred_mic": { + "name": "preferred_mic", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_command": { + "name": "default_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_settings_user_id_unique": { + "name": "user_settings_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 97692e3..4698976 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,27 @@ "when": 1770911965535, "tag": "0005_mixed_alex_wilder", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1771087800896, + "tag": "0006_fancy_sally_floyd", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1771089994865, + "tag": "0007_ancient_grandmaster", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1771200000000, + "tag": "0008_proactive_archive_sync", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/api/index.ts b/src/api/index.ts index 75bd385..1e5f76e 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -2,31 +2,34 @@ * API routes for Agentuity Coder. * Mounts auth, workspace, session, chat, skills, and sources routes. */ -import { createRouter } from '@agentuity/runtime'; -import { auth, authMiddleware, authRoutes } from '../auth'; -import workspaceRoutes from '../routes/workspaces'; -import sessionRoutes from '../routes/sessions'; -import sessionDetailRoutes from '../routes/session-detail'; -import chatRoutes from '../routes/chat'; -import skillRoutes from '../routes/skills'; -import sourceRoutes from '../routes/sources'; -import sessionMcpRoutes from '../routes/session-mcp'; -import sharedRoutes from '../routes/shared'; -import githubRoutes from '../routes/github'; -import githubGlobalRoutes from '../routes/github-global'; -import userSettingsRoutes from '../routes/user-settings'; -import voiceRoutes from '../routes/voice'; -import voiceSettingsRoutes from '../routes/voice-settings'; -import snapshotRoutes from '../routes/snapshots'; +import { createRouter } from "@agentuity/runtime"; +import { auth, authMiddleware, authRoutes } from "../auth"; +import workspaceRoutes from "../routes/workspaces"; +import sessionRoutes from "../routes/sessions"; +import sessionDetailRoutes from "../routes/session-detail"; +import chatRoutes from "../routes/chat"; +import skillRoutes from "../routes/skills"; +import sourceRoutes from "../routes/sources"; +import sessionMcpRoutes from "../routes/session-mcp"; +import sharedRoutes from "../routes/shared"; +import cronRoutes from "../routes/cron"; +import githubRoutes from "../routes/github"; +import githubGlobalRoutes from "../routes/github-global"; +import userSettingsRoutes from "../routes/user-settings"; +import voiceRoutes from "../routes/voice"; +import voiceSettingsRoutes from "../routes/voice-settings"; +import snapshotRoutes from "../routes/snapshots"; const api = createRouter(); // Auth routes (public — no middleware). Uses mountAuthRoutes for proper cookie handling. -api.on(['GET', 'POST'], '/auth/*', authRoutes); +api.on(["GET", "POST"], "/auth/*", authRoutes); // Public endpoint: which auth methods are available -api.get('/auth-methods', (c) => { - const hasGoogle = Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); +api.get("/auth-methods", (c) => { + const hasGoogle = Boolean( + process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET, + ); return c.json({ google: hasGoogle, email: !hasGoogle, @@ -34,55 +37,58 @@ api.get('/auth-methods', (c) => { }); // Shared session routes (public — no authentication required) -api.route('/shared', sharedRoutes); +api.route("/shared", sharedRoutes); + +// Cron routes (no user auth — uses platform signature verification) +api.route("/cron", cronRoutes); // All other routes require authentication -api.use('/*', authMiddleware); +api.use("/*", authMiddleware); // GET /api/me — current authenticated user -api.get('/me', async (c) => { - const session = c.get('session'); - const user = c.get('user'); - return c.json({ user, session }); +api.get("/me", async (c) => { + const session = c.get("session"); + const user = c.get("user"); + return c.json({ user, session }); }); // User settings routes -api.route('/user', userSettingsRoutes); -api.route('/user/voice', voiceSettingsRoutes); +api.route("/user", userSettingsRoutes); +api.route("/user/voice", voiceSettingsRoutes); // Voice routes -api.route('/voice', voiceRoutes); +api.route("/voice", voiceRoutes); // GitHub routes (non-session scoped) -api.route('/github', githubGlobalRoutes); +api.route("/github", githubGlobalRoutes); // Workspace routes -api.route('/workspaces', workspaceRoutes); +api.route("/workspaces", workspaceRoutes); // Session routes (nested under workspaces) -api.route('/workspaces/:wid/sessions', sessionRoutes); +api.route("/workspaces/:wid/sessions", sessionRoutes); // Individual session operations -api.route('/sessions', sessionDetailRoutes); +api.route("/sessions", sessionDetailRoutes); // Chat routes (nested under sessions) -api.route('/sessions', chatRoutes); +api.route("/sessions", chatRoutes); // GitHub integration routes (nested under sessions) -api.route('/sessions', githubRoutes); +api.route("/sessions", githubRoutes); // Skills routes (nested under workspaces + standalone) -api.route('/workspaces/:wid/skills', skillRoutes); -api.route('/skills', skillRoutes); +api.route("/workspaces/:wid/skills", skillRoutes); +api.route("/skills", skillRoutes); // Session-scoped MCP routes -api.route('/sessions', sessionMcpRoutes); +api.route("/sessions", sessionMcpRoutes); // Sources routes (nested under workspaces + standalone) -api.route('/workspaces/:wid/sources', sourceRoutes); -api.route('/sources', sourceRoutes); +api.route("/workspaces/:wid/sources", sourceRoutes); +api.route("/sources", sourceRoutes); // Snapshot routes (nested under workspaces) -api.route('/workspaces/:wid/snapshots', snapshotRoutes); +api.route("/workspaces/:wid/snapshots", snapshotRoutes); export default api; diff --git a/src/db/schema.ts b/src/db/schema.ts index 3ca6dfc..557e022 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -4,7 +4,17 @@ * drizzle-orm/pg-core types without Bun-specific runtime deps, so both * Bun and Node (drizzle-kit) can resolve it. */ -import { pgTable, uuid, text, timestamp, jsonb, boolean } from '@agentuity/drizzle/schema'; +import { + pgTable, + uuid, + text, + timestamp, + jsonb, + boolean, + integer, + doublePrecision, + index, +} from "@agentuity/drizzle/schema"; /** * Workspaces group sessions, skills, and sources for a single user today. @@ -13,81 +23,190 @@ import { pgTable, uuid, text, timestamp, jsonb, boolean } from '@agentuity/drizz * organization support (shared workspaces, teams, and org-level settings) * is implemented. */ -export const workspaces = pgTable('workspaces', { - id: uuid('id').primaryKey().defaultRandom(), - organizationId: text('organization_id').notNull(), - name: text('name').notNull(), - description: text('description'), - settings: jsonb('settings').default({}), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), +export const workspaces = pgTable("workspaces", { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: text("organization_id").notNull(), + name: text("name").notNull(), + description: text("description"), + settings: jsonb("settings").default({}), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), }); -export const chatSessions = pgTable('chat_sessions', { - id: uuid('id').primaryKey().defaultRandom(), - workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }), - createdBy: text('created_by').notNull(), - title: text('title'), - status: text('status').notNull().default('active'), - sandboxId: text('sandbox_id'), - sandboxUrl: text('sandbox_url'), - opencodeSessionId: text('opencode_session_id'), - agent: text('agent'), - model: text('model'), - forkedFromSessionId: uuid('forked_from_session_id'), - flagged: boolean('flagged').default(false), - metadata: jsonb('metadata').default({}), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), +export const chatSessions = pgTable("chat_sessions", { + id: uuid("id").primaryKey().defaultRandom(), + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + createdBy: text("created_by").notNull(), + title: text("title"), + status: text("status").notNull().default("active"), + archiveStatus: text("archive_status").notNull().default("none"), + sandboxId: text("sandbox_id"), + sandboxUrl: text("sandbox_url"), + opencodeSessionId: text("opencode_session_id"), + agent: text("agent"), + model: text("model"), + forkedFromSessionId: uuid("forked_from_session_id"), + flagged: boolean("flagged").default(false), + metadata: jsonb("metadata").default({}), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), + lastArchivedAt: timestamp("last_archived_at", { withTimezone: true }), }); -export const skills = pgTable('skills', { - id: uuid('id').primaryKey().defaultRandom(), - workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }), - type: text('type').notNull().default('custom'), - name: text('name').notNull(), - description: text('description'), - content: text('content').notNull(), - repo: text('repo'), - enabled: boolean('enabled').default(true), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), +export const skills = pgTable("skills", { + id: uuid("id").primaryKey().defaultRandom(), + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + type: text("type").notNull().default("custom"), + name: text("name").notNull(), + description: text("description"), + content: text("content").notNull(), + repo: text("repo"), + enabled: boolean("enabled").default(true), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), }); -export const sources = pgTable('sources', { - id: uuid('id').primaryKey().defaultRandom(), - workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }), - name: text('name').notNull(), - type: text('type').notNull(), - config: jsonb('config').notNull().default({}), - enabled: boolean('enabled').default(true), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), +export const sources = pgTable("sources", { + id: uuid("id").primaryKey().defaultRandom(), + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + name: text("name").notNull(), + type: text("type").notNull(), + config: jsonb("config").notNull().default({}), + enabled: boolean("enabled").default(true), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), }); -export const sandboxSnapshots = pgTable('sandbox_snapshots', { - id: uuid('id').primaryKey().defaultRandom(), - workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }), - createdBy: text('created_by').notNull(), - name: text('name').notNull(), - description: text('description'), - snapshotId: text('snapshot_id').notNull(), - sourceSessionId: uuid('source_session_id'), - metadata: jsonb('metadata').default({}), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), +export const sandboxSnapshots = pgTable("sandbox_snapshots", { + id: uuid("id").primaryKey().defaultRandom(), + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + createdBy: text("created_by").notNull(), + name: text("name").notNull(), + description: text("description"), + snapshotId: text("snapshot_id").notNull(), + sourceSessionId: uuid("source_session_id"), + metadata: jsonb("metadata").default({}), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), }); -export const userSettings = pgTable('user_settings', { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull().unique(), - githubPat: text('github_pat'), - voiceEnabled: boolean('voice_enabled').default(false), - voiceModel: text('voice_model').default('gpt-4o-mini-tts'), - voiceName: text('voice_name').default('coral'), - voiceAutoSpeak: boolean('voice_auto_speak').default(true), - voiceSpeed: text('voice_speed').default('1.0'), - preferredMic: text('preferred_mic'), - defaultCommand: text('default_command').default(''), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), +export const userSettings = pgTable("user_settings", { + id: uuid("id").primaryKey().defaultRandom(), + userId: text("user_id").notNull().unique(), + githubPat: text("github_pat"), + voiceEnabled: boolean("voice_enabled").default(false), + voiceModel: text("voice_model").default("gpt-4o-mini-tts"), + voiceName: text("voice_name").default("coral"), + voiceAutoSpeak: boolean("voice_auto_speak").default(true), + voiceSpeed: text("voice_speed").default("1.0"), + preferredMic: text("preferred_mic"), + defaultCommand: text("default_command").default(""), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), }); + +// ─── Archive Tables ────────────────────────────────────────────────────────── +// Store normalized data extracted from OpenCode's ephemeral SQLite database +// before sandbox destruction. Linked to chatSessions via chatSessionId. + +/** Archived OpenCode sessions (each chat session may have parent+child sessions). */ +export const archivedSessions = pgTable( + "archived_sessions", + { + id: uuid("id").primaryKey().defaultRandom(), + chatSessionId: uuid("chat_session_id") + .notNull() + .references(() => chatSessions.id, { onDelete: "cascade" }), + opencodeSessionId: text("opencode_session_id").notNull(), + parentSessionId: text("parent_session_id"), + title: text("title"), + projectId: text("project_id"), + totalCost: doublePrecision("total_cost").default(0), + totalTokens: integer("total_tokens").default(0), + inputTokens: integer("input_tokens").default(0), + outputTokens: integer("output_tokens").default(0), + reasoningTokens: integer("reasoning_tokens").default(0), + cacheRead: integer("cache_read").default(0), + cacheWrite: integer("cache_write").default(0), + messageCount: integer("message_count").default(0), + timeCreated: timestamp("time_created", { withTimezone: true }), + timeUpdated: timestamp("time_updated", { withTimezone: true }), + metadata: jsonb("metadata").default({}), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + }, + (table) => [ + index("idx_archived_sessions_chat_session_id").on(table.chatSessionId), + ], +); + +/** Archived messages from OpenCode sessions. */ +export const archivedMessages = pgTable( + "archived_messages", + { + id: uuid("id").primaryKey().defaultRandom(), + archivedSessionId: uuid("archived_session_id") + .notNull() + .references(() => archivedSessions.id, { onDelete: "cascade" }), + opencodeMessageId: text("opencode_message_id").notNull(), + role: text("role").notNull(), + agent: text("agent"), + model: text("model"), + cost: doublePrecision("cost"), + tokens: jsonb("tokens"), + error: text("error"), + data: jsonb("data"), + timeCreated: timestamp("time_created", { withTimezone: true }), + timeUpdated: timestamp("time_updated", { withTimezone: true }), + }, + (table) => [ + index("idx_archived_messages_session_id").on(table.archivedSessionId), + ], +); + +/** Archived message parts (text, tool calls, reasoning). */ +export const archivedParts = pgTable( + "archived_parts", + { + id: uuid("id").primaryKey().defaultRandom(), + archivedMessageId: uuid("archived_message_id") + .notNull() + .references(() => archivedMessages.id, { onDelete: "cascade" }), + archivedSessionId: uuid("archived_session_id") + .notNull() + .references(() => archivedSessions.id, { onDelete: "cascade" }), + opencodePartId: text("opencode_part_id").notNull(), + type: text("type").notNull(), + data: jsonb("data").notNull(), + timeCreated: timestamp("time_created", { withTimezone: true }), + timeUpdated: timestamp("time_updated", { withTimezone: true }), + }, + (table) => [ + index("idx_archived_parts_session_id").on(table.archivedSessionId), + index("idx_archived_parts_message_id").on(table.archivedMessageId), + ], +); + +/** Archived todos from OpenCode sessions. */ +export const archivedTodos = pgTable( + "archived_todos", + { + id: uuid("id").primaryKey().defaultRandom(), + archivedSessionId: uuid("archived_session_id") + .notNull() + .references(() => archivedSessions.id, { onDelete: "cascade" }), + content: text("content").notNull(), + status: text("status").notNull(), + priority: text("priority").notNull(), + position: integer("position").notNull(), + }, + (table) => [ + index("idx_archived_todos_session_id").on(table.archivedSessionId), + ], +); diff --git a/src/lib/archive.ts b/src/lib/archive.ts new file mode 100644 index 0000000..75ebd18 --- /dev/null +++ b/src/lib/archive.ts @@ -0,0 +1,990 @@ +/** + * Archive pipeline — downloads OpenCode's SQLite database from a sandbox, + * parses it with the OpenCodeDBReader, and stores normalized data in + * PostgreSQL before the sandbox is destroyed. + * + * Also provides `syncSessionArchive()` for proactive, event-driven background + * syncing via `sandboxExecute` (no file download required). + */ +import { unlink } from "node:fs/promises"; +import { sandboxReadFile, sandboxExecute } from "@agentuity/server"; +import { and, eq, ne, inArray } from "@agentuity/drizzle"; +import { db } from "../db"; +import { + chatSessions, + archivedSessions, + archivedMessages, + archivedParts, + archivedTodos, +} from "../db/schema"; +import { OpenCodeDBReader } from "./sqlite"; +import type { DBSession, SessionCostSummary } from "./sqlite"; + +/** Path to OpenCode's SQLite database inside the sandbox. */ +const OPENCODE_DB_PATH = "/home/agentuity/.local/share/opencode/opencode.db"; + +/** Logger interface matching the app's logger shape. */ +interface Logger { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; +} + +/** Chat session row type inferred from the schema. */ +type ChatSession = typeof chatSessions.$inferSelect; + +/** + * Convert a Unix timestamp (seconds or milliseconds) to a JS Date. + * OpenCode stores timestamps as seconds since epoch. + */ +function unixToDate(ts: number | null | undefined): Date | null { + if (!ts) return null; + // Timestamps < 1e12 are in seconds; >= 1e12 are in milliseconds + return new Date(ts < 1e12 ? ts * 1000 : ts); +} + +/** + * Archive a session's OpenCode data before sandbox destruction. + * + * Downloads the SQLite DB from the sandbox, extracts all sessions/messages/ + * parts/todos, and stores them in PostgreSQL archive tables. + * + * This function NEVER throws — archive failures are logged and the + * archiveStatus is set to 'failed', but the caller can proceed with + * sandbox destruction regardless. + */ +export async function archiveSession( + apiClient: unknown, + chatSession: ChatSession, + logger: Logger, +): Promise { + const sessionId = chatSession.id; + const sandboxId = chatSession.sandboxId; + + if (!sandboxId) { + logger.warn("[archive] No sandbox ID for session, skipping archive", { + sessionId, + }); + return false; + } + + let tmpPath: string | null = null; + + try { + // 1. Atomically claim the archive — only proceed if status is currently 'none' + const [claimed] = await db + .update(chatSessions) + .set({ archiveStatus: "archiving", updatedAt: new Date() }) + .where( + and( + eq(chatSessions.id, sessionId), + eq(chatSessions.archiveStatus, "none"), + ), + ) + .returning(); + + if (!claimed) { + // Already archiving or archived — skip + logger.info( + "[archive] Session already being archived or archived, skipping", + { sessionId }, + ); + return false; + } + + logger.info("[archive] Downloading OpenCode DB from sandbox", { + sessionId, + sandboxId, + }); + + // 2. Download the SQLite DB file from the sandbox + const stream = await sandboxReadFile( + apiClient as Parameters[0], + { + sandboxId, + path: OPENCODE_DB_PATH, + }, + ); + + // 3. Write stream to temp file (wrap in Response — Bun.write accepts Response) + tmpPath = `/tmp/archive-${sessionId}.db`; + await Bun.write(tmpPath, new Response(stream)); + + logger.info("[archive] SQLite DB downloaded, opening reader", { + sessionId, + tmpPath, + }); + + // 4. Open with the SQLite reader (readonly mode for file DBs) + const reader = new OpenCodeDBReader({ dbPath: tmpPath }); + if (!reader.open()) { + throw new Error("Failed to open downloaded SQLite database"); + } + + try { + // 5. Extract all sessions from the SQLite DB + const allSessions = reader.getAllSessions(); + logger.info("[archive] Found sessions in SQLite DB", { + sessionId, + count: allSessions.length, + }); + + if (allSessions.length === 0) { + logger.warn("[archive] No sessions found in SQLite DB", { sessionId }); + await db + .update(chatSessions) + .set({ archiveStatus: "archived", updatedAt: new Date() }) + .where(eq(chatSessions.id, sessionId)); + return true; + } + + // 6. Store each OpenCode session and its data in PostgreSQL + await db.transaction(async (tx) => { + for (const ocSession of allSessions) { + await archiveOneSession(tx, sessionId, ocSession, reader, logger); + } + }); + + logger.info("[archive] Archive complete", { + sessionId, + sessionCount: allSessions.length, + }); + } finally { + reader.close(); + } + + // 7. Mark as archived + await db + .update(chatSessions) + .set({ archiveStatus: "archived", updatedAt: new Date() }) + .where(eq(chatSessions.id, sessionId)); + + return true; + } catch (error) { + logger.error("[archive] Archive failed", { + sessionId, + error: String(error), + }); + + // Mark as failed — don't block the delete + try { + await db + .update(chatSessions) + .set({ archiveStatus: "failed", updatedAt: new Date() }) + .where(eq(chatSessions.id, sessionId)); + } catch (updateError) { + logger.error("[archive] Failed to update archive status", { + sessionId, + error: String(updateError), + }); + } + + return false; + } finally { + // 8. Clean up temp file + if (tmpPath) { + try { + await unlink(tmpPath); + } catch { + // Temp file cleanup is best-effort + } + // Also try to clean up WAL/SHM files that SQLite may have created + try { + await unlink(`${tmpPath}-wal`); + } catch { + // Best-effort + } + try { + await unlink(`${tmpPath}-shm`); + } catch { + // Best-effort + } + } + } +} + +// ─── Proactive Sync via sandboxExecute ──────────────────────────────────────── + +/** Bun script executed inside the sandbox to dump the OpenCode SQLite DB as JSON. */ +const SQLITE_DUMP_SCRIPT = `import{Database}from"bun:sqlite";try{const d=new Database("/home/agentuity/.local/share/opencode/opencode.db",{readonly:true});const r={sessions:d.query("SELECT * FROM session").all(),messages:d.query("SELECT * FROM message").all(),parts:d.query("SELECT * FROM part").all(),todos:d.query("SELECT * FROM todo").all()};d.close();console.log(JSON.stringify(r))}catch(e){console.error(String(e));process.exit(1)}`; + +/** Raw row shapes returned from OpenCode SQLite (snake_case). */ +interface RawSqliteSession { + id: string; + slug?: string; + parent_id?: string | null; + title?: string | null; + project_id?: string | null; + directory?: string | null; + version?: string | null; + summary?: string | null; + share_url?: string | null; + time_created?: number | null; + time_updated?: number | null; +} + +interface RawSqliteMessage { + id: string; + session_id: string; + role: string; + agent?: string | null; + model?: string | null; + cost?: number | null; + tokens?: string | null; + error?: string | null; + time_created?: number | null; + time_updated?: number | null; +} + +interface RawSqlitePart { + id: string; + message_id: string; + session_id: string; + type: string; + data?: string | null; + time_created?: number | null; + time_updated?: number | null; +} + +interface RawSqliteTodo { + id: string; + session_id: string; + content: string; + status: string; + priority: string; + position: number; +} + +interface SqliteDumpData { + sessions: RawSqliteSession[]; + messages: RawSqliteMessage[]; + parts: RawSqlitePart[]; + todos: RawSqliteTodo[]; +} + +/** Parse a JSON string safely, returning null on failure. */ +function safeJsonParse(str: string | null | undefined): unknown { + if (!str) return null; + try { + return JSON.parse(str); + } catch { + return null; + } +} + +/** + * Proactively sync a session's OpenCode data from the sandbox SQLite DB + * into PostgreSQL archive tables using `sandboxExecute`. + * + * Uses a full-replace strategy: deletes all existing archive data for the + * chat session, then inserts fresh data from the SQLite dump. + * + * This function NEVER throws — catches all errors, logs them, and sets + * archiveStatus to 'failed' on error. + * + * @returns true on success, false on failure/skip. + */ +export async function syncSessionArchive( + apiClient: unknown, + chatSession: ChatSession, + logger: Logger, +): Promise { + const sessionId = chatSession.id; + const sandboxId = chatSession.sandboxId; + + if (!sandboxId) { + logger.warn("[archive-sync] No sandbox ID for session, skipping", { + sessionId, + }); + return false; + } + + try { + // 1. Atomic claim — only proceed if not already archiving + const [claimed] = await db + .update(chatSessions) + .set({ archiveStatus: "archiving", updatedAt: new Date() }) + .where( + and( + eq(chatSessions.id, sessionId), + ne(chatSessions.archiveStatus, "archiving"), + ), + ) + .returning(); + + if (!claimed) { + logger.info("[archive-sync] Session already being archived, skipping", { + sessionId, + }); + return false; + } + + // 2. Execute bun script inside sandbox to dump SQLite as JSON + logger.info("[archive-sync] Querying SQLite via sandboxExecute", { + sessionId, + sandboxId, + }); + + const execution = await sandboxExecute( + apiClient as Parameters[0], + { + sandboxId, + options: { + command: ["bun", "-e", SQLITE_DUMP_SCRIPT], + timeout: "30s", + }, + }, + ); + + // 3. Read stdout from the execution + let stdout = ""; + if (execution.stdoutStreamUrl) { + const res = await fetch(execution.stdoutStreamUrl); + stdout = await res.text(); + } + + let stderr = ""; + if (execution.stderrStreamUrl) { + const res = await fetch(execution.stderrStreamUrl); + stderr = await res.text(); + } + + const exitCode = + typeof execution.exitCode === "number" ? execution.exitCode : 0; + + if (exitCode !== 0 || !stdout.trim()) { + logger.warn("[archive-sync] sandboxExecute failed or returned no data", { + sessionId, + exitCode, + stderr: stderr.slice(0, 500), + }); + // Mark as failed + await db + .update(chatSessions) + .set({ archiveStatus: "failed", updatedAt: new Date() }) + .where(eq(chatSessions.id, sessionId)); + return false; + } + + // 4. Parse the JSON output + const data = JSON.parse(stdout.trim()) as SqliteDumpData; + + if (!data.sessions || data.sessions.length === 0) { + logger.warn("[archive-sync] No sessions found in SQLite dump", { + sessionId, + }); + await db + .update(chatSessions) + .set({ + archiveStatus: "archived", + lastArchivedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(chatSessions.id, sessionId)); + return true; + } + + // 5. Group messages, parts, and todos by session_id + const messagesBySession = new Map(); + for (const msg of data.messages) { + const list = messagesBySession.get(msg.session_id) ?? []; + list.push(msg); + messagesBySession.set(msg.session_id, list); + } + + const partsBySession = new Map(); + for (const part of data.parts) { + const list = partsBySession.get(part.session_id) ?? []; + list.push(part); + partsBySession.set(part.session_id, list); + } + + const todosBySession = new Map(); + for (const todo of data.todos) { + const list = todosBySession.get(todo.session_id) ?? []; + list.push(todo); + todosBySession.set(todo.session_id, list); + } + + // 6. Full-replace in a single transaction + await db.transaction(async (tx) => { + // Delete existing archive data — cascades to messages, parts, todos + await tx + .delete(archivedSessions) + .where(eq(archivedSessions.chatSessionId, sessionId)); + + // Insert fresh data for each session + for (const rawSession of data.sessions) { + const sessionMessages = messagesBySession.get(rawSession.id) ?? []; + const sessionParts = partsBySession.get(rawSession.id) ?? []; + const sessionTodos = todosBySession.get(rawSession.id) ?? []; + + // Compute cost aggregates from messages + let totalCost = 0; + let totalTokens = 0; + let inputTokens = 0; + let outputTokens = 0; + let reasoningTokens = 0; + let cacheRead = 0; + let cacheWrite = 0; + + for (const msg of sessionMessages) { + totalCost += msg.cost ?? 0; + const tokens = safeJsonParse(msg.tokens) as Record< + string, + number + > | null; + if (tokens) { + inputTokens += tokens.input ?? 0; + outputTokens += tokens.output ?? 0; + reasoningTokens += tokens.reasoning ?? 0; + cacheRead += tokens.cacheRead ?? 0; + cacheWrite += tokens.cacheWrite ?? 0; + totalTokens += + (tokens.input ?? 0) + + (tokens.output ?? 0) + + (tokens.reasoning ?? 0); + } + } + + // Insert archived session + const [archivedSession] = await tx + .insert(archivedSessions) + .values({ + chatSessionId: sessionId, + opencodeSessionId: rawSession.id, + parentSessionId: rawSession.parent_id ?? null, + title: rawSession.title ?? null, + projectId: rawSession.project_id ?? null, + totalCost, + totalTokens, + inputTokens, + outputTokens, + reasoningTokens, + cacheRead, + cacheWrite, + messageCount: sessionMessages.length, + timeCreated: unixToDate(rawSession.time_created ?? null), + timeUpdated: unixToDate(rawSession.time_updated ?? null), + metadata: { + slug: rawSession.slug, + directory: rawSession.directory, + version: rawSession.version, + summary: rawSession.summary, + shareUrl: rawSession.share_url, + }, + }) + .returning(); + + if (!archivedSession) continue; + + const archivedSessionId = archivedSession.id; + + // Build opencodeMessageId → archivedMessageId map for linking parts + const messageIdMap = new Map(); + + // Insert messages + for (const msg of sessionMessages) { + const tokens = safeJsonParse(msg.tokens) as Record< + string, + number + > | null; + const [archivedMsg] = await tx + .insert(archivedMessages) + .values({ + archivedSessionId, + opencodeMessageId: msg.id, + role: msg.role, + agent: msg.agent ?? null, + model: msg.model ?? null, + cost: msg.cost ?? null, + tokens: tokens ?? null, + error: msg.error ?? null, + data: { + role: msg.role, + agent: msg.agent, + model: msg.model, + cost: msg.cost, + tokens, + error: msg.error, + time: { + created: msg.time_created, + completed: + msg.role === "assistant" && + msg.time_updated !== msg.time_created + ? msg.time_updated + : undefined, + }, + }, + timeCreated: unixToDate(msg.time_created ?? null), + timeUpdated: unixToDate(msg.time_updated ?? null), + }) + .returning(); + + if (archivedMsg) { + messageIdMap.set(msg.id, archivedMsg.id); + } + } + + // Insert parts + for (const part of sessionParts) { + const archivedMessageId = messageIdMap.get(part.message_id); + if (!archivedMessageId) continue; + + const partData = safeJsonParse(part.data) as Record< + string, + unknown + > | null; + + await tx.insert(archivedParts).values({ + archivedMessageId, + archivedSessionId, + opencodePartId: part.id, + type: part.type, + data: partData ?? {}, + timeCreated: unixToDate(part.time_created ?? null), + timeUpdated: unixToDate(part.time_updated ?? null), + }); + } + + // Insert todos + for (const todo of sessionTodos) { + await tx.insert(archivedTodos).values({ + archivedSessionId, + content: todo.content, + status: todo.status, + priority: todo.priority, + position: todo.position, + }); + } + } + }); + + // 7. Mark as archived with timestamp + await db + .update(chatSessions) + .set({ + archiveStatus: "archived", + lastArchivedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(chatSessions.id, sessionId)); + + logger.info("[archive-sync] Sync complete", { + sessionId, + sessionCount: data.sessions.length, + messageCount: data.messages.length, + }); + + return true; + } catch (error) { + logger.error("[archive-sync] Sync failed", { + sessionId, + error: String(error), + }); + + // Mark as failed — don't throw + try { + await db + .update(chatSessions) + .set({ archiveStatus: "failed", updatedAt: new Date() }) + .where(eq(chatSessions.id, sessionId)); + } catch (updateError) { + logger.error("[archive-sync] Failed to update archive status", { + sessionId, + error: String(updateError), + }); + } + + return false; + } +} + +/** + * Archive a single OpenCode session (with its messages, parts, and todos) + * into the PostgreSQL archive tables. + */ +async function archiveOneSession( + tx: Parameters[0]>[0], + chatSessionId: string, + ocSession: DBSession, + reader: OpenCodeDBReader, + logger: Logger, +): Promise { + // Get cost summary for this session + const cost: SessionCostSummary = reader.getSessionCost(ocSession.id); + + // Insert archived session + const [archivedSession] = await tx + .insert(archivedSessions) + .values({ + chatSessionId, + opencodeSessionId: ocSession.id, + parentSessionId: ocSession.parentId ?? null, + title: ocSession.title, + projectId: ocSession.projectId, + totalCost: cost.totalCost, + totalTokens: cost.totalTokens, + inputTokens: cost.inputTokens, + outputTokens: cost.outputTokens, + reasoningTokens: cost.reasoningTokens, + cacheRead: cost.cacheRead, + cacheWrite: cost.cacheWrite, + messageCount: cost.messageCount, + timeCreated: unixToDate(ocSession.timeCreated), + timeUpdated: unixToDate(ocSession.timeUpdated), + metadata: { + slug: ocSession.slug, + directory: ocSession.directory, + version: ocSession.version, + summary: ocSession.summary, + shareUrl: ocSession.shareUrl, + }, + }) + .returning(); + + if (!archivedSession) { + logger.warn("[archive] Failed to insert archived session", { + opencodeSessionId: ocSession.id, + }); + return; + } + + const archivedSessionId = archivedSession.id; + + // Get all messages for this session + const messages = reader.getAllMessages(ocSession.id); + + // Build a map of opencodeMessageId → archivedMessageId for linking parts + const messageIdMap = new Map(); + + // Insert messages in batches + for (const msg of messages) { + const [archivedMsg] = await tx + .insert(archivedMessages) + .values({ + archivedSessionId, + opencodeMessageId: msg.id, + role: msg.role, + agent: msg.agent ?? null, + model: msg.model ?? null, + cost: msg.cost ?? null, + tokens: msg.tokens ?? null, + error: msg.error ?? null, + data: { + role: msg.role, + agent: msg.agent, + model: msg.model, + cost: msg.cost, + tokens: msg.tokens, + error: msg.error, + time: { + created: msg.timeCreated, + completed: + msg.role === "assistant" && msg.timeUpdated !== msg.timeCreated + ? msg.timeUpdated + : undefined, + }, + }, + timeCreated: unixToDate(msg.timeCreated), + timeUpdated: unixToDate(msg.timeUpdated), + }) + .returning(); + + if (archivedMsg) { + messageIdMap.set(msg.id, archivedMsg.id); + } + } + + // Get all parts for this session and insert them + const parts = reader.getAllParts(ocSession.id); + + for (const part of parts) { + const archivedMessageId = messageIdMap.get(part.messageId); + if (!archivedMessageId) { + // Part references a message we didn't archive — skip it + continue; + } + + await tx.insert(archivedParts).values({ + archivedMessageId, + archivedSessionId, + opencodePartId: part.id, + type: part.type, + data: part.data as Record, + timeCreated: unixToDate(part.timeCreated), + timeUpdated: unixToDate(part.timeUpdated), + }); + } + + // Get all todos for this session and insert them + const todos = reader.getTodos(ocSession.id); + + for (const todo of todos) { + await tx.insert(archivedTodos).values({ + archivedSessionId, + content: todo.content, + status: todo.status, + priority: todo.priority, + position: todo.position, + }); + } + + logger.info("[archive] Archived OpenCode session", { + opencodeSessionId: ocSession.id, + archivedSessionId, + messageCount: messages.length, + partCount: parts.length, + todoCount: todos.length, + }); +} + +/** + * List archived child sessions (those with a parent) for a given chat session. + * Returns summary info per child: costs, tokens, message count. + */ +export async function getArchivedChildSessions(chatSessionId: string) { + const sessions = await db + .select() + .from(archivedSessions) + .where( + and( + eq(archivedSessions.chatSessionId, chatSessionId), + // Only children — those with a parentSessionId + ), + ) + .orderBy(archivedSessions.timeCreated); + + // Filter to only child sessions (parentSessionId IS NOT NULL) + return sessions + .filter((s) => s.parentSessionId != null) + .map((s) => ({ + id: s.id, + opencodeSessionId: s.opencodeSessionId, + parentSessionId: s.parentSessionId, + title: s.title, + totalCost: s.totalCost ?? 0, + totalTokens: s.totalTokens ?? 0, + messageCount: s.messageCount ?? 0, + timeCreated: s.timeCreated, + metadata: s.metadata, + })); +} + +/** + * Get full child session data (messages, parts, todos) for a specific archived child. + * The childId is the archived_sessions.id (UUID), not the opencode session ID. + */ +export async function getArchivedChildSessionData( + chatSessionId: string, + childId: string, +) { + // Verify this child belongs to the chat session + const [childSession] = await db + .select() + .from(archivedSessions) + .where( + and( + eq(archivedSessions.id, childId), + eq(archivedSessions.chatSessionId, chatSessionId), + ), + ); + + if (!childSession) return null; + + const opencodeSessionId = childSession.opencodeSessionId; + + // Get messages, parts, todos + const rawMessages = await db + .select() + .from(archivedMessages) + .where(eq(archivedMessages.archivedSessionId, childSession.id)) + .orderBy(archivedMessages.timeCreated); + + const rawParts = await db + .select() + .from(archivedParts) + .where(eq(archivedParts.archivedSessionId, childSession.id)) + .orderBy(archivedParts.timeCreated); + + const rawTodos = await db + .select() + .from(archivedTodos) + .where(eq(archivedTodos.archivedSessionId, childSession.id)) + .orderBy(archivedTodos.position); + + // Build archivedMessageId → opencodeMessageId lookup + const msgIdMap = new Map(); + for (const msg of rawMessages) { + msgIdMap.set(msg.id, msg.opencodeMessageId); + } + + // Transform into frontend-ready format (same pattern as /:id/archive) + const messages: Record[] = rawMessages.map((msg) => { + const data = (msg.data ?? {}) as Record; + return { + ...data, + id: msg.opencodeMessageId, + sessionID: opencodeSessionId, + }; + }); + + const parts: Record[] = rawParts.map((part) => { + const data = (part.data ?? {}) as Record; + const parentMsgId = msgIdMap.get(part.archivedMessageId) ?? ""; + return { + ...data, + id: part.opencodePartId, + sessionID: opencodeSessionId, + messageID: parentMsgId, + }; + }); + + const todos = rawTodos.map((todo) => ({ + id: todo.id, + content: todo.content, + status: todo.status, + priority: todo.priority, + })); + + return { + session: { + id: childSession.id, + opencodeSessionId: childSession.opencodeSessionId, + parentSessionId: childSession.parentSessionId, + title: childSession.title, + totalCost: childSession.totalCost ?? 0, + totalTokens: childSession.totalTokens ?? 0, + messageCount: childSession.messageCount ?? 0, + timeCreated: childSession.timeCreated, + metadata: childSession.metadata, + }, + messages, + parts, + todos, + }; +} + +/** + * Retrieve archived session data for viewing. + * Returns the complete archive: sessions with hierarchy, messages, parts, and todos, + * plus aggregate statistics. + */ +export async function getArchivedData(chatSessionId: string) { + // Get all archived sessions for this chat session + const sessions = await db + .select() + .from(archivedSessions) + .where(eq(archivedSessions.chatSessionId, chatSessionId)) + .orderBy(archivedSessions.timeCreated); + + if (sessions.length === 0) { + return null; + } + + // Batch fetch all related data in 3 queries total (not 3*N) + const sessionIds = sessions.map((s) => s.id); + + const allMessages = await db + .select() + .from(archivedMessages) + .where(inArray(archivedMessages.archivedSessionId, sessionIds)) + .orderBy(archivedMessages.timeCreated); + + const allParts = await db + .select() + .from(archivedParts) + .where(inArray(archivedParts.archivedSessionId, sessionIds)) + .orderBy(archivedParts.timeCreated); + + const allTodos = await db + .select() + .from(archivedTodos) + .where(inArray(archivedTodos.archivedSessionId, sessionIds)) + .orderBy(archivedTodos.position); + + // Group by session ID + const messagesBySession = new Map(); + for (const msg of allMessages) { + const list = messagesBySession.get(msg.archivedSessionId) ?? []; + list.push(msg); + messagesBySession.set(msg.archivedSessionId, list); + } + + const partsBySession = new Map(); + for (const part of allParts) { + const list = partsBySession.get(part.archivedSessionId) ?? []; + list.push(part); + partsBySession.set(part.archivedSessionId, list); + } + + const todosBySession = new Map(); + for (const todo of allTodos) { + const list = todosBySession.get(todo.archivedSessionId) ?? []; + list.push(todo); + todosBySession.set(todo.archivedSessionId, list); + } + + // Build session data from grouped results + const sessionData = sessions.map((session) => ({ + session, + messages: messagesBySession.get(session.id) ?? [], + parts: partsBySession.get(session.id) ?? [], + todos: todosBySession.get(session.id) ?? [], + })); + + // Build parent-child hierarchy + const sessionMap = new Map(sessions.map((s) => [s.opencodeSessionId, s])); + const rootSessions = sessions.filter( + (s) => !s.parentSessionId || !sessionMap.has(s.parentSessionId), + ); + const childMap = new Map(); + for (const s of sessions) { + if (s.parentSessionId && sessionMap.has(s.parentSessionId)) { + const children = childMap.get(s.parentSessionId) ?? []; + children.push(s); + childMap.set(s.parentSessionId, children); + } + } + + // Aggregate stats + const totalCost = sessions.reduce((sum, s) => sum + (s.totalCost ?? 0), 0); + const totalMessages = sessions.reduce( + (sum, s) => sum + (s.messageCount ?? 0), + 0, + ); + const totalTokens = sessions.reduce( + (sum, s) => sum + (s.totalTokens ?? 0), + 0, + ); + + // Agent breakdown across all messages + const flatMessages = sessionData.flatMap((sd) => sd.messages); + const agentBreakdown: Record = {}; + for (const msg of flatMessages) { + if (msg.agent) { + agentBreakdown[msg.agent] = (agentBreakdown[msg.agent] ?? 0) + 1; + } + } + + return { + chatSessionId, + sessions: sessionData, + hierarchy: { + roots: rootSessions.map((r) => r.opencodeSessionId), + children: Object.fromEntries( + [...childMap.entries()].map(([parentId, children]) => [ + parentId, + children.map((c) => c.opencodeSessionId), + ]), + ), + }, + stats: { + totalCost, + totalMessages, + totalTokens, + sessionCount: sessions.length, + agentBreakdown, + }, + }; +} diff --git a/src/lib/sqlite/index.ts b/src/lib/sqlite/index.ts new file mode 100644 index 0000000..92b4eec --- /dev/null +++ b/src/lib/sqlite/index.ts @@ -0,0 +1,16 @@ +export { OpenCodeDBReader } from "./reader"; +export type { + DBMessage, + DBPart, + DBSession, + DBTextPart, + DBTodo, + DBToolCall, + MessageTokens, + OpenCodeDBConfig, + SessionCostSummary, + SessionStatus, + SessionSummary, + SessionTreeNode, + TodoSummary, +} from "./types"; diff --git a/src/lib/sqlite/queries.ts b/src/lib/sqlite/queries.ts new file mode 100644 index 0000000..cfc20dc --- /dev/null +++ b/src/lib/sqlite/queries.ts @@ -0,0 +1,54 @@ +export const QUERIES = { + CHECK_TABLES: + "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('session', 'message', 'part', 'todo')", + + GET_SESSION: "SELECT * FROM session WHERE id = ?", + GET_CHILD_SESSIONS: + "SELECT * FROM session WHERE parent_id = ? ORDER BY time_created DESC", + GET_SESSIONS_BY_PROJECT: + "SELECT * FROM session WHERE project_id = ? ORDER BY time_created DESC", + GET_ALL_SESSIONS: "SELECT * FROM session ORDER BY time_created ASC", + GET_DESCENDANT_SESSIONS: `WITH RECURSIVE descendants AS ( + SELECT * FROM session WHERE parent_id = ? + UNION ALL + SELECT s.* FROM session s JOIN descendants d ON s.parent_id = d.id + ) SELECT * FROM descendants ORDER BY time_created DESC`, + + GET_MESSAGES: + "SELECT * FROM message WHERE session_id = ? ORDER BY time_created DESC LIMIT ? OFFSET ?", + GET_ALL_MESSAGES: + "SELECT * FROM message WHERE session_id = ? ORDER BY time_created ASC", + GET_LATEST_MESSAGE: + "SELECT * FROM message WHERE session_id = ? ORDER BY time_created DESC LIMIT 1", + GET_MESSAGE_COUNT: + "SELECT COUNT(*) as count FROM message WHERE session_id = ?", + + GET_ACTIVE_TOOLS: `SELECT * FROM part WHERE session_id = ? + AND json_valid(data) + AND json_extract(data, '$.type') = 'tool' + AND json_extract(data, '$.state.status') IN ('pending', 'running') + ORDER BY time_created DESC`, + GET_TOOL_HISTORY: `SELECT * FROM part WHERE session_id = ? + AND json_valid(data) + AND json_extract(data, '$.type') = 'tool' + ORDER BY time_created DESC LIMIT ?`, + GET_TEXT_PARTS: `SELECT * FROM part WHERE session_id = ? + AND json_valid(data) + AND json_extract(data, '$.type') = 'text' + ORDER BY time_created DESC LIMIT ?`, + GET_ALL_PARTS: + "SELECT * FROM part WHERE session_id = ? ORDER BY time_created ASC", + + GET_TODOS: "SELECT * FROM todo WHERE session_id = ? ORDER BY position ASC", + + GET_SESSION_COST: `SELECT + COALESCE(SUM(json_extract(data, '$.cost')), 0) as total_cost, + COALESCE(SUM(json_extract(data, '$.tokens.total')), 0) as total_tokens, + COALESCE(SUM(json_extract(data, '$.tokens.input')), 0) as input_tokens, + COALESCE(SUM(json_extract(data, '$.tokens.output')), 0) as output_tokens, + COALESCE(SUM(json_extract(data, '$.tokens.reasoning')), 0) as reasoning_tokens, + COALESCE(SUM(json_extract(data, '$.tokens.cache.read')), 0) as cache_read, + COALESCE(SUM(json_extract(data, '$.tokens.cache.write')), 0) as cache_write, + COUNT(*) as message_count + FROM message WHERE session_id = ? AND json_valid(data) AND json_extract(data, '$.role') = 'assistant'`, +} as const; diff --git a/src/lib/sqlite/reader.ts b/src/lib/sqlite/reader.ts new file mode 100644 index 0000000..702fe7d --- /dev/null +++ b/src/lib/sqlite/reader.ts @@ -0,0 +1,734 @@ +import { Database } from "bun:sqlite"; +import { existsSync } from "node:fs"; +import { homedir, platform } from "node:os"; +import { join } from "node:path"; +import { QUERIES } from "./queries"; +import type { + DBMessage, + DBPart, + DBSession, + DBTextPart, + DBTodo, + DBToolCall, + MessageTokens, + OpenCodeDBConfig, + SessionCostSummary, + SessionStatus, + SessionSummary, + SessionTreeNode, + TodoSummary, +} from "./types"; + +type Statement = ReturnType; + +type SessionRow = { + id: string; + project_id: string; + parent_id: string | null; + slug: string; + directory: string; + title: string; + version: string; + share_url: string | null; + summary_additions: number | null; + summary_deletions: number | null; + summary_files: number | null; + summary_diffs: string | null; + time_created: number; + time_updated: number; + time_compacting: number | null; + time_archived: number | null; +}; + +type MessageRow = { + id: string; + session_id: string; + time_created: number; + time_updated: number; + data: string; +}; + +type PartRow = { + id: string; + message_id: string; + session_id: string; + time_created: number; + time_updated: number; + data: string; +}; + +type TodoRow = { + session_id: string; + content: string; + status: string; + priority: string; + position: number; +}; + +type ToolState = { + status?: string; + input?: unknown; + output?: unknown; + timeStarted?: number; + timeEnded?: number; + time_started?: number; + time_ended?: number; +}; + +type PartData = { + type?: string; + text?: string; + tool?: string; + callID?: string; + callId?: string; + state?: ToolState; +}; + +const REQUIRED_TABLES = new Set(["session", "message", "part", "todo"]); +const DEFAULT_LIMIT = 100; +const DEFAULT_TOOL_LIMIT = 50; + +function safeParseJSON(value: string | null | undefined): T | null { + if (!value) return null; + try { + return JSON.parse(value) as T; + } catch { + return null; + } +} + +function isMemoryPath(path: string): boolean { + return path === ":memory:" || path.includes("mode=memory"); +} + +function resolveDBPath(config?: OpenCodeDBConfig): string | null { + if (config?.dbPath) { + return config.dbPath; + } + + const home = homedir(); + const candidates: string[] = []; + const currentPlatform = platform(); + + if (currentPlatform === "darwin") { + candidates.push( + join(home, "Library", "Application Support", "opencode", "opencode.db"), + ); + } + + if (currentPlatform === "win32") { + const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming"); + const localAppData = + process.env.LOCALAPPDATA ?? join(home, "AppData", "Local"); + candidates.push(join(appData, "opencode", "opencode.db")); + candidates.push(join(localAppData, "opencode", "opencode.db")); + } + + // Linux default + candidates.push(join(home, ".local", "share", "opencode", "opencode.db")); + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +function buildSessionSummary(row: SessionRow): SessionSummary | undefined { + const diffs = safeParseJSON(row.summary_diffs); + const hasSummary = + row.summary_additions !== null || + row.summary_deletions !== null || + row.summary_files !== null || + diffs !== null; + + if (!hasSummary) { + return undefined; + } + + return { + additions: row.summary_additions ?? undefined, + deletions: row.summary_deletions ?? undefined, + files: row.summary_files ?? undefined, + diffs: diffs ?? undefined, + }; +} + +function mapSession(row: SessionRow): DBSession { + return { + id: row.id, + projectId: row.project_id, + parentId: row.parent_id ?? undefined, + slug: row.slug, + directory: row.directory, + title: row.title, + version: row.version, + shareUrl: row.share_url ?? undefined, + summary: buildSessionSummary(row), + timeCreated: row.time_created, + timeUpdated: row.time_updated, + timeCompacting: row.time_compacting ?? undefined, + timeArchived: row.time_archived ?? undefined, + }; +} + +function mapMessage(row: MessageRow): DBMessage { + const payload = safeParseJSON>(row.data) ?? {}; + const tokens = payload.tokens as MessageTokens | undefined; + + return { + id: row.id, + sessionId: row.session_id, + role: typeof payload.role === "string" ? payload.role : "unknown", + agent: typeof payload.agent === "string" ? payload.agent : undefined, + model: typeof payload.model === "string" ? payload.model : undefined, + cost: typeof payload.cost === "number" ? payload.cost : undefined, + tokens: tokens, + error: typeof payload.error === "string" ? payload.error : undefined, + timeCreated: row.time_created, + timeUpdated: row.time_updated, + }; +} + +function mapToolCall(row: PartRow): DBToolCall | null { + const payload = safeParseJSON(row.data); + if (!payload || payload.type !== "tool") { + return null; + } + + const state = payload.state ?? {}; + const callId = payload.callID ?? payload.callId ?? ""; + + return { + id: row.id, + messageId: row.message_id, + sessionId: row.session_id, + callId, + tool: payload.tool ?? "unknown", + status: state.status ?? "unknown", + input: state.input, + output: state.output, + timeStarted: state.timeStarted ?? state.time_started, + timeEnded: state.timeEnded ?? state.time_ended, + }; +} + +function mapTextPart(row: PartRow): DBTextPart | null { + const payload = safeParseJSON(row.data); + if (!payload || payload.type !== "text" || typeof payload.text !== "string") { + return null; + } + + return { + id: row.id, + messageId: row.message_id, + sessionId: row.session_id, + text: payload.text, + timeCreated: row.time_created, + }; +} + +function mapPart(row: PartRow): DBPart { + const payload = safeParseJSON(row.data); + return { + id: row.id, + messageId: row.message_id, + sessionId: row.session_id, + type: payload?.type ?? "unknown", + data: safeParseJSON(row.data) ?? {}, + timeCreated: row.time_created, + timeUpdated: row.time_updated, + }; +} + +function isNotNull(value: T | null): value is T { + return value !== null; +} + +function summarizeTodos(todos: DBTodo[]): TodoSummary { + const total = todos.length; + const completed = todos.filter((todo) => todo.status === "completed").length; + const pending = total - completed; + + return { total, pending, completed }; +} + +function sumTreeCost(node: SessionTreeNode): number { + return ( + (node.costSummary?.totalCost ?? 0) + + node.children.reduce((sum, child) => sum + sumTreeCost(child), 0) + ); +} + +function createEmptySession(sessionId: string): DBSession { + return { + id: sessionId, + projectId: "unknown", + parentId: undefined, + slug: "unknown", + directory: "", + title: "Unknown Session", + version: "unknown", + timeCreated: 0, + timeUpdated: 0, + }; +} + +export class OpenCodeDBReader { + private db: Database | null = null; + private available = false; + private readonly config: OpenCodeDBConfig; + private dbPath: string | null = null; + private statements = new Map(); + + constructor(config?: OpenCodeDBConfig) { + this.config = { + enableSchemaValidation: true, + ...config, + }; + } + + isAvailable(): boolean { + if (this.available && this.db) { + return true; + } + + const resolved = resolveDBPath(this.config); + if (!resolved) return false; + + if (isMemoryPath(resolved)) { + return true; + } + + return existsSync(resolved); + } + + open(): boolean { + if (this.db) { + return this.available; + } + + this.dbPath = resolveDBPath(this.config); + if (!this.dbPath) { + this.available = false; + return false; + } + + const isMemory = isMemoryPath(this.dbPath); + + if (this.config.dbPath && !isMemory && !existsSync(this.dbPath)) { + this.available = false; + return false; + } + + try { + if (isMemory) { + // In-memory shared DBs (used in tests): open in readwrite mode + // so pragmas can be set and the shared cache is accessible. + this.db = new Database(this.dbPath); + this.db.run("PRAGMA journal_mode = WAL"); + } else { + // Real file-based DBs: open read-only for safety. + // WAL is already configured by OpenCode; readers inherit it. + this.db = new Database(this.dbPath, { readonly: true }); + } + // busy_timeout is safe on both readonly and readwrite connections. + this.db.run("PRAGMA busy_timeout = 3000"); + } catch (error) { + console.warn("[OpenCodeDBReader] Failed to open database", error); + this.available = false; + this.db = null; + return false; + } + + if (this.config.enableSchemaValidation && !this.validateSchema()) { + console.warn("[OpenCodeDBReader] Required tables missing in database"); + this.close(); + this.available = false; + return false; + } + + this.available = true; + return true; + } + + close(): void { + if (this.db) { + this.db.close(); + } + this.db = null; + this.available = false; + this.statements.clear(); + } + + getSession(id: string): DBSession | null { + if (!this.ensureOpen()) return null; + + try { + const statement = this.getStatement("GET_SESSION"); + const row = statement?.get(id) as SessionRow | null; + return row ? mapSession(row) : null; + } catch (error) { + console.warn("[OpenCodeDBReader] Failed to get session", error); + return null; + } + } + + getChildSessions(parentId: string): DBSession[] { + if (!this.ensureOpen()) return []; + + try { + const statement = this.getStatement("GET_CHILD_SESSIONS"); + const rows = statement?.all(parentId) as SessionRow[] | null; + return rows ? rows.map(mapSession) : []; + } catch (error) { + console.warn("[OpenCodeDBReader] Failed to get child sessions", error); + return []; + } + } + + getSessionsByProject(projectId: string): DBSession[] { + if (!this.ensureOpen()) return []; + + try { + const statement = this.getStatement("GET_SESSIONS_BY_PROJECT"); + const rows = statement?.all(projectId) as SessionRow[] | null; + return rows ? rows.map(mapSession) : []; + } catch (error) { + console.warn("[OpenCodeDBReader] Failed to get project sessions", error); + return []; + } + } + + /** Get ALL sessions in the database (for archive extraction). */ + getAllSessions(): DBSession[] { + if (!this.ensureOpen()) return []; + + try { + const statement = this.getStatement("GET_ALL_SESSIONS"); + const rows = statement?.all() as SessionRow[] | null; + return rows ? rows.map(mapSession) : []; + } catch (error) { + console.warn("[OpenCodeDBReader] Failed to get all sessions", error); + return []; + } + } + + getSessionTree(rootId: string): SessionTreeNode { + const visited = new Set(); + return this.buildSessionTree(rootId, visited); + } + + getMessages( + sessionId: string, + opts?: { limit?: number; offset?: number }, + ): DBMessage[] { + if (!this.ensureOpen()) return []; + + const limit = opts?.limit ?? DEFAULT_LIMIT; + const offset = opts?.offset ?? 0; + + try { + const statement = this.getStatement("GET_MESSAGES"); + const rows = statement?.all(sessionId, limit, offset) as + | MessageRow[] + | null; + return rows ? rows.map(mapMessage) : []; + } catch (error) { + console.warn("[OpenCodeDBReader] Failed to get messages", error); + return []; + } + } + + /** Get ALL messages for a session without pagination (for archive extraction). */ + getAllMessages(sessionId: string): DBMessage[] { + if (!this.ensureOpen()) return []; + + try { + const statement = this.getStatement("GET_ALL_MESSAGES"); + const rows = statement?.all(sessionId) as MessageRow[] | null; + return rows ? rows.map(mapMessage) : []; + } catch (error) { + console.warn("[OpenCodeDBReader] Failed to get all messages", error); + return []; + } + } + + getLatestMessage(sessionId: string): DBMessage | null { + if (!this.ensureOpen()) return null; + + try { + const statement = this.getStatement("GET_LATEST_MESSAGE"); + const row = statement?.get(sessionId) as MessageRow | null; + return row ? mapMessage(row) : null; + } catch (error) { + console.warn("[OpenCodeDBReader] Failed to get latest message", error); + return null; + } + } + + getMessageCount(sessionId: string): number { + if (!this.ensureOpen()) return 0; + + try { + const statement = this.getStatement("GET_MESSAGE_COUNT"); + const row = statement?.get(sessionId) as { count: number } | null; + return row?.count ?? 0; + } catch (error) { + console.warn("[OpenCodeDBReader] Failed to get message count", error); + return 0; + } + } + + getActiveToolCalls(sessionId: string): DBToolCall[] { + if (!this.ensureOpen()) return []; + + try { + const statement = this.getStatement("GET_ACTIVE_TOOLS"); + const rows = statement?.all(sessionId) as PartRow[] | null; + return rows ? rows.map(mapToolCall).filter(isNotNull) : []; + } catch (error) { + console.warn("[OpenCodeDBReader] Failed to get active tools", error); + return []; + } + } + + getToolCallHistory( + sessionId: string, + opts?: { limit?: number }, + ): DBToolCall[] { + if (!this.ensureOpen()) return []; + + const limit = opts?.limit ?? DEFAULT_TOOL_LIMIT; + + try { + const statement = this.getStatement("GET_TOOL_HISTORY"); + const rows = statement?.all(sessionId, limit) as PartRow[] | null; + return rows ? rows.map(mapToolCall).filter(isNotNull) : []; + } catch (error) { + console.warn("[OpenCodeDBReader] Failed to get tool history", error); + return []; + } + } + + getTextParts(sessionId: string, opts?: { limit?: number }): DBTextPart[] { + if (!this.ensureOpen()) return []; + + const limit = opts?.limit ?? DEFAULT_LIMIT; + + try { + const statement = this.getStatement("GET_TEXT_PARTS"); + const rows = statement?.all(sessionId, limit) as PartRow[] | null; + return rows ? rows.map(mapTextPart).filter(isNotNull) : []; + } catch (error) { + console.warn("[OpenCodeDBReader] Failed to get text parts", error); + return []; + } + } + + /** Get ALL parts for a session regardless of type (for archive extraction). */ + getAllParts(sessionId: string): DBPart[] { + if (!this.ensureOpen()) return []; + + try { + const statement = this.getStatement("GET_ALL_PARTS"); + const rows = statement?.all(sessionId) as PartRow[] | null; + return rows ? rows.map(mapPart) : []; + } catch (error) { + console.warn("[OpenCodeDBReader] Failed to get all parts", error); + return []; + } + } + + getTodos(sessionId: string): DBTodo[] { + if (!this.ensureOpen()) return []; + + try { + const statement = this.getStatement("GET_TODOS"); + const rows = statement?.all(sessionId) as TodoRow[] | null; + return rows + ? rows.map((row) => ({ + sessionId: row.session_id, + content: row.content, + status: row.status, + priority: row.priority, + position: row.position, + })) + : []; + } catch (error) { + console.warn("[OpenCodeDBReader] Failed to get todos", error); + return []; + } + } + + getSessionCost(sessionId: string): SessionCostSummary { + if (!this.ensureOpen()) { + return { + totalCost: 0, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cacheRead: 0, + cacheWrite: 0, + messageCount: 0, + }; + } + + try { + const statement = this.getStatement("GET_SESSION_COST"); + const row = statement?.get(sessionId) as { + total_cost: number; + total_tokens: number; + input_tokens: number; + output_tokens: number; + reasoning_tokens: number; + cache_read: number; + cache_write: number; + message_count: number; + } | null; + + return { + totalCost: row?.total_cost ?? 0, + totalTokens: row?.total_tokens ?? 0, + inputTokens: row?.input_tokens ?? 0, + outputTokens: row?.output_tokens ?? 0, + reasoningTokens: row?.reasoning_tokens ?? 0, + cacheRead: row?.cache_read ?? 0, + cacheWrite: row?.cache_write ?? 0, + messageCount: row?.message_count ?? 0, + }; + } catch (error) { + console.warn("[OpenCodeDBReader] Failed to get session cost", error); + return { + totalCost: 0, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cacheRead: 0, + cacheWrite: 0, + messageCount: 0, + }; + } + } + + getSessionStatus(sessionId: string): SessionStatus { + const session = this.getSession(sessionId); + if (!session) { + return { status: "idle", lastActivity: 0 }; + } + + if (session.timeArchived) { + return { status: "archived", lastActivity: session.timeUpdated }; + } + + if (session.timeCompacting) { + return { status: "compacting", lastActivity: session.timeUpdated }; + } + + const latest = this.getLatestMessage(sessionId); + if (latest?.error) { + return { status: "error", lastActivity: latest.timeUpdated }; + } + + const activeTools = this.getActiveToolCalls(sessionId); + const lastActivity = Math.max( + session.timeUpdated, + latest?.timeUpdated ?? 0, + ); + + if (activeTools.length > 0) { + return { status: "active", lastActivity }; + } + + return { status: "idle", lastActivity }; + } + + getSessionDashboard(parentSessionId: string): { + sessions: SessionTreeNode[]; + totalCost: number; + } { + const sessions = this.getChildSessions(parentSessionId).map((child) => + this.getSessionTree(child.id), + ); + const totalCost = sessions.reduce( + (sum, node) => sum + sumTreeCost(node), + 0, + ); + return { sessions, totalCost }; + } + + private ensureOpen(): boolean { + if (this.db && this.available) { + return true; + } + return this.open(); + } + + private getStatement(key: keyof typeof QUERIES): Statement | null { + if (!this.db) return null; + + const existing = this.statements.get(key); + if (existing) return existing; + + const statement = this.db.prepare(QUERIES[key]); + this.statements.set(key, statement); + return statement; + } + + private validateSchema(): boolean { + if (!this.db) return false; + + try { + const statement = this.db.prepare(QUERIES.CHECK_TABLES); + const rows = statement.all() as Array<{ name: string }>; + const found = new Set(rows.map((row) => row.name)); + for (const table of REQUIRED_TABLES) { + if (!found.has(table)) { + return false; + } + } + return true; + } catch (error) { + console.warn("[OpenCodeDBReader] Failed to validate schema", error); + return false; + } + } + + private buildSessionTree( + rootId: string, + visited: Set, + ): SessionTreeNode { + if (visited.has(rootId)) { + console.warn("[OpenCodeDBReader] Detected session cycle", rootId); + return { + session: createEmptySession(rootId), + children: [], + messageCount: 0, + activeToolCount: 0, + }; + } + + visited.add(rootId); + const session = this.getSession(rootId) ?? createEmptySession(rootId); + const children = this.getChildSessions(rootId).map((child) => + this.buildSessionTree(child.id, visited), + ); + const messageCount = this.getMessageCount(rootId); + const activeToolCount = this.getActiveToolCalls(rootId).length; + const todos = this.getTodos(rootId); + const costSummary = this.getSessionCost(rootId); + + return { + session, + children, + messageCount, + activeToolCount, + todoSummary: todos.length > 0 ? summarizeTodos(todos) : undefined, + costSummary, + }; + } +} diff --git a/src/lib/sqlite/types.ts b/src/lib/sqlite/types.ts new file mode 100644 index 0000000..40ae755 --- /dev/null +++ b/src/lib/sqlite/types.ts @@ -0,0 +1,121 @@ +export interface SessionSummary { + additions?: number; + deletions?: number; + files?: number; + diffs?: unknown; +} + +export interface MessageTokens { + total?: number; + input?: number; + output?: number; + reasoning?: number; + cache?: { + read?: number; + write?: number; + }; +} + +export interface DBSession { + id: string; + projectId: string; + parentId?: string | null; + slug: string; + directory: string; + title: string; + version: string; + shareUrl?: string | null; + summary?: SessionSummary; + timeCreated: number; + timeUpdated: number; + timeCompacting?: number | null; + timeArchived?: number | null; +} + +export interface DBMessage { + id: string; + sessionId: string; + role: string; + agent?: string; + model?: string; + cost?: number; + tokens?: MessageTokens; + error?: string; + timeCreated: number; + timeUpdated: number; +} + +export interface DBPart { + id: string; + messageId: string; + sessionId: string; + type: string; + data: unknown; + timeCreated: number; + timeUpdated: number; +} + +export interface DBToolCall { + id: string; + messageId: string; + sessionId: string; + callId: string; + tool: string; + status: string; + input?: unknown; + output?: unknown; + timeStarted?: number; + timeEnded?: number; +} + +export interface DBTextPart { + id: string; + messageId: string; + sessionId: string; + text: string; + timeCreated: number; +} + +export interface DBTodo { + sessionId: string; + content: string; + status: string; + priority: string; + position: number; +} + +export interface SessionCostSummary { + totalCost: number; + totalTokens: number; + inputTokens: number; + outputTokens: number; + reasoningTokens: number; + cacheRead: number; + cacheWrite: number; + messageCount: number; +} + +export interface SessionStatus { + status: "active" | "idle" | "error" | "archived" | "compacting"; + lastActivity: number; +} + +export interface TodoSummary { + total: number; + pending: number; + completed: number; +} + +export interface SessionTreeNode { + session: DBSession; + children: SessionTreeNode[]; + messageCount: number; + activeToolCount: number; + todoSummary?: TodoSummary; + costSummary?: SessionCostSummary; +} + +export interface OpenCodeDBConfig { + dbPath?: string; + enableSchemaValidation?: boolean; +} diff --git a/src/routes/chat.ts b/src/routes/chat.ts index e10cf4d..698bf34 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -4,98 +4,109 @@ * Phase 3: async prompt, filtered SSE proxy, messages fetch, * permission/question reply endpoints. */ -import { createRouter, sse } from '@agentuity/runtime'; -import { db } from '../db'; -import { chatSessions } from '../db/schema'; -import { and, eq } from '@agentuity/drizzle'; -import { getOpencodeClient, buildBasicAuthHeader } from '../opencode'; -import { sandboxListFiles, sandboxReadFile, sandboxExecute, sandboxWriteFiles, sandboxMkDir } from '@agentuity/server'; -import { normalizeSandboxPath } from '../lib/path-utils'; -import { decrypt } from '../lib/encryption'; -import { SpanStatusCode } from '@opentelemetry/api'; -import { COMMAND_TO_AGENT, TEMPLATE_COMMANDS } from '../lib/agent-commands'; +import { createRouter, sse } from "@agentuity/runtime"; +import { db } from "../db"; +import { chatSessions } from "../db/schema"; +import { and, eq } from "@agentuity/drizzle"; +import { getOpencodeClient, buildBasicAuthHeader } from "../opencode"; +import { + sandboxListFiles, + sandboxReadFile, + sandboxExecute, + sandboxWriteFiles, + sandboxMkDir, +} from "@agentuity/server"; +import { normalizeSandboxPath } from "../lib/path-utils"; +import { decrypt } from "../lib/encryption"; +import { SpanStatusCode } from "@opentelemetry/api"; +import { COMMAND_TO_AGENT, TEMPLATE_COMMANDS } from "../lib/agent-commands"; +import { syncSessionArchive } from "../lib/archive"; /** Extract and decrypt the OpenCode server password from session metadata. */ -function getSessionPassword(session: { metadata?: unknown }): string | undefined { - const meta = (session.metadata ?? {}) as Record; - if (typeof meta.opencodePassword === 'string') { - try { - return decrypt(meta.opencodePassword); - } catch { - // Decryption failed - } - } - return undefined; +function getSessionPassword(session: { + metadata?: unknown; +}): string | undefined { + const meta = (session.metadata ?? {}) as Record; + if (typeof meta.opencodePassword === "string") { + try { + return decrypt(meta.opencodePassword); + } catch { + // Decryption failed + } + } + return undefined; } /** Build Authorization header object for raw fetch to sandbox, if password is set. */ -function getAuthHeaders(session: { metadata?: unknown }): Record { - const pw = getSessionPassword(session); - return pw ? { Authorization: buildBasicAuthHeader(pw) } : {}; +function getAuthHeaders(session: { + metadata?: unknown; +}): Record { + const pw = getSessionPassword(session); + return pw ? { Authorization: buildBasicAuthHeader(pw) } : {}; } -const SANDBOX_HOME = '/home/agentuity'; +const SANDBOX_HOME = "/home/agentuity"; const UPLOADS_DIR = `${SANDBOX_HOME}/uploads`; const MAX_ATTACHMENTS = 5; const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; const ALLOWED_EXTENSIONS = new Set([ - 'txt', - 'md', - 'mdx', - 'json', - 'js', - 'jsx', - 'ts', - 'tsx', - 'py', - 'java', - 'go', - 'rs', - 'rb', - 'php', - 'sh', - 'yaml', - 'yml', - 'toml', - 'csv', - 'log', - 'png', - 'jpg', - 'jpeg', - 'gif', - 'webp', - 'svg', + "txt", + "md", + "mdx", + "json", + "js", + "jsx", + "ts", + "tsx", + "py", + "java", + "go", + "rs", + "rb", + "php", + "sh", + "yaml", + "yml", + "toml", + "csv", + "log", + "png", + "jpg", + "jpeg", + "gif", + "webp", + "svg", ]); /** Ensure a sandbox file path is absolute (rooted at /home/agentuity). */ function toAbsoluteSandboxPath(p: string): string { - if (p.startsWith(SANDBOX_HOME)) { - // Even if it starts with SANDBOX_HOME, normalize to prevent /home/agentuity/../../../etc/passwd - const normalized = new URL(p, 'file:///').pathname; - if (!normalized.startsWith(SANDBOX_HOME)) { - throw new Error('Path traversal detected'); - } - return normalized; - } - const rel = p.startsWith('/') ? p.slice(1) : p; - const joined = `${SANDBOX_HOME}/${rel}`; - // Use URL to normalize the path (resolves .., ., double slashes) - const normalized = new URL(joined, 'file:///').pathname; - if (!normalized.startsWith(SANDBOX_HOME)) { - throw new Error('Path traversal detected'); - } - return normalized; + if (p.startsWith(SANDBOX_HOME)) { + // Even if it starts with SANDBOX_HOME, normalize to prevent /home/agentuity/../../../etc/passwd + const normalized = new URL(p, "file:///").pathname; + if (!normalized.startsWith(SANDBOX_HOME)) { + throw new Error("Path traversal detected"); + } + return normalized; + } + const rel = p.startsWith("/") ? p.slice(1) : p; + const joined = `${SANDBOX_HOME}/${rel}`; + // Use URL to normalize the path (resolves .., ., double slashes) + const normalized = new URL(joined, "file:///").pathname; + if (!normalized.startsWith(SANDBOX_HOME)) { + throw new Error("Path traversal detected"); + } + return normalized; } function sanitizeFilename(filename: string, fallback: string) { - const trimmed = filename.trim().split('/').pop()?.split('\\').pop() || ''; - const safe = trimmed.replace(/[^a-zA-Z0-9._-]/g, '_'); - return safe || fallback; + const trimmed = filename.trim().split("/").pop()?.split("\\").pop() || ""; + const safe = trimmed.replace(/[^a-zA-Z0-9._-]/g, "_"); + return safe || fallback; } function isAllowedFilename(filename: string) { - const ext = filename.split('.').pop()?.toLowerCase() || ''; - return ALLOWED_EXTENSIONS.has(ext); + const ext = filename.split(".").pop()?.toLowerCase() || ""; + return ALLOWED_EXTENSIONS.has(ext); } const api = createRouter(); @@ -103,409 +114,560 @@ const api = createRouter(); // --------------------------------------------------------------------------- // GET /api/sessions/:id/messages — fetch existing messages for page load // --------------------------------------------------------------------------- -api.get('/:id/messages', async (c) => { - const user = c.get('user')!; - const [session] = await db - .select() - .from(chatSessions) - .where(and(eq(chatSessions.id, c.req.param('id')!), eq(chatSessions.createdBy, user.id))); - if (!session) return c.json({ error: 'Session not found' }, 404); - if (!session.sandboxId || !session.sandboxUrl || !session.opencodeSessionId) { - return c.json({ error: 'Session sandbox not ready' }, 503); - } - - c.var.session.metadata.action = 'get-messages'; - c.var.session.metadata.sessionDbId = session.id; - - const client = getOpencodeClient(session.sandboxId, session.sandboxUrl, getSessionPassword(session)); - try { - const result = await client.session.messages({ - path: { id: session.opencodeSessionId }, - }); - return c.json((result as any)?.data || result); - } catch (error) { - return c.json({ error: 'Failed to get messages', details: String(error) }, 500); - } +api.get("/:id/messages", async (c) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")!), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!session) return c.json({ error: "Session not found" }, 404); + if (!session.sandboxId || !session.sandboxUrl || !session.opencodeSessionId) { + return c.json({ error: "Session sandbox not ready" }, 503); + } + + c.var.session.metadata.action = "get-messages"; + c.var.session.metadata.sessionDbId = session.id; + + const client = getOpencodeClient( + session.sandboxId, + session.sandboxUrl, + getSessionPassword(session), + ); + try { + const result = await client.session.messages({ + path: { id: session.opencodeSessionId }, + }); + return c.json((result as any)?.data || result); + } catch (error) { + return c.json( + { error: "Failed to get messages", details: String(error) }, + 500, + ); + } }); // --------------------------------------------------------------------------- // POST /api/sessions/:id/messages — send message (async, non-blocking) // --------------------------------------------------------------------------- -api.post('/:id/messages', async (c) => { - const user = c.get('user')!; - const [session] = await db - .select() - .from(chatSessions) - .where(and(eq(chatSessions.id, c.req.param('id')!), eq(chatSessions.createdBy, user.id))); - if (!session) return c.json({ error: 'Session not found' }, 404); - if (!session.sandboxId || !session.sandboxUrl || !session.opencodeSessionId) { - return c.json({ error: 'Session sandbox not ready' }, 503); - } - - c.var.session.metadata.action = 'send-message'; - c.var.session.metadata.sessionDbId = session.id; - - const body = await c.req.json<{ - text: string; - model?: string; - command?: string; - attachments?: Array<{ filename: string; mime: string; content: string }>; - }>(); - - const messageText = typeof body.text === 'string' ? body.text : ''; - const attachments = Array.isArray(body.attachments) ? body.attachments : []; - if (attachments.length > MAX_ATTACHMENTS) { - return c.json({ error: `Too many attachments (max ${MAX_ATTACHMENTS}).` }, 400); - } - if (body.command && TEMPLATE_COMMANDS.has(body.command.replace(/^\//, '')) && attachments.length > 0) { - return c.json({ error: 'Attachments are not supported for commands.' }, 400); - } - - const client = getOpencodeClient(session.sandboxId, session.sandboxUrl, getSessionPassword(session)); - const apiClient = (c.var.sandbox as any).client; - const fileParts: Array<{ type: 'file'; mime: string; filename?: string; url: string }> = []; - - if (attachments.length > 0) { - try { - await sandboxMkDir(apiClient, { - sandboxId: session.sandboxId, - path: UPLOADS_DIR, - recursive: true, - }); - } catch (mkdirErr) { - c.var.logger?.warn?.('Failed to create upload directory', { error: String(mkdirErr) }); - return c.json({ error: 'Failed to prepare upload directory.' }, 500); - } - - const now = Date.now(); - const filesToWrite: { path: string; content: Buffer }[] = []; - for (const [index, attachment] of attachments.entries()) { - if (!attachment?.filename || !attachment?.content) { - return c.json({ error: 'Invalid attachment payload.' }, 400); - } - const safeName = sanitizeFilename(attachment.filename, `attachment-${index}`); - if (!isAllowedFilename(safeName)) { - return c.json({ error: `Unsupported file type: ${attachment.filename}` }, 400); - } - const buffer = Buffer.from(attachment.content, 'base64'); - if (buffer.length > MAX_ATTACHMENT_SIZE) { - return c.json({ error: `Attachment too large: ${attachment.filename}` }, 400); - } - const filename = `${now}-${index}-${safeName}`; - const filePath = `${UPLOADS_DIR}/${filename}`; - filesToWrite.push({ path: filePath, content: buffer }); - fileParts.push({ - type: 'file', - mime: (() => { - const m = attachment.mime || 'application/octet-stream'; - return m.startsWith('text/') ? 'text/plain' : m; - })(), - filename: safeName, - url: `file://${filePath}`, - }); - } - - await sandboxWriteFiles(apiClient, { - sandboxId: session.sandboxId, - files: filesToWrite, - }); - } - - return c.var.tracer.startActiveSpan('chat.send-message', async (span) => { - span.setAttribute('sessionDbId', session.id); - span.setAttribute('hasAttachments', attachments.length > 0); - span.setAttribute('hasCommand', !!body.command); - try { - // Determine the command slug from explicit command or session's stored agent. - // If command is explicitly '' (user chose "Chat"), don't fall back to stored agent. - const commandSlug = typeof body.command === 'string' - ? (body.command ? body.command.replace(/^\//, '') : null) - : session.agent || null; - - const [providerID, modelID] = body.model ? body.model.split('/') : []; - - // Template commands (cadence, memory-save, cloud, sandbox, etc.) MUST be - // sent as slash command text so OpenCode expands their templates. - // Non-template commands (agentuity-coder, review, etc.) use the agent field. - if (commandSlug && TEMPLATE_COMMANDS.has(commandSlug)) { - // Send as slash command: "/ " — OpenCode expands the template - await client.session.promptAsync({ - path: { id: session.opencodeSessionId! }, - body: { - parts: [{ type: 'text' as const, text: `/${commandSlug} ${messageText}` }, ...fileParts], - ...(providerID && modelID ? { model: { providerID, modelID } } : {}), - }, - }); - } else { - const agentName = commandSlug - ? (COMMAND_TO_AGENT[commandSlug] || commandSlug) - : undefined; - await client.session.promptAsync({ - path: { id: session.opencodeSessionId! }, - body: { - parts: [{ type: 'text' as const, text: messageText }, ...fileParts], - ...(agentName ? { agent: agentName } : {}), - ...(providerID && modelID ? { model: { providerID, modelID } } : {}), - }, - }); - } - - // Persist the agent on the session for real agents (not one-shot template commands). - // Cadence uses the Agentuity Coder team — persist as agentuity-coder. - const persistAgent = commandSlug === 'agentuity-cadence' ? 'agentuity-coder' : - (commandSlug && !TEMPLATE_COMMANDS.has(commandSlug)) ? commandSlug : null; - if (persistAgent && persistAgent !== session.agent) { - await db - .update(chatSessions) - .set({ agent: persistAgent, updatedAt: new Date() }) - .where(eq(chatSessions.id, session.id)); - } - - // Auto-title from first message if untitled - if (!session.title && messageText) { - const title = messageText.length > 60 ? messageText.slice(0, 57) + '...' : messageText; - await db - .update(chatSessions) - .set({ title, updatedAt: new Date() }) - .where(eq(chatSessions.id, session.id)); - } - - // Update thread context with latest activity - const { updateThreadContext } = await import('../lib/thread-context'); - await updateThreadContext(c.var.thread, { - sessionDbId: session.id, - title: session.title ?? null, - lastActivityAt: new Date().toISOString(), - lastMessagePreview: messageText.length > 200 ? messageText.slice(0, 200) : messageText, - }); - - // Tag thread metadata - const existingMeta = await c.var.thread.getMetadata(); - await c.var.thread.setMetadata({ ...existingMeta, sessionDbId: session.id }); - - span.setStatus({ code: SpanStatusCode.OK }); - return c.json({ success: true }); - } catch (error) { - span.setStatus({ code: SpanStatusCode.ERROR, message: String(error) }); - return c.json({ error: 'Failed to send message', details: String(error) }, 500); - } - }); +api.post("/:id/messages", async (c) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")!), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!session) return c.json({ error: "Session not found" }, 404); + if (!session.sandboxId || !session.sandboxUrl || !session.opencodeSessionId) { + return c.json({ error: "Session sandbox not ready" }, 503); + } + + c.var.session.metadata.action = "send-message"; + c.var.session.metadata.sessionDbId = session.id; + + const body = await c.req.json<{ + text: string; + model?: string; + command?: string; + attachments?: Array<{ filename: string; mime: string; content: string }>; + }>(); + + const messageText = typeof body.text === "string" ? body.text : ""; + const attachments = Array.isArray(body.attachments) ? body.attachments : []; + if (attachments.length > MAX_ATTACHMENTS) { + return c.json( + { error: `Too many attachments (max ${MAX_ATTACHMENTS}).` }, + 400, + ); + } + if ( + body.command && + TEMPLATE_COMMANDS.has(body.command.replace(/^\//, "")) && + attachments.length > 0 + ) { + return c.json( + { error: "Attachments are not supported for commands." }, + 400, + ); + } + + const client = getOpencodeClient( + session.sandboxId, + session.sandboxUrl, + getSessionPassword(session), + ); + const apiClient = (c.var.sandbox as any).client; + const fileParts: Array<{ + type: "file"; + mime: string; + filename?: string; + url: string; + }> = []; + + if (attachments.length > 0) { + try { + await sandboxMkDir(apiClient, { + sandboxId: session.sandboxId, + path: UPLOADS_DIR, + recursive: true, + }); + } catch (mkdirErr) { + c.var.logger?.warn?.("Failed to create upload directory", { + error: String(mkdirErr), + }); + return c.json({ error: "Failed to prepare upload directory." }, 500); + } + + const now = Date.now(); + const filesToWrite: { path: string; content: Buffer }[] = []; + for (const [index, attachment] of attachments.entries()) { + if (!attachment?.filename || !attachment?.content) { + return c.json({ error: "Invalid attachment payload." }, 400); + } + const safeName = sanitizeFilename( + attachment.filename, + `attachment-${index}`, + ); + if (!isAllowedFilename(safeName)) { + return c.json( + { error: `Unsupported file type: ${attachment.filename}` }, + 400, + ); + } + const buffer = Buffer.from(attachment.content, "base64"); + if (buffer.length > MAX_ATTACHMENT_SIZE) { + return c.json( + { error: `Attachment too large: ${attachment.filename}` }, + 400, + ); + } + const filename = `${now}-${index}-${safeName}`; + const filePath = `${UPLOADS_DIR}/${filename}`; + filesToWrite.push({ path: filePath, content: buffer }); + fileParts.push({ + type: "file", + mime: (() => { + const m = attachment.mime || "application/octet-stream"; + return m.startsWith("text/") ? "text/plain" : m; + })(), + filename: safeName, + url: `file://${filePath}`, + }); + } + + await sandboxWriteFiles(apiClient, { + sandboxId: session.sandboxId, + files: filesToWrite, + }); + } + + return c.var.tracer.startActiveSpan("chat.send-message", async (span) => { + span.setAttribute("sessionDbId", session.id); + span.setAttribute("hasAttachments", attachments.length > 0); + span.setAttribute("hasCommand", !!body.command); + try { + // Determine the command slug from explicit command or session's stored agent. + // If command is explicitly '' (user chose "Chat"), don't fall back to stored agent. + const commandSlug = + typeof body.command === "string" + ? body.command + ? body.command.replace(/^\//, "") + : null + : session.agent || null; + + const [providerID, modelID] = body.model ? body.model.split("/") : []; + + // Template commands (cadence, memory-save, cloud, sandbox, etc.) MUST be + // sent as slash command text so OpenCode expands their templates. + // Non-template commands (agentuity-coder, review, etc.) use the agent field. + if (commandSlug && TEMPLATE_COMMANDS.has(commandSlug)) { + // Send as slash command: "/ " — OpenCode expands the template + await client.session.promptAsync({ + path: { id: session.opencodeSessionId! }, + body: { + parts: [ + { type: "text" as const, text: `/${commandSlug} ${messageText}` }, + ...fileParts, + ], + ...(providerID && modelID + ? { model: { providerID, modelID } } + : {}), + }, + }); + } else { + const agentName = commandSlug + ? COMMAND_TO_AGENT[commandSlug] || commandSlug + : undefined; + await client.session.promptAsync({ + path: { id: session.opencodeSessionId! }, + body: { + parts: [{ type: "text" as const, text: messageText }, ...fileParts], + ...(agentName ? { agent: agentName } : {}), + ...(providerID && modelID + ? { model: { providerID, modelID } } + : {}), + }, + }); + } + + // Persist the agent on the session for real agents (not one-shot template commands). + // Cadence uses the Agentuity Coder team — persist as agentuity-coder. + const persistAgent = + commandSlug === "agentuity-cadence" + ? "agentuity-coder" + : commandSlug && !TEMPLATE_COMMANDS.has(commandSlug) + ? commandSlug + : null; + if (persistAgent && persistAgent !== session.agent) { + await db + .update(chatSessions) + .set({ agent: persistAgent, updatedAt: new Date() }) + .where(eq(chatSessions.id, session.id)); + } + + // Auto-title from first message if untitled + if (!session.title && messageText) { + const title = + messageText.length > 60 + ? messageText.slice(0, 57) + "..." + : messageText; + await db + .update(chatSessions) + .set({ title, updatedAt: new Date() }) + .where(eq(chatSessions.id, session.id)); + } + + // Update thread context with latest activity + const { updateThreadContext } = await import("../lib/thread-context"); + await updateThreadContext(c.var.thread, { + sessionDbId: session.id, + title: session.title ?? null, + lastActivityAt: new Date().toISOString(), + lastMessagePreview: + messageText.length > 200 ? messageText.slice(0, 200) : messageText, + }); + + // Tag thread metadata + const existingMeta = await c.var.thread.getMetadata(); + await c.var.thread.setMetadata({ + ...existingMeta, + sessionDbId: session.id, + }); + + span.setStatus({ code: SpanStatusCode.OK }); + return c.json({ success: true }); + } catch (error) { + span.setStatus({ code: SpanStatusCode.ERROR, message: String(error) }); + return c.json( + { error: "Failed to send message", details: String(error) }, + 500, + ); + } + }); }); // --------------------------------------------------------------------------- // GET /api/sessions/:id/events — SSE event stream (filtered by session) // --------------------------------------------------------------------------- api.get( - '/:id/events', - sse(async (c, stream) => { - const user = c.get('user')!; - const [session] = await db - .select() - .from(chatSessions) - .where(and(eq(chatSessions.id, c.req.param('id')!), eq(chatSessions.createdBy, user.id))); - if (!session || !session.sandboxId || !session.sandboxUrl || !session.opencodeSessionId) { - await stream.writeSSE({ - data: JSON.stringify({ type: 'error', message: 'Session not ready' }), - }); - stream.close(); - return; - } - - const logger = c.var.logger; - let closed = false; - let reader: ReadableStreamDefaultReader | null = null; - let keepaliveTimer: ReturnType | null = null; - - const safeWrite = async (message: { data: string; event?: string }) => { - if (closed) return; - try { - await stream.writeSSE(message); - } catch (error) { - closed = true; - if (keepaliveTimer) clearInterval(keepaliveTimer); - logger?.warn?.('SSE write failed', { error: String(error) }); - } - }; - - const sendSessionError = async (message: string, details?: unknown) => { - await safeWrite({ - data: JSON.stringify({ - type: 'session.error', - properties: { - sessionID: session.opencodeSessionId, - error: message, - ...(details ? { details } : {}), - }, - }), - }); - }; - - stream.onAbort(() => { - closed = true; - if (keepaliveTimer) clearInterval(keepaliveTimer); - if (reader) { - reader.cancel().catch(() => undefined); - } - }); - - keepaliveTimer = setInterval(() => { - void safeWrite({ event: 'ping', data: 'ping' }); - }, 15000); - - // Use raw fetch to sandbox event stream for reliable SSE proxying - try { - const eventResponse = await fetch(`${session.sandboxUrl}/event`, { - headers: getAuthHeaders(session), - }); - if (!eventResponse.ok || !eventResponse.body) { - await sendSessionError('No event stream', { status: eventResponse.status }); - return; - } - - reader = eventResponse.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - try { - while (!closed) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (!line.startsWith('data: ')) continue; - const jsonStr = line.slice(6).trim(); - if (!jsonStr) continue; - - try { - const event = JSON.parse(jsonStr); - // Filter by session - const props = (event as any)?.properties; - const eventSessionId = - props?.sessionID || - props?.info?.sessionID || - props?.info?.id || - props?.part?.sessionID; - - if (eventSessionId && eventSessionId !== session.opencodeSessionId) { - continue; - } - - await safeWrite({ data: JSON.stringify(event) }); - } catch { - // Skip malformed events - } - } - } - if (!closed) { - await sendSessionError('Event stream ended'); - } - } catch (error) { - if (!closed) { - await sendSessionError('Event stream disconnected', { error: String(error) }); - } - } finally { - reader.releaseLock(); - } - } catch (error) { - if (!closed) { - await sendSessionError('Failed to proxy event stream', { error: String(error) }); - } - } finally { - closed = true; - if (keepaliveTimer) clearInterval(keepaliveTimer); - stream.close(); - } - }), + "/:id/events", + sse(async (c, stream) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")!), + eq(chatSessions.createdBy, user.id), + ), + ); + if ( + !session || + !session.sandboxId || + !session.sandboxUrl || + !session.opencodeSessionId + ) { + await stream.writeSSE({ + data: JSON.stringify({ type: "error", message: "Session not ready" }), + }); + stream.close(); + return; + } + + const logger = c.var.logger; + let closed = false; + let reader: ReadableStreamDefaultReader | null = null; + let keepaliveTimer: ReturnType | null = null; + + // Archive sync debounce — trigger on session.idle events + const SYNC_DEBOUNCE_MS = 5 * 60 * 1000; // 5 minutes + let lastSyncTimestamp = session.lastArchivedAt?.getTime() ?? 0; + + const safeWrite = async (message: { data: string; event?: string }) => { + if (closed) return; + try { + await stream.writeSSE(message); + } catch (error) { + closed = true; + if (keepaliveTimer) clearInterval(keepaliveTimer); + logger?.warn?.("SSE write failed", { error: String(error) }); + } + }; + + const sendSessionError = async (message: string, details?: unknown) => { + await safeWrite({ + data: JSON.stringify({ + type: "session.error", + properties: { + sessionID: session.opencodeSessionId, + error: message, + ...(details ? { details } : {}), + }, + }), + }); + }; + + stream.onAbort(() => { + closed = true; + if (keepaliveTimer) clearInterval(keepaliveTimer); + if (reader) { + reader.cancel().catch(() => undefined); + } + }); + + keepaliveTimer = setInterval(() => { + void safeWrite({ event: "ping", data: "ping" }); + }, 15000); + + // Use raw fetch to sandbox event stream for reliable SSE proxying + try { + const eventResponse = await fetch(`${session.sandboxUrl}/event`, { + headers: getAuthHeaders(session), + }); + if (!eventResponse.ok || !eventResponse.body) { + await sendSessionError("No event stream", { + status: eventResponse.status, + }); + return; + } + + reader = eventResponse.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (!closed) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const jsonStr = line.slice(6).trim(); + if (!jsonStr) continue; + + try { + const event = JSON.parse(jsonStr); + // Extract session ID from event properties + const props = (event as any)?.properties; + const eventSessionId = + props?.sessionID || + props?.info?.sessionID || + props?.info?.id || + props?.part?.sessionID; + + const isParent = + !eventSessionId || eventSessionId === session.opencodeSessionId; + + // Trigger archive sync on session.idle for the parent session + if (event.type === "session.idle" && isParent) { + const now = Date.now(); + if (now - lastSyncTimestamp >= SYNC_DEBOUNCE_MS) { + lastSyncTimestamp = now; // Optimistic update + const syncClient = (c.var.sandbox as any).client; + syncSessionArchive(syncClient, session, logger).catch( + (err: unknown) => { + logger.warn("[archive-sync] Background sync failed", { + error: String(err), + }); + }, + ); + } + } + + // Tag every event with _meta so the frontend can route + // parent vs child session events accordingly + await safeWrite({ + data: JSON.stringify({ + ...event, + _meta: { + sessionId: eventSessionId || session.opencodeSessionId, + isParent, + }, + }), + }); + } catch { + // Skip malformed events + } + } + } + if (!closed) { + await sendSessionError("Event stream ended"); + } + } catch (error) { + if (!closed) { + await sendSessionError("Event stream disconnected", { + error: String(error), + }); + } + } finally { + reader.releaseLock(); + } + } catch (error) { + if (!closed) { + await sendSessionError("Failed to proxy event stream", { + error: String(error), + }); + } + } finally { + closed = true; + if (keepaliveTimer) clearInterval(keepaliveTimer); + stream.close(); + } + }), ); // --------------------------------------------------------------------------- // POST /api/sessions/:id/abort — abort running session // --------------------------------------------------------------------------- -api.post('/:id/abort', async (c) => { - const user = c.get('user')!; - const [session] = await db - .select() - .from(chatSessions) - .where(and(eq(chatSessions.id, c.req.param('id')!), eq(chatSessions.createdBy, user.id))); - if (!session) return c.json({ error: 'Session not found' }, 404); - if (!session.sandboxId || !session.sandboxUrl || !session.opencodeSessionId) { - return c.json({ error: 'Session sandbox not ready' }, 503); - } - - c.var.session.metadata.action = 'abort-session'; - c.var.session.metadata.sessionDbId = session.id; - - const client = getOpencodeClient(session.sandboxId, session.sandboxUrl, getSessionPassword(session)); - try { - await client.session.abort({ path: { id: session.opencodeSessionId } }); - return c.json({ success: true }); - } catch (error) { - return c.json({ error: 'Failed to abort', details: String(error) }, 500); - } +api.post("/:id/abort", async (c) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")!), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!session) return c.json({ error: "Session not found" }, 404); + if (!session.sandboxId || !session.sandboxUrl || !session.opencodeSessionId) { + return c.json({ error: "Session sandbox not ready" }, 503); + } + + c.var.session.metadata.action = "abort-session"; + c.var.session.metadata.sessionDbId = session.id; + + const client = getOpencodeClient( + session.sandboxId, + session.sandboxUrl, + getSessionPassword(session), + ); + try { + await client.session.abort({ path: { id: session.opencodeSessionId } }); + return c.json({ success: true }); + } catch (error) { + return c.json({ error: "Failed to abort", details: String(error) }, 500); + } }); // --------------------------------------------------------------------------- // GET /api/sessions/:id/diff — get session diffs // --------------------------------------------------------------------------- -api.get('/:id/diff', async (c) => { - const user = c.get('user')!; - const [session] = await db - .select() - .from(chatSessions) - .where(and(eq(chatSessions.id, c.req.param('id')!), eq(chatSessions.createdBy, user.id))); - if (!session) return c.json({ error: 'Session not found' }, 404); - if (!session.sandboxId || !session.sandboxUrl || !session.opencodeSessionId) { - return c.json({ error: 'Session sandbox not ready' }, 503); - } - - c.var.session.metadata.action = 'get-diff'; - c.var.session.metadata.sessionDbId = session.id; - - const client = getOpencodeClient(session.sandboxId, session.sandboxUrl, getSessionPassword(session)); - try { - const result = await client.session.diff({ path: { id: session.opencodeSessionId } }); - return c.json((result as any)?.data || result); - } catch (error) { - return c.json({ error: 'Failed to get diffs', details: String(error) }, 500); - } +api.get("/:id/diff", async (c) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")!), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!session) return c.json({ error: "Session not found" }, 404); + if (!session.sandboxId || !session.sandboxUrl || !session.opencodeSessionId) { + return c.json({ error: "Session sandbox not ready" }, 503); + } + + c.var.session.metadata.action = "get-diff"; + c.var.session.metadata.sessionDbId = session.id; + + const client = getOpencodeClient( + session.sandboxId, + session.sandboxUrl, + getSessionPassword(session), + ); + try { + const result = await client.session.diff({ + path: { id: session.opencodeSessionId }, + }); + return c.json((result as any)?.data || result); + } catch (error) { + return c.json( + { error: "Failed to get diffs", details: String(error) }, + 500, + ); + } }); // --------------------------------------------------------------------------- // POST /api/sessions/:id/permissions/:reqId — reply to a permission request // --------------------------------------------------------------------------- -api.post('/:id/permissions/:reqId', async (c) => { - const user = c.get('user')!; - const [session] = await db - .select() - .from(chatSessions) - .where(and(eq(chatSessions.id, c.req.param('id')!), eq(chatSessions.createdBy, user.id))); - if (!session) return c.json({ error: 'Session not found' }, 404); - if (!session.sandboxId || !session.sandboxUrl || !session.opencodeSessionId) { - return c.json({ error: 'Session sandbox not ready' }, 503); - } - - c.var.session.metadata.action = 'reply-permission'; - c.var.session.metadata.sessionDbId = session.id; - - const body = await c.req.json<{ reply: 'once' | 'always' | 'reject' }>(); - const client = getOpencodeClient(session.sandboxId, session.sandboxUrl, getSessionPassword(session)); - - try { - await client.postSessionIdPermissionsPermissionId({ - path: { - id: session.opencodeSessionId, - permissionID: c.req.param('reqId')!, - }, - body: { response: body.reply }, - }); - return c.json({ success: true }); - } catch (error) { - return c.json({ error: 'Failed to reply to permission', details: String(error) }, 500); - } +api.post("/:id/permissions/:reqId", async (c) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")!), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!session) return c.json({ error: "Session not found" }, 404); + if (!session.sandboxId || !session.sandboxUrl || !session.opencodeSessionId) { + return c.json({ error: "Session sandbox not ready" }, 503); + } + + c.var.session.metadata.action = "reply-permission"; + c.var.session.metadata.sessionDbId = session.id; + + const body = await c.req.json<{ reply: "once" | "always" | "reject" }>(); + const client = getOpencodeClient( + session.sandboxId, + session.sandboxUrl, + getSessionPassword(session), + ); + + try { + await client.postSessionIdPermissionsPermissionId({ + path: { + id: session.opencodeSessionId, + permissionID: c.req.param("reqId")!, + }, + body: { response: body.reply }, + }); + return c.json({ success: true }); + } catch (error) { + return c.json( + { error: "Failed to reply to permission", details: String(error) }, + 500, + ); + } }); // --------------------------------------------------------------------------- @@ -513,415 +675,479 @@ api.post('/:id/permissions/:reqId', async (c) => { // NOTE: The SDK does not expose a dedicated question.reply method. // We fall back to posting directly to the expected REST endpoint. // --------------------------------------------------------------------------- -api.post('/:id/questions/:reqId', async (c) => { - const user = c.get('user')!; - const [session] = await db - .select() - .from(chatSessions) - .where(and(eq(chatSessions.id, c.req.param('id')!), eq(chatSessions.createdBy, user.id))); - if (!session) return c.json({ error: 'Session not found' }, 404); - if (!session.sandboxId || !session.sandboxUrl) { - return c.json({ error: 'Session sandbox not ready' }, 503); - } - - c.var.session.metadata.action = 'reply-question'; - c.var.session.metadata.sessionDbId = session.id; - - const body = await c.req.json<{ answers: string[][] }>(); - const reqId = c.req.param('reqId')!; - - try { - // Direct REST call since the SDK does not have a typed question reply method. - // OpenCode endpoint: POST /question/:requestID/reply - const resp = await fetch( - `${session.sandboxUrl}/question/${reqId}/reply`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...getAuthHeaders(session) }, - body: JSON.stringify({ answers: body.answers }), - }, - ); - if (!resp.ok) { - const text = await resp.text(); - return c.json({ error: 'Failed to reply to question', details: text }, 500); - } - return c.json({ success: true }); - } catch (error) { - return c.json({ error: 'Failed to reply to question', details: String(error) }, 500); - } +api.post("/:id/questions/:reqId", async (c) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")!), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!session) return c.json({ error: "Session not found" }, 404); + if (!session.sandboxId || !session.sandboxUrl) { + return c.json({ error: "Session sandbox not ready" }, 503); + } + + c.var.session.metadata.action = "reply-question"; + c.var.session.metadata.sessionDbId = session.id; + + const body = await c.req.json<{ answers: string[][] }>(); + const reqId = c.req.param("reqId")!; + + try { + // Direct REST call since the SDK does not have a typed question reply method. + // OpenCode endpoint: POST /question/:requestID/reply + const resp = await fetch(`${session.sandboxUrl}/question/${reqId}/reply`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...getAuthHeaders(session), + }, + body: JSON.stringify({ answers: body.answers }), + }); + if (!resp.ok) { + const text = await resp.text(); + return c.json( + { error: "Failed to reply to question", details: text }, + 500, + ); + } + return c.json({ success: true }); + } catch (error) { + return c.json( + { error: "Failed to reply to question", details: String(error) }, + 500, + ); + } }); // --------------------------------------------------------------------------- // POST /api/sessions/:id/revert — revert session to a specific message // --------------------------------------------------------------------------- -api.post('/:id/revert', async (c) => { - const user = c.get('user')!; - const [session] = await db - .select() - .from(chatSessions) - .where(and(eq(chatSessions.id, c.req.param('id')!), eq(chatSessions.createdBy, user.id))); - if (!session) return c.json({ error: 'Session not found' }, 404); - if (!session.sandboxId || !session.sandboxUrl || !session.opencodeSessionId) { - return c.json({ error: 'Session sandbox not ready' }, 503); - } - - c.var.session.metadata.action = 'revert'; - c.var.session.metadata.sessionDbId = session.id; - - const body = (await c.req.json().catch(() => ({}))) as { - messageID?: string; - partID?: string; - }; - if (!body.messageID) { - return c.json({ error: 'messageID is required' }, 400); - } - - const client = getOpencodeClient(session.sandboxId, session.sandboxUrl, getSessionPassword(session)); - try { - const result = await client.session.revert({ - path: { id: session.opencodeSessionId }, - body: { - messageID: body.messageID, - ...(body.partID ? { partID: body.partID } : {}), - }, - }); - return c.json({ success: true, session: result.data }); - } catch (error) { - return c.json({ error: 'Failed to revert', details: String(error) }, 500); - } +api.post("/:id/revert", async (c) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")!), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!session) return c.json({ error: "Session not found" }, 404); + if (!session.sandboxId || !session.sandboxUrl || !session.opencodeSessionId) { + return c.json({ error: "Session sandbox not ready" }, 503); + } + + c.var.session.metadata.action = "revert"; + c.var.session.metadata.sessionDbId = session.id; + + const body = (await c.req.json().catch(() => ({}))) as { + messageID?: string; + partID?: string; + }; + if (!body.messageID) { + return c.json({ error: "messageID is required" }, 400); + } + + const client = getOpencodeClient( + session.sandboxId, + session.sandboxUrl, + getSessionPassword(session), + ); + try { + const result = await client.session.revert({ + path: { id: session.opencodeSessionId }, + body: { + messageID: body.messageID, + ...(body.partID ? { partID: body.partID } : {}), + }, + }); + return c.json({ success: true, session: result.data }); + } catch (error) { + return c.json({ error: "Failed to revert", details: String(error) }, 500); + } }); // --------------------------------------------------------------------------- // POST /api/sessions/:id/unrevert — undo a revert // --------------------------------------------------------------------------- -api.post('/:id/unrevert', async (c) => { - const user = c.get('user')!; - const [session] = await db - .select() - .from(chatSessions) - .where(and(eq(chatSessions.id, c.req.param('id')!), eq(chatSessions.createdBy, user.id))); - if (!session) return c.json({ error: 'Session not found' }, 404); - if (!session.sandboxId || !session.sandboxUrl || !session.opencodeSessionId) { - return c.json({ error: 'Session sandbox not ready' }, 503); - } - - c.var.session.metadata.action = 'unrevert'; - c.var.session.metadata.sessionDbId = session.id; - - const client = getOpencodeClient(session.sandboxId, session.sandboxUrl, getSessionPassword(session)); - try { - const result = await client.session.unrevert({ - path: { id: session.opencodeSessionId }, - }); - return c.json({ success: true, session: result.data }); - } catch (error) { - return c.json({ error: 'Failed to unrevert', details: String(error) }, 500); - } +api.post("/:id/unrevert", async (c) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")!), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!session) return c.json({ error: "Session not found" }, 404); + if (!session.sandboxId || !session.sandboxUrl || !session.opencodeSessionId) { + return c.json({ error: "Session sandbox not ready" }, 503); + } + + c.var.session.metadata.action = "unrevert"; + c.var.session.metadata.sessionDbId = session.id; + + const client = getOpencodeClient( + session.sandboxId, + session.sandboxUrl, + getSessionPassword(session), + ); + try { + const result = await client.session.unrevert({ + path: { id: session.opencodeSessionId }, + }); + return c.json({ success: true, session: result.data }); + } catch (error) { + return c.json({ error: "Failed to unrevert", details: String(error) }, 500); + } }); // --------------------------------------------------------------------------- // GET /api/sessions/:id/files — list files in sandbox directory // Uses sandboxExecute with `find` for reliable, deduplicated file listing. // --------------------------------------------------------------------------- -api.get('/:id/files', async (c) => { - const user = c.get('user')!; - const [session] = await db - .select() - .from(chatSessions) - .where(and(eq(chatSessions.id, c.req.param('id')!), eq(chatSessions.createdBy, user.id))); - if (!session) return c.json({ error: 'Session not found' }, 404); - if (!session.sandboxId) return c.json({ error: 'No sandbox' }, 503); - - c.var.session.metadata.action = 'list-files'; - c.var.session.metadata.sessionDbId = session.id; - - const rawPath = c.req.query('path') || '/'; - const dirPath = rawPath === '/' ? SANDBOX_HOME : toAbsoluteSandboxPath(rawPath); - - try { - const apiClient = (c.var.sandbox as any).client; - - const execution = await sandboxExecute(apiClient, { - sandboxId: session.sandboxId, - options: { - command: [ - 'find', - dirPath, - '-maxdepth', - '3', - '-not', - '-path', - '*/node_modules/*', - '-not', - '-path', - '*/.git/*', - '-not', - '-path', - '*/.cache/*', - '-not', - '-path', - '*/.bun/*', - '-not', - '-path', - '*/.config/*', - '-not', - '-path', - '*/.local/*', - '-not', - '-path', - '*/.tmp/*', - '-not', - '-path', - '*/.npm/*', - '-not', - '-path', - '*/.yarn/*', - '-not', - '-path', - '*/.oh-my-*', - '-not', - '-path', - '*/dist/*', - '-not', - '-path', - '*/.agentuity/*', - '-not', - '-path', - '*/.next/*', - '-not', - '-path', - '*/__pycache__/*', - '-not', - '-name', - '.', - '-printf', - '%y %s %p\\n', - ], - timeout: '10s', - }, - }); - - if (execution.status !== 'completed' || !execution.stdoutStreamUrl) { - // Fall back to sandboxListFiles if execute fails - const result = await sandboxListFiles(apiClient, { - sandboxId: session.sandboxId, - path: dirPath === '/home/agentuity' ? undefined : dirPath, - }); - const seen = new Set(); - const entries = result.files - .map((f) => { - const name = f.path.split('/').pop() || f.path; - const abs = normalizeSandboxPath(rawPath, f.path); - return { - name, - path: abs, - type: f.isDir ? ('directory' as const) : ('file' as const), - size: f.size, - }; - }) - .filter((e) => { - if (seen.has(e.path)) return false; - seen.add(e.path); - return true; - }) - .sort((a, b) => { - if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; - return a.name.localeCompare(b.name); - }); - return c.json({ path: rawPath, entries }); - } - - // Fetch stdout content from the stream URL - const stdoutResp = await fetch(execution.stdoutStreamUrl); - const stdout = await stdoutResp.text(); - - const entries = stdout - .split('\n') - .filter((line) => line.trim() !== '') - .map((line) => { - // Format: "d 4096 /path/to/dir" or "f 1234 /path/to/file" - const match = line.match(/^(\w)\s+(\d+)\s+(.+)$/); - if (!match) return null; - - const typeChar = match[1]!; - const sizeStr = match[2]!; - const fullPath = match[3]!; - // Skip the directory itself (find includes the base path) - if (fullPath === dirPath) return null; - - const name = fullPath.split('/').pop() || fullPath; - const type = typeChar === 'd' ? ('directory' as const) : ('file' as const); - const size = parseInt(sizeStr, 10); - - return { name, path: fullPath, type, size }; - }) - .filter((e): e is NonNullable => Boolean(e)) - .sort((a, b) => { - if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; - return a.name.localeCompare(b.name); - }); - - return c.json({ path: rawPath, entries }); - } catch (error) { - return c.json({ error: 'Failed to list files', details: String(error) }, 500); - } +api.get("/:id/files", async (c) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")!), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!session) return c.json({ error: "Session not found" }, 404); + if (!session.sandboxId) return c.json({ error: "No sandbox" }, 503); + + c.var.session.metadata.action = "list-files"; + c.var.session.metadata.sessionDbId = session.id; + + const rawPath = c.req.query("path") || "/"; + const dirPath = + rawPath === "/" ? SANDBOX_HOME : toAbsoluteSandboxPath(rawPath); + + try { + const apiClient = (c.var.sandbox as any).client; + + const execution = await sandboxExecute(apiClient, { + sandboxId: session.sandboxId, + options: { + command: [ + "find", + dirPath, + "-maxdepth", + "3", + "-not", + "-path", + "*/node_modules/*", + "-not", + "-path", + "*/.git/*", + "-not", + "-path", + "*/.cache/*", + "-not", + "-path", + "*/.bun/*", + "-not", + "-path", + "*/.config/*", + "-not", + "-path", + "*/.local/*", + "-not", + "-path", + "*/.tmp/*", + "-not", + "-path", + "*/.npm/*", + "-not", + "-path", + "*/.yarn/*", + "-not", + "-path", + "*/.oh-my-*", + "-not", + "-path", + "*/dist/*", + "-not", + "-path", + "*/.agentuity/*", + "-not", + "-path", + "*/.next/*", + "-not", + "-path", + "*/__pycache__/*", + "-not", + "-name", + ".", + "-printf", + "%y %s %p\\n", + ], + timeout: "10s", + }, + }); + + if (execution.status !== "completed" || !execution.stdoutStreamUrl) { + // Fall back to sandboxListFiles if execute fails + const result = await sandboxListFiles(apiClient, { + sandboxId: session.sandboxId, + path: dirPath === "/home/agentuity" ? undefined : dirPath, + }); + const seen = new Set(); + const entries = result.files + .map((f) => { + const name = f.path.split("/").pop() || f.path; + const abs = normalizeSandboxPath(rawPath, f.path); + return { + name, + path: abs, + type: f.isDir ? ("directory" as const) : ("file" as const), + size: f.size, + }; + }) + .filter((e) => { + if (seen.has(e.path)) return false; + seen.add(e.path); + return true; + }) + .sort((a, b) => { + if (a.type !== b.type) return a.type === "directory" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + return c.json({ path: rawPath, entries }); + } + + // Fetch stdout content from the stream URL + const stdoutResp = await fetch(execution.stdoutStreamUrl); + const stdout = await stdoutResp.text(); + + const entries = stdout + .split("\n") + .filter((line) => line.trim() !== "") + .map((line) => { + // Format: "d 4096 /path/to/dir" or "f 1234 /path/to/file" + const match = line.match(/^(\w)\s+(\d+)\s+(.+)$/); + if (!match) return null; + + const typeChar = match[1]!; + const sizeStr = match[2]!; + const fullPath = match[3]!; + // Skip the directory itself (find includes the base path) + if (fullPath === dirPath) return null; + + const name = fullPath.split("/").pop() || fullPath; + const type = + typeChar === "d" ? ("directory" as const) : ("file" as const); + const size = parseInt(sizeStr, 10); + + return { name, path: fullPath, type, size }; + }) + .filter((e): e is NonNullable => Boolean(e)) + .sort((a, b) => { + if (a.type !== b.type) return a.type === "directory" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + return c.json({ path: rawPath, entries }); + } catch (error) { + return c.json( + { error: "Failed to list files", details: String(error) }, + 500, + ); + } }); // --------------------------------------------------------------------------- // GET /api/sessions/:id/files/content — read file content from sandbox // --------------------------------------------------------------------------- -api.get('/:id/files/content', async (c) => { - const user = c.get('user')!; - const [session] = await db - .select() - .from(chatSessions) - .where(and(eq(chatSessions.id, c.req.param('id')!), eq(chatSessions.createdBy, user.id))); - if (!session) return c.json({ error: 'Session not found' }, 404); - if (!session.sandboxId) return c.json({ error: 'No sandbox' }, 503); - - c.var.session.metadata.action = 'read-file'; - c.var.session.metadata.sessionDbId = session.id; - - const rawFilePath = c.req.query('path'); - if (!rawFilePath) return c.json({ error: 'Missing path parameter' }, 400); - - const filePath = toAbsoluteSandboxPath(rawFilePath); - - try { - const apiClient = (c.var.sandbox as any).client; - const stream = await sandboxReadFile(apiClient, { - sandboxId: session.sandboxId, - path: filePath, - }); - - // Read the stream to get content as string - const reader = stream.getReader(); - const chunks: Uint8Array[] = []; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); - } - const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); - const merged = new Uint8Array(totalLength); - let offset = 0; - for (const chunk of chunks) { - merged.set(chunk, offset); - offset += chunk.length; - } - const content = new TextDecoder().decode(merged); - const ext = filePath.split('.').pop() || ''; - - return c.json({ path: filePath, content, extension: ext }); - } catch (error) { - return c.json({ error: 'Failed to read file', details: String(error) }, 500); - } +api.get("/:id/files/content", async (c) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")!), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!session) return c.json({ error: "Session not found" }, 404); + if (!session.sandboxId) return c.json({ error: "No sandbox" }, 503); + + c.var.session.metadata.action = "read-file"; + c.var.session.metadata.sessionDbId = session.id; + + const rawFilePath = c.req.query("path"); + if (!rawFilePath) return c.json({ error: "Missing path parameter" }, 400); + + const filePath = toAbsoluteSandboxPath(rawFilePath); + + try { + const apiClient = (c.var.sandbox as any).client; + const stream = await sandboxReadFile(apiClient, { + sandboxId: session.sandboxId, + path: filePath, + }); + + // Read the stream to get content as string + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const merged = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.length; + } + const content = new TextDecoder().decode(merged); + const ext = filePath.split(".").pop() || ""; + + return c.json({ path: filePath, content, extension: ext }); + } catch (error) { + return c.json( + { error: "Failed to read file", details: String(error) }, + 500, + ); + } }); // --------------------------------------------------------------------------- // GET /api/sessions/:id/files/image — serve raw image from sandbox // --------------------------------------------------------------------------- -api.get('/:id/files/image', async (c) => { - const user = c.get('user')!; - const [session] = await db - .select() - .from(chatSessions) - .where(and(eq(chatSessions.id, c.req.param('id')!), eq(chatSessions.createdBy, user.id))); - if (!session) return c.json({ error: 'Session not found' }, 404); - if (!session.sandboxId) return c.json({ error: 'No sandbox' }, 503); - - const rawFilePath = c.req.query('path'); - if (!rawFilePath) return c.json({ error: 'Missing path parameter' }, 400); - - const filePath = toAbsoluteSandboxPath(rawFilePath); - - // Only allow image extensions - const ext = filePath.split('.').pop()?.toLowerCase() || ''; - const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg']); - if (!IMAGE_EXTS.has(ext)) return c.json({ error: 'Not an image file' }, 400); - - // Map extension to MIME type - const MIME_MAP: Record = { - png: 'image/png', - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - gif: 'image/gif', - webp: 'image/webp', - svg: 'image/svg+xml', - }; - - try { - const apiClient = (c.var.sandbox as any).client; - const stream = await sandboxReadFile(apiClient, { - sandboxId: session.sandboxId, - path: filePath, - }); - - // Read stream into binary buffer - const reader = stream.getReader(); - const chunks: Uint8Array[] = []; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); - } - const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); - const merged = new Uint8Array(totalLength); - let offset = 0; - for (const chunk of chunks) { - merged.set(chunk, offset); - offset += chunk.length; - } - - // Return raw bytes with caching headers - return new Response(merged, { - headers: { - 'Content-Type': MIME_MAP[ext] || 'application/octet-stream', - 'Content-Length': String(totalLength), - 'Cache-Control': 'private, max-age=3600, immutable', - }, - }); - } catch (error) { - return c.json({ error: 'Failed to read image', details: String(error) }, 500); - } +api.get("/:id/files/image", async (c) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")!), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!session) return c.json({ error: "Session not found" }, 404); + if (!session.sandboxId) return c.json({ error: "No sandbox" }, 503); + + const rawFilePath = c.req.query("path"); + if (!rawFilePath) return c.json({ error: "Missing path parameter" }, 400); + + const filePath = toAbsoluteSandboxPath(rawFilePath); + + // Only allow image extensions + const ext = filePath.split(".").pop()?.toLowerCase() || ""; + const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg"]); + if (!IMAGE_EXTS.has(ext)) return c.json({ error: "Not an image file" }, 400); + + // Map extension to MIME type + const MIME_MAP: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + }; + + try { + const apiClient = (c.var.sandbox as any).client; + const stream = await sandboxReadFile(apiClient, { + sandboxId: session.sandboxId, + path: filePath, + }); + + // Read stream into binary buffer + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const merged = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.length; + } + + // Return raw bytes with caching headers + return new Response(merged, { + headers: { + "Content-Type": MIME_MAP[ext] || "application/octet-stream", + "Content-Length": String(totalLength), + "Cache-Control": "private, max-age=3600, immutable", + }, + }); + } catch (error) { + return c.json( + { error: "Failed to read image", details: String(error) }, + 500, + ); + } }); // --------------------------------------------------------------------------- // PUT /api/sessions/:id/files/content — write file content to sandbox // Body: { path: string, content: string } // --------------------------------------------------------------------------- -api.put('/:id/files/content', async (c) => { - const user = c.get('user')!; - const [session] = await db - .select() - .from(chatSessions) - .where(and(eq(chatSessions.id, c.req.param('id')!), eq(chatSessions.createdBy, user.id))); - if (!session) return c.json({ error: 'Session not found' }, 404); - if (!session.sandboxId) return c.json({ error: 'No sandbox' }, 503); - - c.var.session.metadata.action = 'write-file'; - c.var.session.metadata.sessionDbId = session.id; - - const body = await c.req - .json<{ path?: string; content?: string }>() - .catch(() => ({ path: undefined, content: undefined })); - if (!body.path) return c.json({ error: 'Missing path parameter' }, 400); - if (typeof body.content !== 'string') return c.json({ error: 'Missing content' }, 400); - - const filePath = toAbsoluteSandboxPath(body.path); - - try { - const apiClient = (c.var.sandbox as any).client; - await sandboxWriteFiles(apiClient, { - sandboxId: session.sandboxId, - files: [{ path: filePath, content: Buffer.from(body.content) }], - }); - - return c.json({ success: true, path: filePath }); - } catch (error) { - console.error('File write error:', error); - return c.json({ error: 'Failed to write file', details: String(error) }, 500); - } +api.put("/:id/files/content", async (c) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")!), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!session) return c.json({ error: "Session not found" }, 404); + if (!session.sandboxId) return c.json({ error: "No sandbox" }, 503); + + c.var.session.metadata.action = "write-file"; + c.var.session.metadata.sessionDbId = session.id; + + const body = await c.req + .json<{ path?: string; content?: string }>() + .catch(() => ({ path: undefined, content: undefined })); + if (!body.path) return c.json({ error: "Missing path parameter" }, 400); + if (typeof body.content !== "string") + return c.json({ error: "Missing content" }, 400); + + const filePath = toAbsoluteSandboxPath(body.path); + + try { + const apiClient = (c.var.sandbox as any).client; + await sandboxWriteFiles(apiClient, { + sandboxId: session.sandboxId, + files: [{ path: filePath, content: Buffer.from(body.content) }], + }); + + return c.json({ success: true, path: filePath }); + } catch (error) { + console.error("File write error:", error); + return c.json( + { error: "Failed to write file", details: String(error) }, + 500, + ); + } }); export default api; diff --git a/src/routes/cron.ts b/src/routes/cron.ts new file mode 100644 index 0000000..2297553 --- /dev/null +++ b/src/routes/cron.ts @@ -0,0 +1,70 @@ +/** + * Cron routes — periodic background tasks. + */ +import { createRouter, cron } from "@agentuity/runtime"; +import { db } from "../db"; +import { chatSessions } from "../db/schema"; +import { and, eq, or, isNull, lt } from "@agentuity/drizzle"; +import { syncSessionArchive } from "../lib/archive"; + +const api = createRouter(); + +const SYNC_STALE_MS = 5 * 60 * 1000; // 5 minutes + +// POST /api/cron/archive-sync — sync stale active sessions +api.post( + "/archive-sync", + cron("*/5 * * * *", { auth: true }, async (c) => { + const logger = c.var.logger; + const apiClient = (c.var.sandbox as any)?.client; + + if (!apiClient) { + logger.error("[cron] Sandbox client not available in cron context"); + return { checked: 0, synced: 0, failed: 0 }; + } + + const threshold = new Date(Date.now() - SYNC_STALE_MS); + + // Find active sessions with sandboxes that haven't synced recently + const staleSessions = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.status, "active"), + or( + isNull(chatSessions.lastArchivedAt), + lt(chatSessions.lastArchivedAt, threshold), + ), + ), + ); + + let synced = 0; + let failed = 0; + + for (const session of staleSessions) { + if (!session.sandboxId) continue; + + try { + const result = await syncSessionArchive(apiClient, session, logger); + if (result) synced++; + } catch (err) { + failed++; + logger.warn("[cron] Archive sync failed", { + sessionId: session.id, + error: String(err), + }); + } + } + + logger.info("[cron] Archive sync complete", { + checked: staleSessions.length, + synced, + failed, + }); + + return { checked: staleSessions.length, synced, failed }; + }), +); + +export default api; diff --git a/src/routes/session-detail.ts b/src/routes/session-detail.ts index 5129c9d..9155a79 100644 --- a/src/routes/session-detail.ts +++ b/src/routes/session-detail.ts @@ -1,633 +1,1048 @@ /** * Individual session operations. */ -import { createRouter } from '@agentuity/runtime'; -import { db } from '../db'; -import { chatSessions, sandboxSnapshots, skills, sources, userSettings } from '../db/schema'; -import { and, eq } from '@agentuity/drizzle'; -import { randomUUID } from 'node:crypto'; +import { createRouter } from "@agentuity/runtime"; +import { db } from "../db"; import { - createSandbox, - forkSandbox, - generateOpenCodeConfig, - serializeOpenCodeConfig, - getOpencodeClient, - removeOpencodeClient, - destroySandbox, - buildBasicAuthHeader, -} from '../opencode'; -import { sandboxDownloadArchive } from '@agentuity/server'; -import type { SandboxContext } from '../opencode'; + chatSessions, + sandboxSnapshots, + skills, + sources, + userSettings, +} from "../db/schema"; +import { and, eq } from "@agentuity/drizzle"; +import { randomUUID } from "node:crypto"; import { - isSandboxHealthy, - getCachedHealthTimestamp, - setCachedHealthTimestamp, - recordHealthResult, - shouldMarkTerminated, - SANDBOX_STATUS_TTL_MS, -} from '../lib/sandbox-health'; -import { decrypt, encrypt } from '../lib/encryption'; -import { parseMetadata } from '../lib/parse-metadata'; -import { SpanStatusCode } from '@opentelemetry/api'; + createSandbox, + forkSandbox, + generateOpenCodeConfig, + serializeOpenCodeConfig, + getOpencodeClient, + removeOpencodeClient, + destroySandbox, + buildBasicAuthHeader, +} from "../opencode"; +import { sandboxDownloadArchive } from "@agentuity/server"; +import type { SandboxContext } from "../opencode"; +import { + isSandboxHealthy, + getCachedHealthTimestamp, + setCachedHealthTimestamp, + recordHealthResult, + shouldMarkTerminated, + SANDBOX_STATUS_TTL_MS, +} from "../lib/sandbox-health"; +import { decrypt, encrypt } from "../lib/encryption"; +import { parseMetadata } from "../lib/parse-metadata"; +import { + archiveSession, + syncSessionArchive, + getArchivedData, + getArchivedChildSessions, + getArchivedChildSessionData, +} from "../lib/archive"; +import { SpanStatusCode } from "@opentelemetry/api"; /** Extract and decrypt the OpenCode server password from session metadata. */ -function getSessionPassword(session: { metadata?: unknown }): string | undefined { - const meta = (session.metadata ?? {}) as Record; - if (typeof meta.opencodePassword === 'string') { - try { - return decrypt(meta.opencodePassword); - } catch { - // Decryption failed — password may be corrupted or from a different key - } - } - return undefined; +function getSessionPassword(session: { + metadata?: unknown; +}): string | undefined { + const meta = (session.metadata ?? {}) as Record; + if (typeof meta.opencodePassword === "string") { + try { + return decrypt(meta.opencodePassword); + } catch { + // Decryption failed — password may be corrupted or from a different key + } + } + return undefined; } const api = createRouter(); // GET /api/sessions/:id — get session with messages -api.get('/:id', async (c) => { - const user = c.get('user')!; - const [session] = await db - .select() - .from(chatSessions) - .where(and(eq(chatSessions.id, c.req.param('id')), eq(chatSessions.createdBy, user.id))); - if (!session) return c.json({ error: 'Session not found' }, 404); - - c.var.session.metadata.action = 'get-session'; - c.var.session.metadata.sessionDbId = session.id; - - let effectiveSession = session; - // Only health-check 'active' sessions — NOT 'creating' (sandbox is still booting) - if (session.sandboxId && session.sandboxUrl && session.status === 'active') { - const now = Date.now(); - const lastChecked = getCachedHealthTimestamp(session.id) ?? 0; - if (now - lastChecked >= SANDBOX_STATUS_TTL_MS) { - setCachedHealthTimestamp(session.id, now); - const pw = getSessionPassword(session); - const healthy = await isSandboxHealthy(session.sandboxUrl, pw ? buildBasicAuthHeader(pw) : undefined); - recordHealthResult(session.id, healthy); - if (!healthy && shouldMarkTerminated(session.id)) { - const [updated] = await db - .update(chatSessions) - .set({ status: 'terminated', updatedAt: new Date() }) - .where(eq(chatSessions.id, session.id)) - .returning(); - effectiveSession = updated ?? { ...session, status: 'terminated' }; - } - } - } - - // Fetch messages from OpenCode if sandbox is active - let messages: unknown[] = []; - if ( - effectiveSession.status !== 'terminated' && - effectiveSession.sandboxId && - effectiveSession.sandboxUrl && - effectiveSession.opencodeSessionId - ) { - try { - const client = getOpencodeClient(effectiveSession.sandboxId, effectiveSession.sandboxUrl, getSessionPassword(effectiveSession)); - const result = await client.session.messages({ path: { id: effectiveSession.opencodeSessionId } }); - messages = (result as any)?.data || []; - } catch { - // Sandbox may be down — return session without messages - } - } - - return c.json({ ...effectiveSession, messages }); +api.get("/:id", async (c) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!session) return c.json({ error: "Session not found" }, 404); + + c.var.session.metadata.action = "get-session"; + c.var.session.metadata.sessionDbId = session.id; + + let effectiveSession = session; + // Only health-check 'active' sessions — NOT 'creating' (sandbox is still booting) + if (session.sandboxId && session.sandboxUrl && session.status === "active") { + const now = Date.now(); + const lastChecked = getCachedHealthTimestamp(session.id) ?? 0; + if (now - lastChecked >= SANDBOX_STATUS_TTL_MS) { + setCachedHealthTimestamp(session.id, now); + const pw = getSessionPassword(session); + const healthy = await isSandboxHealthy( + session.sandboxUrl, + pw ? buildBasicAuthHeader(pw) : undefined, + ); + recordHealthResult(session.id, healthy); + if (!healthy && shouldMarkTerminated(session.id)) { + // Best-effort sync before marking terminated + try { + const syncClient = (c.var.sandbox as any).client; + await syncSessionArchive(syncClient, session, c.var.logger); + } catch { + // Sandbox likely already gone — that's fine + } + + const [updated] = await db + .update(chatSessions) + .set({ status: "terminated", updatedAt: new Date() }) + .where(eq(chatSessions.id, session.id)) + .returning(); + effectiveSession = updated ?? { ...session, status: "terminated" }; + } + } + } + + // Fetch messages from OpenCode if sandbox is active + let messages: unknown[] = []; + if ( + effectiveSession.status !== "terminated" && + effectiveSession.sandboxId && + effectiveSession.sandboxUrl && + effectiveSession.opencodeSessionId + ) { + try { + const client = getOpencodeClient( + effectiveSession.sandboxId, + effectiveSession.sandboxUrl, + getSessionPassword(effectiveSession), + ); + const result = await client.session.messages({ + path: { id: effectiveSession.opencodeSessionId }, + }); + messages = (result as any)?.data || []; + } catch { + // Sandbox may be down — return session without messages + } + } + + return c.json({ ...effectiveSession, messages }); }); // PATCH /api/sessions/:id — update session -api.patch('/:id', async (c) => { - const user = c.get('user')!; - c.var.session.metadata.action = 'update-session'; - c.var.session.metadata.sessionDbId = c.req.param('id'); - - const body = await c.req.json<{ - title?: string; - status?: string; - flagged?: boolean; - agent?: string; - model?: string; - }>(); - const [session] = await db - .update(chatSessions) - .set({ ...body, updatedAt: new Date() }) - .where(and(eq(chatSessions.id, c.req.param('id')), eq(chatSessions.createdBy, user.id))) - .returning(); - if (!session) return c.json({ error: 'Session not found' }, 404); - return c.json(session); +api.patch("/:id", async (c) => { + const user = c.get("user")!; + c.var.session.metadata.action = "update-session"; + c.var.session.metadata.sessionDbId = c.req.param("id"); + + const body = await c.req.json<{ + title?: string; + status?: string; + flagged?: boolean; + agent?: string; + model?: string; + }>(); + const [session] = await db + .update(chatSessions) + .set({ ...body, updatedAt: new Date() }) + .where( + and( + eq(chatSessions.id, c.req.param("id")), + eq(chatSessions.createdBy, user.id), + ), + ) + .returning(); + if (!session) return c.json({ error: "Session not found" }, 404); + return c.json(session); }); // POST /api/sessions/:id/retry — retry establishing OpenCode session -api.post('/:id/retry', async (c) => { - const user = c.get('user')!; - const [session] = await db - .select() - .from(chatSessions) - .where(and(eq(chatSessions.id, c.req.param('id')), eq(chatSessions.createdBy, user.id))); - if (!session) return c.json({ error: 'Session not found' }, 404); - - c.var.session.metadata.action = 'retry-session'; - c.var.session.metadata.sessionDbId = session.id; - - if (session.opencodeSessionId) { - return c.json({ error: 'Session already has an OpenCode session', session }, 400); - } - - if (!session.sandboxId || !session.sandboxUrl) { - return c.json({ error: 'Session has no sandbox — cannot retry' }, 400); - } - - const client = getOpencodeClient(session.sandboxId, session.sandboxUrl, getSessionPassword(session)); - let opencodeSessionId: string | null = null; - - for (let attempt = 1; attempt <= 5; attempt++) { - try { - const opencodeSession = await client.session.create({ body: {} }); - opencodeSessionId = - (opencodeSession as any)?.data?.id || - (opencodeSession as any)?.id || - (opencodeSession as any)?.sessionId || - (opencodeSession as any)?.session?.id || - null; - if (opencodeSessionId) break; - c.var.logger.warn(`retry session.create attempt ${attempt}: no session ID`, { - response: JSON.stringify(opencodeSession).slice(0, 500), - }); - } catch (err) { - c.var.logger.warn(`retry session.create attempt ${attempt} failed`, { error: String(err) }); - } - if (attempt < 5) await new Promise(r => setTimeout(r, 2000)); - } - - if (!opencodeSessionId) { - return c.json({ error: 'Failed to create OpenCode session after retries' }, 503); - } - - const [updated] = await db - .update(chatSessions) - .set({ opencodeSessionId, status: 'active', updatedAt: new Date() }) - .where(eq(chatSessions.id, session.id)) - .returning(); - - return c.json(updated); +api.post("/:id/retry", async (c) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!session) return c.json({ error: "Session not found" }, 404); + + c.var.session.metadata.action = "retry-session"; + c.var.session.metadata.sessionDbId = session.id; + + if (session.opencodeSessionId) { + return c.json( + { error: "Session already has an OpenCode session", session }, + 400, + ); + } + + if (!session.sandboxId || !session.sandboxUrl) { + return c.json({ error: "Session has no sandbox — cannot retry" }, 400); + } + + const client = getOpencodeClient( + session.sandboxId, + session.sandboxUrl, + getSessionPassword(session), + ); + let opencodeSessionId: string | null = null; + + for (let attempt = 1; attempt <= 5; attempt++) { + try { + const opencodeSession = await client.session.create({ body: {} }); + opencodeSessionId = + (opencodeSession as any)?.data?.id || + (opencodeSession as any)?.id || + (opencodeSession as any)?.sessionId || + (opencodeSession as any)?.session?.id || + null; + if (opencodeSessionId) break; + c.var.logger.warn( + `retry session.create attempt ${attempt}: no session ID`, + { + response: JSON.stringify(opencodeSession).slice(0, 500), + }, + ); + } catch (err) { + c.var.logger.warn(`retry session.create attempt ${attempt} failed`, { + error: String(err), + }); + } + if (attempt < 5) await new Promise((r) => setTimeout(r, 2000)); + } + + if (!opencodeSessionId) { + return c.json( + { error: "Failed to create OpenCode session after retries" }, + 503, + ); + } + + const [updated] = await db + .update(chatSessions) + .set({ opencodeSessionId, status: "active", updatedAt: new Date() }) + .where(eq(chatSessions.id, session.id)) + .returning(); + + return c.json(updated); }); // POST /api/sessions/:id/fork — snapshot-based fork with full state preservation -api.post('/:id/fork', async (c) => { - const user = c.get('user')!; - const [sourceSession] = await db - .select() - .from(chatSessions) - .where(and(eq(chatSessions.id, c.req.param('id')), eq(chatSessions.createdBy, user.id))); - if (!sourceSession) return c.json({ error: 'Session not found' }, 404); - - c.var.session.metadata.action = 'fork-session'; - c.var.session.metadata.sessionDbId = sourceSession.id; - c.var.session.metadata.userId = user.id; - - // Validate source session has an active sandbox we can snapshot - if (!sourceSession.sandboxId || !sourceSession.sandboxUrl) { - return c.json({ error: 'Source session has no sandbox to fork from' }, 400); - } - if (!sourceSession.opencodeSessionId) { - return c.json({ error: 'Source session has no OpenCode session to fork' }, 400); - } - - const metadata = parseMetadata(sourceSession); - const repoUrl = typeof metadata.repoUrl === 'string' ? metadata.repoUrl : undefined; - const branch = typeof metadata.branch === 'string' ? metadata.branch : undefined; - const baseTitle = sourceSession.title || 'Untitled Session'; - const title = `Fork of ${baseTitle}`; - - // Derive workDir from repoUrl (same logic as createSandbox) - const repoName = repoUrl ? repoUrl.split('/').pop()?.replace('.git', '') || 'project' : 'project'; - const workDir = `/home/agentuity/${repoName}`; - - // Client-side UUID for idempotent INSERT (see sessions.ts for explanation) - const forkSessionId = randomUUID(); - const forkInsertedRows = await db - .insert(chatSessions) - .values({ - id: forkSessionId, - workspaceId: sourceSession.workspaceId, - createdBy: sourceSession.createdBy || user.id, - status: 'creating', - title, - agent: sourceSession.agent ?? null, - model: sourceSession.model ?? null, - forkedFromSessionId: sourceSession.id, - metadata: { ...metadata, repoUrl, branch }, - }) - .onConflictDoNothing() - .returning(); - - let session = forkInsertedRows[0]; - if (!session) { - const [existing] = await db - .select() - .from(chatSessions) - .where(eq(chatSessions.id, forkSessionId)) - .limit(1); - session = existing; - } - - const sandbox = c.var.sandbox; - const logger = c.var.logger; - const tracer = c.var.tracer; - - // Update thread context for fork lineage - const { updateThreadContext } = await import('../lib/thread-context'); - await updateThreadContext(c.var.thread, { - sessionDbId: session!.id, - title, - model: sourceSession.model ?? null, - agent: sourceSession.agent ?? null, - workspaceId: sourceSession.workspaceId, - userId: user.id, - forkedFromSessionId: sourceSession.id, - status: 'creating', - createdAt: new Date().toISOString(), - lastActivityAt: new Date().toISOString(), - }); - - // Tag thread metadata - { - const existingMeta = await c.var.thread.getMetadata(); - await c.var.thread.setMetadata({ - ...existingMeta, - userId: user.id, - sessionDbId: session!.id, - forkedFrom: sourceSession.id, - }); - } - - // Async: snapshot → new sandbox → OpenCode fork → update DB - const sourceOpencodeSessionId = sourceSession.opencodeSessionId; - (async () => { - await tracer.startActiveSpan('session.fork', async (parentSpan) => { - parentSpan.setAttribute('sourceSessionId', sourceSession.id); - parentSpan.setAttribute('forkSessionId', session!.id); - let snapshotId: string | undefined; - try { - const sandboxCtx: SandboxContext = { - sandbox: sandbox as any, - logger, - }; - - // 1. Get GitHub token for the new sandbox - let githubToken: string | undefined; - try { - const [settings] = await db - .select() - .from(userSettings) - .where(eq(userSettings.userId, user.id)); - if (settings?.githubPat) { - githubToken = decrypt(settings.githubPat); - } - } catch { - logger.warn('Failed to load GitHub token for fork sandbox', { userId: user.id }); - } - - // 2. Snapshot source sandbox → create new sandbox from snapshot - const forkResult = await tracer.startActiveSpan('session.fork.snapshot', async (snap) => { - snap.setAttribute('sourceSandboxId', sourceSession.sandboxId!); - const result = await forkSandbox(sandboxCtx, { - sourceSandboxId: sourceSession.sandboxId!, - workDir, - githubToken, - }); - snap.setStatus({ code: SpanStatusCode.OK }); - return result; - }); - snapshotId = forkResult.snapshotId; - - // 3. Use OpenCode's fork API to create a new session with full message history - const forkPassword = forkResult.password; - const client = getOpencodeClient(forkResult.sandboxId, forkResult.sandboxUrl, forkPassword); - const opencodeSessionId = await tracer.startActiveSpan('session.fork.opencode', async (oc) => { - let id: string | null = null; - for (let attempt = 1; attempt <= 5; attempt++) { - try { - const forkedSession = await client.session.fork({ - path: { id: sourceOpencodeSessionId }, - body: {}, - }); - id = - (forkedSession as any)?.data?.id || (forkedSession as any)?.id || null; - if (id) break; - logger.warn(`fork session.fork attempt ${attempt}: no session ID returned`); - } catch (err) { - logger.warn(`fork session.fork attempt ${attempt} failed`, { error: err }); - } - if (attempt < 5) await new Promise((r) => setTimeout(r, 2000)); - } - oc.setStatus({ code: SpanStatusCode.OK }); - return id; - }); - - const newStatus = opencodeSessionId ? 'active' : 'creating'; - - const encryptedForkPassword = encrypt(forkPassword); - await db - .update(chatSessions) - .set({ - sandboxId: forkResult.sandboxId, - sandboxUrl: forkResult.sandboxUrl, - opencodeSessionId, - status: newStatus, - updatedAt: new Date(), - metadata: { ...metadata, repoUrl, branch, opencodePassword: encryptedForkPassword }, - }) - .where(eq(chatSessions.id, session!.id)); - - // 4. Clean up snapshot (it was only needed for creating the new sandbox) - try { - await sandbox.snapshot.delete(snapshotId); - logger.info(`Cleaned up fork snapshot: ${snapshotId}`); - } catch { - logger.warn(`Failed to clean up fork snapshot: ${snapshotId}`); - } - - parentSpan.setStatus({ code: SpanStatusCode.OK }); - } catch (error) { - parentSpan.setStatus({ code: SpanStatusCode.ERROR, message: String(error) }); - logger.error('Fork failed', { error }); - await db - .update(chatSessions) - .set({ - status: 'error', - metadata: { ...metadata, repoUrl, branch, error: String(error) }, - updatedAt: new Date(), - }) - .where(eq(chatSessions.id, session!.id)); - // Try to clean up snapshot on failure too - if (snapshotId) { - try { - await sandbox.snapshot.delete(snapshotId); - } catch { - // Ignore - } - } - } - }); - })(); - - return c.json(session, 201); +api.post("/:id/fork", async (c) => { + const user = c.get("user")!; + const [sourceSession] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!sourceSession) return c.json({ error: "Session not found" }, 404); + + c.var.session.metadata.action = "fork-session"; + c.var.session.metadata.sessionDbId = sourceSession.id; + c.var.session.metadata.userId = user.id; + + // Validate source session has an active sandbox we can snapshot + if (!sourceSession.sandboxId || !sourceSession.sandboxUrl) { + return c.json({ error: "Source session has no sandbox to fork from" }, 400); + } + if (!sourceSession.opencodeSessionId) { + return c.json( + { error: "Source session has no OpenCode session to fork" }, + 400, + ); + } + + const metadata = parseMetadata(sourceSession); + const repoUrl = + typeof metadata.repoUrl === "string" ? metadata.repoUrl : undefined; + const branch = + typeof metadata.branch === "string" ? metadata.branch : undefined; + const baseTitle = sourceSession.title || "Untitled Session"; + const title = `Fork of ${baseTitle}`; + + // Derive workDir from repoUrl (same logic as createSandbox) + const repoName = repoUrl + ? repoUrl.split("/").pop()?.replace(".git", "") || "project" + : "project"; + const workDir = `/home/agentuity/${repoName}`; + + // Client-side UUID for idempotent INSERT (see sessions.ts for explanation) + const forkSessionId = randomUUID(); + const forkInsertedRows = await db + .insert(chatSessions) + .values({ + id: forkSessionId, + workspaceId: sourceSession.workspaceId, + createdBy: sourceSession.createdBy || user.id, + status: "creating", + title, + agent: sourceSession.agent ?? null, + model: sourceSession.model ?? null, + forkedFromSessionId: sourceSession.id, + metadata: { ...metadata, repoUrl, branch }, + }) + .onConflictDoNothing() + .returning(); + + let session = forkInsertedRows[0]; + if (!session) { + const [existing] = await db + .select() + .from(chatSessions) + .where(eq(chatSessions.id, forkSessionId)) + .limit(1); + session = existing; + } + + const sandbox = c.var.sandbox; + const logger = c.var.logger; + const tracer = c.var.tracer; + + // Update thread context for fork lineage + const { updateThreadContext } = await import("../lib/thread-context"); + await updateThreadContext(c.var.thread, { + sessionDbId: session!.id, + title, + model: sourceSession.model ?? null, + agent: sourceSession.agent ?? null, + workspaceId: sourceSession.workspaceId, + userId: user.id, + forkedFromSessionId: sourceSession.id, + status: "creating", + createdAt: new Date().toISOString(), + lastActivityAt: new Date().toISOString(), + }); + + // Tag thread metadata + { + const existingMeta = await c.var.thread.getMetadata(); + await c.var.thread.setMetadata({ + ...existingMeta, + userId: user.id, + sessionDbId: session!.id, + forkedFrom: sourceSession.id, + }); + } + + // Async: snapshot → new sandbox → OpenCode fork → update DB + const sourceOpencodeSessionId = sourceSession.opencodeSessionId; + (async () => { + await tracer.startActiveSpan("session.fork", async (parentSpan) => { + parentSpan.setAttribute("sourceSessionId", sourceSession.id); + parentSpan.setAttribute("forkSessionId", session!.id); + let snapshotId: string | undefined; + try { + const sandboxCtx: SandboxContext = { + sandbox: sandbox as any, + logger, + }; + + // 1. Get GitHub token for the new sandbox + let githubToken: string | undefined; + try { + const [settings] = await db + .select() + .from(userSettings) + .where(eq(userSettings.userId, user.id)); + if (settings?.githubPat) { + githubToken = decrypt(settings.githubPat); + } + } catch { + logger.warn("Failed to load GitHub token for fork sandbox", { + userId: user.id, + }); + } + + // 2. Snapshot source sandbox → create new sandbox from snapshot + const forkResult = await tracer.startActiveSpan( + "session.fork.snapshot", + async (snap) => { + snap.setAttribute("sourceSandboxId", sourceSession.sandboxId!); + const result = await forkSandbox(sandboxCtx, { + sourceSandboxId: sourceSession.sandboxId!, + workDir, + githubToken, + }); + snap.setStatus({ code: SpanStatusCode.OK }); + return result; + }, + ); + snapshotId = forkResult.snapshotId; + + // 3. Use OpenCode's fork API to create a new session with full message history + const forkPassword = forkResult.password; + const client = getOpencodeClient( + forkResult.sandboxId, + forkResult.sandboxUrl, + forkPassword, + ); + const opencodeSessionId = await tracer.startActiveSpan( + "session.fork.opencode", + async (oc) => { + let id: string | null = null; + for (let attempt = 1; attempt <= 5; attempt++) { + try { + const forkedSession = await client.session.fork({ + path: { id: sourceOpencodeSessionId }, + body: {}, + }); + id = + (forkedSession as any)?.data?.id || + (forkedSession as any)?.id || + null; + if (id) break; + logger.warn( + `fork session.fork attempt ${attempt}: no session ID returned`, + ); + } catch (err) { + logger.warn(`fork session.fork attempt ${attempt} failed`, { + error: err, + }); + } + if (attempt < 5) await new Promise((r) => setTimeout(r, 2000)); + } + oc.setStatus({ code: SpanStatusCode.OK }); + return id; + }, + ); + + const newStatus = opencodeSessionId ? "active" : "creating"; + + const encryptedForkPassword = encrypt(forkPassword); + await db + .update(chatSessions) + .set({ + sandboxId: forkResult.sandboxId, + sandboxUrl: forkResult.sandboxUrl, + opencodeSessionId, + status: newStatus, + updatedAt: new Date(), + metadata: { + ...metadata, + repoUrl, + branch, + opencodePassword: encryptedForkPassword, + }, + }) + .where(eq(chatSessions.id, session!.id)); + + // 4. Clean up snapshot (it was only needed for creating the new sandbox) + try { + await sandbox.snapshot.delete(snapshotId); + logger.info(`Cleaned up fork snapshot: ${snapshotId}`); + } catch { + logger.warn(`Failed to clean up fork snapshot: ${snapshotId}`); + } + + parentSpan.setStatus({ code: SpanStatusCode.OK }); + } catch (error) { + parentSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: String(error), + }); + logger.error("Fork failed", { error }); + await db + .update(chatSessions) + .set({ + status: "error", + metadata: { ...metadata, repoUrl, branch, error: String(error) }, + updatedAt: new Date(), + }) + .where(eq(chatSessions.id, session!.id)); + // Try to clean up snapshot on failure too + if (snapshotId) { + try { + await sandbox.snapshot.delete(snapshotId); + } catch { + // Ignore + } + } + } + }); + })(); + + return c.json(session, 201); }); // POST /api/sessions/:id/snapshot — create a reusable snapshot from a session's sandbox -api.post('/:id/snapshot', async (c) => { - const user = c.get('user')!; - const [session] = await db - .select() - .from(chatSessions) - .where(and(eq(chatSessions.id, c.req.param('id')), eq(chatSessions.createdBy, user.id))); - if (!session) return c.json({ error: 'Session not found' }, 404); - - c.var.session.metadata.action = 'create-snapshot'; - c.var.session.metadata.sessionDbId = session.id; - - if (!session.sandboxId) { - return c.json({ error: 'Session has no active sandbox to snapshot' }, 400); - } - - const body = await c.req.json<{ name: string; description?: string }>().catch(() => ({ name: '', description: undefined as string | undefined })); - if (!body.name?.trim()) { - return c.json({ error: 'Snapshot name is required' }, 400); - } - - try { - // Create the sandbox snapshot via Agentuity API - const snapshotName = body.name.trim(); - const snapshotDesc = body.description?.trim(); - const snapshot = await c.var.sandbox.snapshot.create(session.sandboxId, { - name: snapshotName, - description: snapshotDesc, - }); - - // Gather session metadata for recreating later - const metadata = parseMetadata(session); - const repoUrl = typeof metadata.repoUrl === 'string' ? metadata.repoUrl : undefined; - const branch = typeof metadata.branch === 'string' ? metadata.branch : undefined; - const repoName = repoUrl ? repoUrl.split('/').pop()?.replace('.git', '') || 'project' : 'project'; - const workDir = `/home/agentuity/${repoName}`; - - // Store in DB - const [snapshotRecord] = await db - .insert(sandboxSnapshots) - .values({ - workspaceId: session.workspaceId, - createdBy: user.id, - name: snapshotName, - description: snapshotDesc || null, - snapshotId: snapshot.snapshotId, - sourceSessionId: session.id, - metadata: { repoUrl, branch, workDir }, - }) - .returning(); - - c.var.logger.info(`Snapshot created: ${snapshot.snapshotId} for session ${session.id}`); - return c.json(snapshotRecord, 201); - } catch (error) { - c.var.logger.error('Failed to create snapshot', { error, sessionId: session.id }); - return c.json({ error: 'Failed to create snapshot' }, 500); - } +api.post("/:id/snapshot", async (c) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!session) return c.json({ error: "Session not found" }, 404); + + c.var.session.metadata.action = "create-snapshot"; + c.var.session.metadata.sessionDbId = session.id; + + if (!session.sandboxId) { + return c.json({ error: "Session has no active sandbox to snapshot" }, 400); + } + + const body = await c.req + .json<{ name: string; description?: string }>() + .catch(() => ({ name: "", description: undefined as string | undefined })); + if (!body.name?.trim()) { + return c.json({ error: "Snapshot name is required" }, 400); + } + + try { + // Create the sandbox snapshot via Agentuity API + const snapshotName = body.name.trim(); + const snapshotDesc = body.description?.trim(); + const snapshot = await c.var.sandbox.snapshot.create(session.sandboxId, { + name: snapshotName, + description: snapshotDesc, + }); + + // Gather session metadata for recreating later + const metadata = parseMetadata(session); + const repoUrl = + typeof metadata.repoUrl === "string" ? metadata.repoUrl : undefined; + const branch = + typeof metadata.branch === "string" ? metadata.branch : undefined; + const repoName = repoUrl + ? repoUrl.split("/").pop()?.replace(".git", "") || "project" + : "project"; + const workDir = `/home/agentuity/${repoName}`; + + // Store in DB + const [snapshotRecord] = await db + .insert(sandboxSnapshots) + .values({ + workspaceId: session.workspaceId, + createdBy: user.id, + name: snapshotName, + description: snapshotDesc || null, + snapshotId: snapshot.snapshotId, + sourceSessionId: session.id, + metadata: { repoUrl, branch, workDir }, + }) + .returning(); + + c.var.logger.info( + `Snapshot created: ${snapshot.snapshotId} for session ${session.id}`, + ); + return c.json(snapshotRecord, 201); + } catch (error) { + c.var.logger.error("Failed to create snapshot", { + error, + sessionId: session.id, + }); + return c.json({ error: "Failed to create snapshot" }, 500); + } +}); + +// GET /api/sessions/:id/archive — get archived session data in frontend-ready format +api.get("/:id/archive", async (c) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!session) return c.json({ error: "Session not found" }, 404); + + c.var.session.metadata.action = "get-archive"; + c.var.session.metadata.sessionDbId = session.id; + + if (session.archiveStatus === "archiving") { + return c.json({ error: "Archive in progress, try again shortly" }, 503); + } + if (session.archiveStatus === "failed") { + return c.json({ error: "Archive failed — data unavailable" }, 410); + } + if (session.archiveStatus !== "archived") { + return c.json({ error: "Session is not archived" }, 400); + } + + const archiveData = await getArchivedData(session.id); + if (!archiveData) { + return c.json({ error: "No archived data found" }, 404); + } + + // Transform archived data → frontend-ready Message/Part/Todo format + const messages: Record[] = []; + const parts: Record[] = []; + const todos: Record[] = []; + + for (const sessionData of archiveData.sessions) { + const opencodeSessionId = sessionData.session.opencodeSessionId; + + // Build archivedMessageId → opencodeMessageId lookup + const msgIdMap = new Map(); + for (const msg of sessionData.messages) { + msgIdMap.set(msg.id, msg.opencodeMessageId); + } + + for (const msg of sessionData.messages) { + const data = (msg.data ?? {}) as Record; + messages.push({ + ...data, + id: msg.opencodeMessageId, + sessionID: opencodeSessionId, + }); + } + + for (const part of sessionData.parts) { + const data = (part.data ?? {}) as Record; + const parentMsgId = msgIdMap.get(part.archivedMessageId) ?? ""; + parts.push({ + ...data, + id: part.opencodePartId, + sessionID: opencodeSessionId, + messageID: parentMsgId, + }); + } + + for (const todo of sessionData.todos) { + todos.push({ + id: todo.id, + content: todo.content, + status: todo.status, + priority: todo.priority, + }); + } + } + + return c.json({ + messages, + parts, + todos, + hierarchy: archiveData.hierarchy, + stats: archiveData.stats, + archivedAt: archiveData.sessions[0]?.session.createdAt ?? null, + }); +}); + +// GET /api/sessions/:id/archive/children — list archived child sessions +api.get("/:id/archive/children", async (c) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!session) return c.json({ error: "Session not found" }, 404); + + c.var.session.metadata.action = "get-archive-children"; + c.var.session.metadata.sessionDbId = session.id; + + if (session.archiveStatus === "archiving") { + return c.json({ error: "Archive in progress, try again shortly" }, 503); + } + if (session.archiveStatus === "failed") { + return c.json({ error: "Archive failed — data unavailable" }, 410); + } + if (session.archiveStatus !== "archived") { + return c.json({ error: "Session is not archived" }, 400); + } + + const children = await getArchivedChildSessions(session.id); + return c.json({ children }); +}); + +// GET /api/sessions/:id/archive/children/:childId — get full child session data +api.get("/:id/archive/children/:childId", async (c) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!session) return c.json({ error: "Session not found" }, 404); + + c.var.session.metadata.action = "get-archive-child-detail"; + c.var.session.metadata.sessionDbId = session.id; + + if (session.archiveStatus === "archiving") { + return c.json({ error: "Archive in progress, try again shortly" }, 503); + } + if (session.archiveStatus === "failed") { + return c.json({ error: "Archive failed — data unavailable" }, 410); + } + if (session.archiveStatus !== "archived") { + return c.json({ error: "Session is not archived" }, 400); + } + + const childData = await getArchivedChildSessionData( + session.id, + c.req.param("childId"), + ); + if (!childData) { + return c.json({ error: "Child session not found" }, 404); + } + + return c.json(childData); +}); + +// GET /api/sessions/:id/children — list live child sessions from running sandbox SQLite +api.get("/:id/children", async (c) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!session) return c.json({ error: "Session not found" }, 404); + + c.var.session.metadata.action = "get-live-children"; + c.var.session.metadata.sessionDbId = session.id; + + if (!session.sandboxId) { + return c.json({ error: "Session has no active sandbox" }, 400); + } + + // Download the SQLite DB from the sandbox once, extract all child data, then clean up + const { sandboxReadFile } = await import("@agentuity/server"); + const { OpenCodeDBReader } = await import("../lib/sqlite"); + const { unlink } = await import("node:fs/promises"); + + const OPENCODE_DB_PATH = "/home/agentuity/.local/share/opencode/opencode.db"; + const tmpPath = `/tmp/live-children-${session.id}-${Date.now()}.db`; + + try { + const apiClient = (c.var.sandbox as any).client; + const stream = await sandboxReadFile(apiClient, { + sandboxId: session.sandboxId, + path: OPENCODE_DB_PATH, + }); + + await Bun.write(tmpPath, new Response(stream)); + + const reader = new OpenCodeDBReader({ dbPath: tmpPath }); + if (!reader.open()) { + c.var.logger.warn("Failed to open sandbox SQLite", { + sessionId: session.id, + }); + return c.json({ children: [], warning: "Sandbox database unavailable" }); + } + + try { + // Find all sessions with a parent_id (i.e. child sessions) + const allSessions = reader.getAllSessions(); + const childSessions = allSessions.filter((s) => s.parentId); + + const children = childSessions.map((child) => { + const cost = reader.getSessionCost(child.id); + return { + id: child.id, + opencodeSessionId: child.id, + parentSessionId: child.parentId, + title: child.title, + totalCost: cost.totalCost, + totalTokens: cost.totalTokens, + messageCount: cost.messageCount, + timeCreated: child.timeCreated, + metadata: { + slug: child.slug, + directory: child.directory, + version: child.version, + }, + }; + }); + + return c.json({ children }); + } finally { + reader.close(); + } + } catch (error) { + c.var.logger.debug( + "Live child sessions unavailable (sandbox may not have OpenCode SQLite DB)", + { + sessionId: session.id, + }, + ); + return c.json({ children: [], warning: "Live child sessions unavailable" }); + } finally { + try { + await unlink(tmpPath); + } catch { + // Best-effort cleanup + } + try { + await unlink(`${tmpPath}-wal`); + } catch { + // Best-effort cleanup + } + try { + await unlink(`${tmpPath}-shm`); + } catch { + // Best-effort cleanup + } + } }); -// DELETE /api/sessions/:id — delete session and destroy sandbox -api.delete('/:id', async (c) => { - const user = c.get('user')!; - const [session] = await db - .select() - .from(chatSessions) - .where(and(eq(chatSessions.id, c.req.param('id')), eq(chatSessions.createdBy, user.id))); - if (!session) return c.json({ error: 'Session not found' }, 404); - - c.var.session.metadata.action = 'delete-session'; - c.var.session.metadata.sessionDbId = session.id; - - // Destroy sandbox - if (session.sandboxId) { - const sandboxCtx: SandboxContext = { - sandbox: c.var.sandbox, - logger: c.var.logger, - }; - await destroySandbox(sandboxCtx, session.sandboxId); - removeOpencodeClient(session.sandboxId); - } - - // Delete from DB - await db.delete(chatSessions).where(eq(chatSessions.id, session.id)); - return c.json({ success: true }); +// DELETE /api/sessions/:id — destroy sandbox, soft-delete (archiving handled by proactive sync) +api.delete("/:id", async (c) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!session) return c.json({ error: "Session not found" }, 404); + + c.var.session.metadata.action = "delete-session"; + c.var.session.metadata.sessionDbId = session.id; + + // Destroy sandbox + if (session.sandboxId) { + const sandboxCtx: SandboxContext = { + sandbox: c.var.sandbox, + logger: c.var.logger, + }; + await destroySandbox(sandboxCtx, session.sandboxId); + removeOpencodeClient(session.sandboxId); + } + + // Soft-delete: mark as 'deleted' but keep the row for archived data access + await db + .update(chatSessions) + .set({ status: "deleted", updatedAt: new Date() }) + .where(eq(chatSessions.id, session.id)); + return c.json({ success: true }); }); // GET /api/sessions/:id/password — decrypt and return the OpenCode server password -api.get('/:id/password', async (c) => { - const user = c.get('user')!; - const [session] = await db - .select() - .from(chatSessions) - .where(and(eq(chatSessions.id, c.req.param('id')), eq(chatSessions.createdBy, user.id))); - if (!session) return c.json({ error: 'Session not found' }, 404); - - const password = getSessionPassword(session); - return c.json({ password: password ?? null }); +api.get("/:id/password", async (c) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!session) return c.json({ error: "Session not found" }, 404); + + const password = getSessionPassword(session); + return c.json({ password: password ?? null }); }); // POST /api/sessions/:id/share — create a public share URL via durable stream -api.post('/:id/share', async (c) => { - const user = c.get('user')!; - const [session] = await db - .select() - .from(chatSessions) - .where(and(eq(chatSessions.id, c.req.param('id')), eq(chatSessions.createdBy, user.id))); - if (!session) return c.json({ error: 'Session not found' }, 404); - - c.var.session.metadata.action = 'share-session'; - c.var.session.metadata.sessionDbId = session.id; - - // Fetch messages from OpenCode if sandbox is active - let messages: unknown[] = []; - if (session.sandboxId && session.sandboxUrl && session.opencodeSessionId) { - try { - const client = getOpencodeClient(session.sandboxId, session.sandboxUrl, getSessionPassword(session)); - const result = await client.session.messages({ path: { id: session.opencodeSessionId } }); - messages = (result as any)?.data || []; - } catch { - // Sandbox may be down - } - } - - if (messages.length === 0) { - return c.json({ error: 'No messages to share' }, 400); - } - - // Check for sensitive data patterns before sharing publicly - const sensitivePatterns = [ - /\bapi[_-]?key\b/i, - /\bpassword\b/i, - /\bsecret\b/i, - /\btoken\b/i, - /\bcredentials?\b/i, - /\bbearer\b/i, - /sk-[a-zA-Z0-9]{20,}/, // OpenAI-style keys - /\bAIza[a-zA-Z0-9_-]{35}\b/, // Google API keys - /AKIA[0-9A-Z]{16}/, // AWS access key IDs - /ghp_[a-zA-Z0-9]{36}/, // GitHub personal access tokens - /gho_[a-zA-Z0-9]{36}/, // GitHub OAuth tokens - /github_pat_[a-zA-Z0-9_]{22,}/, // GitHub fine-grained PATs - /xox[bpras]-[a-zA-Z0-9-]+/, // Slack tokens - /-----BEGIN (RSA |EC |DSA )?PRIVATE KEY-----/, // PEM private keys - ]; - const messagesStr = JSON.stringify(messages); - const hasSensitive = sensitivePatterns.some((p) => p.test(messagesStr)); - - if (hasSensitive) { - return c.json( - { - error: - 'Session may contain sensitive data (API keys, tokens, passwords). Please review before sharing.', - }, - 400, - ); - } - - return c.var.tracer.startActiveSpan('session.share', async (span) => { - span.setAttribute('sessionDbId', session.id); - span.setAttribute('messageCount', messages.length); - try { - const streamService = c.var.stream; - const shareData = { - session: { - id: session.id, - title: session.title || 'Untitled Session', - agent: session.agent, - model: session.model, - createdAt: session.createdAt, - }, - messages, - sharedAt: new Date().toISOString(), - }; - - const stream = await streamService.create('shared-sessions', { - contentType: 'application/json', - compress: true, - ttl: 2592000, // 30 days - metadata: { - title: session.title || 'Shared Session', - sessionId: session.id, - createdAt: new Date().toISOString(), - }, - }); - - await stream.write(shareData); - await stream.close(); - - // Update thread context with share URL - const { updateThreadContext } = await import('../lib/thread-context'); - await updateThreadContext(c.var.thread, { - shareUrl: stream.url, - lastActivityAt: new Date().toISOString(), - }); - - span.setStatus({ code: SpanStatusCode.OK }); - return c.json({ url: stream.url, id: stream.id }); - } catch (error) { - span.setStatus({ code: SpanStatusCode.ERROR, message: String(error) }); - c.var.logger.error('Failed to create share stream', { error }); - return c.json({ error: 'Failed to create share link' }, 500); - } - }); +api.post("/:id/share", async (c) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!session) return c.json({ error: "Session not found" }, 404); + + c.var.session.metadata.action = "share-session"; + c.var.session.metadata.sessionDbId = session.id; + + // Fetch messages from OpenCode if sandbox is active + let messages: unknown[] = []; + if (session.sandboxId && session.sandboxUrl && session.opencodeSessionId) { + try { + const client = getOpencodeClient( + session.sandboxId, + session.sandboxUrl, + getSessionPassword(session), + ); + const result = await client.session.messages({ + path: { id: session.opencodeSessionId }, + }); + messages = (result as any)?.data || []; + } catch { + // Sandbox may be down + } + } + + if (messages.length === 0) { + return c.json({ error: "No messages to share" }, 400); + } + + // Check for sensitive data patterns before sharing publicly + const sensitivePatterns = [ + /\bapi[_-]?key\b/i, + /\bpassword\b/i, + /\bsecret\b/i, + /\btoken\b/i, + /\bcredentials?\b/i, + /\bbearer\b/i, + /sk-[a-zA-Z0-9]{20,}/, // OpenAI-style keys + /\bAIza[a-zA-Z0-9_-]{35}\b/, // Google API keys + /AKIA[0-9A-Z]{16}/, // AWS access key IDs + /ghp_[a-zA-Z0-9]{36}/, // GitHub personal access tokens + /gho_[a-zA-Z0-9]{36}/, // GitHub OAuth tokens + /github_pat_[a-zA-Z0-9_]{22,}/, // GitHub fine-grained PATs + /xox[bpras]-[a-zA-Z0-9-]+/, // Slack tokens + /-----BEGIN (RSA |EC |DSA )?PRIVATE KEY-----/, // PEM private keys + ]; + const messagesStr = JSON.stringify(messages); + const hasSensitive = sensitivePatterns.some((p) => p.test(messagesStr)); + + if (hasSensitive) { + return c.json( + { + error: + "Session may contain sensitive data (API keys, tokens, passwords). Please review before sharing.", + }, + 400, + ); + } + + return c.var.tracer.startActiveSpan("session.share", async (span) => { + span.setAttribute("sessionDbId", session.id); + span.setAttribute("messageCount", messages.length); + try { + const streamService = c.var.stream; + const shareData = { + session: { + id: session.id, + title: session.title || "Untitled Session", + agent: session.agent, + model: session.model, + createdAt: session.createdAt, + }, + messages, + sharedAt: new Date().toISOString(), + }; + + const stream = await streamService.create("shared-sessions", { + contentType: "application/json", + compress: true, + ttl: 2592000, // 30 days + metadata: { + title: session.title || "Shared Session", + sessionId: session.id, + createdAt: new Date().toISOString(), + }, + }); + + await stream.write(shareData); + await stream.close(); + + // Update thread context with share URL + const { updateThreadContext } = await import("../lib/thread-context"); + await updateThreadContext(c.var.thread, { + shareUrl: stream.url, + lastActivityAt: new Date().toISOString(), + }); + + span.setStatus({ code: SpanStatusCode.OK }); + return c.json({ url: stream.url, id: stream.id }); + } catch (error) { + span.setStatus({ code: SpanStatusCode.ERROR, message: String(error) }); + c.var.logger.error("Failed to create share stream", { error }); + return c.json({ error: "Failed to create share link" }, 500); + } + }); }); // GET /api/sessions/:id/download — download sandbox files as a tar.gz archive -api.get('/:id/download', async (c) => { - const user = c.get('user')!; - const [session] = await db - .select() - .from(chatSessions) - .where(and(eq(chatSessions.id, c.req.param('id')), eq(chatSessions.createdBy, user.id))); - if (!session) return c.json({ error: 'Session not found' }, 404); - - c.var.session.metadata.action = 'download-sandbox'; - c.var.session.metadata.sessionDbId = session.id; - - if (!session.sandboxId) { - return c.json({ error: 'Session has no active sandbox' }, 400); - } - - // Determine the project working directory - const metadata = parseMetadata(session); - const repoUrl = typeof metadata.repoUrl === 'string' ? metadata.repoUrl : undefined; - const repoName = repoUrl ? repoUrl.split('/').pop()?.replace('.git', '') : undefined; - const downloadPath = repoName ? `/home/agentuity/${repoName}` : '/home/agentuity'; - - try { - const apiClient = (c.var.sandbox as any).client; - const archiveStream = await sandboxDownloadArchive(apiClient, { - sandboxId: session.sandboxId, - path: downloadPath, - format: 'tar.gz', - }); - - const sanitizedId = session.sandboxId.replace(/[^a-zA-Z0-9_-]/g, '_'); - c.header('Content-Type', 'application/gzip'); - c.header('Content-Disposition', `attachment; filename="sandbox-${sanitizedId}.tar.gz"`); - - return new Response(archiveStream, { - status: 200, - headers: { - 'Content-Type': 'application/gzip', - 'Content-Disposition': `attachment; filename="sandbox-${sanitizedId}.tar.gz"`, - }, - }); - } catch (error) { - c.var.logger.error('Failed to download sandbox archive', { error, sessionId: session.id }); - return c.json({ error: 'Failed to download sandbox files', details: String(error) }, 500); - } +api.get("/:id/download", async (c) => { + const user = c.get("user")!; + const [session] = await db + .select() + .from(chatSessions) + .where( + and( + eq(chatSessions.id, c.req.param("id")), + eq(chatSessions.createdBy, user.id), + ), + ); + if (!session) return c.json({ error: "Session not found" }, 404); + + c.var.session.metadata.action = "download-sandbox"; + c.var.session.metadata.sessionDbId = session.id; + + if (!session.sandboxId) { + return c.json({ error: "Session has no active sandbox" }, 400); + } + + // Determine the project working directory + const metadata = parseMetadata(session); + const repoUrl = + typeof metadata.repoUrl === "string" ? metadata.repoUrl : undefined; + const repoName = repoUrl + ? repoUrl.split("/").pop()?.replace(".git", "") + : undefined; + const downloadPath = repoName + ? `/home/agentuity/${repoName}` + : "/home/agentuity"; + + try { + const apiClient = (c.var.sandbox as any).client; + const archiveStream = await sandboxDownloadArchive(apiClient, { + sandboxId: session.sandboxId, + path: downloadPath, + format: "tar.gz", + }); + + const sanitizedId = session.sandboxId.replace(/[^a-zA-Z0-9_-]/g, "_"); + c.header("Content-Type", "application/gzip"); + c.header( + "Content-Disposition", + `attachment; filename="sandbox-${sanitizedId}.tar.gz"`, + ); + + return new Response(archiveStream, { + status: 200, + headers: { + "Content-Type": "application/gzip", + "Content-Disposition": `attachment; filename="sandbox-${sanitizedId}.tar.gz"`, + }, + }); + } catch (error) { + c.var.logger.error("Failed to download sandbox archive", { + error, + sessionId: session.id, + }); + return c.json( + { error: "Failed to download sandbox files", details: String(error) }, + 500, + ); + } }); export default api; diff --git a/src/web/components/chat/ArchivedBanner.tsx b/src/web/components/chat/ArchivedBanner.tsx new file mode 100644 index 0000000..b06d741 --- /dev/null +++ b/src/web/components/chat/ArchivedBanner.tsx @@ -0,0 +1,66 @@ +import { Archive, GitFork, Loader2 } from "lucide-react"; +import { Button } from "../ui/button"; + +interface ArchivedBannerProps { + stats?: { + totalCost?: number; + totalMessages?: number; + totalTokens?: number; + sessionCount?: number; + }; + onFork?: () => void; + isForking?: boolean; +} + +export function ArchivedBanner({ + stats, + onFork, + isForking, +}: ArchivedBannerProps) { + return ( +
+
+
+ +
+

+ This session is archived +

+

+ Read-only — the sandbox was terminated +

+
+
+ {onFork && ( + + )} +
+ {stats && (stats.totalCost != null || stats.totalMessages != null) && ( +
+ {stats.totalMessages != null && stats.totalMessages > 0 && ( + {stats.totalMessages} messages + )} + {stats.totalCost != null && stats.totalCost > 0 && ( + ${stats.totalCost.toFixed(4)} + )} + {stats.sessionCount != null && stats.sessionCount > 1 && ( + {stats.sessionCount} sub-sessions + )} +
+ )} +
+ ); +} diff --git a/src/web/components/chat/ChildSessionView.tsx b/src/web/components/chat/ChildSessionView.tsx new file mode 100644 index 0000000..fe97411 --- /dev/null +++ b/src/web/components/chat/ChildSessionView.tsx @@ -0,0 +1,428 @@ +/** + * ChildSessionView — renders a sub-agent's conversation inline within the + * parent chat. Fetches child session messages and renders them with the same + * components used for the main conversation (TextPartView, ToolCallCard, etc.) + * in a visually-distinguished indented container. + */ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Bot, + ChevronDown, + ChevronRight, + DollarSign, + Hash, + Loader2, + MessageSquare, +} from "lucide-react"; +import { Badge } from "../ui/badge"; +import { TextPartView } from "./TextPartView"; +import { ToolCallCard } from "./ToolCallCard"; +import type { + Message, + Part, + ReasoningPart, + ToolPart, +} from "../../types/opencode"; +import type { ChildSessionData } from "../../hooks/useChildSessions"; +import { + Reasoning, + ReasoningContent, + ReasoningTrigger, +} from "../ai-elements/reasoning"; +import { MessageResponse } from "../ai-elements/message"; +import { apiFetch } from "../../lib/api"; + +// --------------------------------------------------------------------------- +// Agent label helper (same as ToolCallCard) +// --------------------------------------------------------------------------- + +function getAgentBadge(agent: string): string { + const normalized = agent.replace("Agentuity Coder ", "").trim(); + const labels: Record = { + Lead: "Lead", + Scout: "Scout", + Builder: "Builder", + Architect: "Architect", + Reviewer: "Reviewer", + Memory: "Memory", + Expert: "Expert", + Runner: "Runner", + Product: "Product", + Monitor: "Monitor", + }; + return labels[normalized] ?? normalized; +} + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface ChildSessionViewProps { + /** The archived child session ID (UUID from archive tables) */ + childSessionId: string; + /** The parent chat session's DB id */ + parentSessionId: string; + /** Agent name/type to display */ + agentName?: string; + /** Description from the tool invocation */ + description?: string; + /** Whether the parent session is archived */ + archived?: boolean; + /** Pre-loaded child data (if available from cache) */ + initialData?: ChildSessionData | null; + /** Callback to fetch child data (from useChildSessions hook) */ + fetchChildData?: (childId: string) => Promise; + /** Live streaming messages for this child session (from useSessionEvents) */ + liveMessages?: Message[]; + /** Callback to get live parts for a message in this child session */ + liveGetParts?: (messageID: string) => Part[]; + /** Live session status for this child (from useSessionEvents) */ + liveStatus?: { type: string }; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function ChildSessionView({ + childSessionId, + parentSessionId, + agentName, + description, + archived = false, + initialData, + fetchChildData, + liveMessages, + liveGetParts, + liveStatus, +}: ChildSessionViewProps) { + const [isExpanded, setIsExpanded] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [data, setData] = useState( + initialData ?? null, + ); + const [error, setError] = useState(null); + + // Fetch child session data when expanded for the first time + useEffect(() => { + if (!isExpanded || data || isLoading) return; + + let isMounted = true; + + const fetchData = async () => { + setIsLoading(true); + setError(null); + + try { + let result: ChildSessionData | null = null; + + if (fetchChildData) { + result = await fetchChildData(childSessionId); + } else { + // Fallback: direct API call + const url = archived + ? `/api/sessions/${parentSessionId}/archive/children/${childSessionId}` + : `/api/sessions/${parentSessionId}/children/${childSessionId}`; + const res = await apiFetch(url); + result = (await res.json()) as ChildSessionData; + } + + if (isMounted) { + setData(result); + if (!result) setError("No data returned"); + } + } catch (err) { + if (isMounted) { + setError( + err instanceof Error ? err.message : "Failed to load child session", + ); + } + } finally { + if (isMounted) setIsLoading(false); + } + }; + + void fetchData(); + + return () => { + isMounted = false; + }; + }, [ + isExpanded, + data, + isLoading, + childSessionId, + parentSessionId, + archived, + fetchChildData, + ]); + + // Determine if we have live streaming data for this child + const hasLiveData = liveMessages !== undefined && liveMessages.length > 0; + const isLive = liveStatus?.type === "busy"; + + // Build messages: merge fetched (archived/historical) + live streaming data. + // Live messages take priority (they're more current). + const messages = useMemo(() => { + const msgMap = new Map(); + + // First, add fetched (historical) messages + if (data?.messages) { + for (const msg of data.messages as Message[]) { + msgMap.set(msg.id, msg); + } + } + + // Then overlay live messages (more recent state wins) + if (liveMessages) { + for (const msg of liveMessages) { + msgMap.set(msg.id, msg); + } + } + + return Array.from(msgMap.values()).sort( + (a, b) => (a.time?.created ?? 0) - (b.time?.created ?? 0), + ); + }, [data?.messages, liveMessages]); + + // Build parts map: merge fetched + live parts + const partsByMessage = useMemo(() => { + const map = new Map(); + + // Add fetched parts + if (data?.parts) { + for (const part of data.parts as Part[]) { + const existing = map.get(part.messageID) ?? []; + existing.push(part); + map.set(part.messageID, existing); + } + } + + return map; + }, [data?.parts]); + + const getPartsForMessage = useCallback( + (messageID: string): Part[] => { + // Live parts take priority (more current streaming state) + if (liveGetParts) { + const liveParts = liveGetParts(messageID); + if (liveParts.length > 0) return liveParts; + } + return partsByMessage.get(messageID) ?? []; + }, + [partsByMessage, liveGetParts], + ); + + // Auto-scroll to bottom when live-streaming new content + const scrollRef = useRef(null); + const scrollTrigger = messages.length + (liveMessages?.length ?? 0); + useEffect(() => { + if (isExpanded && isLive && scrollRef.current && scrollTrigger > 0) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [isExpanded, isLive, scrollTrigger]); + + // Stats + const stats = data?.session; + const displayAgent = agentName + ? getAgentBadge(agentName) + : stats?.title || "Sub-agent"; + + return ( +
+ {/* Collapsed / Expand header */} + + + {/* Expanded content */} + {isExpanded && ( +
+ {isLoading && ( +
+ + Loading sub-agent conversation... +
+ )} + + {error && ( +
+ Failed to load: {error} +
+ )} + + {!hasLiveData && data && messages.length === 0 && !isLoading && ( +
+ No messages in this sub-agent session. +
+ )} + + {messages.length > 0 && ( +
+ {messages.map((message, msgIndex) => { + const parts = getPartsForMessage(message.id); + if (parts.length === 0) return null; + + // Determine if this is the last assistant message being streamed + const isLastAssistant = + isLive && + message.role === "assistant" && + msgIndex === messages.length - 1; + + return ( +
+ {/* Agent/role indicator */} + {message.role === "assistant" && ( +
+ + {(message as { agent?: string }).agent + ? getAgentBadge( + (message as { agent: string }).agent, + ) + : "Assistant"} + + {(message as { cost?: number }).cost != null && + (message as { cost: number }).cost > 0 && ( + + ${(message as { cost: number }).cost.toFixed(4)} + + )} +
+ )} + {message.role === "user" && ( +
+ + User + +
+ )} + + {/* Render parts */} + {parts.map((part) => { + switch (part.type) { + case "text": + return ( + + + + ); + case "reasoning": + return ( + + + + {(part as ReasoningPart).text} + + + ); + case "tool": + return ( + + ); + default: + return null; + } + })} +
+ ); + })} + {/* Live streaming indicator */} + {isLive && ( +
+ + Sub-agent is working... +
+ )} +
+ )} +
+ )} +
+ ); +} diff --git a/src/web/components/chat/ToolCallCard.tsx b/src/web/components/chat/ToolCallCard.tsx index 6d1d944..8f68374 100644 --- a/src/web/components/chat/ToolCallCard.tsx +++ b/src/web/components/chat/ToolCallCard.tsx @@ -1,143 +1,232 @@ -import React, { useMemo, useState } from 'react'; -import type { ToolPart } from '../../types/opencode'; -import { FileDiff as PierreDiff } from '@pierre/diffs/react'; -import { parseDiffFromFile, type DiffLineAnnotation, type SelectedLineRange } from '@pierre/diffs'; -import { Badge } from '../ui/badge'; -import { Button } from '../ui/button'; -import { Check, CheckCircle2, Circle, Copy, Loader2 } from 'lucide-react'; -import { getLangFromPath } from '../../lib/shiki'; -import { parseFileOutput } from '../../lib/file-output'; -import { CodeWithComments } from './CodeWithComments'; -import type { CodeComment } from '../../hooks/useCodeComments'; -import { Commit } from '../ai-elements/commit'; -import { Streamdown } from 'streamdown'; -import { createCodePlugin } from '@streamdown/code'; +import React, { useMemo, useState } from "react"; +import type { ToolPart } from "../../types/opencode"; +import { FileDiff as PierreDiff } from "@pierre/diffs/react"; import { - Tool, - ToolContent, - ToolHeader, - ToolInput, - ToolOutput, -} from '../ai-elements/tool'; -import type { ToolState, ToolStatus } from '../ai-elements/tool'; -import { SourcesView, type SourceItem } from './SourcesView'; + parseDiffFromFile, + type DiffLineAnnotation, + type SelectedLineRange, +} from "@pierre/diffs"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { + Bot, + Check, + CheckCircle2, + ChevronDown, + ChevronRight, + Circle, + Copy, + Loader2, +} from "lucide-react"; +import { getLangFromPath } from "../../lib/shiki"; +import { parseFileOutput } from "../../lib/file-output"; +import { CodeWithComments } from "./CodeWithComments"; +import type { CodeComment } from "../../hooks/useCodeComments"; +import { Commit } from "../ai-elements/commit"; +import { Streamdown } from "streamdown"; +import { createCodePlugin } from "@streamdown/code"; +import { + Tool, + ToolContent, + ToolHeader, + ToolInput, + ToolOutput, +} from "../ai-elements/tool"; +import type { ToolState, ToolStatus } from "../ai-elements/tool"; +import { SourcesView, type SourceItem } from "./SourcesView"; +import { ChildSessionView } from "./ChildSessionView"; const toolCallCodePlugin = createCodePlugin({ - themes: ['github-dark', 'github-light'], + themes: ["github-dark", "github-light"], }); +/** Sub-agent tool names that create child sessions. */ +const SUB_AGENT_TOOLS = new Set(["task", "agentuity_background_task"]); + interface ToolCallCardProps { - part: ToolPart; - onAddComment?: (file: string, selection: SelectedLineRange, comment: string, origin: 'diff' | 'file') => void; - getDiffAnnotations?: (file: string) => DiffLineAnnotation<{ id: string; comment: string }>[]; - getFileComments?: (file: string) => CodeComment[]; - sources?: SourceItem[]; + part: ToolPart; + onAddComment?: ( + file: string, + selection: SelectedLineRange, + comment: string, + origin: "diff" | "file", + ) => void; + getDiffAnnotations?: ( + file: string, + ) => DiffLineAnnotation<{ id: string; comment: string }>[]; + getFileComments?: (file: string) => CodeComment[]; + sources?: SourceItem[]; + /** Parent session ID — needed for sub-agent inspection API calls */ + sessionId?: string; + /** Whether the parent session is archived */ + archived?: boolean; + /** Child sessions list (from useChildSessions) for matching tool calls to child sessions */ + childSessions?: Array<{ + id: string; + opencodeSessionId: string; + parentSessionId: string | null; + title: string | null; + totalCost: number; + totalTokens: number; + messageCount: number; + timeCreated: string | number | null; + metadata?: Record | null; + }>; + /** Callback to fetch full child session data */ + fetchChildData?: (childId: string) => Promise; + /** Get live streaming messages for a child session ID */ + getChildMessages?: ( + childSessionId: string, + ) => import("../../types/opencode").Message[]; + /** Get live streaming parts for a child session + message */ + getChildPartsForMessage?: ( + childSessionId: string, + messageID: string, + ) => import("../../types/opencode").Part[]; + /** Get live session status for a child session */ + getChildStatus?: ( + childSessionId: string, + ) => import("../../types/opencode").SessionStatus; + /** Set of child session IDs that have received live events */ + liveChildSessionIds?: Set; } function getToolDisplayName(tool: string): string { - return tool.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + return tool.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); } // --------------------------------------------------------------------------- // Tool-type detection helpers // --------------------------------------------------------------------------- -function isEditTool(input: Record): input is Record & { +function isEditTool(input: Record): input is Record< + string, + unknown +> & { filePath: string; oldString: string; newString: string; } { return ( - typeof input.filePath === 'string' && - typeof input.oldString === 'string' && - typeof input.newString === 'string' + typeof input.filePath === "string" && + typeof input.oldString === "string" && + typeof input.newString === "string" ); } -function isBashTool(input: Record): input is Record & { +function isBashTool(input: Record): input is Record< + string, + unknown +> & { command: string; } { - return typeof input.command === 'string'; + return typeof input.command === "string"; } -function isWebFetchTool(input: Record): input is Record & { - url: string; +function isWebFetchTool(input: Record): input is Record< + string, + unknown +> & { + url: string; } { - return typeof input.url === 'string'; + return typeof input.url === "string"; } -function isWriteTool(input: Record): input is Record & { +function isWriteTool(input: Record): input is Record< + string, + unknown +> & { filePath: string; content: string; } { return ( - typeof input.filePath === 'string' && - typeof input.content === 'string' && - typeof input.oldString !== 'string' + typeof input.filePath === "string" && + typeof input.content === "string" && + typeof input.oldString !== "string" ); } -function isReadTool(input: Record): input is Record & { +function isReadTool(input: Record): input is Record< + string, + unknown +> & { filePath: string; } { return ( - typeof input.filePath === 'string' && - typeof input.oldString !== 'string' && - typeof input.content !== 'string' && - typeof input.command !== 'string' + typeof input.filePath === "string" && + typeof input.oldString !== "string" && + typeof input.content !== "string" && + typeof input.command !== "string" ); } -function isAgentInvocation(input: Record): input is Record & { - subagent_type: string; - description?: string; - prompt?: string; +function isAgentInvocation(input: Record): input is Record< + string, + unknown +> & { + subagent_type: string; + description?: string; + prompt?: string; } { - return typeof input.subagent_type === 'string'; + return typeof input.subagent_type === "string"; } function isGitCommand(command: string) { - return command.trim().startsWith('git ') || command.includes(' git '); + return command.trim().startsWith("git ") || command.includes(" git "); } -function parseGitFiles(output?: string): Array<{ path: string; status: 'added' | 'modified' | 'deleted' }> { - if (!output) return []; - const files: Array<{ path: string; status: 'added' | 'modified' | 'deleted' }> = []; - const lines = output.split('\n').map((line) => line.trim()).filter(Boolean); - for (const line of lines) { - const match = line.match(/^([MADRCU?!]{1,2})\s+(.+)$/); - if (!match) continue; - const statusToken = match[1] ?? ''; - const path = match[2] ?? ''; - if (!path) continue; - const status: 'added' | 'modified' | 'deleted' = statusToken.includes('A') - ? 'added' - : statusToken.includes('D') - ? 'deleted' - : 'modified'; - files.push({ path, status }); - } - return files; +function parseGitFiles( + output?: string, +): Array<{ path: string; status: "added" | "modified" | "deleted" }> { + if (!output) return []; + const files: Array<{ + path: string; + status: "added" | "modified" | "deleted"; + }> = []; + const lines = output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + for (const line of lines) { + const match = line.match(/^([MADRCU?!]{1,2})\s+(.+)$/); + if (!match) continue; + const statusToken = match[1] ?? ""; + const path = match[2] ?? ""; + if (!path) continue; + const status: "added" | "modified" | "deleted" = statusToken.includes("A") + ? "added" + : statusToken.includes("D") + ? "deleted" + : "modified"; + files.push({ path, status }); + } + return files; } -function parseGitCommitInfo(command: string, output?: string): { - message: string; - hash?: string; - files?: Array<{ path: string; status: 'added' | 'modified' | 'deleted' }>; +function parseGitCommitInfo( + command: string, + output?: string, +): { + message: string; + hash?: string; + files?: Array<{ path: string; status: "added" | "modified" | "deleted" }>; } { - const outputText = output ?? ''; - const commitLineMatch = outputText.match(/^\[(.+?)\s+([0-9a-f]{7,40})\]\s+(.+)$/m); - const messageFromOutput = commitLineMatch?.[3]; - const hashFromOutput = commitLineMatch?.[2] - ?? outputText.match(/\bcommit\s+([0-9a-f]{7,40})\b/i)?.[1]; - const messageFromCommand = command.match(/-m\s+["']([^"']+)["']/)?.[1]; - const message = messageFromOutput || messageFromCommand || `Git: ${command}`; - const files = parseGitFiles(outputText); - return { - message, - hash: hashFromOutput, - files: files.length > 0 ? files : undefined, - }; + const outputText = output ?? ""; + const commitLineMatch = outputText.match( + /^\[(.+?)\s+([0-9a-f]{7,40})\]\s+(.+)$/m, + ); + const messageFromOutput = commitLineMatch?.[3]; + const hashFromOutput = + commitLineMatch?.[2] ?? + outputText.match(/\bcommit\s+([0-9a-f]{7,40})\b/i)?.[1]; + const messageFromCommand = command.match(/-m\s+["']([^"']+)["']/)?.[1]; + const message = messageFromOutput || messageFromCommand || `Git: ${command}`; + const files = parseGitFiles(outputText); + return { + message, + hash: hashFromOutput, + files: files.length > 0 ? files : undefined, + }; } // --------------------------------------------------------------------------- @@ -148,172 +237,210 @@ function parseGitCommitInfo(command: string, output?: string): { // Parse read tool output — strip tags and line number prefixes // --------------------------------------------------------------------------- - - // --------------------------------------------------------------------------- // Specialised sub-views // --------------------------------------------------------------------------- function shortenPath(filePath: string): string { // Show at most the last 3 segments - const parts = filePath.split('/'); + const parts = filePath.split("/"); if (parts.length <= 3) return filePath; - return '…/' + parts.slice(-3).join('/'); + return "…/" + parts.slice(-3).join("/"); } function getAgentBadge(agent: string) { - const normalized = agent.replace('Agentuity Coder ', '').trim(); - const labels: Record = { - Lead: 'Lead', - Scout: 'Scout', - Builder: 'Builder', - Architect: 'Architect', - Reviewer: 'Reviewer', - Memory: 'Memory', - Expert: 'Expert', - Runner: 'Runner', - Product: 'Product', - }; - return labels[normalized] ?? normalized; + const normalized = agent.replace("Agentuity Coder ", "").trim(); + const labels: Record = { + Lead: "Lead", + Scout: "Scout", + Builder: "Builder", + Architect: "Architect", + Reviewer: "Reviewer", + Memory: "Memory", + Expert: "Expert", + Runner: "Runner", + Product: "Product", + }; + return labels[normalized] ?? normalized; } -function CopyButton({ text, label = 'Copy' }: { text: string; label?: string }) { - const [copied, setCopied] = useState(false); - - const handleCopy = async () => { - if (!text || typeof window === 'undefined' || !navigator?.clipboard?.writeText) return; - try { - await navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - // Ignore copy errors - } - }; - - return ( - - ); +function CopyButton({ + text, + label = "Copy", +}: { + text: string; + label?: string; +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + if ( + !text || + typeof window === "undefined" || + !navigator?.clipboard?.writeText + ) + return; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Ignore copy errors + } + }; + + return ( + + ); } -function AgentInvocationView({ input }: { input: { subagent_type: string; description?: string; prompt?: string } }) { - const agentLabel = getAgentBadge(input.subagent_type); - return ( -
-
- 🤖 - {agentLabel} - {input.description ?? 'Agent task'} -
- {input.prompt && ( -
- - {input.prompt} - -
- )} -
- ); +function AgentInvocationView({ + input, +}: { + input: { subagent_type: string; description?: string; prompt?: string }; +}) { + const agentLabel = getAgentBadge(input.subagent_type); + return ( +
+
+ 🤖 + + {agentLabel} + + {input.description ?? "Agent task"} +
+ {input.prompt && ( +
+ + {input.prompt} + +
+ )} +
+ ); } function DiffView({ - filePath, - oldString, - newString, - onAddComment, - annotations, + filePath, + oldString, + newString, + onAddComment, + annotations, }: { - filePath: string; - oldString: string; - newString: string; - onAddComment?: (file: string, selection: SelectedLineRange, comment: string, origin: 'diff' | 'file') => void; - annotations?: DiffLineAnnotation<{ id: string; comment: string }>[]; + filePath: string; + oldString: string; + newString: string; + onAddComment?: ( + file: string, + selection: SelectedLineRange, + comment: string, + origin: "diff" | "file", + ) => void; + annotations?: DiffLineAnnotation<{ id: string; comment: string }>[]; }) { - const lang = getLangFromPath(filePath) as any; - const fileName = filePath.split('/').pop() || filePath; - const [selectedRange, setSelectedRange] = useState(null); - const [commentText, setCommentText] = useState(''); - const diffText = useMemo( - () => `--- ${filePath}\n+++ ${filePath}\n\n--- Original\n${oldString}\n\n+++ Updated\n${newString}`, - [filePath, oldString, newString] - ); - - const diffData = useMemo(() => { - try { - return parseDiffFromFile( - { name: fileName, contents: oldString, lang }, - { name: fileName, contents: newString, lang }, - ); - } catch { - return null; - } - }, [fileName, oldString, newString, lang]); - - const handleAddComment = () => { - if (!onAddComment || !selectedRange) return; - const trimmed = commentText.trim(); - if (!trimmed) return; - onAddComment(filePath, selectedRange, trimmed, 'diff'); - setCommentText(''); - setSelectedRange(null); - }; - - return ( -
-
- edit - {shortenPath(filePath)} - - -
-
- {diffData ? ( - ( -
- {annotation.metadata?.comment ?? 'Comment'} -
- )} - options={{ - theme: { dark: 'github-dark', light: 'github-light' }, - themeType: 'system', - disableFileHeader: true, - diffStyle: 'unified', - diffIndicators: 'bars', - enableLineSelection: true, - onLineSelected: (range) => setSelectedRange(range), - }} - /> - ) : ( -
Unable to render diff
- )} -
- {onAddComment && selectedRange && ( -
- setCommentText(event.target.value)} - placeholder="Add a comment" - className="flex-1 rounded border border-[var(--border)] bg-[var(--background)] px-2 py-1 text-xs" - /> - -
- )} -
- ); + const lang = getLangFromPath(filePath) as any; + const fileName = filePath.split("/").pop() || filePath; + const [selectedRange, setSelectedRange] = useState( + null, + ); + const [commentText, setCommentText] = useState(""); + const diffText = useMemo( + () => + `--- ${filePath}\n+++ ${filePath}\n\n--- Original\n${oldString}\n\n+++ Updated\n${newString}`, + [filePath, oldString, newString], + ); + + const diffData = useMemo(() => { + try { + return parseDiffFromFile( + { name: fileName, contents: oldString, lang }, + { name: fileName, contents: newString, lang }, + ); + } catch { + return null; + } + }, [fileName, oldString, newString, lang]); + + const handleAddComment = () => { + if (!onAddComment || !selectedRange) return; + const trimmed = commentText.trim(); + if (!trimmed) return; + onAddComment(filePath, selectedRange, trimmed, "diff"); + setCommentText(""); + setSelectedRange(null); + }; + + return ( +
+
+ + edit + + + {shortenPath(filePath)} + + + +
+
+ {diffData ? ( + ( +
+ {annotation.metadata?.comment ?? "Comment"} +
+ )} + options={{ + theme: { dark: "github-dark", light: "github-light" }, + themeType: "system", + disableFileHeader: true, + diffStyle: "unified", + diffIndicators: "bars", + enableLineSelection: true, + onLineSelected: (range) => setSelectedRange(range), + }} + /> + ) : ( +
+ Unable to render diff +
+ )} +
+ {onAddComment && selectedRange && ( +
+ setCommentText(event.target.value)} + placeholder="Add a comment" + className="flex-1 rounded border border-[var(--border)] bg-[var(--background)] px-2 py-1 text-xs" + /> + +
+ )} +
+ ); } function BashView({ command, output }: { command: string; output?: string }) { @@ -321,7 +448,9 @@ function BashView({ command, output }: { command: string; output?: string }) {
{/* Command */}
- $ + + $ + {command}
{/* Output */} @@ -341,117 +470,142 @@ function BashView({ command, output }: { command: string; output?: string }) { } function FileListView({ title, output }: { title: string; output?: string }) { - const items = output ? output.split('\n').filter(Boolean) : []; - return ( -
-
{title}
- {items.length === 0 ? ( -
No results
- ) : ( -
    - {items.map((item) => ( -
  • {item}
  • - ))} -
- )} -
- ); + const items = output ? output.split("\n").filter(Boolean) : []; + return ( +
+
+ {title} +
+ {items.length === 0 ? ( +
No results
+ ) : ( +
    + {items.map((item) => ( +
  • + {item} +
  • + ))} +
+ )} +
+ ); } function WebFetchView({ url, output }: { url: string; output?: string }) { - const preview = output ? output.slice(0, 400) : ''; - return ( -
-
WebFetch
-
- {url} -
- {preview && ( -
- {preview} - {output && output.length > preview.length ? '…' : ''} -
- )} -
- ); + const preview = output ? output.slice(0, 400) : ""; + return ( +
+
+ WebFetch +
+
+ {url} +
+ {preview && ( +
+ {preview} + {output && output.length > preview.length ? "…" : ""} +
+ )} +
+ ); } function WriteView({ - filePath, - content, - output, - onAddComment, - comments, + filePath, + content, + output, + onAddComment, + comments, }: { - filePath: string; - content: string; - output?: string; - onAddComment?: (file: string, selection: SelectedLineRange, comment: string, origin: 'diff' | 'file') => void; - comments?: CodeComment[]; + filePath: string; + content: string; + output?: string; + onAddComment?: ( + file: string, + selection: SelectedLineRange, + comment: string, + origin: "diff" | "file", + ) => void; + comments?: CodeComment[]; }) { - const lines = content.split('\n'); - - return ( -
-
- 📄 - {shortenPath(filePath)} -
- {lines.length} lines - -
-
- - {output && ( -
-
Output
-
-						{output}
-					
-
- )} -
- ); + const lines = content.split("\n"); + + return ( +
+
+ 📄 + + {shortenPath(filePath)} + +
+ {lines.length} lines + +
+
+ + {output && ( +
+
+ Output +
+
+            {output}
+          
+
+ )} +
+ ); } function ReadView({ - filePath, - output, - onAddComment, - comments, + filePath, + output, + onAddComment, + comments, }: { - filePath: string; - output?: string; - onAddComment?: (file: string, selection: SelectedLineRange, comment: string, origin: 'diff' | 'file') => void; - comments?: CodeComment[]; + filePath: string; + output?: string; + onAddComment?: ( + file: string, + selection: SelectedLineRange, + comment: string, + origin: "diff" | "file", + ) => void; + comments?: CodeComment[]; }) { - const parsed = useMemo(() => (output ? parseFileOutput(output) : ''), [output]); + const parsed = useMemo( + () => (output ? parseFileOutput(output) : ""), + [output], + ); - const lines = parsed.split('\n'); + const lines = parsed.split("\n"); - return ( + return (
📖 - Read: {shortenPath(filePath)} + + Read: {shortenPath(filePath)} +
{lines.length} lines
- {parsed && ( - - )} + {parsed && ( + + )}
); } @@ -463,16 +617,20 @@ function ReadView({ function TodoView({ input, output }: { input?: string; output?: string }) { const data = useMemo(() => { try { - const raw = input || output || '{}'; - const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; + const raw = input || output || "{}"; + const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; return parsed; } catch { return {}; } }, [input, output]); - const todos: Array<{ id?: string; content?: string; status?: string; priority?: string }> = - data.todos || (Array.isArray(data) ? data : []); + const todos: Array<{ + id?: string; + content?: string; + status?: string; + priority?: string; + }> = data.todos || (Array.isArray(data) ? data : []); if (todos.length === 0) { return ( @@ -486,23 +644,23 @@ function TodoView({ input, output }: { input?: string; output?: string }) {
{todos.map((todo, idx) => (
- {todo.status === 'completed' ? ( + {todo.status === "completed" ? ( - ) : todo.status === 'in_progress' ? ( + ) : todo.status === "in_progress" ? ( ) : ( )} {todo.content} - {todo.priority === 'high' && ( + {todo.priority === "high" && ( HIGH )}
@@ -515,18 +673,28 @@ function TodoView({ input, output }: { input?: string; output?: string }) { // Default JSON fallback (original behaviour) // --------------------------------------------------------------------------- -function DefaultView({ input, output }: { input: Record; output?: string }) { +function DefaultView({ + input, + output, +}: { + input: Record; + output?: string; +}) { return ( <>
-
Input
+
+ Input +
           {JSON.stringify(input, null, 2)}
         
{output !== undefined && (
-
Output
+
+ Output +
             {output}
           
@@ -541,149 +709,319 @@ function DefaultView({ input, output }: { input: Record; output // --------------------------------------------------------------------------- export const ToolCallCard = React.memo(function ToolCallCard({ - part, - onAddComment, - getDiffAnnotations, - getFileComments, - sources = [], + part, + onAddComment, + getDiffAnnotations, + getFileComments, + sources = [], + sessionId, + archived, + childSessions, + fetchChildData, + getChildMessages, + getChildPartsForMessage, + getChildStatus, + liveChildSessionIds, }: ToolCallCardProps) { - const title = ('title' in part.state && part.state.title) ? part.state.title : getToolDisplayName(part.tool); - const duration = ('time' in part.state && part.state.time && 'end' in part.state.time) - ? (((part.state.time as { start: number; end: number }).end - part.state.time.start) / 1000).toFixed(1) - : null; + const title = + "title" in part.state && part.state.title + ? part.state.title + : getToolDisplayName(part.tool); + const duration = + "time" in part.state && part.state.time && "end" in part.state.time + ? ( + ((part.state.time as { start: number; end: number }).end - + part.state.time.start) / + 1000 + ).toFixed(1) + : null; const input = part.state.input; - const output = 'output' in part.state ? part.state.output : undefined; - - const toolState: ToolState = part.state.status === 'running' - ? 'partial-call' - : part.state.status === 'pending' - ? 'call' - : 'result'; - const toolStatus = part.state.status as ToolStatus; - - // Determine which specialised view to use - function renderBody() { - if (isEditTool(input)) { - return ( - <> - - {output && ( -
- {output} -
- )} - - ); - } - - if (isBashTool(input)) { - if (isGitCommand(input.command)) { - const gitInfo = parseGitCommitInfo(input.command, output); - return ( -
- - {output && ( -
-								{output}
-							
- )} -
- ); - } - return ; - } - - if (isWriteTool(input)) { - return ( - - ); - } - - if (isReadTool(input)) { - return ( - - ); - } - - if (part.tool === 'glob' || part.tool === 'grep') { - return ; - } - - if (part.tool === 'webfetch' && isWebFetchTool(input)) { - return ; - } - - if (part.tool === 'task' && isAgentInvocation(input)) { - return ; - } - - // Todo tool — render styled checklist instead of raw JSON - if (part.tool === 'todowrite' || part.tool === 'TodoWrite' || part.tool === 'todo_write') { - return ; - } - - // Fallback: raw JSON (original behaviour) - return ( - <> - - {part.state.status === 'completed' && ( - - )} - - ); - } - - const isEdit = isEditTool(input); - const isWrite = isWriteTool(input); - const isMutationByName = ['edit', 'write', 'create', 'patch', 'multi_edit'].includes(part.tool?.toLowerCase() ?? ''); - const shouldOpen = part.state.status === 'error' || isEdit || isWrite || isMutationByName; - const agentTitle = part.tool === 'task' && isAgentInvocation(input) - ? `Agent · ${getAgentBadge(input.subagent_type)}` - : title; - - return ( - - - - {renderBody()} - {part.state.status === 'error' && ( -
-
Error
-
-							{part.state.error}
-						
-
- )} - {sources.length > 0 && ( -
- -
- )} -
-
- ); + const output = "output" in part.state ? part.state.output : undefined; + + const toolState: ToolState = + part.state.status === "running" + ? "partial-call" + : part.state.status === "pending" + ? "call" + : "result"; + const toolStatus = part.state.status as ToolStatus; + + // Determine which specialised view to use + function renderBody() { + if (isEditTool(input)) { + return ( + <> + + {output && ( +
+ + {output} + +
+ )} + + ); + } + + if (isBashTool(input)) { + if (isGitCommand(input.command)) { + const gitInfo = parseGitCommitInfo(input.command, output); + return ( +
+ + {output && ( +
+                {output}
+              
+ )} +
+ ); + } + return ; + } + + if (isWriteTool(input)) { + return ( + + ); + } + + if (isReadTool(input)) { + return ( + + ); + } + + if (part.tool === "glob" || part.tool === "grep") { + return ; + } + + if (part.tool === "webfetch" && isWebFetchTool(input)) { + return ; + } + + if (part.tool === "task" && isAgentInvocation(input)) { + return ; + } + + // Todo tool — render styled checklist instead of raw JSON + if ( + part.tool === "todowrite" || + part.tool === "TodoWrite" || + part.tool === "todo_write" + ) { + return ; + } + + // Fallback: raw JSON (original behaviour) + return ( + <> + + {part.state.status === "completed" && } + + ); + } + + const isEdit = isEditTool(input); + const isWrite = isWriteTool(input); + const isMutationByName = [ + "edit", + "write", + "create", + "patch", + "multi_edit", + ].includes(part.tool?.toLowerCase() ?? ""); + const shouldOpen = + part.state.status === "error" || isEdit || isWrite || isMutationByName; + const agentTitle = + part.tool === "task" && isAgentInvocation(input) + ? `Agent · ${getAgentBadge(input.subagent_type)}` + : title; + + // Sub-agent inspection: detect if this tool call creates a child session + const isSubAgentTool = SUB_AGENT_TOOLS.has(part.tool); + const [showInspection, setShowInspection] = useState(false); + + // Try to find the matching child session from the provided list. + // For "task" tools, the output often contains a task_id that maps to the child session. + // For background tasks, match by description or agent type. + const matchedChild = useMemo(() => { + if (!isSubAgentTool || !childSessions || childSessions.length === 0) + return null; + + // Try to extract task_id from the tool output + if (output) { + try { + const parsed = typeof output === "string" ? JSON.parse(output) : output; + if (parsed?.task_id) { + const match = childSessions.find( + (c) => + c.opencodeSessionId === parsed.task_id || c.id === parsed.task_id, + ); + if (match) return match; + } + } catch { + // Output might not be JSON — that's fine + } + } + + // Match by agent type from input + if (isAgentInvocation(input)) { + const agentType = input.subagent_type?.toLowerCase() ?? ""; + const desc = (input.description as string) ?? ""; + // Find a child whose title or metadata matches + const match = childSessions.find((c) => { + const childTitle = (c.title ?? "").toLowerCase(); + const childMeta = c.metadata as Record | null; + const childAgent = ((childMeta?.agent as string) ?? "").toLowerCase(); + return ( + childTitle.includes(agentType) || + childAgent.includes(agentType) || + (desc && childTitle.includes(desc.toLowerCase().slice(0, 20))) + ); + }); + if (match) return match; + } + + // Fallback: if there's only one child and one sub-agent tool, assume they match + return null; + }, [isSubAgentTool, childSessions, output, input]); + + return ( + + + + {renderBody()} + {/* Sub-agent inspection button + inline child session view */} + {isSubAgentTool && sessionId && matchedChild && ( +
+ + {showInspection && ( + Promise< + | import("../../hooks/useChildSessions").ChildSessionData + | null + >) + | undefined + } + liveMessages={ + getChildMessages + ? getChildMessages(matchedChild.opencodeSessionId) + : undefined + } + liveGetParts={ + getChildPartsForMessage + ? (messageID: string) => + getChildPartsForMessage( + matchedChild.opencodeSessionId, + messageID, + ) + : undefined + } + liveStatus={ + getChildStatus + ? getChildStatus(matchedChild.opencodeSessionId) + : undefined + } + /> + )} +
+ )} + {/* Show a softer "Inspect Agent" hint for sub-agent tools without matched children */} + {isSubAgentTool && + sessionId && + !matchedChild && + childSessions && + childSessions.length > 0 && ( +
+ + Sub-agent session — child session data may not be available yet + +
+ )} + {part.state.status === "error" && ( +
+
+ Error +
+
+              {part.state.error}
+            
+
+ )} + {sources.length > 0 && ( +
+ +
+ )} +
+
+ ); }); diff --git a/src/web/components/pages/ChatPage.tsx b/src/web/components/pages/ChatPage.tsx index 2ea9a88..74253c8 100644 --- a/src/web/components/pages/ChatPage.tsx +++ b/src/web/components/pages/ChatPage.tsx @@ -1,101 +1,107 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { ChangeEventHandler } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { ChangeEventHandler } from "react"; import { - AlertTriangle, - Check, - Clock, - Code2, - Copy, - Download, - GitBranch, - GitFork, - Camera, - Circle, - ListOrdered, - ListTodo, - Loader2, - MessageSquare, - Paperclip, - ExternalLink, - RotateCcw, - Terminal, - - WifiOff, - X, -} from 'lucide-react'; -import { Button } from '../ui/button'; -import { Badge } from '../ui/badge'; -import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; -import { useSessionEvents } from '../../hooks/useSessionEvents'; -import { FileExplorer } from '../chat/FileExplorer'; -import type { FileTreeNode } from '../ai-elements/file-tree'; -import { CommandPicker } from '../chat/AgentSelector'; -import { ModelSelector } from '../chat/ModelSelector'; -import { GitPanel, useGitStatus } from '../chat/GitPanel'; + AlertTriangle, + Check, + Clock, + Code2, + Copy, + Download, + GitBranch, + GitFork, + Camera, + Circle, + ListOrdered, + ListTodo, + Loader2, + MessageSquare, + Paperclip, + ExternalLink, + RotateCcw, + Terminal, + WifiOff, + X, +} from "lucide-react"; +import { Button } from "../ui/button"; +import { Badge } from "../ui/badge"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { useSessionEvents } from "../../hooks/useSessionEvents"; +import { FileExplorer } from "../chat/FileExplorer"; +import type { FileTreeNode } from "../ai-elements/file-tree"; +import { CommandPicker } from "../chat/AgentSelector"; +import { ModelSelector } from "../chat/ModelSelector"; +import { GitPanel, useGitStatus } from "../chat/GitPanel"; import type { - Message as ChatMessage, - Part, - ReasoningPart, - ToolPart, -} from '../../types/opencode'; -import { TextPartView } from '../chat/TextPartView'; -import { ToolCallCard } from '../chat/ToolCallCard'; -import { FilePartView } from '../chat/FilePartView'; -import { SubtaskView } from '../chat/SubtaskView'; -import { PermissionCard } from '../chat/PermissionCard'; -import { QuestionCard } from '../chat/QuestionCard'; -import { ContextIndicator } from '../chat/ContextIndicator'; -import type { SourceItem } from '../chat/SourcesView'; -import { IDELayout } from '../ide/IDELayout'; -import { CodePanel } from '../ide/CodePanel'; + Message as ChatMessage, + Part, + ReasoningPart, + ToolPart, +} from "../../types/opencode"; +import { TextPartView } from "../chat/TextPartView"; +import { ToolCallCard } from "../chat/ToolCallCard"; +import { FilePartView } from "../chat/FilePartView"; +import { SubtaskView } from "../chat/SubtaskView"; +import { PermissionCard } from "../chat/PermissionCard"; +import { QuestionCard } from "../chat/QuestionCard"; +import { ContextIndicator } from "../chat/ContextIndicator"; +import { ArchivedBanner } from "../chat/ArchivedBanner"; +import type { SourceItem } from "../chat/SourcesView"; +import { IDELayout } from "../ide/IDELayout"; +import { CodePanel } from "../ide/CodePanel"; import { - Conversation, - ConversationContent, - ConversationEmptyState, - ConversationScrollButton, -} from '../ai-elements/conversation'; -import { ChainOfThought } from '../ai-elements/chain-of-thought'; -import { Plan } from '../ai-elements/plan'; -import { AgentDisplay } from '../ai-elements/agent'; + Conversation, + ConversationContent, + ConversationEmptyState, + ConversationScrollButton, +} from "../ai-elements/conversation"; +import { ChainOfThought } from "../ai-elements/chain-of-thought"; +import { Plan } from "../ai-elements/plan"; +import { AgentDisplay } from "../ai-elements/agent"; import { - Message, - MessageActions, - MessageAction, - MessageContent, - MessageResponse, - MessageToolbar, -} from '../ai-elements/message'; + Message, + MessageActions, + MessageAction, + MessageContent, + MessageResponse, + MessageToolbar, +} from "../ai-elements/message"; import { - PromptInput, - PromptInputFooter, - PromptInputProvider, - PromptInputSubmit, - PromptInputTextarea, -} from '../ai-elements/prompt-input'; -import { Reasoning, ReasoningContent, ReasoningTrigger } from '../ai-elements/reasoning'; -import { Loader } from '../ai-elements/loader'; -import { useToast } from '../ui/toast'; -import { useFileTabs } from '../../hooks/useFileTabs'; -import { useCodeComments } from '../../hooks/useCodeComments'; -import { useEditorSettings } from '../../hooks/useEditorSettings'; -import { useNarratorMode } from '../../hooks/useNarratorMode'; -import { useAudioPlayback } from '../../hooks/useAudioPlayback'; -import { VoiceControls } from '../ui/VoiceControls'; -import { cn } from '../../lib/utils'; -import { apiFetch } from '../../lib/api'; -import { useNavigate, useSearch } from '@tanstack/react-router'; -import type { z } from 'zod'; -import { sessionSearchSchema } from '../../router'; -import { useAnalytics } from '@agentuity/react'; -import { useHotkeys } from 'react-hotkeys-hook'; -import { useKeybindings } from '../../hooks/useKeybindings'; -import { TEMPLATE_COMMANDS } from '../../../lib/agent-commands'; + PromptInput, + PromptInputFooter, + PromptInputProvider, + PromptInputSubmit, + PromptInputTextarea, +} from "../ai-elements/prompt-input"; +import { + Reasoning, + ReasoningContent, + ReasoningTrigger, +} from "../ai-elements/reasoning"; +import { Loader } from "../ai-elements/loader"; +import { useToast } from "../ui/toast"; +import { useFileTabs } from "../../hooks/useFileTabs"; +import { useCodeComments } from "../../hooks/useCodeComments"; +import { useEditorSettings } from "../../hooks/useEditorSettings"; +import { useNarratorMode } from "../../hooks/useNarratorMode"; +import { useAudioPlayback } from "../../hooks/useAudioPlayback"; +import { useChildSessions } from "../../hooks/useChildSessions"; +import { VoiceControls } from "../ui/VoiceControls"; +import { cn } from "../../lib/utils"; +import { apiFetch } from "../../lib/api"; +import { useNavigate, useSearch } from "@tanstack/react-router"; +import type { z } from "zod"; +import { sessionSearchSchema } from "../../router"; +import { useAnalytics } from "@agentuity/react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { useKeybindings } from "../../hooks/useKeybindings"; +import { TEMPLATE_COMMANDS } from "../../../lib/agent-commands"; interface ChatPageProps { sessionId: string; session: { title: string | null; status: string; + archiveStatus?: string; agent: string | null; model: string | null; sandboxId: string | null; @@ -119,18 +125,18 @@ interface ChatPageProps { } type QueuedMessage = { - text: string; - model: string; - command?: string; - attachments?: AttachmentItem[]; + text: string; + model: string; + command?: string; + attachments?: AttachmentItem[]; }; type AttachmentItem = { - id: string; - filename: string; - mime: string; - size: number; - content: string; + id: string; + filename: string; + mime: string; + size: number; + content: string; }; type SessionSearch = z.infer; @@ -138,35 +144,40 @@ type SessionSearch = z.infer; const MAX_ATTACHMENTS = 5; const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; const ALLOWED_EXTENSIONS = new Set([ - 'txt', - 'md', - 'mdx', - 'json', - 'js', - 'jsx', - 'ts', - 'tsx', - 'py', - 'java', - 'go', - 'rs', - 'rb', - 'php', - 'sh', - 'yaml', - 'yml', - 'toml', - 'csv', - 'log', - 'png', - 'jpg', - 'jpeg', - 'gif', - 'webp', - 'svg', + "txt", + "md", + "mdx", + "json", + "js", + "jsx", + "ts", + "tsx", + "py", + "java", + "go", + "rs", + "rb", + "php", + "sh", + "yaml", + "yml", + "toml", + "csv", + "log", + "png", + "jpg", + "jpeg", + "gif", + "webp", + "svg", ]); -export function ChatPage({ sessionId, session: initialSession, onForkedSession, githubAvailable = true }: ChatPageProps) { +export function ChatPage({ + sessionId, + session: initialSession, + onForkedSession, + githubAvailable = true, +}: ChatPageProps) { const [session, setSession] = useState(initialSession); const { toast } = useToast(); const { track } = useAnalytics(); @@ -181,9 +192,20 @@ export function ChatPage({ sessionId, session: initialSession, onForkedSession, return Number.isFinite(created) ? Math.max(0, Date.now() - created) : 0; }); const [archivedMessages, setArchivedMessages] = useState([]); - const [archivedParts, setArchivedParts] = useState>(new Map()); + const [archivedParts, setArchivedParts] = useState>( + new Map(), + ); const [archiveError, setArchiveError] = useState(null); const [isLoadingArchive, setIsLoadingArchive] = useState(false); + const [archiveStats, setArchiveStats] = useState<{ + totalCost?: number; + totalMessages?: number; + totalTokens?: number; + sessionCount?: number; + } | null>(null); + const [archivedTodos, setArchivedTodos] = useState< + Array<{ id: string; content: string; status: string; priority: string }> + >([]); useEffect(() => { setSession(initialSession); @@ -191,19 +213,19 @@ export function ChatPage({ sessionId, session: initialSession, onForkedSession, useEffect(() => { if (!sessionId) return; - if (session.status === 'active') { + if (session.status === "active") { setStatusElapsedMs(0); return; } // Use session createdAt so navigating away and back doesn't reset the timer. - const created = Date.parse(session.createdAt ?? ''); + const created = Date.parse(session.createdAt ?? ""); const start = Number.isFinite(created) ? created : Date.now(); setStatusStartedAt(start); setStatusElapsedMs(Math.max(0, Date.now() - start)); }, [session.status, sessionId, session.createdAt]); useEffect(() => { - if (session.status === 'active') return; + if (session.status === "active") return; const interval = setInterval(() => { setStatusElapsedMs(Date.now() - statusStartedAt); }, 1000); @@ -212,25 +234,27 @@ export function ChatPage({ sessionId, session: initialSession, onForkedSession, // Poll for session readiness when not yet active useEffect(() => { - if (session.status === 'active') return; + if (session.status === "active") return; const controller = new AbortController(); let aborted = false; const poll = setInterval(async () => { try { - const res = await apiFetch(`/api/sessions/${sessionId}`, { signal: controller.signal }); + const res = await apiFetch(`/api/sessions/${sessionId}`, { + signal: controller.signal, + }); const data = await res.json(); - if (data.status === 'active' && !aborted) { + if (data.status === "active" && !aborted) { setSession((prev) => ({ ...prev, - status: 'active', + status: "active", sandboxId: data.sandboxId ?? prev.sandboxId, sandboxUrl: data.sandboxUrl ?? prev.sandboxUrl, })); } } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') return; + if (err instanceof DOMException && err.name === "AbortError") return; // Ignore — will retry on next interval } }, 3000); @@ -243,11 +267,13 @@ export function ChatPage({ sessionId, session: initialSession, onForkedSession, }, [session.status, sessionId]); useEffect(() => { - if (session.status !== 'terminated') { + if (session.status !== "terminated" && session.status !== "deleted") { setArchivedMessages([]); setArchivedParts(new Map()); setArchiveError(null); setIsLoadingArchive(false); + setArchiveStats(null); + setArchivedTodos([]); return; } @@ -256,8 +282,18 @@ export function ChatPage({ sessionId, session: initialSession, onForkedSession, setIsLoadingArchive(true); setArchiveError(null); - apiFetch(`/api/sessions/${sessionId}/messages`, { signal: controller.signal }) - .then((res) => res.json()) + // Use archive API when session data has been archived to PostgreSQL, + // otherwise fall back to the OpenCode messages API (which may fail if sandbox is gone) + const useArchiveApi = session.archiveStatus === "archived"; + const url = useArchiveApi + ? `/api/sessions/${sessionId}/archive` + : `/api/sessions/${sessionId}/messages`; + + apiFetch(url, { signal: controller.signal }) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) .then((data: unknown) => { if (!isMounted) return; const messages: ChatMessage[] = []; @@ -271,19 +307,56 @@ export function ChatPage({ sessionId, session: initialSession, onForkedSession, const record = data as Record; - if (record?.messages && Array.isArray(record.messages)) { - for (const item of record.messages as Array>) { - if (item.info) messages.push(item.info as ChatMessage); - if (Array.isArray(item.parts)) { - for (const part of item.parts as Part[]) addPart(part); + if (useArchiveApi) { + // Archive API shape: { messages: Message[], parts: Part[], todos, stats } + if (Array.isArray(record?.messages)) { + for (const msg of record.messages as ChatMessage[]) { + messages.push(msg); + } + } + if (Array.isArray(record?.parts)) { + for (const part of record.parts as Part[]) { + addPart(part); } } - } else if (Array.isArray(data)) { - for (const item of data as Array>) { - if (item.info) messages.push(item.info as ChatMessage); - else if (item.role) messages.push(item as unknown as ChatMessage); - if (Array.isArray(item.parts)) { - for (const part of item.parts as Part[]) addPart(part); + if (Array.isArray(record?.todos)) { + setArchivedTodos( + record.todos as Array<{ + id: string; + content: string; + status: string; + priority: string; + }>, + ); + } + if (record?.stats && typeof record.stats === "object") { + setArchiveStats( + record.stats as { + totalCost?: number; + totalMessages?: number; + totalTokens?: number; + sessionCount?: number; + }, + ); + } + } else { + // OpenCode messages API shape: { messages: Array<{ info, parts }> } or Array<{ info, role, parts }> + if (record?.messages && Array.isArray(record.messages)) { + for (const item of record.messages as Array< + Record + >) { + if (item.info) messages.push(item.info as ChatMessage); + if (Array.isArray(item.parts)) { + for (const part of item.parts as Part[]) addPart(part); + } + } + } else if (Array.isArray(data)) { + for (const item of data as Array>) { + if (item.info) messages.push(item.info as ChatMessage); + else if (item.role) messages.push(item as unknown as ChatMessage); + if (Array.isArray(item.parts)) { + for (const part of item.parts as Part[]) addPart(part); + } } } } @@ -294,8 +367,8 @@ export function ChatPage({ sessionId, session: initialSession, onForkedSession, }) .catch((err) => { if (!isMounted) return; - if (err instanceof DOMException && err.name === 'AbortError') return; - setArchiveError('Unable to load chat history'); + if (err instanceof DOMException && err.name === "AbortError") return; + setArchiveError("Unable to load chat history"); }) .finally(() => { if (!isMounted) return; @@ -306,1765 +379,2122 @@ export function ChatPage({ sessionId, session: initialSession, onForkedSession, isMounted = false; controller.abort(); }; - }, [session.status, sessionId]); + }, [session.status, session.archiveStatus, sessionId]); // Only connect SSE when session is active (pass undefined to skip connection) - const activeSessionId = session.status === 'active' ? sessionId : undefined; - const { - messages, - getPartsForMessage, - sessionStatus, - pendingPermissions, - pendingQuestions, - todos, - isConnected, - error, - revertState, - } = useSessionEvents(activeSessionId); - - const [isRetrying, setIsRetrying] = useState(false); - - const handleRetry = async () => { - setIsRetrying(true); - try { - const res = await fetch(`/api/sessions/${sessionId}/retry`, { method: 'POST' }); - if (res.ok) { - // Reload the page to reconnect - window.location.reload(); - } - } catch { - // Ignore - } finally { - setIsRetrying(false); - } - }; - - - const [inputText, setInputText] = useState(''); - const [isSending, setIsSending] = useState(false); - const [attachments, setAttachments] = useState([]); + const activeSessionId = session.status === "active" ? sessionId : undefined; + const { + messages, + getPartsForMessage, + sessionStatus, + pendingPermissions, + pendingQuestions, + todos, + isConnected, + error, + revertState, + // Child session live streaming data + getChildMessages, + getChildPartsForMessage, + getChildStatus, + liveChildSessionIds, + } = useSessionEvents(activeSessionId); + + const [isRetrying, setIsRetrying] = useState(false); + + const handleRetry = async () => { + setIsRetrying(true); + try { + const res = await fetch(`/api/sessions/${sessionId}/retry`, { + method: "POST", + }); + if (res.ok) { + // Reload the page to reconnect + window.location.reload(); + } + } catch { + // Ignore + } finally { + setIsRetrying(false); + } + }; + + const [inputText, setInputText] = useState(""); + const [isSending, setIsSending] = useState(false); + const [attachments, setAttachments] = useState([]); // Only initialize from session.agent if it's a real agent (not a template command). // Template commands (memory-save, cadence, etc.) are one-shot and shouldn't persist. // DB stores without '/' prefix, picker uses with '/' — normalize on load. const [selectedCommand, setSelectedCommand] = useState(() => { - const agent = session.agent || ''; - if (!agent || TEMPLATE_COMMANDS.has(agent)) return ''; - return agent.startsWith('/') ? agent : `/${agent}`; + const agent = session.agent || ""; + if (!agent || TEMPLATE_COMMANDS.has(agent)) return ""; + return agent.startsWith("/") ? agent : `/${agent}`; + }); + const [hasManuallySelectedCommand, setHasManuallySelectedCommand] = + useState(false); + const [selectedModel, setSelectedModel] = useState( + session.model || "anthropic/claude-sonnet-4-5", + ); + const [messageQueue, setMessageQueue] = useState([]); + const [showTodos, setShowTodos] = useState(false); + const [showChanges, setShowChanges] = useState(false); + const [isForking, setIsForking] = useState(false); + const [isSharing, setIsSharing] = useState(false); + const [showSnapshotDialog, setShowSnapshotDialog] = useState(false); + const [snapshotName, setSnapshotName] = useState(""); + const [snapshotDescription, setSnapshotDescription] = useState(""); + const [isSavingSnapshot, setIsSavingSnapshot] = useState(false); + const [shareUrl, setShareUrl] = useState(null); + const [shareCopied, setShareCopied] = useState(false); + const { v: viewMode, tab: sidebarTab } = useSearch({ + from: "/session/$sessionId", }); - const [hasManuallySelectedCommand, setHasManuallySelectedCommand] = useState(false); - const [selectedModel, setSelectedModel] = useState(session.model || 'anthropic/claude-sonnet-4-5'); - const [messageQueue, setMessageQueue] = useState([]); - const [showTodos, setShowTodos] = useState(false); - const [showChanges, setShowChanges] = useState(false); - const [isForking, setIsForking] = useState(false); - const [isSharing, setIsSharing] = useState(false); - const [showSnapshotDialog, setShowSnapshotDialog] = useState(false); - const [snapshotName, setSnapshotName] = useState(''); - const [snapshotDescription, setSnapshotDescription] = useState(''); - const [isSavingSnapshot, setIsSavingSnapshot] = useState(false); - const [shareUrl, setShareUrl] = useState(null); - const [shareCopied, setShareCopied] = useState(false); - const { v: viewMode, tab: sidebarTab } = useSearch({ from: '/session/$sessionId' }); - const navigate = useNavigate({ from: '/session/$sessionId' }); - const [sshCopied, setSshCopied] = useState(false); - const [sandboxCopied, setSandboxCopied] = useState(false); - const [attachCopied, setAttachCopied] = useState(false); - const [passwordCopied, setPasswordCopied] = useState(false); - const [opencodePassword, setOpencodePassword] = useState(null); - const [isDownloading, setIsDownloading] = useState(false); - const [isEditingTitle, setIsEditingTitle] = useState(false); - const [editTitle, setEditTitle] = useState(session.title || ''); - const fileInputRef = useRef(null); - const passwordFetchController = useRef(null); - - useEffect(() => () => { - passwordFetchController.current?.abort(); - }, []); - - const handleModelChange = useCallback((model: string) => { - setSelectedModel(model); - track('model_changed', { model }); - }, [track]); - - const handleViewModeChange = useCallback((mode: 'chat' | 'ide') => { - navigate({ search: (prev: SessionSearch) => ({ ...prev, v: mode }) }); - track('view_mode_changed', { mode }); - }, [navigate, track]); - - const focusChatInput = useCallback(() => { - const textarea = document.querySelector('textarea[data-prompt-input="true"]'); - if (!textarea) return false; - textarea.focus(); - textarea.setSelectionRange(textarea.value.length, textarea.value.length); - return true; - }, []); - - const { enqueue: enqueueAudio, clearQueue: clearAudioQueue, isSpeaking } = useAudioPlayback(); - - const handleSendRef = useRef<(text: string) => Promise>(undefined); - - const handleNarratorAutoSend = useCallback((text: string) => { - void handleSendRef.current?.(text); - }, []); - - const handleNarratorCancel = useCallback(() => { - toast({ type: 'info', message: 'Cancelled' }); - }, [toast]); - - const handleDictation = useCallback((text: string) => { - setInputText((prev) => (prev ? `${prev} ${text}` : text)); - }, []); - - const { - narratorEnabled, - toggleNarrator, - isListening, - isProcessing, - isSupported: voiceSupported, - toggleMic, - accumulatedText, - interimText, - cancelCountdown, - isCountingDown, - countdownProgress, - voiceError, - } = useNarratorMode({ - onAutoSend: handleNarratorAutoSend, - onCancel: handleNarratorCancel, - onDictation: handleDictation, - isSpeaking, - }); - - useEffect(() => { - if (voiceError) { - toast({ type: 'error', message: voiceError }); - } - }, [voiceError, toast]); - - const handleNarratorToggle = useCallback(() => { - const nextEnabled = !narratorEnabled; - toggleNarrator(); - track('narrator_toggled', { enabled: nextEnabled }); - }, [narratorEnabled, toggleNarrator, track]); - - const handleMicToggle = useCallback(() => { - const nextEnabled = !isListening; - toggleMic(); - track('voice_input_toggled', { enabled: nextEnabled }); - }, [isListening, toggleMic, track]); - - // Stop audio playback when user starts speaking (interruption) - useEffect(() => { - if (isListening) clearAudioQueue(); - }, [isListening, clearAudioQueue]); - - // Sync narrator accumulated text into the input field - useEffect(() => { - if (!narratorEnabled) return; - const display = interimText - ? `${accumulatedText} ${interimText}`.trim() - : accumulatedText; - setInputText(display); - }, [narratorEnabled, accumulatedText, interimText]); - - // When user types manually, cancel the silence countdown - const handleInputChange = useCallback((event: React.ChangeEvent) => { - setInputText(event.target.value); - if (narratorEnabled) { - cancelCountdown(); - } - }, [narratorEnabled, cancelCountdown]); - - const formatFileSize = useCallback((size: number) => { - if (size < 1024) return `${size} B`; - if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; - return `${(size / (1024 * 1024)).toFixed(1)} MB`; - }, []); - - const AttachmentPill = useCallback(({ attachment, onRemove }: { attachment: AttachmentItem; onRemove: (id: string) => void }) => { - const isImage = attachment.mime?.startsWith('image/'); - - return ( -
- {isImage ? ( - {attachment.filename} - ) : null} - - {attachment.filename} - - - {formatFileSize(attachment.size)} - - -
- ); - }, [formatFileSize]); - - const handleOpenAttachmentPicker = useCallback(() => { - fileInputRef.current?.click(); - }, []); - - const handleRemoveAttachment = useCallback((id: string) => { - setAttachments((prev) => prev.filter((item) => item.id !== id)); - }, []); - - const handleAttachmentChange: ChangeEventHandler = useCallback( - async (event) => { - const files = Array.from(event.target.files || []); - event.target.value = ''; - if (files.length === 0) return; - - const remainingSlots = MAX_ATTACHMENTS - attachments.length; - if (remainingSlots <= 0) { - toast({ type: 'error', message: `You can only attach ${MAX_ATTACHMENTS} files.` }); - return; - } - - const accepted = files.slice(0, remainingSlots); - const rejected = files.slice(remainingSlots); - if (rejected.length > 0) { - toast({ type: 'error', message: `Only ${MAX_ATTACHMENTS} files can be attached at once.` }); - } - - const invalidFiles: string[] = []; - const oversizedFiles: string[] = []; - - const readFile = (file: File) => - new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const result = typeof reader.result === 'string' ? reader.result : ''; - const base64 = result.includes(',') ? result.split(',')[1] || '' : ''; - resolve({ - id: `${file.name}-${file.lastModified}-${Math.random().toString(16).slice(2)}`, - filename: file.name, - mime: file.type || 'text/plain', - size: file.size, - content: base64, - }); - }; - reader.onerror = () => reject(new Error('Failed to read file')); - reader.readAsDataURL(file); - }); - - const newItems: AttachmentItem[] = []; - for (const file of accepted) { - const ext = file.name.split('.').pop()?.toLowerCase() || ''; - if (!ALLOWED_EXTENSIONS.has(ext)) { - invalidFiles.push(file.name); - continue; - } - if (file.size > MAX_ATTACHMENT_SIZE) { - oversizedFiles.push(file.name); - continue; - } - try { - const item = await readFile(file); - newItems.push(item); - track('file_attached', { fileType: file.type || ext || 'unknown' }); - } catch { - invalidFiles.push(file.name); - } - } - - if (invalidFiles.length > 0) { - toast({ - type: 'error', - message: `Unsupported file types: ${invalidFiles.slice(0, 3).join(', ')}`, - }); - } - - if (oversizedFiles.length > 0) { - toast({ - type: 'error', - message: `Files over 10MB: ${oversizedFiles.slice(0, 3).join(', ')}`, - }); - } - - if (newItems.length > 0) { - setAttachments((prev) => [...prev, ...newItems]); - } - }, - [attachments.length, toast, track], - ); - const { getKeys } = useKeybindings(); - const { - tabs, - activeId, - activeTab, - setActiveId, - openFile, - openDiff, - closeTab, - updateTab, - } = useFileTabs(); - - useHotkeys( - getKeys('focus-chat'), - (event) => { - event.preventDefault(); - if (focusChatInput()) return; - if (viewMode !== 'chat') { - handleViewModeChange('chat'); - setTimeout(() => { - focusChatInput(); - }, 50); - } - }, - { enableOnFormTags: ['INPUT', 'TEXTAREA', 'SELECT'], enableOnContentEditable: true }, - [getKeys('focus-chat'), viewMode] - ); - - useHotkeys( - getKeys('toggle-view'), - (event) => { - event.preventDefault(); - handleViewModeChange(viewMode === 'ide' ? 'chat' : 'ide'); - }, - { enableOnFormTags: ['INPUT', 'TEXTAREA', 'SELECT'], enableOnContentEditable: true }, - [getKeys('toggle-view'), viewMode] - ); - - useHotkeys( - getKeys('close-tab'), - (event) => { - if (viewMode !== 'ide' || !activeId) return; - event.preventDefault(); - closeTab(activeId); - }, - { enableOnFormTags: false, enableOnContentEditable: false }, - [getKeys('close-tab'), viewMode, activeId] - ); - - useHotkeys( - getKeys('toggle-sidebar-tab'), - (event) => { - if (viewMode !== 'ide') return; - event.preventDefault(); - const nextTab = sidebarTab === 'files' ? 'git' : 'files'; - navigate({ search: (prev: SessionSearch) => ({ ...prev, tab: nextTab }) }); - }, - { enableOnFormTags: false, enableOnContentEditable: false }, - [getKeys('toggle-sidebar-tab'), viewMode, sidebarTab] - ); - - useEffect(() => { - const handler = () => { - if (viewMode !== 'ide' || !activeId) return; - closeTab(activeId); - }; - window.addEventListener('app-close-active-tab', handler); - return () => window.removeEventListener('app-close-active-tab', handler); - }, [activeId, closeTab, viewMode]); - - // File tree cache — persists across IDE tab switches (mount/unmount of FileExplorer) - const [cachedTreeNodes, setCachedTreeNodes] = useState([]); - const [cachedTreeEntryCount, setCachedTreeEntryCount] = useState(0); - const handleTreeLoaded = useCallback((treeNodes: FileTreeNode[], treeEntryCount: number) => { - setCachedTreeNodes(treeNodes); - setCachedTreeEntryCount(treeEntryCount); - }, []); - - const { - commentCount, - addComment, - clearComments, - formatForPrompt, - getDiffAnnotations, - getFileComments, - } = useCodeComments(); - const { settings: editorSettings, updateSettings: updateEditorSettings } = useEditorSettings(); - const handleAddComment = useCallback((...args: Parameters) => { - addComment(...args); - track('code_comment_added'); - }, [addComment, track]); - useEffect(() => { - if (!sessionId) return; - setAttachments([]); - }, [sessionId]); - - // Load user's default agent preference (once on mount) - const hasManuallySelectedRef = useRef(false); - useEffect(() => { - hasManuallySelectedRef.current = hasManuallySelectedCommand; - }, [hasManuallySelectedCommand]); - - useEffect(() => { - if (initialSession.agent) return; - fetch('/api/user/settings') - .then((r) => r.json()) - .then((data: { defaultCommand?: string }) => { - if (!hasManuallySelectedRef.current && data.defaultCommand) { - setSelectedCommand(data.defaultCommand); - } - }) - .catch(() => {}); - }, [initialSession.agent]); - - - const activeFilePath = activeTab?.filePath ?? null; - const isBusy = sessionStatus.type === 'busy'; - const displayMessages = session.status === 'terminated' ? archivedMessages : messages; - const sshCommand = session.sandboxId ? `agentuity cloud ssh ${session.sandboxId}` : ''; - const attachCommand = session.sandboxUrl - ? `opencode attach ${session.sandboxUrl}${opencodePassword ? ` --password ${opencodePassword}` : ''}` - : ''; - const getDisplayParts = useCallback( - (messageID: string) => { - if (session.status === 'terminated') { - return archivedParts.get(messageID) ?? []; - } - return getPartsForMessage(messageID); - }, - [archivedParts, getPartsForMessage, session.status], - ); - const { branch: gitBranch, changedCount: gitChangedCount, hasRepo: isGitRepo, refresh: refreshGitStatus } = useGitStatus(activeSessionId, githubAvailable); - - useEffect(() => { - if (!isEditingTitle) { - setEditTitle(session.title || ''); - } - }, [isEditingTitle, session.title]); - - const promptPlaceholder = session.status === 'active' - ? 'Message the agent...' - : session.status === 'creating' - ? 'Type your message... (will send when ready)' - : session.status === 'terminated' - ? 'This session is read-only.' - : session.status === 'error' - ? 'Session failed to start.' - : 'Waiting for sandbox to be ready...'; - const attachmentAccept = Array.from(ALLOWED_EXTENSIONS) - .map((ext) => `.${ext}`) - .join(','); - const isSessionInputEnabled = session.status === 'active' || session.status === 'creating'; - const attachmentDisabled = !isSessionInputEnabled || attachments.length >= MAX_ATTACHMENTS; - - const sendMessage = useCallback( - async (payload: QueuedMessage) => { - setIsSending(true); - try { - if (payload.command && TEMPLATE_COMMANDS.has(payload.command.replace(/^\//, '')) && payload.attachments && payload.attachments.length > 0) { - throw new Error('Attachments are not supported for commands.'); - } - const res = await fetch(`/api/sessions/${sessionId}/messages`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - text: payload.text, - model: payload.model, - command: payload.command, - attachments: payload.attachments?.map(({ filename, mime, content }) => ({ - filename, - mime, - content, - })), - }), - }); - if (!res.ok) { - throw new Error('Failed to send message'); - } - track('message_sent', { - hasAttachments: Boolean(payload.attachments?.length), - attachmentCount: payload.attachments?.length ?? 0, - command: payload.command ?? null, - model: payload.model, - }); - } catch (err) { - console.error('Failed to send message:', err); - toast({ type: 'error', message: 'Failed to send message' }); - } finally { - setIsSending(false); - } - }, - [sessionId, toast, track], - ); - - const handleSend = async (text: string) => { - if (!text.trim() && attachments.length === 0) return; - const isTemplateCmd = selectedCommand && TEMPLATE_COMMANDS.has(selectedCommand.replace(/^\//, '')); - if (isTemplateCmd && attachments.length > 0) { - toast({ type: 'error', message: 'Attachments are not supported with commands.' }); - return; - } - const commentsBlock = formatForPrompt(); - const baseText = text.trim() || (attachments.length > 0 ? 'Attached files.' : ''); - const fullMessage = commentsBlock - ? `${baseText}\n\n---\nCode Comments:\n${commentsBlock}` - : baseText; - const nextAttachments = attachments; - const payload: QueuedMessage = { - text: fullMessage, - model: selectedModel, - command: selectedCommand, - attachments: nextAttachments, - }; - - setInputText(''); - setAttachments([]); - if (commentCount > 0) { - clearComments(); - } - - if (isBusy || isSending || session.status === 'creating') { - setMessageQueue((prev) => [...prev, payload]); - return; - } - - await sendMessage(payload); - }; - handleSendRef.current = handleSend; - - useEffect(() => { - if (session.status !== 'active' || isBusy || isSending || messageQueue.length === 0) return; - const [next, ...rest] = messageQueue; - if (!next) return; - setMessageQueue(rest); - void sendMessage(next); - }, [session.status, isBusy, isSending, messageQueue, sendMessage]); + const navigate = useNavigate({ from: "/session/$sessionId" }); + const [sshCopied, setSshCopied] = useState(false); + const [sandboxCopied, setSandboxCopied] = useState(false); + const [attachCopied, setAttachCopied] = useState(false); + const [passwordCopied, setPasswordCopied] = useState(false); + const [opencodePassword, setOpencodePassword] = useState(null); + const [isDownloading, setIsDownloading] = useState(false); + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [editTitle, setEditTitle] = useState(session.title || ""); + const fileInputRef = useRef(null); + const passwordFetchController = useRef(null); + + useEffect( + () => () => { + passwordFetchController.current?.abort(); + }, + [], + ); + + const handleModelChange = useCallback( + (model: string) => { + setSelectedModel(model); + track("model_changed", { model }); + }, + [track], + ); + + const handleViewModeChange = useCallback( + (mode: "chat" | "ide") => { + navigate({ search: (prev: SessionSearch) => ({ ...prev, v: mode }) }); + track("view_mode_changed", { mode }); + }, + [navigate, track], + ); + + const focusChatInput = useCallback(() => { + const textarea = document.querySelector( + 'textarea[data-prompt-input="true"]', + ); + if (!textarea) return false; + textarea.focus(); + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + return true; + }, []); + + const { + enqueue: enqueueAudio, + clearQueue: clearAudioQueue, + isSpeaking, + } = useAudioPlayback(); + + const handleSendRef = useRef<(text: string) => Promise>(undefined); + + const handleNarratorAutoSend = useCallback((text: string) => { + void handleSendRef.current?.(text); + }, []); + + const handleNarratorCancel = useCallback(() => { + toast({ type: "info", message: "Cancelled" }); + }, [toast]); + + const handleDictation = useCallback((text: string) => { + setInputText((prev) => (prev ? `${prev} ${text}` : text)); + }, []); + + const { + narratorEnabled, + toggleNarrator, + isListening, + isProcessing, + isSupported: voiceSupported, + toggleMic, + accumulatedText, + interimText, + cancelCountdown, + isCountingDown, + countdownProgress, + voiceError, + } = useNarratorMode({ + onAutoSend: handleNarratorAutoSend, + onCancel: handleNarratorCancel, + onDictation: handleDictation, + isSpeaking, + }); + + useEffect(() => { + if (voiceError) { + toast({ type: "error", message: voiceError }); + } + }, [voiceError, toast]); + + const handleNarratorToggle = useCallback(() => { + const nextEnabled = !narratorEnabled; + toggleNarrator(); + track("narrator_toggled", { enabled: nextEnabled }); + }, [narratorEnabled, toggleNarrator, track]); + + const handleMicToggle = useCallback(() => { + const nextEnabled = !isListening; + toggleMic(); + track("voice_input_toggled", { enabled: nextEnabled }); + }, [isListening, toggleMic, track]); + + // Stop audio playback when user starts speaking (interruption) + useEffect(() => { + if (isListening) clearAudioQueue(); + }, [isListening, clearAudioQueue]); + + // Sync narrator accumulated text into the input field + useEffect(() => { + if (!narratorEnabled) return; + const display = interimText + ? `${accumulatedText} ${interimText}`.trim() + : accumulatedText; + setInputText(display); + }, [narratorEnabled, accumulatedText, interimText]); + + // When user types manually, cancel the silence countdown + const handleInputChange = useCallback( + (event: React.ChangeEvent) => { + setInputText(event.target.value); + if (narratorEnabled) { + cancelCountdown(); + } + }, + [narratorEnabled, cancelCountdown], + ); + + const formatFileSize = useCallback((size: number) => { + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / (1024 * 1024)).toFixed(1)} MB`; + }, []); + + const AttachmentPill = useCallback( + ({ + attachment, + onRemove, + }: { + attachment: AttachmentItem; + onRemove: (id: string) => void; + }) => { + const isImage = attachment.mime?.startsWith("image/"); + + return ( +
+ {isImage ? ( + {attachment.filename} + ) : null} + + {attachment.filename} + + + {formatFileSize(attachment.size)} + + +
+ ); + }, + [formatFileSize], + ); + + const handleOpenAttachmentPicker = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleRemoveAttachment = useCallback((id: string) => { + setAttachments((prev) => prev.filter((item) => item.id !== id)); + }, []); + + const handleAttachmentChange: ChangeEventHandler = + useCallback( + async (event) => { + const files = Array.from(event.target.files || []); + event.target.value = ""; + if (files.length === 0) return; + + const remainingSlots = MAX_ATTACHMENTS - attachments.length; + if (remainingSlots <= 0) { + toast({ + type: "error", + message: `You can only attach ${MAX_ATTACHMENTS} files.`, + }); + return; + } + + const accepted = files.slice(0, remainingSlots); + const rejected = files.slice(remainingSlots); + if (rejected.length > 0) { + toast({ + type: "error", + message: `Only ${MAX_ATTACHMENTS} files can be attached at once.`, + }); + } + + const invalidFiles: string[] = []; + const oversizedFiles: string[] = []; + + const readFile = (file: File) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = + typeof reader.result === "string" ? reader.result : ""; + const base64 = result.includes(",") + ? result.split(",")[1] || "" + : ""; + resolve({ + id: `${file.name}-${file.lastModified}-${Math.random().toString(16).slice(2)}`, + filename: file.name, + mime: file.type || "text/plain", + size: file.size, + content: base64, + }); + }; + reader.onerror = () => reject(new Error("Failed to read file")); + reader.readAsDataURL(file); + }); + + const newItems: AttachmentItem[] = []; + for (const file of accepted) { + const ext = file.name.split(".").pop()?.toLowerCase() || ""; + if (!ALLOWED_EXTENSIONS.has(ext)) { + invalidFiles.push(file.name); + continue; + } + if (file.size > MAX_ATTACHMENT_SIZE) { + oversizedFiles.push(file.name); + continue; + } + try { + const item = await readFile(file); + newItems.push(item); + track("file_attached", { fileType: file.type || ext || "unknown" }); + } catch { + invalidFiles.push(file.name); + } + } + + if (invalidFiles.length > 0) { + toast({ + type: "error", + message: `Unsupported file types: ${invalidFiles.slice(0, 3).join(", ")}`, + }); + } + + if (oversizedFiles.length > 0) { + toast({ + type: "error", + message: `Files over 10MB: ${oversizedFiles.slice(0, 3).join(", ")}`, + }); + } + + if (newItems.length > 0) { + setAttachments((prev) => [...prev, ...newItems]); + } + }, + [attachments.length, toast, track], + ); + const { getKeys } = useKeybindings(); + const { + tabs, + activeId, + activeTab, + setActiveId, + openFile, + openDiff, + closeTab, + updateTab, + } = useFileTabs(); + + useHotkeys( + getKeys("focus-chat"), + (event) => { + event.preventDefault(); + if (focusChatInput()) return; + if (viewMode !== "chat") { + handleViewModeChange("chat"); + setTimeout(() => { + focusChatInput(); + }, 50); + } + }, + { + enableOnFormTags: ["INPUT", "TEXTAREA", "SELECT"], + enableOnContentEditable: true, + }, + [getKeys("focus-chat"), viewMode], + ); + + useHotkeys( + getKeys("toggle-view"), + (event) => { + event.preventDefault(); + handleViewModeChange(viewMode === "ide" ? "chat" : "ide"); + }, + { + enableOnFormTags: ["INPUT", "TEXTAREA", "SELECT"], + enableOnContentEditable: true, + }, + [getKeys("toggle-view"), viewMode], + ); + + useHotkeys( + getKeys("close-tab"), + (event) => { + if (viewMode !== "ide" || !activeId) return; + event.preventDefault(); + closeTab(activeId); + }, + { enableOnFormTags: false, enableOnContentEditable: false }, + [getKeys("close-tab"), viewMode, activeId], + ); + + useHotkeys( + getKeys("toggle-sidebar-tab"), + (event) => { + if (viewMode !== "ide") return; + event.preventDefault(); + const nextTab = sidebarTab === "files" ? "git" : "files"; + navigate({ + search: (prev: SessionSearch) => ({ ...prev, tab: nextTab }), + }); + }, + { enableOnFormTags: false, enableOnContentEditable: false }, + [getKeys("toggle-sidebar-tab"), viewMode, sidebarTab], + ); + + useEffect(() => { + const handler = () => { + if (viewMode !== "ide" || !activeId) return; + closeTab(activeId); + }; + window.addEventListener("app-close-active-tab", handler); + return () => window.removeEventListener("app-close-active-tab", handler); + }, [activeId, closeTab, viewMode]); + + // File tree cache — persists across IDE tab switches (mount/unmount of FileExplorer) + const [cachedTreeNodes, setCachedTreeNodes] = useState([]); + const [cachedTreeEntryCount, setCachedTreeEntryCount] = useState(0); + const handleTreeLoaded = useCallback( + (treeNodes: FileTreeNode[], treeEntryCount: number) => { + setCachedTreeNodes(treeNodes); + setCachedTreeEntryCount(treeEntryCount); + }, + [], + ); + + const { + commentCount, + addComment, + clearComments, + formatForPrompt, + getDiffAnnotations, + getFileComments, + } = useCodeComments(); + const { settings: editorSettings, updateSettings: updateEditorSettings } = + useEditorSettings(); + const handleAddComment = useCallback( + (...args: Parameters) => { + addComment(...args); + track("code_comment_added"); + }, + [addComment, track], + ); + useEffect(() => { + if (!sessionId) return; + setAttachments([]); + }, [sessionId]); + + // Load user's default agent preference (once on mount) + const hasManuallySelectedRef = useRef(false); + useEffect(() => { + hasManuallySelectedRef.current = hasManuallySelectedCommand; + }, [hasManuallySelectedCommand]); + + useEffect(() => { + if (initialSession.agent) return; + fetch("/api/user/settings") + .then((r) => r.json()) + .then((data: { defaultCommand?: string }) => { + if (!hasManuallySelectedRef.current && data.defaultCommand) { + setSelectedCommand(data.defaultCommand); + } + }) + .catch(() => {}); + }, [initialSession.agent]); + + const activeFilePath = activeTab?.filePath ?? null; + const isBusy = sessionStatus.type === "busy"; + const isArchivedSession = + session.archiveStatus === "archived" && + (session.status === "terminated" || session.status === "deleted"); + const displayMessages = + session.status === "terminated" || session.status === "deleted" + ? archivedMessages + : messages; + const displayTodos = isArchivedSession + ? archivedTodos.map((t) => ({ + id: t.id, + content: t.content, + status: t.status as + | "pending" + | "in_progress" + | "completed" + | "cancelled", + priority: t.priority as "high" | "medium" | "low", + })) + : todos; + const sshCommand = session.sandboxId + ? `agentuity cloud ssh ${session.sandboxId}` + : ""; + const attachCommand = session.sandboxUrl + ? `opencode attach ${session.sandboxUrl}${opencodePassword ? ` --password ${opencodePassword}` : ""}` + : ""; + const getDisplayParts = useCallback( + (messageID: string) => { + if (session.status === "terminated" || session.status === "deleted") { + return archivedParts.get(messageID) ?? []; + } + return getPartsForMessage(messageID); + }, + [archivedParts, getPartsForMessage, session.status], + ); + const { + branch: gitBranch, + changedCount: gitChangedCount, + hasRepo: isGitRepo, + refresh: refreshGitStatus, + } = useGitStatus(activeSessionId, githubAvailable); + + // Child sessions for sub-agent inspection + const { children: childSessionsList, fetchChildMessages } = useChildSessions( + sessionId, + { archived: isArchivedSession }, + ); + useEffect(() => { + if (!isEditingTitle) { + setEditTitle(session.title || ""); + } + }, [isEditingTitle, session.title]); + + const promptPlaceholder = + session.status === "active" + ? "Message the agent..." + : session.status === "creating" + ? "Type your message... (will send when ready)" + : session.status === "terminated" + ? "This session is read-only." + : session.status === "error" + ? "Session failed to start." + : "Waiting for sandbox to be ready..."; + const attachmentAccept = Array.from(ALLOWED_EXTENSIONS) + .map((ext) => `.${ext}`) + .join(","); + const isSessionInputEnabled = + session.status === "active" || session.status === "creating"; + const attachmentDisabled = + !isSessionInputEnabled || attachments.length >= MAX_ATTACHMENTS; + + const sendMessage = useCallback( + async (payload: QueuedMessage) => { + setIsSending(true); + try { + if ( + payload.command && + TEMPLATE_COMMANDS.has(payload.command.replace(/^\//, "")) && + payload.attachments && + payload.attachments.length > 0 + ) { + throw new Error("Attachments are not supported for commands."); + } + const res = await fetch(`/api/sessions/${sessionId}/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: payload.text, + model: payload.model, + command: payload.command, + attachments: payload.attachments?.map( + ({ filename, mime, content }) => ({ + filename, + mime, + content, + }), + ), + }), + }); + if (!res.ok) { + throw new Error("Failed to send message"); + } + track("message_sent", { + hasAttachments: Boolean(payload.attachments?.length), + attachmentCount: payload.attachments?.length ?? 0, + command: payload.command ?? null, + model: payload.model, + }); + } catch (err) { + console.error("Failed to send message:", err); + toast({ type: "error", message: "Failed to send message" }); + } finally { + setIsSending(false); + } + }, + [sessionId, toast, track], + ); + + const handleSend = async (text: string) => { + if (!text.trim() && attachments.length === 0) return; + const isTemplateCmd = + selectedCommand && + TEMPLATE_COMMANDS.has(selectedCommand.replace(/^\//, "")); + if (isTemplateCmd && attachments.length > 0) { + toast({ + type: "error", + message: "Attachments are not supported with commands.", + }); + return; + } + const commentsBlock = formatForPrompt(); + const baseText = + text.trim() || (attachments.length > 0 ? "Attached files." : ""); + const fullMessage = commentsBlock + ? `${baseText}\n\n---\nCode Comments:\n${commentsBlock}` + : baseText; + const nextAttachments = attachments; + const payload: QueuedMessage = { + text: fullMessage, + model: selectedModel, + command: selectedCommand, + attachments: nextAttachments, + }; + + setInputText(""); + setAttachments([]); + if (commentCount > 0) { + clearComments(); + } + if (isBusy || isSending || session.status === "creating") { + setMessageQueue((prev) => [...prev, payload]); + return; + } + + await sendMessage(payload); + }; + handleSendRef.current = handleSend; + + useEffect(() => { + if ( + session.status !== "active" || + isBusy || + isSending || + messageQueue.length === 0 + ) + return; + const [next, ...rest] = messageQueue; + if (!next) return; + setMessageQueue(rest); + void sendMessage(next); + }, [session.status, isBusy, isSending, messageQueue, sendMessage]); const handleFork = async () => { if (isForking) return; setIsForking(true); try { - const res = await fetch(`/api/sessions/${sessionId}/fork`, { method: 'POST' }); + const res = await fetch(`/api/sessions/${sessionId}/fork`, { + method: "POST", + }); if (!res.ok) { - throw new Error('Failed to fork session'); + throw new Error("Failed to fork session"); } const newSession = await res.json(); onForkedSession?.(newSession); - track('session_forked'); + track("session_forked"); } catch (error) { - console.error('Failed to fork session:', error); - toast({ type: 'error', message: 'Failed to fork session' }); + console.error("Failed to fork session:", error); + toast({ type: "error", message: "Failed to fork session" }); } finally { setIsForking(false); } }; - const handleCreateSnapshot = async () => { - if (isSavingSnapshot || !snapshotName.trim()) return; - setIsSavingSnapshot(true); - try { - const res = await fetch(`/api/sessions/${sessionId}/snapshot`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: snapshotName.trim(), description: snapshotDescription.trim() || undefined }), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error || 'Failed to save snapshot'); - } - toast({ type: 'success', message: 'Snapshot saved!' }); - track('snapshot_created'); - setShowSnapshotDialog(false); - setSnapshotName(''); - setSnapshotDescription(''); - } catch (error) { - toast({ type: 'error', message: error instanceof Error ? error.message : 'Failed to save snapshot' }); - } finally { - setIsSavingSnapshot(false); - } - }; - - const handleShare = async () => { - if (isSharing) return; - setIsSharing(true); - try { - const res = await fetch(`/api/sessions/${sessionId}/share`, { method: 'POST' }); + const handleCreateSnapshot = async () => { + if (isSavingSnapshot || !snapshotName.trim()) return; + setIsSavingSnapshot(true); + try { + const res = await fetch(`/api/sessions/${sessionId}/snapshot`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: snapshotName.trim(), + description: snapshotDescription.trim() || undefined, + }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "Failed to save snapshot"); + } + toast({ type: "success", message: "Snapshot saved!" }); + track("snapshot_created"); + setShowSnapshotDialog(false); + setSnapshotName(""); + setSnapshotDescription(""); + } catch (error) { + toast({ + type: "error", + message: + error instanceof Error ? error.message : "Failed to save snapshot", + }); + } finally { + setIsSavingSnapshot(false); + } + }; + + const handleShare = async () => { + if (isSharing) return; + setIsSharing(true); + try { + const res = await fetch(`/api/sessions/${sessionId}/share`, { + method: "POST", + }); if (!res.ok) { const data = await res.json().catch(() => ({})); - throw new Error(data.error || 'Failed to share session'); + throw new Error(data.error || "Failed to share session"); } const { url } = await res.json(); setShareUrl(url); - toast({ type: 'success', message: 'Share link created!' }); - track('session_shared'); + toast({ type: "success", message: "Share link created!" }); + track("session_shared"); + } catch (error) { + console.error("Failed to share session:", error); + toast({ + type: "error", + message: + error instanceof Error ? error.message : "Failed to share session", + }); + } finally { + setIsSharing(false); + } + }; + + const handleCopyShareLink = async () => { + if (!shareUrl) return; + try { + await navigator.clipboard.writeText(shareUrl); + setShareCopied(true); + setTimeout(() => setShareCopied(false), 2000); + } catch { + toast({ type: "error", message: "Failed to copy link" }); + } + }; + + const saveTitle = async () => { + const trimmed = editTitle.trim(); + if (!trimmed || trimmed === session.title) return; + try { + await fetch(`/api/sessions/${sessionId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: trimmed }), + }); + setSession((prev) => ({ ...prev, title: trimmed })); + } catch { + // silent + } + }; + + const handleCopySshCommand = useCallback(async () => { + if (!sshCommand) return; + try { + await navigator.clipboard.writeText(sshCommand); + setSshCopied(true); + setTimeout(() => setSshCopied(false), 2000); + } catch { + toast({ type: "error", message: "Failed to copy SSH command" }); + } + }, [sshCommand, toast]); + + const handleCopySandboxId = useCallback(async () => { + if (!session.sandboxId) return; + try { + await navigator.clipboard.writeText(session.sandboxId); + setSandboxCopied(true); + setTimeout(() => setSandboxCopied(false), 2000); + } catch { + toast({ type: "error", message: "Failed to copy sandbox ID" }); + } + }, [session.sandboxId, toast]); + + const handleCopyAttachCommand = useCallback(async () => { + if (!attachCommand) return; + try { + await navigator.clipboard.writeText(attachCommand); + setAttachCopied(true); + setTimeout(() => setAttachCopied(false), 2000); + } catch { + toast({ type: "error", message: "Failed to copy attach command" }); + } + }, [attachCommand, toast]); + + const handleCopyPassword = useCallback(async () => { + if (!opencodePassword) return; + try { + await navigator.clipboard.writeText(opencodePassword); + setPasswordCopied(true); + setTimeout(() => setPasswordCopied(false), 2000); + } catch { + toast({ type: "error", message: "Failed to copy password" }); + } + }, [opencodePassword, toast]); + + const handleDownloadSandbox = useCallback(async () => { + if (isDownloading || !sessionId) return; + setIsDownloading(true); + try { + const res = await apiFetch(`/api/sessions/${sessionId}/download`); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "Download failed"); + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `sandbox-${session.sandboxId || sessionId}.tar.gz`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + track("sandbox_downloaded"); } catch (error) { - console.error('Failed to share session:', error); - toast({ type: 'error', message: error instanceof Error ? error.message : 'Failed to share session' }); - } finally { - setIsSharing(false); - } - }; - - const handleCopyShareLink = async () => { - if (!shareUrl) return; - try { - await navigator.clipboard.writeText(shareUrl); - setShareCopied(true); - setTimeout(() => setShareCopied(false), 2000); - } catch { - toast({ type: 'error', message: 'Failed to copy link' }); - } - }; - - const saveTitle = async () => { - const trimmed = editTitle.trim(); - if (!trimmed || trimmed === session.title) return; - try { - await fetch(`/api/sessions/${sessionId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title: trimmed }), - }); - setSession((prev) => ({ ...prev, title: trimmed })); - } catch { - // silent - } - }; - - const handleCopySshCommand = useCallback(async () => { - if (!sshCommand) return; - try { - await navigator.clipboard.writeText(sshCommand); - setSshCopied(true); - setTimeout(() => setSshCopied(false), 2000); - } catch { - toast({ type: 'error', message: 'Failed to copy SSH command' }); - } - }, [sshCommand, toast]); - - const handleCopySandboxId = useCallback(async () => { - if (!session.sandboxId) return; - try { - await navigator.clipboard.writeText(session.sandboxId); - setSandboxCopied(true); - setTimeout(() => setSandboxCopied(false), 2000); - } catch { - toast({ type: 'error', message: 'Failed to copy sandbox ID' }); - } - }, [session.sandboxId, toast]); - - const handleCopyAttachCommand = useCallback(async () => { - if (!attachCommand) return; - try { - await navigator.clipboard.writeText(attachCommand); - setAttachCopied(true); - setTimeout(() => setAttachCopied(false), 2000); - } catch { - toast({ type: 'error', message: 'Failed to copy attach command' }); - } - }, [attachCommand, toast]); - - const handleCopyPassword = useCallback(async () => { - if (!opencodePassword) return; - try { - await navigator.clipboard.writeText(opencodePassword); - setPasswordCopied(true); - setTimeout(() => setPasswordCopied(false), 2000); - } catch { - toast({ type: 'error', message: 'Failed to copy password' }); - } - }, [opencodePassword, toast]); - - const handleDownloadSandbox = useCallback(async () => { - if (isDownloading || !sessionId) return; - setIsDownloading(true); - try { - const res = await apiFetch(`/api/sessions/${sessionId}/download`); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error || 'Download failed'); - } - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `sandbox-${session.sandboxId || sessionId}.tar.gz`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - track('sandbox_downloaded'); - } catch (error) { - toast({ type: 'error', message: error instanceof Error ? error.message : 'Failed to download sandbox files' }); - } finally { - setIsDownloading(false); - } - }, [isDownloading, sessionId, session.sandboxId, toast, track]); + toast({ + type: "error", + message: + error instanceof Error + ? error.message + : "Failed to download sandbox files", + }); + } finally { + setIsDownloading(false); + } + }, [isDownloading, sessionId, session.sandboxId, toast, track]); // Abort const handleAbort = async () => { try { - const res = await fetch(`/api/sessions/${sessionId}/abort`, { method: 'POST' }); + const res = await fetch(`/api/sessions/${sessionId}/abort`, { + method: "POST", + }); if (res.ok) { - track('session_aborted'); + track("session_aborted"); } } catch { // Ignore abort errors } }; - const handleRevert = useCallback(async (messageID: string) => { - if (!sessionId) return; - try { - const res = await fetch(`/api/sessions/${sessionId}/revert`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ messageID }), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - console.error('Revert failed:', data); - return; - } - track('checkpoint_reverted'); - // UI updates come via SSE session.updated event - } catch (error) { - console.error('Revert failed:', error); - } - }, [sessionId, track]); - - const handleUnrevert = useCallback(async () => { - if (!sessionId) return; - try { - const res = await fetch(`/api/sessions/${sessionId}/unrevert`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - console.error('Unrevert failed:', data); - } - } catch (error) { - console.error('Unrevert failed:', error); - } - }, [sessionId]); - - const lastAssistantMessage = useMemo( - () => [...displayMessages].reverse().find((message) => message.role === 'assistant'), - [displayMessages] - ); - const lastAssistantParts = lastAssistantMessage - ? getDisplayParts(lastAssistantMessage.id) - : []; - const hasStreamingContent = lastAssistantParts.length > 0; - const isStreaming = isBusy; - const submitDisabled = - !isSessionInputEnabled - || isSending - || (!inputText.trim() && attachments.length === 0); - - // Narrator: on busy→idle transition, speak the assistant's response - const wasBusyRef = useRef(false); - const lastNarratedMessageIdRef = useRef(null); - - useEffect(() => { - if (!narratorEnabled) { - wasBusyRef.current = isBusy; - return; - } - - if (wasBusyRef.current && !isBusy) { - const lastAssistant = displayMessages.length > 0 - ? [...displayMessages].reverse().find(m => m.role === 'assistant') - : null; - - if (lastAssistant && lastAssistant.id !== lastNarratedMessageIdRef.current) { - lastNarratedMessageIdRef.current = lastAssistant.id; - - const parts = getDisplayParts(lastAssistant.id); - const textContent = parts - .filter(p => p.type === 'text') - .map(p => (p as { text: string }).text || '') - .join('\n') - .replace(/```[\s\S]*?```/g, '') - .replace(/\*\*([^*]+)\*\*/g, '$1') - .replace(/`([^`]+)`/g, '$1') - .replace(/#{1,6}\s/g, '') - .replace(/\n{2,}/g, '. ') - .replace(/\s{2,}/g, ' ') - .trim(); - - if (textContent) { - const recentChat = displayMessages.slice(-6).map(m => { - const msgParts = getDisplayParts(m.id); - const msgText = msgParts - .filter(p => p.type === 'text') - .map(p => (p as { text: string }).text || '') - .join('\n') - .replace(/```[\s\S]*?```/g, '') - .trim() - .slice(0, 500); - return { role: m.role, text: msgText }; - }).filter(m => m.text.length > 0); - - fetch('/api/voice/narrate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - text: textContent.slice(0, 5000), - conversationHistory: recentChat, - }), - }) - .then(res => res.json()) - .then((data: { text?: string; audio?: { base64: string; mimeType: string } }) => { - if (data.audio) enqueueAudio(data.audio); - }) - .catch(() => { - // Silent fail — narrator is best-effort - }); - } - } - } - - wasBusyRef.current = isBusy; - }, [narratorEnabled, isBusy, displayMessages, getDisplayParts, enqueueAudio]); - - // Clear audio on session change - useEffect(() => { - if (sessionId) { - clearAudioQueue(); - lastNarratedMessageIdRef.current = null; - } - }, [sessionId, clearAudioQueue]); - - const copyMessage = useCallback( - (message: ChatMessage) => { - const parts = getDisplayParts(message.id); - const text = parts - .filter((part) => part.type === 'text') - .map((part) => (part as { text: string }).text) - .join(''); - if (text.trim().length === 0) return; - if (navigator?.clipboard?.writeText) { - void navigator.clipboard.writeText(text); - } - }, - [getDisplayParts] - ); - - const sessionUsage = useMemo(() => { - const totals = { - tokens: { - input: 0, - output: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - cost: 0, - modelIDs: new Set(), - providerIDs: new Set(), - }; - - for (const message of displayMessages) { - if (message.role !== 'assistant') continue; - totals.cost += message.cost ?? 0; - totals.tokens.input += message.tokens?.input ?? 0; - totals.tokens.output += message.tokens?.output ?? 0; - totals.tokens.reasoning += message.tokens?.reasoning ?? 0; - totals.tokens.cache.read += message.tokens?.cache?.read ?? 0; - totals.tokens.cache.write += message.tokens?.cache?.write ?? 0; - if (message.modelID) totals.modelIDs.add(message.modelID); - if (message.providerID) totals.providerIDs.add(message.providerID); - } - - const totalTokens = - totals.tokens.input + - totals.tokens.output + - totals.tokens.reasoning + - totals.tokens.cache.read + - totals.tokens.cache.write; - - return { - ...totals, - totalTokens, - modelID: totals.modelIDs.size === 1 ? Array.from(totals.modelIDs)[0] : null, - providerID: totals.providerIDs.size === 1 ? Array.from(totals.providerIDs)[0] : null, - }; - }, [displayMessages]); - - const queuedCount = messageQueue.length; - - const getSourcesForToolPart = useCallback((part: ToolPart): SourceItem[] => { - const sources: SourceItem[] = []; - const seen = new Set(); - - const addSource = (item: SourceItem) => { - const key = `${item.type}:${item.label}`; - if (seen.has(key)) return; - seen.add(key); - sources.push(item); - }; - - const input = part.state.input ?? {}; - const output = 'output' in part.state ? part.state.output : undefined; - - if (typeof (input as { filePath?: unknown }).filePath === 'string') { - addSource({ type: 'file', label: (input as { filePath: string }).filePath }); - } - - if ((part.tool === 'glob' || part.tool === 'grep') && output) { - for (const filePath of output.split('\n').map((line) => line.trim()).filter(Boolean)) { - addSource({ type: 'file', label: filePath }); - } - } - - if (part.tool === 'webfetch' && typeof (input as { url?: unknown }).url === 'string') { - const url = (input as { url: string }).url; - addSource({ type: 'url', label: url, href: url }); - } - - return sources; - }, []); - - const renderReasoning = useCallback((part: ReasoningPart, message: ChatMessage) => { - const duration = part.time.end - ? Math.max(1, Math.ceil((part.time.end - part.time.start) / 1000)) - : undefined; - const shouldStream = isStreaming && message.id === lastAssistantMessage?.id; - return ( - - - {part.text} - - ); - }, [isStreaming, lastAssistantMessage]); - - type ChainGroup = { type: 'chain'; filePath: string; parts: ToolPart[] }; - type CurrentChain = { - filePath: string; - parts: ToolPart[]; - startsWithRead: boolean; - hasWriteOrEdit: boolean; - }; - - const extractFilePath = useCallback((part: ToolPart): string | null => { - const input = part.state?.input; - if (!input) return null; - try { - const parsed = typeof input === 'string' ? JSON.parse(input) : input; - const candidate = parsed as { filePath?: string; path?: string; file?: string }; - return candidate.filePath || candidate.path || candidate.file || null; - } catch { - return null; - } - }, []); - - const isReadTool = useCallback((part: ToolPart): boolean => { - const input = part.state?.input; - if (!input || typeof input !== 'object') return false; - return ( - typeof (input as { filePath?: unknown }).filePath === 'string' - && typeof (input as { content?: unknown }).content !== 'string' - && typeof (input as { oldString?: unknown }).oldString !== 'string' - && typeof (input as { command?: unknown }).command !== 'string' - ); - }, []); - - const isWriteOrEditTool = useCallback((part: ToolPart): boolean => { - const input = part.state?.input; - if (!input || typeof input !== 'object') return false; - const hasEdit = - typeof (input as { oldString?: unknown }).oldString === 'string' - && typeof (input as { newString?: unknown }).newString === 'string'; - const hasWrite = typeof (input as { content?: unknown }).content === 'string'; - return hasEdit || hasWrite; - }, []); - - const groupPartsIntoChains = useCallback((parts: Part[]): (Part | ChainGroup)[] => { - const groups: (Part | ChainGroup)[] = []; - let currentChain: CurrentChain | null = null; - - const flushChain = () => { - if (!currentChain) return; - const shouldChain = - currentChain.parts.length > 1 - && currentChain.startsWithRead - && currentChain.hasWriteOrEdit; - if (shouldChain) { - groups.push({ - type: 'chain', - filePath: currentChain.filePath, - parts: currentChain.parts, - }); - } else { - groups.push(...currentChain.parts); - } - currentChain = null; - }; - - for (const part of parts) { - if (part.type === 'tool') { - const filePath = extractFilePath(part); - if (filePath) { - if (currentChain && currentChain.filePath === filePath) { - currentChain.parts.push(part); - if (isWriteOrEditTool(part)) currentChain.hasWriteOrEdit = true; - } else { - flushChain(); - currentChain = { - filePath, - parts: [part], - startsWithRead: isReadTool(part), - hasWriteOrEdit: isWriteOrEditTool(part), - }; - } - continue; - } - } - flushChain(); - groups.push(part); - } - - flushChain(); - return groups; - }, [extractFilePath, isReadTool, isWriteOrEditTool]); - - - - const renderPart = useCallback((part: Part, message: ChatMessage) => { - switch (part.type) { - case 'text': - return ( - - - - ); - case 'reasoning': - return renderReasoning(part, message); - case 'tool': - return ( - - ); - case 'file': - return ; - case 'subtask': - return ; - case 'agent': - return ; - case 'step-finish': - return null; - case 'patch': - return ( -
-
- Files changed ({part.files.length}) -
-
- {part.files.map((file) => ( -
- {file} -
- ))} -
-
- ); - case 'snapshot': - return ( -
- {'📸'} Context snapshot saved -
- ); - case 'compaction': - return ( -
- {'🗜️'} Context compacted{part.auto ? ' (auto)' : ''} -
- ); - case 'retry': - return ( -
- Retry attempt {part.attempt}: {part.error.message || part.error.type} -
- ); - case 'step-start': - return null; - default: - return null; - } - }, [isStreaming, lastAssistantMessage, renderReasoning, handleAddComment, getDiffAnnotations, getFileComments, getSourcesForToolPart, sessionId]); - - const renderedMessages = useMemo(() => { - if (displayMessages.length === 0) return null; - return displayMessages.map((message, msgIndex) => { - const parts = getDisplayParts(message.id); - const agent = 'agent' in message ? message.agent : undefined; - const errorInfo = 'error' in message ? message.error : undefined; - const isAfterRevertPoint = isGitRepo && revertState != null && (() => { - const revertMsgIndex = displayMessages.findIndex(m => m.id === revertState.messageID); - return msgIndex > revertMsgIndex; - })(); - - return ( -
- - - {agent && ( -
- {agent} -
- )} - {groupPartsIntoChains(parts).map((part) => { - if (part.type === 'chain') { - return ( - - {part.parts.map((chainPart) => renderPart(chainPart, message))} - - ); - } - return renderPart(part, message); - })} - {errorInfo && (() => { - const errorText = errorInfo.message || errorInfo.type || ''; - const isAbort = !errorText || /abort/i.test(errorText) || /abort/i.test(errorInfo.type || ''); - if (isAbort) { - return ( -
- Response stopped -
- ); - } - return ( -
- Error: {errorText} -
- ); - })()} -
- {message.role === 'assistant' && ( - - - - {isGitRepo && ( - handleRevert(message.id)} - title="Restore to this checkpoint" - > - - - )} - copyMessage(message)} - title="Copy" - > - - - - - )} -
-
- ); - }); - }, [displayMessages, getDisplayParts, isGitRepo, revertState, groupPartsIntoChains, renderPart, copyMessage, handleRevert]); - - const conversationView = ( - - - {session.status !== 'active' && session.status === 'creating' && ( -
-
-
-
-
-
-

- {statusElapsedMs > 25000 - ? 'Almost ready...' - : statusElapsedMs > 15000 - ? 'Starting AI agent' - : statusElapsedMs > 8000 - ? 'Installing tools & skills' - : statusElapsedMs > 3000 - ? 'Setting up environment' - : 'Creating sandbox'} -

-

- {statusElapsedMs > 25000 - ? 'Verifying the agent is responsive' - : statusElapsedMs > 15000 - ? 'Launching OpenCode server' - : statusElapsedMs > 8000 - ? 'Configuring agent capabilities' - : statusElapsedMs > 3000 - ? 'Cloning repository and preparing workspace' - : 'Provisioning an isolated sandbox environment'} -

-
-
- {[3000, 8000, 15000, 25000].map((threshold) => ( -
threshold - ? 'bg-[var(--primary)]' - : 'bg-[var(--border)]' - }`} - /> - ))} -
-

- {Math.floor(statusElapsedMs / 1000)}s -

-
- )} - {session.status !== 'active' && session.status !== 'creating' && ( -
-
- - {session.status === 'error' && '❌ Failed to create sandbox.'} - {session.status === 'terminated' && "This session's sandbox has been terminated. Chat history is read-only."} - - {session.status === 'error' && ( - - )} -
- {session.status === 'terminated' && (archiveError || isLoadingArchive) && ( -

- {isLoadingArchive ? 'Loading chat history...' : archiveError} -

- )} -
- )} - {typeof (session.metadata as Record | null)?.cloneError === 'string' && ( -
-
⚠️ Repository clone failed
-

{(session.metadata as Record).cloneError}

-

- The session started without code. Check your GitHub PAT permissions in Profile settings, or provide the repo URL to the agent. -

-
- )} - {displayMessages.length === 0 && !isBusy ? ( - - {!isConnected && error ? ( -
- -

- Connection failed -

-

- Unable to connect to the AI agent -

- -
- ) : ( -
- {session.status === 'terminated' ? ( -

- No messages available for this session. -

- ) : ( - <> -

- Start a conversation... -

-

- Press Enter to send, Shift+Enter for newline -

- - )} -
- )} -
- ) : ( - renderedMessages - )} - - {error && isConnected && displayMessages.length > 0 && !/abort/i.test(typeof error === 'string' ? error : '') && ( -
- - {typeof error === 'string' ? error : String(error)} -
- )} - - {pendingPermissions.map((perm) => ( - - ))} - {pendingQuestions.map((question) => ( - - ))} - - {isStreaming && !hasStreamingContent && ( - - - - - - )} - - - - ); - - const attachmentInput = ( - - ); - - const inputArea = ( -
- {revertState && isGitRepo && ( -
- Session reverted to an earlier checkpoint. New messages will continue from this point. - -
- )} - {messageQueue.length > 0 && ( -
- - Queued ({messageQueue.length}) - - {messageQueue.map((msg, i) => ( -
- - {msg.text.slice(0, 60)}... -
- ))} -
- )} - - handleSend(text)}> - - {attachments.length > 0 && ( -
- {attachments.map((attachment) => ( - - ))} -
- )} - -
- { - setSelectedCommand(cmd); - setHasManuallySelectedCommand(true); - }} /> - - Enter to send · Shift+Enter for new line - {commentCount > 0 && ( - - {commentCount} comment{commentCount > 1 ? 's' : ''} - - )} - {queuedCount > 0 && ( - - - Queued {queuedCount} - - )} - {commentCount > 0 && ( - - )} -
-
- - {session.status === 'active' && ( - - )} - - {session.status === 'creating' ? : undefined} - -
-
-
-
-
- ); + const handleRevert = useCallback( + async (messageID: string) => { + if (!sessionId) return; + try { + const res = await fetch(`/api/sessions/${sessionId}/revert`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messageID }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + console.error("Revert failed:", data); + return; + } + track("checkpoint_reverted"); + // UI updates come via SSE session.updated event + } catch (error) { + console.error("Revert failed:", error); + } + }, + [sessionId, track], + ); + + const handleUnrevert = useCallback(async () => { + if (!sessionId) return; + try { + const res = await fetch(`/api/sessions/${sessionId}/unrevert`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + console.error("Unrevert failed:", data); + } + } catch (error) { + console.error("Unrevert failed:", error); + } + }, [sessionId]); + + const lastAssistantMessage = useMemo( + () => + [...displayMessages] + .reverse() + .find((message) => message.role === "assistant"), + [displayMessages], + ); + const lastAssistantParts = lastAssistantMessage + ? getDisplayParts(lastAssistantMessage.id) + : []; + const hasStreamingContent = lastAssistantParts.length > 0; + const isStreaming = isBusy; + const submitDisabled = + !isSessionInputEnabled || + isSending || + (!inputText.trim() && attachments.length === 0); + + // Narrator: on busy→idle transition, speak the assistant's response + const wasBusyRef = useRef(false); + const lastNarratedMessageIdRef = useRef(null); + + useEffect(() => { + if (!narratorEnabled) { + wasBusyRef.current = isBusy; + return; + } + + if (wasBusyRef.current && !isBusy) { + const lastAssistant = + displayMessages.length > 0 + ? [...displayMessages].reverse().find((m) => m.role === "assistant") + : null; + + if ( + lastAssistant && + lastAssistant.id !== lastNarratedMessageIdRef.current + ) { + lastNarratedMessageIdRef.current = lastAssistant.id; + + const parts = getDisplayParts(lastAssistant.id); + const textContent = parts + .filter((p) => p.type === "text") + .map((p) => (p as { text: string }).text || "") + .join("\n") + .replace(/```[\s\S]*?```/g, "") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/`([^`]+)`/g, "$1") + .replace(/#{1,6}\s/g, "") + .replace(/\n{2,}/g, ". ") + .replace(/\s{2,}/g, " ") + .trim(); + + if (textContent) { + const recentChat = displayMessages + .slice(-6) + .map((m) => { + const msgParts = getDisplayParts(m.id); + const msgText = msgParts + .filter((p) => p.type === "text") + .map((p) => (p as { text: string }).text || "") + .join("\n") + .replace(/```[\s\S]*?```/g, "") + .trim() + .slice(0, 500); + return { role: m.role, text: msgText }; + }) + .filter((m) => m.text.length > 0); + + fetch("/api/voice/narrate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: textContent.slice(0, 5000), + conversationHistory: recentChat, + }), + }) + .then((res) => res.json()) + .then( + (data: { + text?: string; + audio?: { base64: string; mimeType: string }; + }) => { + if (data.audio) enqueueAudio(data.audio); + }, + ) + .catch(() => { + // Silent fail — narrator is best-effort + }); + } + } + } + + wasBusyRef.current = isBusy; + }, [narratorEnabled, isBusy, displayMessages, getDisplayParts, enqueueAudio]); + + // Clear audio on session change + useEffect(() => { + if (sessionId) { + clearAudioQueue(); + lastNarratedMessageIdRef.current = null; + } + }, [sessionId, clearAudioQueue]); + + const copyMessage = useCallback( + (message: ChatMessage) => { + const parts = getDisplayParts(message.id); + const text = parts + .filter((part) => part.type === "text") + .map((part) => (part as { text: string }).text) + .join(""); + if (text.trim().length === 0) return; + if (navigator?.clipboard?.writeText) { + void navigator.clipboard.writeText(text); + } + }, + [getDisplayParts], + ); + + const sessionUsage = useMemo(() => { + const totals = { + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + cost: 0, + modelIDs: new Set(), + providerIDs: new Set(), + }; + + for (const message of displayMessages) { + if (message.role !== "assistant") continue; + totals.cost += message.cost ?? 0; + totals.tokens.input += message.tokens?.input ?? 0; + totals.tokens.output += message.tokens?.output ?? 0; + totals.tokens.reasoning += message.tokens?.reasoning ?? 0; + totals.tokens.cache.read += message.tokens?.cache?.read ?? 0; + totals.tokens.cache.write += message.tokens?.cache?.write ?? 0; + if (message.modelID) totals.modelIDs.add(message.modelID); + if (message.providerID) totals.providerIDs.add(message.providerID); + } + + const totalTokens = + totals.tokens.input + + totals.tokens.output + + totals.tokens.reasoning + + totals.tokens.cache.read + + totals.tokens.cache.write; + + return { + ...totals, + totalTokens, + modelID: + totals.modelIDs.size === 1 ? Array.from(totals.modelIDs)[0] : null, + providerID: + totals.providerIDs.size === 1 + ? Array.from(totals.providerIDs)[0] + : null, + }; + }, [displayMessages]); + + const queuedCount = messageQueue.length; + + const getSourcesForToolPart = useCallback((part: ToolPart): SourceItem[] => { + const sources: SourceItem[] = []; + const seen = new Set(); + + const addSource = (item: SourceItem) => { + const key = `${item.type}:${item.label}`; + if (seen.has(key)) return; + seen.add(key); + sources.push(item); + }; + + const input = part.state.input ?? {}; + const output = "output" in part.state ? part.state.output : undefined; + + if (typeof (input as { filePath?: unknown }).filePath === "string") { + addSource({ + type: "file", + label: (input as { filePath: string }).filePath, + }); + } + + if ((part.tool === "glob" || part.tool === "grep") && output) { + for (const filePath of output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean)) { + addSource({ type: "file", label: filePath }); + } + } + + if ( + part.tool === "webfetch" && + typeof (input as { url?: unknown }).url === "string" + ) { + const url = (input as { url: string }).url; + addSource({ type: "url", label: url, href: url }); + } + + return sources; + }, []); + + const renderReasoning = useCallback( + (part: ReasoningPart, message: ChatMessage) => { + const duration = part.time.end + ? Math.max(1, Math.ceil((part.time.end - part.time.start) / 1000)) + : undefined; + const shouldStream = + isStreaming && message.id === lastAssistantMessage?.id; + return ( + + + {part.text} + + ); + }, + [isStreaming, lastAssistantMessage], + ); + + type ChainGroup = { type: "chain"; filePath: string; parts: ToolPart[] }; + type CurrentChain = { + filePath: string; + parts: ToolPart[]; + startsWithRead: boolean; + hasWriteOrEdit: boolean; + }; + + const extractFilePath = useCallback((part: ToolPart): string | null => { + const input = part.state?.input; + if (!input) return null; + try { + const parsed = typeof input === "string" ? JSON.parse(input) : input; + const candidate = parsed as { + filePath?: string; + path?: string; + file?: string; + }; + return candidate.filePath || candidate.path || candidate.file || null; + } catch { + return null; + } + }, []); + + const isReadTool = useCallback((part: ToolPart): boolean => { + const input = part.state?.input; + if (!input || typeof input !== "object") return false; + return ( + typeof (input as { filePath?: unknown }).filePath === "string" && + typeof (input as { content?: unknown }).content !== "string" && + typeof (input as { oldString?: unknown }).oldString !== "string" && + typeof (input as { command?: unknown }).command !== "string" + ); + }, []); + + const isWriteOrEditTool = useCallback((part: ToolPart): boolean => { + const input = part.state?.input; + if (!input || typeof input !== "object") return false; + const hasEdit = + typeof (input as { oldString?: unknown }).oldString === "string" && + typeof (input as { newString?: unknown }).newString === "string"; + const hasWrite = + typeof (input as { content?: unknown }).content === "string"; + return hasEdit || hasWrite; + }, []); + + const groupPartsIntoChains = useCallback( + (parts: Part[]): (Part | ChainGroup)[] => { + const groups: (Part | ChainGroup)[] = []; + let currentChain: CurrentChain | null = null; + + const flushChain = () => { + if (!currentChain) return; + const shouldChain = + currentChain.parts.length > 1 && + currentChain.startsWithRead && + currentChain.hasWriteOrEdit; + if (shouldChain) { + groups.push({ + type: "chain", + filePath: currentChain.filePath, + parts: currentChain.parts, + }); + } else { + groups.push(...currentChain.parts); + } + currentChain = null; + }; + + for (const part of parts) { + if (part.type === "tool") { + const filePath = extractFilePath(part); + if (filePath) { + if (currentChain && currentChain.filePath === filePath) { + currentChain.parts.push(part); + if (isWriteOrEditTool(part)) currentChain.hasWriteOrEdit = true; + } else { + flushChain(); + currentChain = { + filePath, + parts: [part], + startsWithRead: isReadTool(part), + hasWriteOrEdit: isWriteOrEditTool(part), + }; + } + continue; + } + } + flushChain(); + groups.push(part); + } + + flushChain(); + return groups; + }, + [extractFilePath, isReadTool, isWriteOrEditTool], + ); + + const renderPart = useCallback( + (part: Part, message: ChatMessage) => { + switch (part.type) { + case "text": + return ( + + + + ); + case "reasoning": + return renderReasoning(part, message); + case "tool": + return ( + + ); + case "file": + return ( + + ); + case "subtask": + return ; + case "agent": + return ; + case "step-finish": + return null; + case "patch": + return ( +
+
+ Files changed ({part.files.length}) +
+
+ {part.files.map((file) => ( +
+ {file} +
+ ))} +
+
+ ); + case "snapshot": + return ( +
+ {"📸"} Context snapshot saved +
+ ); + case "compaction": + return ( +
+ {"🗜️"} Context compacted{part.auto ? " (auto)" : ""} +
+ ); + case "retry": + return ( +
+ + Retry attempt {part.attempt}:{" "} + {part.error.message || part.error.type} + +
+ ); + case "step-start": + return null; + default: + return null; + } + }, + [ + isStreaming, + lastAssistantMessage, + renderReasoning, + handleAddComment, + getDiffAnnotations, + getFileComments, + getSourcesForToolPart, + sessionId, + isArchivedSession, + childSessionsList, + fetchChildMessages, + getChildMessages, + getChildPartsForMessage, + getChildStatus, + liveChildSessionIds, + ], + ); + + const renderedMessages = useMemo(() => { + if (displayMessages.length === 0) return null; + return displayMessages.map((message, msgIndex) => { + const parts = getDisplayParts(message.id); + const agent = "agent" in message ? message.agent : undefined; + const errorInfo = "error" in message ? message.error : undefined; + const isAfterRevertPoint = + isGitRepo && + revertState != null && + (() => { + const revertMsgIndex = displayMessages.findIndex( + (m) => m.id === revertState.messageID, + ); + return msgIndex > revertMsgIndex; + })(); + + return ( +
+ + + {agent && ( +
+ {agent} +
+ )} + {groupPartsIntoChains(parts).map((part) => { + if (part.type === "chain") { + return ( + + {part.parts.map((chainPart) => + renderPart(chainPart, message), + )} + + ); + } + return renderPart(part, message); + })} + {errorInfo && + (() => { + const errorText = errorInfo.message || errorInfo.type || ""; + const isAbort = + !errorText || + /abort/i.test(errorText) || + /abort/i.test(errorInfo.type || ""); + if (isAbort) { + return ( +
+ Response stopped +
+ ); + } + return ( +
+ Error: {errorText} +
+ ); + })()} +
+ {message.role === "assistant" && ( + + + + {isGitRepo && ( + handleRevert(message.id)} + title="Restore to this checkpoint" + > + + + )} + copyMessage(message)} + title="Copy" + > + + + + + )} +
+
+ ); + }); + }, [ + displayMessages, + getDisplayParts, + isGitRepo, + revertState, + groupPartsIntoChains, + renderPart, + copyMessage, + handleRevert, + ]); + + const conversationView = ( + + + {session.status !== "active" && session.status === "creating" && ( +
+
+
+
+
+
+

+ {statusElapsedMs > 25000 + ? "Almost ready..." + : statusElapsedMs > 15000 + ? "Starting AI agent" + : statusElapsedMs > 8000 + ? "Installing tools & skills" + : statusElapsedMs > 3000 + ? "Setting up environment" + : "Creating sandbox"} +

+

+ {statusElapsedMs > 25000 + ? "Verifying the agent is responsive" + : statusElapsedMs > 15000 + ? "Launching OpenCode server" + : statusElapsedMs > 8000 + ? "Configuring agent capabilities" + : statusElapsedMs > 3000 + ? "Cloning repository and preparing workspace" + : "Provisioning an isolated sandbox environment"} +

+
+
+ {[3000, 8000, 15000, 25000].map((threshold) => ( +
threshold + ? "bg-[var(--primary)]" + : "bg-[var(--border)]" + }`} + /> + ))} +
+

+ {Math.floor(statusElapsedMs / 1000)}s +

+
+ )} + {session.status !== "active" && session.status !== "creating" && ( + <> + {isArchivedSession ? ( + <> + + {(archiveError || isLoadingArchive) && ( +

+ {isLoadingArchive + ? "Loading chat history..." + : archiveError} +

+ )} + + ) : ( +
+
+ + {session.status === "error" && + "❌ Failed to create sandbox."} + {session.status === "terminated" && + "This session's sandbox has been terminated. Chat history is read-only."} + + {session.status === "error" && ( + + )} +
+ {session.status === "terminated" && + (archiveError || isLoadingArchive) && ( +

+ {isLoadingArchive + ? "Loading chat history..." + : archiveError} +

+ )} +
+ )} + + )} + {typeof (session.metadata as Record | null) + ?.cloneError === "string" && ( +
+
⚠️ Repository clone failed
+

+ {(session.metadata as Record).cloneError} +

+

+ The session started without code. Check your GitHub PAT + permissions in Profile settings, or provide the repo URL to the + agent. +

+
+ )} + {displayMessages.length === 0 && !isBusy ? ( + + {!isConnected && error ? ( +
+ +

+ Connection failed +

+

+ Unable to connect to the AI agent +

+ +
+ ) : ( +
+ {session.status === "terminated" ? ( +

+ No messages available for this session. +

+ ) : ( + <> +

+ Start a conversation... +

+

+ Press Enter to send, Shift+Enter for newline +

+ + )} +
+ )} +
+ ) : ( + renderedMessages + )} + + {error && + isConnected && + displayMessages.length > 0 && + !/abort/i.test(typeof error === "string" ? error : "") && ( +
+ + {typeof error === "string" ? error : String(error)} +
+ )} + + {pendingPermissions.map((perm) => ( + + ))} + {pendingQuestions.map((question) => ( + + ))} + + {isStreaming && !hasStreamingContent && ( + + + + + + )} + + + + ); + + const attachmentInput = ( + + ); + + const inputArea = ( +
+ {revertState && isGitRepo && ( +
+ + Session reverted to an earlier checkpoint. New messages will + continue from this point. + + +
+ )} + {messageQueue.length > 0 && ( +
+ + Queued ({messageQueue.length}) + + {messageQueue.map((msg, i) => ( +
+ + {msg.text.slice(0, 60)}... +
+ ))} +
+ )} + + handleSend(text)}> + + {attachments.length > 0 && ( +
+ {attachments.map((attachment) => ( + + ))} +
+ )} + +
+ { + setSelectedCommand(cmd); + setHasManuallySelectedCommand(true); + }} + /> + + + Enter to send · Shift+Enter for new line + + {commentCount > 0 && ( + + {commentCount} comment{commentCount > 1 ? "s" : ""} + + )} + {queuedCount > 0 && ( + + + Queued {queuedCount} + + )} + {commentCount > 0 && ( + + )} +
+
+ + {session.status === "active" && ( + + )} + + {session.status === "creating" ? ( + + ) : undefined} + +
+
+
+
+
+ ); return ( -
- {attachmentInput} +
+ {attachmentInput} {/* Header */} -
-
- {isEditingTitle ? ( - setEditTitle(event.target.value)} - onBlur={() => { - void saveTitle(); - setIsEditingTitle(false); - }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - void saveTitle(); - setIsEditingTitle(false); - } - if (event.key === 'Escape') { - setEditTitle(session.title || ''); - setIsEditingTitle(false); - } - }} - maxLength={100} - className="max-w-[140px] md:max-w-[200px] border-b border-[var(--border)] bg-transparent text-sm font-semibold text-[var(--foreground)] outline-none" - /> - ) : ( -

- -

- )} - {session.status === 'active' && ( - - - - - - - - - +
+
+ {isEditingTitle ? ( + setEditTitle(event.target.value)} + onBlur={() => { + void saveTitle(); + setIsEditingTitle(false); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + void saveTitle(); + setIsEditingTitle(false); + } + if (event.key === "Escape") { + setEditTitle(session.title || ""); + setIsEditingTitle(false); + } + }} + maxLength={100} + className="max-w-[140px] md:max-w-[200px] border-b border-[var(--border)] bg-transparent text-sm font-semibold text-[var(--foreground)] outline-none" + /> + ) : ( +

+ +

+ )} + {session.status === "active" && ( + + + + + + + + + )} - {shareUrl ? ( - - ) : ( - session.status === 'active' && ( - - ) - )} -
- {sessionUsage.totalTokens > 0 && ( - - )} -
- - {sessionStatus.type === 'retry' && ( + {shareUrl ? ( + + ) : ( + session.status === "active" && ( + + ) + )} +
+ {sessionUsage.totalTokens > 0 && ( + + )} +
+ + {sessionStatus.type === "retry" && ( Retrying ({sessionStatus.attempt}) )} -
-
- {/* SSH info popover */} - {session.sandboxId && ( - { - if (open && !opencodePassword) { - passwordFetchController.current?.abort(); - const controller = new AbortController(); - passwordFetchController.current = controller; - apiFetch(`/api/sessions/${sessionId}/password`, { signal: controller.signal }) - .then((r) => r.json()) - .then((data) => { if (data?.password) setOpencodePassword(data.password); }) - .catch((err) => { - if (err instanceof DOMException && err.name === 'AbortError') return; - }); - } - }}> - - - - -
-
- - - {session.status === 'terminated' ? 'Terminated' : 'Active'} - -
-
-

Sandbox ID

-
- - {session.sandboxId} - - -
-
-
-

SSH Command

-
- - {sshCommand} - - -
-
- {attachCommand && ( -
-

OpenCode Attach

-
- - {attachCommand} - - -
-
- )} - {opencodePassword && ( -
-

OpenCode Password

-
- - {'•'.repeat(opencodePassword.length)} - - -
-
- )} - {session.status !== 'terminated' && ( - - )} -
-
-
- )} - - {/* Git badge */} - {githubAvailable && gitBranch && ( -
- - - {gitBranch} - - {gitChangedCount > 0 && ( - - {gitChangedCount} - - )} -
- )} - {/* Todo toggle */} - {todos.length > 0 && ( - + + +
+
+ + + {session.status === "terminated" + ? "Terminated" + : "Active"} + +
+
+

+ Sandbox ID +

+
+ + {session.sandboxId} + + +
+
+
+

+ SSH Command +

+
+ + {sshCommand} + + +
+
+ {attachCommand && ( +
+

+ OpenCode Attach +

+
+ + {attachCommand} + + +
+
+ )} + {opencodePassword && ( +
+

+ OpenCode Password +

+
+ + {"•".repeat(opencodePassword.length)} + + +
+
+ )} + {session.status !== "terminated" && ( + + )} +
+
+ + )} + + {/* Git badge */} + {githubAvailable && gitBranch && ( +
+ + + {gitBranch} + + {gitChangedCount > 0 && ( + + {gitChangedCount} + + )} +
+ )} + {/* Todo toggle */} + {displayTodos.length > 0 && ( + - )} - {/* View mode toggle */} -
- - -
-
-
-
- - {/* Body */} - {viewMode === 'ide' ? ( -
-
- - {githubAvailable ? ( -
- - -
- ) : ( -
- - Files - -
- )} -
- {githubAvailable && sidebarTab === 'git' ? ( - - openDiff(path, oldContent, newContent) - } - onBranchChange={refreshGitStatus} - /> - ) : ( - openDiff(path, '', '')} - activeFilePath={activeFilePath} - cachedNodes={cachedTreeNodes} - cachedEntryCount={cachedTreeEntryCount} - onTreeLoaded={handleTreeLoaded} - /> - )} -
-
- } - codePanel={ - - } - /> -
- {/* Minimal input bar for IDE mode */} -
- - handleSend(text)}> - {attachments.length > 0 && ( -
- {attachments.map((attachment) => ( - - ))} -
- )} - {messageQueue.length > 0 && ( -
- - Queued ({messageQueue.length}) - - {messageQueue.map((msg, i) => ( -
- - {msg.text.slice(0, 60)}... -
- ))} -
- )} -
-
- -
- {commentCount > 0 && ( - - {commentCount} comment{commentCount > 1 ? 's' : ''} - - )} - - {session.status === 'active' && ( - - )} - - {session.status === 'creating' ? : undefined} - -
-
-
-
-
- ) : ( - <> -
- {conversationView} - - {/* Todo sidebar */} - {showTodos && todos.length > 0 && ( -
-
- ({ - text: todo.content, - status: - todo.status === 'completed' - ? 'done' - : todo.status === 'in_progress' - ? 'in-progress' - : 'pending', - }))} - /> -
-
- )} -
- {inputArea} - - )} - - {/* Snapshot dialog */} - {showSnapshotDialog && ( -
-
-

Save Snapshot

-

- Save the current state of your sandbox for reuse in new sessions. -

- setSnapshotName(e.target.value)} - placeholder="Snapshot name" - className="mt-3 w-full rounded-md border border-[var(--border)] bg-[var(--input)] px-3 py-2 text-sm text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] outline-none focus:ring-1 focus:ring-[var(--ring)]" - /> -