From b63bb6159643526b427bf16f1efd1f7a6181843b Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 20:26:28 +0530 Subject: [PATCH 01/40] feat(server): bots as projects with one long-lived session A bot is a project rooted at /bots/ whose bot record holds its session thread, avatar and description. BotService creates the folder (AGENTS.md, artifacts/, its own git repo), launches the session in full-access with auto-settle off, and edits, deletes and generates avatars through Codex image generation. The bot's agent sets its own description with the t3_bot_update MCP tool. --- apps/server/src/bots/BotService.test.ts | 277 +++++++++++ apps/server/src/bots/BotService.ts | 458 ++++++++++++++++++ .../src/mcp/toolkits/project/handlers.ts | 17 + apps/server/src/mcp/toolkits/project/tools.ts | 21 + .../src/observability/RpcInstrumentation.ts | 4 + .../orchestration-v2/ProjectCommands.test.ts | 1 + .../src/orchestration-v2/ProjectCommands.ts | 5 + .../src/orchestration-v2/ProjectStore.ts | 10 + .../orchestration-v2/RuntimePolicy.test.ts | 1 + .../ThreadLaunchService.test.ts | 1 + .../ThreadTitleRegenerationService.test.ts | 1 + .../src/orchestration-v2/runtimeLayer.ts | 7 + apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/078_ProjectionProjectBot.ts | 16 + apps/server/src/project/ProjectService.ts | 7 + .../src/textGeneration/CodexTextGeneration.ts | 19 +- apps/server/src/ws.test.ts | 2 + apps/server/src/ws.ts | 15 + packages/contracts/src/applicationEvent.ts | 3 + packages/contracts/src/bot.ts | 129 +++++ .../contracts/src/clientRpcPermissions.ts | 4 + packages/contracts/src/index.ts | 1 + .../contracts/src/orchestrationProject.ts | 2 + packages/contracts/src/project.ts | 2 + packages/contracts/src/rpc.ts | 43 ++ .../src/server/textGeneration.ts | 16 + .../src/server/textGeneration.ts | 1 + packages/shared/src/t3McpToolPresentation.ts | 1 + 28 files changed, 1065 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/bots/BotService.test.ts create mode 100644 apps/server/src/bots/BotService.ts create mode 100644 apps/server/src/persistence/Migrations/078_ProjectionProjectBot.ts create mode 100644 packages/contracts/src/bot.ts diff --git a/apps/server/src/bots/BotService.test.ts b/apps/server/src/bots/BotService.test.ts new file mode 100644 index 0000000000000..23110dea85ab1 --- /dev/null +++ b/apps/server/src/bots/BotService.test.ts @@ -0,0 +1,277 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + CommandId, + type OrchestrationV2ServerCommand, + type ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import type * as Scope from "effect/Scope"; + +import * as ServerConfig from "../config.ts"; +import * as GitManager from "../git/GitManager.ts"; +import * as GitWorkflow from "../git/GitWorkflowService.ts"; +import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; +import * as ThreadLaunch from "../orchestration-v2/ThreadLaunchService.ts"; +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; +import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import * as BotService from "./BotService.ts"; + +type ThreadManagementApi = ThreadManagement.ThreadManagementService["Service"]; + +interface Harness { + readonly baseDir: string; + readonly launches: Array; + readonly commands: Array; + /** What the fake Codex reports as its saved image. */ + generatedImagePath: string; + /** The project the fake calling thread belongs to. */ + threadProjectId: ProjectId | undefined; +} + +const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]); + +const layerEnrichment = ProjectEnrichmentService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: () => Effect.succeed(null), + }), + Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, + resolvePath: () => Effect.succeed(null), + }), + ), + ), +); + +const layerGitWorkflow = GitWorkflow.layer.pipe( + Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), + Layer.provide(Layer.mock(GitManager.GitManager)({})), +); + +const layerFakes = (harness: Harness) => + Layer.mergeAll( + Layer.mock(ThreadLaunch.ThreadLaunchService)({ + launch: (input) => + Effect.sync(() => { + harness.launches.push(input); + return { + threadId: input.threadId ?? ThreadId.make("unused"), + projection: {} as ThreadLaunch.ThreadLaunchResult["projection"], + resumed: false, + }; + }), + }), + Layer.mock(ThreadManagement.ThreadManagementService)({ + dispatch: (command) => + Effect.sync(() => { + harness.commands.push(command); + return {} as Effect.Success>; + }), + getThreadShell: (threadId) => + Effect.sync(() => + harness.threadProjectId === undefined + ? null + : ({ id: threadId, projectId: harness.threadProjectId } as Effect.Success< + ReturnType + >), + ), + }), + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + listInstances: Effect.sync(() => [ + // Only the fields BotService reads. + { + instanceId: ProviderInstanceId.make("codex"), + driverKind: ProviderDriverKind.make("codex"), + enabled: true, + snapshot: { getSnapshot: Effect.succeed({ models: [{ slug: "gpt-test" }] }) }, + textGeneration: { + generateImage: () => Effect.sync(() => ({ imagePath: harness.generatedImagePath })), + }, + } as unknown as ProviderInstance, + ]), + }), + ); + +const layer = (harness: Harness) => + BotService.layer.pipe( + Layer.provide(layerFakes(harness)), + Layer.provideMerge(RuntimeLayer.layerProjectService), + Layer.provideMerge(layerEnrichment), + Layer.provideMerge(WorkspacePaths.layer), + Layer.provideMerge(layerGitWorkflow), + Layer.provideMerge(GitVcsDriver.layer.pipe(Layer.provide(VcsProcess.layer))), + Layer.provideMerge(SqlitePersistence.layerMemory), + Layer.provideMerge(ServerSettings.layerTest()), + Layer.provideMerge(ServerConfig.layerTest(harness.baseDir, harness.baseDir)), + Layer.provideMerge(NodeServices.layer), + ); + +const withBots = ( + body: ( + harness: Harness, + ) => Effect.Effect< + A, + E, + BotService.BotService | ProjectService.ProjectService | NodeServices.NodeServices | Scope.Scope + >, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const harness: Harness = { + baseDir: yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-bots-" }), + launches: [], + commands: [], + generatedImagePath: "", + threadProjectId: undefined, + }; + return yield* body(harness).pipe(Effect.provide(layer(harness))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + +const createBot = (name: string, commandId: string) => + BotService.BotService.pipe( + Effect.flatMap((bots) => + bots.create({ + commandId: CommandId.make(commandId), + name, + avatar: { shape: "star", color: "teal" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-test" }, + }), + ), + ); + +const readProject = (projectId: ProjectId) => + ProjectService.ProjectService.pipe( + Effect.flatMap((projects) => projects.getById(projectId)), + Effect.map(Option.getOrThrow), + ); + +it.effect("creates a bot as a project in its own folder with one always-on session", () => + withBots((harness) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const first = yield* createBot("Research Pal", "bot-1"); + const second = yield* createBot("Research Pal", "bot-2"); + + const project = yield* readProject(first.projectId); + assert.equal(project.workspaceRoot, path.resolve(harness.baseDir, "bots", "research-pal")); + assert.deepEqual(project.bot, { + threadId: first.threadId, + avatar: { shape: "star", color: "teal", imagePath: null }, + description: null, + }); + assert.equal( + (yield* readProject(second.projectId)).workspaceRoot, + path.resolve(harness.baseDir, "bots", "research-pal-2"), + ); + assert.isTrue(yield* fileSystem.exists(path.join(project.workspaceRoot, "artifacts"))); + assert.include( + yield* fileSystem.readFileString(path.join(project.workspaceRoot, "AGENTS.md")), + "t3_bot_update", + ); + + const launch = harness.launches[0]; + assert.equal(launch?.threadId, first.threadId); + assert.equal(launch?.projectId, first.projectId); + assert.equal(launch?.runtimeMode, "full-access"); + assert.isTrue((launch?.initialMessage?.text.length ?? 0) > 0); + assert.deepInclude(harness.commands, { + type: "thread.auto-settle.set", + commandId: CommandId.make("bot-1:auto-settle"), + threadId: first.threadId, + enabled: false, + }); + }), + ), +); + +it.effect("keeps a generated avatar when only the shape changes", () => + withBots((harness) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const bots = yield* BotService.BotService; + const { projectId } = yield* createBot("Painter", "bot-paint"); + + const generatedDir = path.join(harness.baseDir, "codex", "generated_images", "session"); + yield* fileSystem.makeDirectory(generatedDir, { recursive: true }); + harness.generatedImagePath = path.join(generatedDir, "exec-1.png"); + yield* fileSystem.writeFile(harness.generatedImagePath, PNG_BYTES); + const { imagePath } = yield* bots.generateAvatar({ projectId }); + + const project = yield* readProject(projectId); + assert.match(imagePath, /^avatars\/\d+\.png$/); + assert.isTrue(yield* fileSystem.exists(path.join(project.workspaceRoot, imagePath))); + assert.equal(project.bot?.avatar.imagePath, imagePath); + + yield* bots.update({ projectId, avatar: { shape: "heart", color: "red" } }); + assert.deepEqual((yield* readProject(projectId)).bot?.avatar, { + shape: "heart", + color: "red", + imagePath, + }); + yield* bots.update({ projectId, avatar: { shape: "heart", color: "red", imagePath: null } }); + assert.equal((yield* readProject(projectId)).bot?.avatar.imagePath, null); + }), + ), +); + +it.effect("refuses a generated image path outside Codex's generated images", () => + withBots((harness) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const bots = yield* BotService.BotService; + const { projectId } = yield* createBot("Painter", "bot-escape"); + harness.generatedImagePath = path.join(harness.baseDir, "secret.png"); + yield* fileSystem.writeFile(harness.generatedImagePath, PNG_BYTES); + + const error = yield* Effect.flip(bots.generateAvatar({ projectId })); + assert.equal(error.message, "Codex did not return a generated image."); + assert.equal((yield* readProject(projectId)).bot?.avatar.imagePath, null); + }), + ), +); + +it.effect("lets only the bot's own session edit its profile", () => + withBots((harness) => + Effect.gen(function* () { + const bots = yield* BotService.BotService; + const { projectId, threadId } = yield* createBot("Scout", "bot-scout"); + harness.threadProjectId = projectId; + + const stranger = yield* Effect.flip( + bots.updateProfile({ threadId: ThreadId.make("another-thread"), description: "Hijacked" }), + ); + assert.equal(stranger.message, "This thread is not a bot's session."); + + yield* bots.updateProfile({ threadId, description: "Finds flights.", name: "Flight Scout" }); + const project = yield* readProject(projectId); + assert.equal(project.bot?.description, "Finds flights."); + assert.equal(project.title, "Flight Scout"); + const rename = harness.commands.find((command) => command.type === "thread.metadata.update"); + assert.equal(rename?.threadId, threadId); + assert.equal(rename?.type === "thread.metadata.update" ? rename.title : null, "Flight Scout"); + }), + ), +); diff --git a/apps/server/src/bots/BotService.ts b/apps/server/src/bots/BotService.ts new file mode 100644 index 0000000000000..ab8e66d5679a0 --- /dev/null +++ b/apps/server/src/bots/BotService.ts @@ -0,0 +1,458 @@ +import { + type BotCreateInput, + type BotCreateResult, + type BotDeleteInput, + BotError, + type BotGenerateAvatarInput, + type BotGenerateAvatarResult, + type BotMutationResult, + type BotUpdateInput, + CommandId, + type ModelSelection, + type Project, + type ProjectBot, + ProjectId, + ProviderDriverKind, + ThreadId, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { newProjectFolderName } from "@t3tools/shared/path"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Semaphore from "effect/Semaphore"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +import * as ServerConfig from "../config.ts"; +import * as ThreadLaunch from "../orchestration-v2/ThreadLaunchService.ts"; +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; + +export interface BotProfileUpdateInput { + readonly threadId: ThreadId; + readonly name?: string | undefined; + readonly description?: string | undefined; +} + +export class BotService extends Context.Service< + BotService, + { + readonly create: (input: BotCreateInput) => Effect.Effect; + readonly update: (input: BotUpdateInput) => Effect.Effect; + readonly delete: (input: BotDeleteInput) => Effect.Effect; + readonly generateAvatar: ( + input: BotGenerateAvatarInput, + ) => Effect.Effect; + /** The bot's own agent edits its profile from inside its thread. */ + readonly updateProfile: ( + input: BotProfileUpdateInput, + ) => Effect.Effect; + } +>()("t3/bots/BotService") {} + +const MAX_FOLDER_ATTEMPTS = 100; +const MAX_GENERATED_IMAGE_BYTES = 25 * 1024 * 1024; +const AVATAR_SIZE_PX = 256; + +function botInstructions(name: string): string { + return [ + `# ${name}`, + "", + `You are ${name}, a personal bot in T3 Code. This thread is your single long-lived session with the user, and this folder is your home.`, + "", + "- Save files you make for the user in `artifacts/`. The user browses them in your Artifacts tab.", + "- Keep notes, memory and scripts anywhere else in this folder.", + "- Routines: create them with `schedule_task`, which runs them in this thread. For a one-time routine, delete the task after it runs.", + "- Other machines: `t3_environment_links` lists the linked machines you can work on.", + "- Profile: call `t3_bot_update` to set your description, one or two sentences on what you do for the user. Update it when your job changes. Change your name only when the user asks.", + "", + "## First conversation", + "", + "Greet the user, ask a few short questions about what they want you to do, then set your description.", + "", + ].join("\n"); +} + +const kickoffMessage = (name: string) => + `Hi ${name}. Ask me a few questions about what you'll be doing for me, then write your description.`; + +const isImageBytes = (bytes: Uint8Array) => { + const startsWith = (...prefix: number[]) => prefix.every((byte, index) => bytes[index] === byte); + return ( + startsWith(0x89, 0x50, 0x4e, 0x47) || + startsWith(0xff, 0xd8, 0xff) || + (startsWith(0x52, 0x49, 0x46, 0x46) && + bytes[8] === 0x57 && + bytes[9] === 0x45 && + bytes[10] === 0x42 && + bytes[11] === 0x50) + ); +}; + +const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const git = yield* GitVcsDriver.GitVcsDriver; + const projects = yield* ProjectService.ProjectService; + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const providers = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + const settings = yield* ServerSettings.ServerSettingsService; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + // Bot edits read the stored record and write it back whole; serialize them. + const mutations = yield* Semaphore.make(1); + const botsRoot = path.resolve(config.baseDir, "bots"); + + const fail = (message: string, projectId?: ProjectId) => (cause: unknown) => + new BotError({ + message, + ...(projectId === undefined ? {} : { projectId }), + cause, + }); + + const newCommandId = (prefix: string) => + crypto.randomUUIDv4().pipe( + Effect.map((uuid) => CommandId.make(`${prefix}:${uuid}`)), + Effect.mapError(fail("Could not allocate an id.")), + ); + + const readBot = Effect.fn("BotService.readBot")(function* (projectId: ProjectId) { + const project = yield* projects + .getById(projectId) + .pipe(Effect.mapError(fail("Could not read the bot.", projectId))); + if (Option.isNone(project) || project.value.bot == null) { + return yield* new BotError({ message: "The bot was not found.", projectId }); + } + return { project: project.value, bot: project.value.bot }; + }); + + const writeBot = Effect.fn("BotService.writeBot")(function* (input: { + readonly project: Project; + readonly bot: ProjectBot; + readonly title?: string; + }) { + const projectId = input.project.id; + yield* projects + .update({ + commandId: yield* newCommandId("bot-update"), + projectId, + bot: input.bot, + ...(input.title === undefined ? {} : { title: input.title }), + }) + .pipe(Effect.mapError((error) => fail(error.message, projectId)(error))); + if (input.title !== undefined && input.title !== input.project.title) { + yield* threads + .dispatch({ + type: "thread.metadata.update", + commandId: yield* newCommandId("bot-rename"), + threadId: input.bot.threadId, + title: input.title, + }) + .pipe(Effect.mapError(fail("Could not rename the bot's thread.", projectId))); + } + return { projectId }; + }); + + // Non-recursive mkdir claims a folder name atomically. + const claimFolder = Effect.fn("BotService.claimFolder")(function* (name: string) { + yield* fileSystem + .makeDirectory(botsRoot, { recursive: true }) + .pipe(Effect.mapError(fail("Could not create the bots folder."))); + const base = newProjectFolderName(name); + for (let attempt = 1; attempt <= MAX_FOLDER_ATTEMPTS; attempt++) { + const folder = path.join(botsRoot, attempt === 1 ? base : `${base}-${attempt}`); + const claimed = yield* fileSystem.makeDirectory(folder).pipe( + Effect.as(true), + Effect.catchIf( + (error) => error.reason._tag === "AlreadyExists", + () => Effect.succeed(false), + ), + Effect.mapError(fail("Could not create the bot's folder.")), + ); + if (claimed) return folder; + } + return yield* new BotError({ message: `Every folder name for "${base}" is taken.` }); + }); + + const scaffoldFolder = Effect.fn("BotService.scaffoldFolder")(function* ( + folder: string, + name: string, + ) { + yield* fileSystem.makeDirectory(path.join(folder, "artifacts")); + yield* fileSystem.writeFileString(path.join(folder, "AGENTS.md"), botInstructions(name)); + yield* fileSystem.writeFileString(path.join(folder, "CLAUDE.md"), "@AGENTS.md\n"); + }); + + const create: BotService["Service"]["create"] = Effect.fn("BotService.create")(function* (input) { + const folder = yield* claimFolder(input.name); + const removeFolder = fileSystem.remove(folder, { recursive: true }).pipe(Effect.ignore); + yield* scaffoldFolder(folder, input.name).pipe( + Effect.mapError(fail("Could not set up the bot's folder.")), + Effect.tapError(() => removeFolder), + ); + // Its own repository keeps checkpoints out of any checkout the data dir lives in. + yield* git + .execute({ + operation: "BotService.init", + cwd: folder, + args: ["init", "--quiet"], + timeoutMs: 10_000, + }) + .pipe(Effect.ignore); + + const projectId = ProjectId.make(input.commandId); + const threadId = ThreadId.make(input.commandId); + yield* projects + .create({ + commandId: CommandId.make(`${input.commandId}:project`), + projectId, + title: input.name, + workspaceRoot: folder, + bot: { + threadId, + avatar: { shape: input.avatar.shape, color: input.avatar.color, imagePath: null }, + description: null, + }, + }) + .pipe( + Effect.mapError((error) => fail(error.message)(error)), + Effect.tapError(() => removeFolder), + ); + + const deleteProject = projects + .delete({ commandId: CommandId.make(`${input.commandId}:rollback`), projectId, force: true }) + .pipe(Effect.ignore); + yield* launches + .launch({ + commandId: CommandId.make(`${input.commandId}:thread`), + threadId, + projectId, + title: input.name, + modelSelection: input.modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + workspaceStrategy: { type: "root" }, + initialMessage: { text: kickoffMessage(input.name), attachments: [] }, + createdBy: "user", + creationSource: "server", + }) + .pipe( + Effect.mapError((error) => fail("Could not start the bot's session.", projectId)(error)), + Effect.tapError(() => deleteProject), + ); + // Settled threads drop out of shell snapshots; a bot's session stays in view. + yield* threads + .dispatch({ + type: "thread.auto-settle.set", + commandId: CommandId.make(`${input.commandId}:auto-settle`), + threadId, + enabled: false, + }) + .pipe(Effect.ignore); + return { projectId, threadId }; + }); + + const update: BotService["Service"]["update"] = Effect.fn("BotService.update")(function* (input) { + return yield* mutations.withPermits(1)( + Effect.gen(function* () { + const { project, bot } = yield* readBot(input.projectId); + return yield* writeBot({ + project, + bot: { + ...bot, + ...(input.avatar === undefined + ? {} + : { + avatar: { + shape: input.avatar.shape, + color: input.avatar.color, + imagePath: + input.avatar.imagePath === undefined + ? (bot.avatar.imagePath ?? null) + : input.avatar.imagePath, + }, + }), + ...(input.description === undefined ? {} : { description: input.description }), + }, + ...(input.name === undefined ? {} : { title: input.name }), + }); + }), + ); + }); + + const updateProfile: BotService["Service"]["updateProfile"] = Effect.fn( + "BotService.updateProfile", + )(function* (input) { + const shell = yield* threads + .getThreadShell(input.threadId) + .pipe(Effect.mapError(fail("Could not read the calling thread."))); + if (shell === null) + return yield* new BotError({ message: "The calling thread was not found." }); + return yield* mutations.withPermits(1)( + Effect.gen(function* () { + const { project, bot } = yield* readBot(shell.projectId).pipe( + Effect.filterOrFail( + ({ bot }) => bot.threadId === input.threadId, + () => new BotError({ message: "This thread is not a bot's session." }), + ), + ); + return yield* writeBot({ + project, + bot: input.description === undefined ? bot : { ...bot, description: input.description }, + ...(input.name === undefined ? {} : { title: input.name }), + }); + }), + ); + }); + + const remove: BotService["Service"]["delete"] = Effect.fn("BotService.delete")(function* (input) { + yield* readBot(input.projectId); + yield* projects + .delete({ + commandId: yield* newCommandId("bot-delete"), + projectId: input.projectId, + force: true, + }) + .pipe(Effect.mapError((error) => fail(error.message, input.projectId)(error))); + return { projectId: input.projectId }; + }); + + const resolveImageGenerator = Effect.fn("BotService.resolveImageGenerator")(function* () { + const codex = ProviderDriverKind.make("codex"); + const preferred = yield* settings.getSettings.pipe( + Effect.map((current) => current.textGenerationModelSelection), + Effect.orElseSucceed(() => undefined), + ); + const candidates = (yield* providers.listInstances).filter( + (instance) => + instance.driverKind === codex && + instance.enabled && + instance.textGeneration.generateImage !== undefined, + ); + const instance = + candidates.find((candidate) => candidate.instanceId === preferred?.instanceId) ?? + candidates[0]; + const generateImage = instance?.textGeneration.generateImage; + if (instance === undefined || generateImage === undefined) { + return yield* new BotError({ message: "Avatar generation needs Codex." }); + } + let model = preferred?.instanceId === instance.instanceId ? preferred.model : undefined; + if (model === undefined) { + const snapshot = yield* instance.snapshot.getSnapshot; + model = snapshot.models[0]?.slug; + } + if (model === undefined) { + return yield* new BotError({ message: "Codex has no model available." }); + } + const modelSelection: ModelSelection = { instanceId: instance.instanceId, model }; + return { generateImage, modelSelection }; + }); + + // The path comes from model output: accept only an image Codex saved itself. + const readGeneratedImage = Effect.fn("BotService.readGeneratedImage")(function* ( + imagePath: string, + projectId: ProjectId, + ) { + const invalid = new BotError({ + message: "Codex did not return a generated image.", + projectId, + }); + if (!path.isAbsolute(imagePath)) return yield* invalid; + const real = yield* fileSystem.realPath(imagePath).pipe(Effect.mapError(() => invalid)); + if (!real.split(path.sep).includes("generated_images") || !/\.(png|jpe?g|webp)$/i.test(real)) { + return yield* invalid; + } + const info = yield* fileSystem.stat(real).pipe(Effect.mapError(() => invalid)); + if (info.type !== "File" || Number(info.size) > MAX_GENERATED_IMAGE_BYTES) { + return yield* invalid; + } + const bytes = yield* fileSystem.readFile(real).pipe(Effect.mapError(() => invalid)); + if (!isImageBytes(bytes)) return yield* invalid; + return { real, bytes, extension: path.extname(real).slice(1).toLowerCase() }; + }); + + const generateAvatar: BotService["Service"]["generateAvatar"] = Effect.fn( + "BotService.generateAvatar", + )(function* (input) { + const { project } = yield* readBot(input.projectId); + const { generateImage, modelSelection } = yield* resolveImageGenerator(); + const prompt = [ + `Use your image generation tool to create one square avatar image for a personal assistant bot named "${project.title}".`, + input.prompt === undefined + ? "Make it a simple, friendly, iconic character on a plain background." + : `The user describes it as: ${input.prompt}`, + "No text or letters in the image.", + "Then reply with the absolute path of the saved image file as imagePath.", + ].join("\n"); + const generated = yield* generateImage({ + cwd: project.workspaceRoot, + prompt, + modelSelection, + }).pipe(Effect.mapError((error) => fail(error.message, input.projectId)(error))); + const image = yield* readGeneratedImage(generated.imagePath, input.projectId); + + const avatarsDir = path.join(project.workspaceRoot, "avatars"); + const stamp = DateTime.formatIso(yield* DateTime.now).replace(/[^0-9]/g, ""); + const relativePath = `avatars/${stamp}.png`; + const target = path.join(project.workspaceRoot, relativePath); + const saveFailed = fail("Could not save the avatar.", input.projectId); + yield* fileSystem + .makeDirectory(avatarsDir, { recursive: true }) + .pipe(Effect.mapError(saveFailed)); + // Generated images are ~1.5MB; a sidebar avatar should be a few KB. sips ships with macOS. + const resized = + (yield* HostProcess.Platform) === "darwin" + ? yield* spawner + .exitCode( + ChildProcess.make("sips", [ + "-s", + "format", + "png", + "-Z", + String(AVATAR_SIZE_PX), + image.real, + "--out", + target, + ]), + ) + .pipe( + Effect.map((code) => code === 0), + Effect.orElseSucceed(() => false), + ) + : false; + const imagePath = resized + ? relativePath + : `avatars/${stamp}.${image.extension === "jpeg" ? "jpg" : image.extension}`; + if (!resized) { + yield* fileSystem + .writeFile(path.join(project.workspaceRoot, imagePath), image.bytes) + .pipe(Effect.mapError(saveFailed)); + } + + yield* mutations.withPermits(1)( + Effect.gen(function* () { + const current = yield* readBot(input.projectId); + yield* writeBot({ + project: current.project, + bot: { ...current.bot, avatar: { ...current.bot.avatar, imagePath } }, + }); + }), + ); + return { imagePath }; + }); + + return BotService.of({ create, update, delete: remove, generateAvatar, updateProfile }); +}); + +export const layer = Layer.effect(BotService, make); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 59399c7febdea..adb721d4ccf0e 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -15,9 +15,11 @@ import { OrchestratorRuntimeModeAboveCeilingError } from "../../../orchestration import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts"; import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts"; import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; +import * as Bots from "../../../bots/BotService.ts"; import * as Project from "../../../project/ProjectService.ts"; import * as ProjectCloneTracker from "../../../project/ProjectCloneTracker.ts"; import { mutateProjectWithClone } from "../../../project/ProjectMutation.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; @@ -373,6 +375,21 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { }).pipe(Effect.mapError(projectFailure)); }), ), + t3_bot_update: McpToolAccess.actsAsCaller((input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const { thread } = yield* McpInvocationContext.requireThreadScope(scope, "t3_bot_update"); + const bots = yield* Bots.BotService; + return yield* bots + .updateProfile({ threadId: thread.threadId, ...input }) + .pipe( + Effect.mapError( + (error) => + new OrchestratorMcpFailure({ code: "invalid_request", message: error.message }), + ), + ); + }), + ), t3_project_clone: McpToolAccess.writesEnvironment( (input) => Effect.gen(function* () { diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index bd7c986f4c48a..b852ce1c5ab74 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -16,6 +16,9 @@ import { ProjectId, OrchestratorMcpClientRequestId, OrchestratorMcpEnvironmentTarget, + BotDescription, + BotMutationResult, + BotName, OrchestratorMcpFailure, SourceControlCloneRepositoryInput, SourceControlCloneRepositoryResult, @@ -29,6 +32,7 @@ import * as ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchServ import * as Crypto from "effect/Crypto"; import * as Schema from "effect/Schema"; import { Tool, Toolkit } from "effect/ai"; +import * as BotService from "../../../bots/BotService.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; import * as ProjectCloneTracker from "../../../project/ProjectCloneTracker.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; @@ -176,6 +180,22 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { .annotate(Tool.Destructive, true) .annotate(Tool.OpenWorld, true); +const BotUpdateTool = Tool.make("t3_bot_update", { + description: "Bots only: set this bot's description or name.", + parameters: Schema.Struct({ + description: Schema.optional(BotDescription), + name: Schema.optional(BotName), + }), + success: BotMutationResult, + failure: OrchestratorMcpFailure, + failureMode: "return" as const, + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + BotService.BotService, + ], +}).annotate(Tool.Destructive, false); + export const ProjectToolkit = Toolkit.make( ThreadLaunchTool, ProjectListTool, @@ -184,4 +204,5 @@ export const ProjectToolkit = Toolkit.make( ProjectUpdateTool, ProjectDeleteTool, ProjectCloneTool, + BotUpdateTool, ); diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 7bb8bf8a34c18..55d81924cb57c 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -88,6 +88,10 @@ const RPC_AGGREGATES = { [WS_METHODS.serverRefreshUsageRates]: "server", [WS_METHODS.serverSignalProcess]: "server", [WS_METHODS.serverReportClientActivity]: "server", + [WS_METHODS.botsCreate]: "bots", + [WS_METHODS.botsUpdate]: "bots", + [WS_METHODS.botsDelete]: "bots", + [WS_METHODS.botsGenerateAvatar]: "bots", [WS_METHODS.scheduledTasksSubscribe]: "scheduledTasks", [WS_METHODS.scheduledTasksUpsert]: "scheduledTasks", [WS_METHODS.scheduledTasksSetEnabled]: "scheduledTasks", diff --git a/apps/server/src/orchestration-v2/ProjectCommands.test.ts b/apps/server/src/orchestration-v2/ProjectCommands.test.ts index 96249ece4c106..a69bd318d69b3 100644 --- a/apps/server/src/orchestration-v2/ProjectCommands.test.ts +++ b/apps/server/src/orchestration-v2/ProjectCommands.test.ts @@ -22,6 +22,7 @@ const row = (overrides: Partial = {}): ProjectRow => ({ autoPull: false, faviconPath: null, projectIcon: null, + bot: null, scripts: [], pinnedAt: null, archivedAt: null, diff --git a/apps/server/src/orchestration-v2/ProjectCommands.ts b/apps/server/src/orchestration-v2/ProjectCommands.ts index d4c1abb3ceda2..58ce90119c5cc 100644 --- a/apps/server/src/orchestration-v2/ProjectCommands.ts +++ b/apps/server/src/orchestration-v2/ProjectCommands.ts @@ -3,6 +3,7 @@ import { type EventId, MAX_SCRIPT_ID_LENGTH, type ModelSelection, + type ProjectBot, type ProjectIconOverride, ProjectId, type ProjectScript, @@ -23,6 +24,7 @@ export interface ProjectCreateCommand { readonly title: string; readonly workspaceRoot: string; readonly scripts?: ReadonlyArray; + readonly bot?: ProjectBot; } export interface ProjectMetaUpdateCommand { @@ -39,6 +41,7 @@ export interface ProjectMetaUpdateCommand { readonly scripts?: ReadonlyArray; readonly pinned?: boolean; readonly archived?: boolean; + readonly bot?: ProjectBot | null; } export interface ProjectDeleteCommand { @@ -163,6 +166,7 @@ export function planProjectCommand(input: { faviconPath: null, projectIcon: null, scripts: command.scripts ?? [], + ...(command.bot === undefined ? {} : { bot: command.bot }), createdAt: occurredAt, updatedAt: occurredAt, }, @@ -217,6 +221,7 @@ export function planProjectCommand(input: { ...(command.archived === undefined ? {} : { archivedAt: command.archived ? (project.archivedAt ?? occurredAt) : null }), + ...(command.bot === undefined ? {} : { bot: command.bot }), updatedAt: occurredAt, }, }); diff --git a/apps/server/src/orchestration-v2/ProjectStore.ts b/apps/server/src/orchestration-v2/ProjectStore.ts index 2be0a765c784f..38a11bff5fe6e 100644 --- a/apps/server/src/orchestration-v2/ProjectStore.ts +++ b/apps/server/src/orchestration-v2/ProjectStore.ts @@ -3,6 +3,7 @@ import { IsoDateTime, ModelSelection, type OrchestrationProjectShell, + ProjectBot, StoredProjectIcon, ProjectId, ProjectScript, @@ -41,6 +42,7 @@ export const ProjectRow = Schema.Struct({ scripts: Schema.Array(ProjectScript), pinnedAt: Schema.NullOr(IsoDateTime), archivedAt: Schema.NullOr(IsoDateTime), + bot: Schema.NullOr(ProjectBot), createdAt: IsoDateTime, updatedAt: IsoDateTime, deletedAt: Schema.NullOr(IsoDateTime), @@ -53,6 +55,7 @@ const ProjectDbRow = Schema.Struct({ autoPull: Schema.BooleanFromBit, projectIcon: Schema.NullOr(Schema.fromJsonString(StoredProjectIcon)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), + bot: Schema.NullOr(Schema.fromJsonString(ProjectBot)), }); function toShell(row: ProjectRow): OrchestrationProjectShell { @@ -69,6 +72,7 @@ function toShell(row: ProjectRow): OrchestrationProjectShell { scripts: row.scripts, pinnedAt: row.pinnedAt, archivedAt: row.archivedAt, + bot: row.bot, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -126,6 +130,7 @@ export const make = Effect.gen(function* () { scripts_json AS "scripts", pinned_at AS "pinnedAt", archived_at AS "archivedAt", + bot_json AS "bot", created_at AS "createdAt", updated_at AS "updatedAt", deleted_at AS "deletedAt" @@ -158,6 +163,7 @@ export const make = Effect.gen(function* () { scripts_json, pinned_at, archived_at, + bot_json, created_at, updated_at, deleted_at @@ -174,6 +180,7 @@ export const make = Effect.gen(function* () { ${encoded.scripts}, ${encoded.pinnedAt}, ${encoded.archivedAt}, + ${encoded.bot}, ${encoded.createdAt}, ${encoded.updatedAt}, ${encoded.deletedAt} @@ -190,6 +197,7 @@ export const make = Effect.gen(function* () { scripts_json = excluded.scripts_json, pinned_at = excluded.pinned_at, archived_at = excluded.archived_at, + bot_json = excluded.bot_json, created_at = excluded.created_at, updated_at = excluded.updated_at, deleted_at = excluded.deleted_at @@ -240,6 +248,7 @@ export const make = Effect.gen(function* () { scripts: payload.scripts, pinnedAt: null, archivedAt: null, + bot: payload.bot ?? null, createdAt: payload.createdAt, updatedAt: payload.updatedAt, deletedAt: null, @@ -272,6 +281,7 @@ export const make = Effect.gen(function* () { ...(payload.scripts === undefined ? {} : { scripts: payload.scripts }), ...(payload.pinnedAt === undefined ? {} : { pinnedAt: payload.pinnedAt }), ...(payload.archivedAt === undefined ? {} : { archivedAt: payload.archivedAt }), + ...(payload.bot === undefined ? {} : { bot: payload.bot }), updatedAt: payload.updatedAt, }).pipe(mapError("apply")); }, diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts index 21647ef49bf28..734ae673f85e3 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts @@ -99,6 +99,7 @@ const layerWithPolicy = (policy?: ManagedPolicySummary) => autoPull: false, faviconPath: null, projectIcon: null, + bot: null, scripts: [], pinnedAt: null, archivedAt: null, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index ce9e872b9774a..ccd101018795e 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -249,6 +249,7 @@ function makeHarness(options: HarnessOptions = {}) { autoPull: false, faviconPath: null, projectIcon: null, + bot: null, scripts: project.scripts, pinnedAt: null, archivedAt: null, diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index 8f7de819f5718..28a5a6c10e740 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -81,6 +81,7 @@ function makeHarness( autoPull: false, faviconPath: null, projectIcon: null, + bot: null, scripts: [], pinnedAt: null, archivedAt: null, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 1b54627595372..19267b3739e68 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -55,6 +55,7 @@ import * as ThreadLaunchService from "./ThreadLaunchService.ts"; import * as ThreadForkService from "./ThreadForkService.ts"; import * as TurnItemPositionStore from "./TurnItemPositionStore.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as BotService from "../bots/BotService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; export const layerEventInfrastructure = Layer.mergeAll( @@ -300,6 +301,11 @@ const layerScratchThreadPromotionProvided = ScratchThreadPromotion.layer.pipe( ), ), ); +const layerBotServiceProvided = BotService.layer.pipe( + Layer.provide( + Layer.mergeAll(layerProjectService, layerThreadLaunchProvided, layerThreadManagementProvided), + ), +); const layerProviderContinuationWorkerProvided = ProviderContinuationService.layer.pipe( Layer.provide( Layer.mergeAll( @@ -374,6 +380,7 @@ export const layerProduction = Layer.mergeAll( layerThreadLaunchProvided, layerScheduledTaskProvided, layerScratchThreadPromotionProvided, + layerBotServiceProvided, layerSecretRequestsProvided, GuestShareService.layer.pipe( Layer.provide(Layer.merge(layerOrchestratorProvided, layerCommandOutputHubProvided)), diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index feab4ef823f00..d2fa4af032da2 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -80,6 +80,7 @@ import Migration0074 from "./Migrations/074_ExcludeAttachmentPlaceholdersFromSea import Migration0075 from "./Migrations/075_SubagentDeliveryState.ts"; import Migration0076 from "./Migrations/076_ProjectionProjectsPinArchive.ts"; import Migration0077 from "./Migrations/077_RepairBooleanSettledOverride.ts"; +import Migration0078 from "./Migrations/078_ProjectionProjectBot.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -160,6 +161,7 @@ const migrationEntries = [ [75, "SubagentDeliveryState", Migration0075], [76, "ProjectionProjectsPinArchive", Migration0076], [77, "RepairBooleanSettledOverride", Migration0077], + [78, "ProjectionProjectBot", Migration0078], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/078_ProjectionProjectBot.ts b/apps/server/src/persistence/Migrations/078_ProjectionProjectBot.ts new file mode 100644 index 0000000000000..9880c38918ecf --- /dev/null +++ b/apps/server/src/persistence/Migrations/078_ProjectionProjectBot.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "bot_json")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN bot_json TEXT + `; + } +}); diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 914456a500c51..b32c8546e16fb 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -1,6 +1,7 @@ import { CommandId, type OrchestrationProjectShell, + type ProjectBot, ProjectId, type Project, type ProjectCreatePayload, @@ -38,11 +39,14 @@ import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; export interface ProjectCreateInput extends ProjectCreatePayload { readonly commandId: CommandId; readonly projectId: ProjectId; + /** Server-only: bots are created through BotService, never by project mutations. */ + readonly bot?: ProjectBot; } export interface ProjectUpdateInput extends ProjectUpdatePayload { readonly commandId: CommandId; readonly projectId: ProjectId; + readonly bot?: ProjectBot | null; } export interface ProjectBootstrapInput extends ProjectCreateInput {} @@ -181,6 +185,7 @@ export const make = Effect.gen(function* () { scripts: row.scripts, pinnedAt: row.pinnedAt, archivedAt: row.archivedAt, + bot: row.bot, createdAt: row.createdAt, updatedAt: row.updatedAt, deletedAt: row.deletedAt, @@ -352,6 +357,7 @@ export const make = Effect.gen(function* () { title: input.title, workspaceRoot, ...(input.scripts === undefined ? {} : { scripts: input.scripts }), + ...(input.bot === undefined ? {} : { bot: input.bot }), }); yield* projectEnrichment.invalidate([workspaceRoot]); return yield* readCommitted(input.projectId); @@ -390,6 +396,7 @@ export const make = Effect.gen(function* () { ...(input.scripts === undefined ? {} : { scripts: input.scripts }), ...(input.pinned === undefined ? {} : { pinned: input.pinned }), ...(input.archived === undefined ? {} : { archived: input.archived }), + ...(input.bot === undefined ? {} : { bot: input.bot }), }); if (workspaceRoot !== previousRoot) { yield* projectEnrichment.invalidate([previousRoot, workspaceRoot]); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index e17fa4c4c00e7..7696f2c926de5 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -20,6 +20,7 @@ import * as ServerConfig from "../config.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { codexExecLaunchArgs, resolveCodexLaunchArgs } from "../provider/codexLaunchArgs.ts"; import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; +import type { ImageGenerationInput } from "@t3tools/provider-core/server/textGeneration"; import { normalizeCliError, toJsonSchemaObject, @@ -392,5 +393,21 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func }).pipe(Effect.ensuring(cleanup)); }); - return TextGenerationOperations.fromRunner("CodexTextGeneration", runCodexJson); + // Codex saves hosted image tool output under its home and reports the path. + const generateImage = Effect.fn("CodexTextGeneration.generateImage")(function* ( + input: ImageGenerationInput, + ) { + return yield* runCodexJson({ + operation: "generateImage", + cwd: input.cwd, + prompt: input.prompt, + outputSchema: Schema.Struct({ imagePath: Schema.String }), + modelSelection: input.modelSelection, + }); + }); + + return { + ...TextGenerationOperations.fromRunner("CodexTextGeneration", runCodexJson), + generateImage, + }; }); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index caa06c2e97a80..768635a31176f 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -81,6 +81,7 @@ import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolve import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; +import * as Bots from "./bots/BotService.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; import * as GuestShareService from "./guest/GuestShareService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; @@ -580,6 +581,7 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(ResourceTelemetry.ResourceTelemetry)({}), Layer.mock(ReviewService.ReviewService)({}), Layer.mock(ScheduledTasks.ScheduledTaskService)({}), + Layer.mock(Bots.BotService)({}), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(SecretResolver.SecretResolver)({}), Layer.mock(GuestShareService.GuestShareService)({}), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 00842afde41b7..fcd424c31c2c6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -123,6 +123,7 @@ import * as ThreadMessageIntake from "./orchestration-v2/ThreadMessageIntake.ts" import * as DocumentText from "./provider/DocumentText.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; +import * as Bots from "./bots/BotService.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; import * as GuestShareService from "./guest/GuestShareService.ts"; import { @@ -1329,6 +1330,7 @@ export const layerWsRpc = ( const commandOutputHub = yield* CommandOutputHub.CommandOutputHub; const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2; const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; + const bots = yield* Bots.BotService; const secretRequests = yield* SecretRequests.SecretRequests; const guestShares = yield* GuestShareService.GuestShareService; const pullRequests = yield* PullRequestService.PullRequestService; @@ -2226,6 +2228,19 @@ export const layerWsRpc = ( Effect.annotateCurrentSpan({ "scheduled_task.id": input.id }).pipe( Effect.andThen(scheduledTasks.rotateWebhookToken(input)), ), + [WS_METHODS.botsCreate]: (input) => bots.create(input), + [WS_METHODS.botsUpdate]: (input) => + Effect.annotateCurrentSpan({ "project.id": input.projectId }).pipe( + Effect.andThen(bots.update(input)), + ), + [WS_METHODS.botsDelete]: (input) => + Effect.annotateCurrentSpan({ "project.id": input.projectId }).pipe( + Effect.andThen(bots.delete(input)), + ), + [WS_METHODS.botsGenerateAvatar]: (input) => + Effect.annotateCurrentSpan({ "project.id": input.projectId }).pipe( + Effect.andThen(bots.generateAvatar(input)), + ), [WS_METHODS.guestShareCreate]: (input) => Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId }).pipe( Effect.andThen(guestShares.create(input)), diff --git a/packages/contracts/src/applicationEvent.ts b/packages/contracts/src/applicationEvent.ts index 30150b9af5247..3a759916c3ed7 100644 --- a/packages/contracts/src/applicationEvent.ts +++ b/packages/contracts/src/applicationEvent.ts @@ -12,6 +12,7 @@ import { TrimmedNonEmptyString, } from "./baseSchemas.ts"; import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; +import { ProjectBot } from "./bot.ts"; import { ModelSelection } from "./modelSelection.ts"; import type { OrchestrationV2StoredEvent } from "./orchestrationV2.ts"; import { ProjectScript, StoredProjectIcon } from "./project.ts"; @@ -43,6 +44,7 @@ export const ApplicationProjectCreatedPayload = Schema.Struct({ faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), projectIcon: Schema.optional(Schema.NullOr(StoredProjectIcon)), scripts: Schema.Array(ProjectScript), + bot: Schema.optional(Schema.NullOr(ProjectBot)), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -62,6 +64,7 @@ export const ApplicationProjectMetaUpdatedPayload = Schema.Struct({ scripts: Schema.optional(Schema.Array(ProjectScript)), pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), archivedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + bot: Schema.optional(Schema.NullOr(ProjectBot)), updatedAt: IsoDateTime, }); export type ApplicationProjectMetaUpdatedPayload = typeof ApplicationProjectMetaUpdatedPayload.Type; diff --git a/packages/contracts/src/bot.ts b/packages/contracts/src/bot.ts new file mode 100644 index 0000000000000..9cc3ce395ee57 --- /dev/null +++ b/packages/contracts/src/bot.ts @@ -0,0 +1,129 @@ +import * as Schema from "effect/Schema"; + +import { CommandId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ModelSelection } from "./modelSelection.ts"; + +/** + * A bot is a project rooted at `/bots/` whose single thread is + * the bot's long-lived session. The `bot` record on the project marks it and + * carries what the thread itself cannot: avatar and description. + */ +export const BOT_AVATAR_SHAPES = [ + "circle", + "cloud", + "diamond", + "star", + "flower", + "heart", + "gear", + "drop", + "pill", + "triangle", + "shield", + "burst", + "hexagon", + "pumpkin", +] as const; +export const BotAvatarShape = Schema.Literals(BOT_AVATAR_SHAPES); +export type BotAvatarShape = typeof BotAvatarShape.Type; + +export const BOT_AVATAR_COLORS = [ + "white", + "brown", + "red", + "orange", + "amber", + "green", + "teal", + "blue", + "purple", + "pink", + "gray", +] as const; +export const BotAvatarColor = Schema.Literals(BOT_AVATAR_COLORS); +export type BotAvatarColor = typeof BotAvatarColor.Type; + +/** Generated images live in the bot folder under `avatars/`. */ +export const BotAvatarImagePath = TrimmedNonEmptyString.check( + Schema.isMaxLength(128), + Schema.isPattern(/^avatars\/[A-Za-z0-9._-]+\.(?:png|jpe?g|webp)$/), +); +export type BotAvatarImagePath = typeof BotAvatarImagePath.Type; + +// Received as plain strings so a shape or color added later still decodes on +// older clients, which draw their default instead. +export const BotAvatar = Schema.Struct({ + shape: TrimmedNonEmptyString.check(Schema.isMaxLength(32)), + color: TrimmedNonEmptyString.check(Schema.isMaxLength(32)), + imagePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString.check(Schema.isMaxLength(128)))), +}); +export type BotAvatar = typeof BotAvatar.Type; + +export const BOT_NAME_MAX_LENGTH = 48; +export const BotName = TrimmedNonEmptyString.check(Schema.isMaxLength(BOT_NAME_MAX_LENGTH)); + +export const BOT_DESCRIPTION_MAX_LENGTH = 2000; +export const BotDescription = Schema.String.check(Schema.isMaxLength(BOT_DESCRIPTION_MAX_LENGTH)); + +export const ProjectBot = Schema.Struct({ + threadId: ThreadId, + avatar: BotAvatar, + description: Schema.NullOr(BotDescription), +}); +export type ProjectBot = typeof ProjectBot.Type; + +const BotAvatarChoice = Schema.Struct({ + shape: BotAvatarShape, + color: BotAvatarColor, +}); + +export const BotCreateInput = Schema.Struct({ + commandId: CommandId, + name: BotName, + avatar: BotAvatarChoice, + modelSelection: ModelSelection, +}); +export type BotCreateInput = typeof BotCreateInput.Type; + +export const BotCreateResult = Schema.Struct({ + projectId: ProjectId, + threadId: ThreadId, +}); +export type BotCreateResult = typeof BotCreateResult.Type; + +export const BotUpdateInput = Schema.Struct({ + projectId: ProjectId, + name: Schema.optional(BotName), + avatar: Schema.optional( + Schema.Struct({ + ...BotAvatarChoice.fields, + /** Null switches back to the drawn shape. */ + imagePath: Schema.optional(Schema.NullOr(BotAvatarImagePath)), + }), + ), + description: Schema.optional(Schema.NullOr(BotDescription)), +}); +export type BotUpdateInput = typeof BotUpdateInput.Type; + +export const BotDeleteInput = Schema.Struct({ projectId: ProjectId }); +export type BotDeleteInput = typeof BotDeleteInput.Type; + +export const BotMutationResult = Schema.Struct({ projectId: ProjectId }); +export type BotMutationResult = typeof BotMutationResult.Type; + +export const BotGenerateAvatarInput = Schema.Struct({ + projectId: ProjectId, + prompt: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(500))), +}); +export type BotGenerateAvatarInput = typeof BotGenerateAvatarInput.Type; + +export const BotGenerateAvatarResult = Schema.Struct({ + imagePath: BotAvatarImagePath, +}); +export type BotGenerateAvatarResult = typeof BotGenerateAvatarResult.Type; + +export class BotError extends Schema.TaggedError()("BotError", { + message: Schema.String, + projectId: Schema.optional(ProjectId), + cause: Schema.optional(Schema.Defect()), +}) {} diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index d3139f2eeff24..5d16118b10400 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -59,6 +59,10 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope, + [WS_METHODS.botsCreate]: AuthOrchestrationOperateScope, + [WS_METHODS.botsUpdate]: AuthOrchestrationOperateScope, + [WS_METHODS.botsDelete]: AuthOrchestrationOperateScope, + [WS_METHODS.botsGenerateAvatar]: AuthOrchestrationOperateScope, [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope, // Force stop kills agent processes, as Stop on a terminal does. [ORCHESTRATION_V2_WS_METHODS.forceStopThread]: AuthOrchestrationOperateScope, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 896733c5197e6..cba35c7b1b232 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -64,6 +64,7 @@ export * from "./previewAutomation.ts"; export * from "./previewCapture.ts"; export * from "./usage.ts"; export * from "./scheduledTask.ts"; +export * from "./bot.ts"; export * from "./worktreeMcp.ts"; export * from "./resourceTelemetry.ts"; export * from "./rpc.ts"; diff --git a/packages/contracts/src/orchestrationProject.ts b/packages/contracts/src/orchestrationProject.ts index d98c50e56e8d1..d6b183fc806b7 100644 --- a/packages/contracts/src/orchestrationProject.ts +++ b/packages/contracts/src/orchestrationProject.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; import { IsoDateTime, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; +import { ProjectBot } from "./bot.ts"; import { ModelSelection } from "./modelSelection.ts"; import { ProjectScript, ReceivedProjectIcon } from "./project.ts"; @@ -20,6 +21,7 @@ export const OrchestrationProjectShell = Schema.Struct({ // Pinned projects lead project pickers; archived ones leave them. Optional so older servers decode. pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), archivedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + bot: Schema.optional(Schema.NullOr(ProjectBot)), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 39696c62e4c99..f4d0d53bef486 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -1,6 +1,7 @@ import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; +import { ProjectBot } from "./bot.ts"; import { ModelSelection } from "./modelSelection.ts"; import { CommandId, @@ -163,6 +164,7 @@ export const Project = Schema.Struct({ scripts: Schema.Array(ProjectScript), pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), archivedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + bot: Schema.optional(Schema.NullOr(ProjectBot)), createdAt: IsoDateTime, updatedAt: IsoDateTime, deletedAt: Schema.NullOr(IsoDateTime), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 01989ff8a474f..fcc571f9ee465 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -377,6 +377,16 @@ import { ScheduledTaskMutationResult, } from "./scheduledTask.ts"; import { SecretRequestAnswerInput, SecretRequestError } from "./secretRequest.ts"; +import { + BotCreateInput, + BotCreateResult, + BotDeleteInput, + BotError, + BotGenerateAvatarInput, + BotGenerateAvatarResult, + BotMutationResult, + BotUpdateInput, +} from "./bot.ts"; import { ProjectCloneActionInput, ProjectCloneActionResult, @@ -549,6 +559,11 @@ export const WS_METHODS = { scheduledTasksListWebhookDeliveries: "scheduledTasks.listWebhookDeliveries", scheduledTasksGetWebhookDelivery: "scheduledTasks.getWebhookDelivery", + botsCreate: "bots.create", + botsUpdate: "bots.update", + botsDelete: "bots.delete", + botsGenerateAvatar: "bots.generateAvatar", + cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", @@ -1931,6 +1946,30 @@ const WsScheduledTasksRotateWebhookTokenRpc = Rpc.make( }, ); +const WsBotsCreateRpc = Rpc.make(WS_METHODS.botsCreate, { + payload: BotCreateInput, + success: BotCreateResult, + error: Schema.Union([BotError, EnvironmentAuthorizationError]), +}); + +const WsBotsUpdateRpc = Rpc.make(WS_METHODS.botsUpdate, { + payload: BotUpdateInput, + success: BotMutationResult, + error: Schema.Union([BotError, EnvironmentAuthorizationError]), +}); + +const WsBotsDeleteRpc = Rpc.make(WS_METHODS.botsDelete, { + payload: BotDeleteInput, + success: BotMutationResult, + error: Schema.Union([BotError, EnvironmentAuthorizationError]), +}); + +const WsBotsGenerateAvatarRpc = Rpc.make(WS_METHODS.botsGenerateAvatar, { + payload: BotGenerateAvatarInput, + success: BotGenerateAvatarResult, + error: Schema.Union([BotError, EnvironmentAuthorizationError]), +}); + const WsGuestShareCreateRpc = Rpc.make(WS_METHODS.guestShareCreate, { payload: GuestShareCreateInput, success: GuestShareCreateResult, @@ -2045,6 +2084,10 @@ export const WsRpcGroup = RpcGroup.make( WsSecretsAnswerRequestRpc, WsScheduledTasksListWebhookDeliveriesRpc, WsScheduledTasksGetWebhookDeliveryRpc, + WsBotsCreateRpc, + WsBotsUpdateRpc, + WsBotsDeleteRpc, + WsBotsGenerateAvatarRpc, WsServerReportClientActivityRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, diff --git a/packages/provider-core/src/server/textGeneration.ts b/packages/provider-core/src/server/textGeneration.ts index 3a80457cad0bf..801f8db1491c3 100644 --- a/packages/provider-core/src/server/textGeneration.ts +++ b/packages/provider-core/src/server/textGeneration.ts @@ -97,6 +97,17 @@ export type ReviewGuideGenerationResult = Omit< "headSha" | "generatedAt" | "modelSelection" >; +export interface ImageGenerationInput { + cwd: string; + prompt: string; + modelSelection: ModelSelection; +} + +export interface ImageGenerationResult { + /** Where the provider saved the image. Model output: validate before reading it. */ + imagePath: string; +} + export interface ProviderTextGeneration { readonly generateCommitMessage: ( input: CommitMessageGenerationInput, @@ -117,4 +128,9 @@ export interface ProviderTextGeneration { readonly generateReviewGuide: ( input: ReviewGuideGenerationInput, ) => Effect.Effect; + + /** Only providers with a hosted image tool implement this. */ + readonly generateImage?: ( + input: ImageGenerationInput, + ) => Effect.Effect; } diff --git a/packages/provider-opencode/src/server/textGeneration.ts b/packages/provider-opencode/src/server/textGeneration.ts index ab8d41f1568b2..ac189bdc27db8 100644 --- a/packages/provider-opencode/src/server/textGeneration.ts +++ b/packages/provider-opencode/src/server/textGeneration.ts @@ -18,6 +18,7 @@ const OpenCodeTextGenerationOperation = Schema.Literals([ "generateBranchName", "generateThreadTitle", "generateReviewGuide", + "generateImage", ]); const openCodeTextGenerationErrorContext = { diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 864bcd8950d41..a3e4da8739d20 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -361,6 +361,7 @@ const T3_MCP_TOOLS: Readonly> = { t3_project_update: tool(["Update", "Updating", "Updated", "a project"], "project-update"), t3_project_delete: tool(["Delete", "Deleting", "Deleted", "a project"], "project-delete"), t3_project_clone: tool(["Clone", "Cloning", "Cloned", "a repository"], "project-clone"), + t3_bot_update: tool(["Update", "Updating", "Updated", "the bot profile"], "project-update"), t3_attachment_prepare_upload: tool( ["Prepare", "Preparing", "Prepared", "an attachment upload"], "attachment-prepare", From 256325179e5393cc8715dca0dfbb733d2da638d8 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 20:43:04 +0530 Subject: [PATCH 02/40] fix(server): bot creation is retry-safe and cleans up on any failure A retried create returns the bot it already made. Failure or interruption after the folder is claimed deletes only this attempt's project and then its folder. git init failures fail the create. Generated avatars must resolve inside the Codex instance's generated_images directory. --- apps/server/src/bots/BotService.test.ts | 61 ++++++-- apps/server/src/bots/BotService.ts | 180 ++++++++++++++---------- 2 files changed, 159 insertions(+), 82 deletions(-) diff --git a/apps/server/src/bots/BotService.test.ts b/apps/server/src/bots/BotService.test.ts index 23110dea85ab1..7acb9a25f3db4 100644 --- a/apps/server/src/bots/BotService.test.ts +++ b/apps/server/src/bots/BotService.test.ts @@ -45,6 +45,7 @@ interface Harness { generatedImagePath: string; /** The project the fake calling thread belongs to. */ threadProjectId: ProjectId | undefined; + failLaunch: boolean; } const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]); @@ -72,14 +73,23 @@ const layerFakes = (harness: Harness) => Layer.mergeAll( Layer.mock(ThreadLaunch.ThreadLaunchService)({ launch: (input) => - Effect.sync(() => { - harness.launches.push(input); - return { - threadId: input.threadId ?? ThreadId.make("unused"), - projection: {} as ThreadLaunch.ThreadLaunchResult["projection"], - resumed: false, - }; - }), + harness.failLaunch + ? Effect.fail( + new ThreadLaunch.ThreadLaunchError({ + operation: "create-thread", + commandId: input.commandId, + projectId: input.projectId, + cause: "provider disabled", + }), + ) + : Effect.sync(() => { + harness.launches.push(input); + return { + threadId: input.threadId ?? ThreadId.make("unused"), + projection: {} as ThreadLaunch.ThreadLaunchResult["projection"], + resumed: false, + }; + }), }), Layer.mock(ThreadManagement.ThreadManagementService)({ dispatch: (command) => @@ -103,7 +113,12 @@ const layerFakes = (harness: Harness) => instanceId: ProviderInstanceId.make("codex"), driverKind: ProviderDriverKind.make("codex"), enabled: true, - snapshot: { getSnapshot: Effect.succeed({ models: [{ slug: "gpt-test" }] }) }, + snapshot: { + getSnapshot: Effect.succeed({ + models: [{ slug: "gpt-test" }], + runtimePaths: { homePath: `${harness.baseDir}/codex`, shadowHomePath: null }, + }), + }, textGeneration: { generateImage: () => Effect.sync(() => ({ imagePath: harness.generatedImagePath })), }, @@ -143,6 +158,7 @@ const withBots = ( commands: [], generatedImagePath: "", threadProjectId: undefined, + failLaunch: false, }; return yield* body(harness).pipe(Effect.provide(layer(harness))); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); @@ -205,6 +221,29 @@ it.effect("creates a bot as a project in its own folder with one always-on sessi ), ); +it.effect( + "a retried create returns the existing bot and a failed launch leaves nothing behind", + () => + withBots((harness) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const created = yield* createBot("Helper", "bot-retry"); + harness.failLaunch = true; + assert.deepEqual(yield* createBot("Helper", "bot-retry"), created); + assert.equal((yield* readProject(created.projectId)).deletedAt, null); + + const failed = yield* Effect.flip(createBot("Helper", "bot-broken")); + assert.equal(failed.message, "Could not start the bot's session."); + assert.isFalse(yield* fileSystem.exists(path.resolve(harness.baseDir, "bots", "helper-2"))); + const projects = yield* ProjectService.ProjectService; + assert.isTrue( + Option.isNone(yield* projects.getById(failed.projectId ?? created.projectId)), + ); + }), + ), +); + it.effect("keeps a generated avatar when only the shape changes", () => withBots((harness) => Effect.gen(function* () { @@ -243,7 +282,9 @@ it.effect("refuses a generated image path outside Codex's generated images", () const path = yield* Path.Path; const bots = yield* BotService.BotService; const { projectId } = yield* createBot("Painter", "bot-escape"); - harness.generatedImagePath = path.join(harness.baseDir, "secret.png"); + const elsewhere = path.join(harness.baseDir, "private", "generated_images"); + yield* fileSystem.makeDirectory(elsewhere, { recursive: true }); + harness.generatedImagePath = path.join(elsewhere, "photo.png"); yield* fileSystem.writeFile(harness.generatedImagePath, PNG_BYTES); const error = yield* Effect.flip(bots.generateAvatar({ projectId })); diff --git a/apps/server/src/bots/BotService.ts b/apps/server/src/bots/BotService.ts index ab8e66d5679a0..2f9538c0d4122 100644 --- a/apps/server/src/bots/BotService.ts +++ b/apps/server/src/bots/BotService.ts @@ -21,6 +21,7 @@ import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -195,72 +196,98 @@ const make = Effect.gen(function* () { }); const create: BotService["Service"]["create"] = Effect.fn("BotService.create")(function* (input) { - const folder = yield* claimFolder(input.name); - const removeFolder = fileSystem.remove(folder, { recursive: true }).pipe(Effect.ignore); - yield* scaffoldFolder(folder, input.name).pipe( - Effect.mapError(fail("Could not set up the bot's folder.")), - Effect.tapError(() => removeFolder), - ); - // Its own repository keeps checkpoints out of any checkout the data dir lives in. - yield* git - .execute({ - operation: "BotService.init", - cwd: folder, - args: ["init", "--quiet"], - timeoutMs: 10_000, - }) - .pipe(Effect.ignore); - const projectId = ProjectId.make(input.commandId); const threadId = ThreadId.make(input.commandId); - yield* projects - .create({ - commandId: CommandId.make(`${input.commandId}:project`), - projectId, - title: input.name, - workspaceRoot: folder, - bot: { - threadId, - avatar: { shape: input.avatar.shape, color: input.avatar.color, imagePath: null }, - description: null, - }, - }) - .pipe( - Effect.mapError((error) => fail(error.message)(error)), - Effect.tapError(() => removeFolder), + // A retried create returns the bot it already made instead of building a second one. + const existing = yield* projects + .getById(projectId, { includeDeleted: true }) + .pipe(Effect.mapError(fail("Could not read the bot.", projectId))); + if (Option.isSome(existing)) { + const bot = existing.value.bot; + if (existing.value.deletedAt !== null || bot == null) { + return yield* new BotError({ message: "This request was already used.", projectId }); + } + return { projectId, threadId: bot.threadId }; + } + + const folder = yield* claimFolder(input.name); + // The folder was claimed by this attempt, so only this attempt's project can own it. + const rollback = Effect.gen(function* () { + const owner = yield* projects.getByWorkspaceRoot(folder).pipe( + Effect.map(Option.getOrUndefined), + Effect.orElseSucceed(() => undefined), ); + if (owner !== undefined) { + if (owner.id !== projectId) return; + const deleted = yield* projects + .delete({ + commandId: CommandId.make(`${input.commandId}:rollback`), + projectId, + force: true, + }) + .pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (!deleted) return; + } + yield* fileSystem.remove(folder, { recursive: true }).pipe(Effect.ignore); + }); - const deleteProject = projects - .delete({ commandId: CommandId.make(`${input.commandId}:rollback`), projectId, force: true }) - .pipe(Effect.ignore); - yield* launches - .launch({ - commandId: CommandId.make(`${input.commandId}:thread`), - threadId, - projectId, - title: input.name, - modelSelection: input.modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - workspaceStrategy: { type: "root" }, - initialMessage: { text: kickoffMessage(input.name), attachments: [] }, - createdBy: "user", - creationSource: "server", - }) - .pipe( - Effect.mapError((error) => fail("Could not start the bot's session.", projectId)(error)), - Effect.tapError(() => deleteProject), + return yield* Effect.gen(function* () { + yield* scaffoldFolder(folder, input.name).pipe( + Effect.mapError(fail("Could not set up the bot's folder.")), ); - // Settled threads drop out of shell snapshots; a bot's session stays in view. - yield* threads - .dispatch({ - type: "thread.auto-settle.set", - commandId: CommandId.make(`${input.commandId}:auto-settle`), - threadId, - enabled: false, - }) - .pipe(Effect.ignore); - return { projectId, threadId }; + // Its own repository keeps checkpoints out of any checkout the data dir lives in. + yield* git + .execute({ + operation: "BotService.init", + cwd: folder, + args: ["init", "--quiet"], + timeoutMs: 10_000, + }) + .pipe(Effect.mapError(fail("Could not create the bot's repository."))); + yield* projects + .create({ + commandId: CommandId.make(`${input.commandId}:project`), + projectId, + title: input.name, + workspaceRoot: folder, + bot: { + threadId, + avatar: { shape: input.avatar.shape, color: input.avatar.color, imagePath: null }, + description: null, + }, + }) + .pipe(Effect.mapError((error) => fail(error.message)(error))); + yield* launches + .launch({ + commandId: CommandId.make(`${input.commandId}:thread`), + threadId, + projectId, + title: input.name, + modelSelection: input.modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + workspaceStrategy: { type: "root" }, + initialMessage: { text: kickoffMessage(input.name), attachments: [] }, + createdBy: "user", + creationSource: "server", + }) + .pipe( + Effect.mapError((error) => fail("Could not start the bot's session.", projectId)(error)), + ); + // Settled threads drop out of shell snapshots; a bot's session stays in view. + yield* threads + .dispatch({ + type: "thread.auto-settle.set", + commandId: CommandId.make(`${input.commandId}:auto-settle`), + threadId, + enabled: false, + }) + .pipe(Effect.ignore); + return { projectId, threadId }; + }).pipe(Effect.onExit((exit) => (Exit.isFailure(exit) ? rollback : Effect.void))); }); const update: BotService["Service"]["update"] = Effect.fn("BotService.update")(function* (input) { @@ -347,21 +374,24 @@ const make = Effect.gen(function* () { if (instance === undefined || generateImage === undefined) { return yield* new BotError({ message: "Avatar generation needs Codex." }); } - let model = preferred?.instanceId === instance.instanceId ? preferred.model : undefined; - if (model === undefined) { - const snapshot = yield* instance.snapshot.getSnapshot; - model = snapshot.models[0]?.slug; - } + const snapshot = yield* instance.snapshot.getSnapshot; + const model = + preferred?.instanceId === instance.instanceId ? preferred.model : snapshot.models[0]?.slug; if (model === undefined) { return yield* new BotError({ message: "Codex has no model available." }); } + // Codex saves images under /generated_images; the shadow home is the one it runs with. + const imageRoots = [snapshot.runtimePaths?.homePath, snapshot.runtimePaths?.shadowHomePath] + .filter((home): home is string => typeof home === "string") + .map((home) => path.join(home, "generated_images")); const modelSelection: ModelSelection = { instanceId: instance.instanceId, model }; - return { generateImage, modelSelection }; + return { generateImage, modelSelection, imageRoots }; }); // The path comes from model output: accept only an image Codex saved itself. const readGeneratedImage = Effect.fn("BotService.readGeneratedImage")(function* ( imagePath: string, + imageRoots: ReadonlyArray, projectId: ProjectId, ) { const invalid = new BotError({ @@ -370,9 +400,15 @@ const make = Effect.gen(function* () { }); if (!path.isAbsolute(imagePath)) return yield* invalid; const real = yield* fileSystem.realPath(imagePath).pipe(Effect.mapError(() => invalid)); - if (!real.split(path.sep).includes("generated_images") || !/\.(png|jpe?g|webp)$/i.test(real)) { - return yield* invalid; - } + const realRoots = yield* Effect.forEach(imageRoots, (root) => + fileSystem.realPath(root).pipe(Effect.option), + ); + const contained = realRoots.some((root) => { + if (Option.isNone(root)) return false; + const relative = path.relative(root.value, real); + return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative); + }); + if (!contained || !/\.(png|jpe?g|webp)$/i.test(real)) return yield* invalid; const info = yield* fileSystem.stat(real).pipe(Effect.mapError(() => invalid)); if (info.type !== "File" || Number(info.size) > MAX_GENERATED_IMAGE_BYTES) { return yield* invalid; @@ -386,7 +422,7 @@ const make = Effect.gen(function* () { "BotService.generateAvatar", )(function* (input) { const { project } = yield* readBot(input.projectId); - const { generateImage, modelSelection } = yield* resolveImageGenerator(); + const { generateImage, modelSelection, imageRoots } = yield* resolveImageGenerator(); const prompt = [ `Use your image generation tool to create one square avatar image for a personal assistant bot named "${project.title}".`, input.prompt === undefined @@ -400,7 +436,7 @@ const make = Effect.gen(function* () { prompt, modelSelection, }).pipe(Effect.mapError((error) => fail(error.message, input.projectId)(error))); - const image = yield* readGeneratedImage(generated.imagePath, input.projectId); + const image = yield* readGeneratedImage(generated.imagePath, imageRoots, input.projectId); const avatarsDir = path.join(project.workspaceRoot, "avatars"); const stamp = DateTime.formatIso(yield* DateTime.now).replace(/[^0-9]/g, ""); From 4fa396891b23bf65ee4f715166a2e5e3cd6b3e3a Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 20:46:54 +0530 Subject: [PATCH 03/40] feat(client): bot list state and bot commands Bot projects and every thread inside them leave the project and thread lists but stay reachable by id. A shared bot list orders bots by latest activity and marks replies the user has not opened as unread. --- .../client-runtime/src/state/bots.test.ts | 158 ++++++++++++++++++ packages/client-runtime/src/state/bots.ts | 139 +++++++++++++++ .../src/state/projectEntities.ts | 36 +++- packages/client-runtime/src/state/server.ts | 21 +++ .../client-runtime/src/state/threadShell.ts | 16 +- 5 files changed, 360 insertions(+), 10 deletions(-) create mode 100644 packages/client-runtime/src/state/bots.test.ts create mode 100644 packages/client-runtime/src/state/bots.ts diff --git a/packages/client-runtime/src/state/bots.test.ts b/packages/client-runtime/src/state/bots.test.ts new file mode 100644 index 0000000000000..d8d72c37c1954 --- /dev/null +++ b/packages/client-runtime/src/state/bots.test.ts @@ -0,0 +1,158 @@ +import { + EnvironmentId, + MessageId, + ProjectId, + ThreadId, + type OrchestrationV2ShellSnapshot, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import { Atom, AtomRegistry } from "effect/reactivity"; +import { describe, expect, it } from "vite-plus/test"; + +import { PrimaryConnectionTarget } from "../connection/model.ts"; +import { createBotListAtom } from "./bots.ts"; +import { v2Project, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; +import { createEnvironmentProjectAtoms } from "./projectEntities.ts"; +import { createEnvironmentThreadShellAtoms } from "./threadShell.ts"; + +const environmentId = EnvironmentId.make("environment-bots"); +const botProjectId = ProjectId.make("bot-project"); +const botThreadId = ThreadId.make("bot-thread"); +const botChildThreadId = ThreadId.make("bot-child"); + +const botProject = { + ...v2Project, + id: botProjectId, + title: "Scout", + workspaceRoot: "/t3/bots/scout", + bot: { + threadId: botThreadId, + avatar: { shape: "star", color: "teal" }, + description: null, + }, +}; + +const reply = (at: string) => ({ + id: MessageId.make(`reply-${at}`), + role: "assistant" as const, + text: "Found three\nflights.", + updatedAt: DateTime.makeUnsafe(at), +}); + +function makeHarness(snapshot: OrchestrationV2ShellSnapshot) { + const snapshotAtom = Atom.family((_environmentId: EnvironmentId) => + Atom.make(snapshot), + ); + const catalogValueAtom = Atom.make({ + isReady: true, + entries: new Map([ + [ + environmentId, + { + target: new PrimaryConnectionTarget({ + environmentId, + label: "Environment", + httpBaseUrl: "https://example.test", + wsBaseUrl: "wss://example.test", + }), + profile: Option.none(), + enabled: true, + }, + ], + ]), + }); + const projects = createEnvironmentProjectAtoms({ catalogValueAtom, snapshotAtom }); + const threads = createEnvironmentThreadShellAtoms({ catalogValueAtom, snapshotAtom }); + const bots = createBotListAtom({ + botsAtom: projects.botsAtom, + quietThreadShellAtom: threads.quietThreadShellAtom, + }); + return { registry: AtomRegistry.make(), snapshotAtom, projects, threads, bots }; +} + +describe("bots", () => { + it("keeps bots and their threads out of project and thread lists but reachable by id", () => { + const { registry, projects, threads, bots } = makeHarness({ + ...v2ShellSnapshot, + projects: [v2Project, botProject], + threads: [ + v2ThreadShell, + { ...v2ThreadShell, id: botThreadId, projectId: botProjectId }, + { + ...v2ThreadShell, + id: botChildThreadId, + projectId: botProjectId, + lineage: { + rootThreadId: botThreadId, + parentThreadId: botThreadId, + relationshipToParent: "subagent", + }, + }, + ], + }); + + expect(registry.get(projects.projectsAtom).map((project) => project.id)).toEqual([ + v2Project.id, + ]); + expect(registry.get(threads.threadShellsAtom).map((thread) => thread.id)).toEqual([ + v2ThreadShell.id, + ]); + expect(registry.get(threads.threadRefsAtom)).toHaveLength(1); + expect(registry.get(projects.projectAtom({ environmentId, projectId: botProjectId }))).not.toBe( + null, + ); + expect( + registry.get(threads.threadShellAtom({ environmentId, threadId: botThreadId }))?.id, + ).toBe(botThreadId); + expect(registry.get(bots).map((bot) => bot.name)).toEqual(["Scout"]); + registry.dispose(); + }); + + it("marks a reply unread until the thread is visited after it, newest activity first", () => { + const { registry, snapshotAtom, bots } = makeHarness({ + ...v2ShellSnapshot, + projects: [ + botProject, + { + ...botProject, + id: ProjectId.make("quiet"), + title: "Quiet", + bot: { ...botProject.bot, threadId: ThreadId.make("quiet-thread") }, + }, + ], + threads: [ + { + ...v2ThreadShell, + id: botThreadId, + projectId: botProjectId, + latestVisibleMessage: reply("2026-06-20T10:00:00.000Z"), + lastVisitedAt: DateTime.makeUnsafe("2026-06-20T09:00:00.000Z"), + }, + { + ...v2ThreadShell, + id: ThreadId.make("quiet-thread"), + projectId: ProjectId.make("quiet"), + latestVisibleMessage: reply("2026-06-20T08:00:00.000Z"), + lastVisitedAt: DateTime.makeUnsafe("2026-06-20T08:30:00.000Z"), + }, + ], + }); + + const [first, second] = registry.get(bots); + expect(first).toMatchObject({ name: "Scout", unread: true, preview: "Found three flights." }); + expect(second).toMatchObject({ name: "Quiet", unread: false }); + + const visited = registry.get(snapshotAtom(environmentId))!; + registry.set(snapshotAtom(environmentId), { + ...visited, + threads: visited.threads.map((thread) => + thread.id === botThreadId + ? { ...thread, lastVisitedAt: DateTime.makeUnsafe("2026-06-20T10:05:00.000Z") } + : thread, + ), + }); + expect(registry.get(bots)[0]).toMatchObject({ name: "Scout", unread: false }); + registry.dispose(); + }); +}); diff --git a/packages/client-runtime/src/state/bots.ts b/packages/client-runtime/src/state/bots.ts new file mode 100644 index 0000000000000..f950922a24566 --- /dev/null +++ b/packages/client-runtime/src/state/bots.ts @@ -0,0 +1,139 @@ +import type { + BotAvatar, + EnvironmentId, + OrchestrationProjectShell, + ProjectId, + ScopedThreadRef, + ThreadId, +} from "@t3tools/contracts"; + +import * as DateTime from "effect/DateTime"; +import { Atom } from "effect/reactivity"; + +import { arrayElementsEqual } from "./entities.ts"; +import { + type EnvironmentProject, + type EnvironmentThreadShell, + threadRuntimeIsActive, +} from "./models.ts"; + +const EMPTY_PROJECT_IDS: ReadonlySet = new Set(); + +export function isBotProject(project: Pick): boolean { + return project.bot != null; +} + +/** Bot projects and everything in them stay out of project and thread lists. */ +export function botProjectIds( + projects: ReadonlyArray>, +): ReadonlySet { + let ids: Set | null = null; + for (const project of projects) { + if (project.bot == null) continue; + ids ??= new Set(); + ids.add(project.id); + } + return ids ?? EMPTY_PROJECT_IDS; +} + +export interface BotSummary { + readonly key: string; + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + readonly threadId: ThreadId; + readonly threadRef: ScopedThreadRef; + readonly name: string; + readonly avatar: BotAvatar; + readonly description: string | null; + readonly workspaceRoot: string; + readonly preview: string | null; + /** ISO time of the bot's latest activity, used to order the list. */ + readonly activityAt: string; + readonly unread: boolean; + readonly working: boolean; +} + +/** A reply the user has not opened yet. Opening the thread moves `lastVisitedAt` past it. */ +export function hasUnreadBotReply(shell: EnvironmentThreadShell): boolean { + const message = shell.source.latestVisibleMessage; + if (message === null || message.role !== "assistant") return false; + if (shell.lastVisitedAt == null) return true; + return DateTime.toEpochMillis(message.updatedAt) > Date.parse(shell.lastVisitedAt); +} + +function previewText(text: string): string | null { + const line = text.replace(/\s+/g, " ").trim(); + return line.length === 0 ? null : line.slice(0, 160); +} + +export function summarizeBot( + project: EnvironmentProject, + shell: EnvironmentThreadShell | null, +): BotSummary | null { + const bot = project.bot; + if (bot == null) return null; + const message = shell?.source.latestVisibleMessage ?? null; + const working = threadRuntimeIsActive(shell?.runtime); + return { + key: `${project.environmentId}:${project.id}`, + environmentId: project.environmentId, + projectId: project.id, + threadId: bot.threadId, + threadRef: { environmentId: project.environmentId, threadId: bot.threadId }, + name: project.title, + avatar: bot.avatar, + description: bot.description, + workspaceRoot: project.workspaceRoot, + preview: message === null ? null : previewText(message.text), + activityAt: + message === null + ? (shell?.updatedAt ?? project.createdAt) + : DateTime.formatIso(message.updatedAt), + unread: shell !== null && hasUnreadBotReply(shell), + working, + }; +} + +/** Like a chat list: the most recent activity first. */ +export function compareBots(left: BotSummary, right: BotSummary): number { + const byActivity = Date.parse(right.activityAt) - Date.parse(left.activityAt); + if (byActivity !== 0 && !Number.isNaN(byActivity)) return byActivity; + return left.name.localeCompare(right.name); +} + +/** + * Reads quiet shells, so the list reorders when a reply lands rather than on + * every streamed token. Summaries keep their identity while nothing changed. + */ +export function createBotListAtom(input: { + readonly botsAtom: Atom.Atom>; + readonly quietThreadShellAtom: (ref: ScopedThreadRef) => Atom.Atom; +}) { + const cache = new WeakMap< + EnvironmentProject, + { shell: EnvironmentThreadShell | null; summary: BotSummary | null } + >(); + let previous: ReadonlyArray = []; + return Atom.make((get) => { + const next: BotSummary[] = []; + for (const project of get(input.botsAtom)) { + if (project.bot == null) continue; + const shell = get( + input.quietThreadShellAtom({ + environmentId: project.environmentId, + threadId: project.bot.threadId, + }), + ); + let entry = cache.get(project); + if (entry === undefined || entry.shell !== shell) { + entry = { shell, summary: summarizeBot(project, shell) }; + cache.set(project, entry); + } + if (entry.summary !== null) next.push(entry.summary); + } + next.sort(compareBots); + if (arrayElementsEqual(previous, next)) return previous; + previous = next; + return next; + }).pipe(Atom.withLabel("bot-list")); +} diff --git a/packages/client-runtime/src/state/projectEntities.ts b/packages/client-runtime/src/state/projectEntities.ts index 313f845797e97..bfef7580908ee 100644 --- a/packages/client-runtime/src/state/projectEntities.ts +++ b/packages/client-runtime/src/state/projectEntities.ts @@ -11,6 +11,7 @@ import { Atom } from "effect/reactivity"; import type { EnvironmentProject } from "./models.ts"; import { scopeProject } from "./models.ts"; import { isScratchProject } from "./projects.ts"; +import { isBotProject } from "./bots.ts"; import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts"; import { arrayElementsEqual, parseProjectKey, projectKey, projectRefsEqual } from "./entities.ts"; @@ -43,20 +44,26 @@ export function createEnvironmentProjectAtoms(input: { }).pipe(Atom.withLabel(`environment-project-index:${environmentId}`)), ); - const environmentProjectRefsAtom = Atom.family((environmentId: EnvironmentId) => { + // Bots are projects, but they live in the Bots list rather than among projects. + const makeRefsAtom = (environmentId: EnvironmentId, bots: boolean, label: string) => { let previous: ReadonlyArray = []; return Atom.make((get) => { - const next = get(environmentProjectsAtom(environmentId)).map((project) => ({ - environmentId, - projectId: project.id, - })); + const next = get(environmentProjectsAtom(environmentId)).flatMap((project) => + isBotProject(project) === bots ? [{ environmentId, projectId: project.id }] : [], + ); if (projectRefsEqual(previous, next)) { return previous; } previous = next; return next; - }).pipe(Atom.withLabel(`environment-project-refs:${environmentId}`)); - }); + }).pipe(Atom.withLabel(`${label}:${environmentId}`)); + }; + const environmentProjectRefsAtom = Atom.family((environmentId: EnvironmentId) => + makeRefsAtom(environmentId, false, "environment-project-refs"), + ); + const environmentBotRefsAtom = Atom.family((environmentId: EnvironmentId) => + makeRefsAtom(environmentId, true, "environment-bot-refs"), + ); const projectAtomFamily = Atom.family((key: string) => { const ref = parseProjectKey(key); @@ -115,6 +122,20 @@ export function createEnvironmentProjectAtoms(input: { return previousProjects; }).pipe(Atom.withLabel("environment-project-list")); + let previousBots: ReadonlyArray = []; + const botsAtom = Atom.make((get) => { + const next: EnvironmentProject[] = []; + for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { + for (const ref of get(environmentBotRefsAtom(environmentId))) { + const project = get(projectAtomFamily(projectKey(ref))); + if (project !== null) next.push(project); + } + } + if (arrayElementsEqual(previousBots, next)) return previousBots; + previousBots = next; + return next; + }).pipe(Atom.withLabel("environment-bot-list")); + const projectConfigsReadyAtom = Atom.make((get) => { const catalog = get(input.catalogValueAtom); if (!catalog.isReady) return false; @@ -132,6 +153,7 @@ export function createEnvironmentProjectAtoms(input: { environmentProjectRefsAtom, projectRefsAtom, projectsAtom, + botsAtom, projectConfigsReadyAtom, projectAtom: (ref: ScopedProjectRef) => projectAtomFamily(projectKey(ref)), }; diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 04a2d66321517..8e12c584e44b6 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1541,6 +1541,27 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:signal-process", tag: WS_METHODS.serverSignalProcess, }), + createBot: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:bot:create", + tag: WS_METHODS.botsCreate, + }), + updateBot: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:bot:update", + tag: WS_METHODS.botsUpdate, + }), + deleteBot: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:bot:delete", + tag: WS_METHODS.botsDelete, + }), + // Image generation takes about a minute; one at a time per bot. + generateBotAvatar: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:bot:generate-avatar", + tag: WS_METHODS.botsGenerateAvatar, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => `${environmentId}:${input.projectId}`, + }, + }), upsertScheduledTask: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:scheduled-task:upsert", tag: WS_METHODS.scheduledTasksUpsert, diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index f5ced761c5665..c7b8fae0c615b 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -17,6 +17,7 @@ import { Atom } from "effect/reactivity"; import type { EnvironmentThreadShell, PendingSubagentRequest } from "./models.ts"; import { presentThreadShell, threadRuntimeIsActive } from "./models.ts"; import { hiddenThreadKeys, NO_HIDDEN_THREADS } from "./threadTagFilter.ts"; +import { botProjectIds } from "./bots.ts"; import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts"; import { arrayElementsEqual, @@ -253,6 +254,15 @@ export function createEnvironmentThreadShellAtoms(input: { }).pipe(Atom.withLabel(`environment-thread-index:${environmentId}`)), ); + // Bot sessions and their children are reachable by id but stay out of thread lists. + const environmentListedThreadsAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get): ReadonlyArray => { + const threads = get(environmentThreadsAtom(environmentId)); + const bots = botProjectIds(get(input.snapshotAtom(environmentId))?.projects ?? []); + return bots.size === 0 ? threads : threads.filter((thread) => !bots.has(thread.projectId)); + }).pipe(Atom.withLabel(`environment-listed-threads:${environmentId}`)), + ); + // Side chats by parent, built once per change for every parent's row. Without side chats // it stays the same empty map, so those rows do no work. const environmentSideChatsByParentAtom = Atom.family((environmentId: EnvironmentId) => @@ -347,7 +357,7 @@ export function createEnvironmentThreadShellAtoms(input: { const environmentThreadRefsAtom = Atom.family((environmentId: EnvironmentId) => { let previous: ReadonlyArray = []; return Atom.make((get) => { - const next = get(environmentThreadsAtom(environmentId)).map((thread) => ({ + const next = get(environmentListedThreadsAtom(environmentId)).map((thread) => ({ environmentId, threadId: thread.id, })); @@ -583,7 +593,7 @@ export function createEnvironmentThreadShellAtoms(input: { const next: EnvironmentThreadShell[] = []; for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { const subagentRequests = get(pendingSubagentRequestsAtom(environmentId)); - for (const thread of get(environmentThreadsAtom(environmentId))) { + for (const thread of get(environmentListedThreadsAtom(environmentId))) { next.push(scopedThread(environmentId, thread, subagentRequests)); } } @@ -698,7 +708,7 @@ export function createEnvironmentThreadShellAtoms(input: { const hidden = get(hiddenThreadKeysAtom); for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { const subagentRequests = get(pendingSubagentRequestsAtom(environmentId)); - for (const thread of get(environmentThreadsAtom(environmentId))) { + for (const thread of get(environmentListedThreadsAtom(environmentId))) { if ( thread.archivedAt !== null || isShownUnderParent({ environmentId, threadId: thread.id }, thread.lineage, underParent) || From 32976798ba75511db3da7cad5e461770bf24f523 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 20:48:31 +0530 Subject: [PATCH 04/40] feat(client): shared bot avatar shapes and colors --- packages/client-runtime/package.json | 8 ++++ packages/client-runtime/src/botAvatar.ts | 48 ++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 packages/client-runtime/src/botAvatar.ts diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d1b48e1d76f4f..3bea4da6f29cc 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -518,6 +518,14 @@ "./provider-clients": { "types": "./src/providerClients.ts", "default": "./src/providerClients.ts" + }, + "./bot-avatar": { + "types": "./src/botAvatar.ts", + "default": "./src/botAvatar.ts" + }, + "./state/bots": { + "types": "./src/state/bots.ts", + "default": "./src/state/bots.ts" } }, "scripts": { diff --git a/packages/client-runtime/src/botAvatar.ts b/packages/client-runtime/src/botAvatar.ts new file mode 100644 index 0000000000000..f5805ab604574 --- /dev/null +++ b/packages/client-runtime/src/botAvatar.ts @@ -0,0 +1,48 @@ +import type { BotAvatarColor, BotAvatarShape } from "@t3tools/contracts"; + +/** SVG path data on a 100x100 view box. Overlapping subpaths fill with the nonzero rule. */ +export const BOT_AVATAR_SHAPE_PATHS: Record = { + circle: "M8.0 50.0a42 42 0 1 0 84 0a42 42 0 1 0 -84 0Z", + cloud: + "M12.0 58.0a22 22 0 1 0 44 0a22 22 0 1 0 -44 0ZM30.0 44.0a26 26 0 1 0 52 0a26 26 0 1 0 -52 0ZM54.0 62.0a18 18 0 1 0 36 0a18 18 0 1 0 -36 0ZM34 62V80H72V62Z", + diamond: "M50 6L92 50L50 94L8 50Z", + star: "M50.0 8.0 L61.8 37.8 L93.7 39.8 L69.0 60.2 L77.0 91.2 L50.0 74.0 L23.0 91.2 L31.0 60.2 L6.3 39.8 L38.2 37.8Z", + flower: + "M52.0 50.0a20 20 0 1 0 40 0a20 20 0 1 0 -40 0ZM41.0 69.1a20 20 0 1 0 40 0a20 20 0 1 0 -40 0ZM19.0 69.1a20 20 0 1 0 40 0a20 20 0 1 0 -40 0ZM8.0 50.0a20 20 0 1 0 40 0a20 20 0 1 0 -40 0ZM19.0 30.9a20 20 0 1 0 40 0a20 20 0 1 0 -40 0ZM41.0 30.9a20 20 0 1 0 40 0a20 20 0 1 0 -40 0ZM28.0 50.0a22 22 0 1 0 44 0a22 22 0 1 0 -44 0Z", + heart: + "M50 88C22 68 6 52 6 33C6 20 16 10 29 10C38 10 46 15 50 23C54 15 62 10 71 10C84 10 94 20 94 33C94 52 78 68 50 88Z", + gear: "M45.7 6.2 L54.3 6.2 L59.6 18.4 L65.6 20.9 L77.9 16.0 L84.0 22.1 L79.1 34.4 L81.6 40.4 L93.8 45.7 L93.8 54.3 L81.6 59.6 L79.1 65.6 L84.0 77.9 L77.9 84.0 L65.6 79.1 L59.6 81.6 L54.3 93.8 L45.7 93.8 L40.4 81.6 L34.4 79.1 L22.1 84.0 L16.0 77.9 L20.9 65.6 L18.4 59.6 L6.2 54.3 L6.2 45.7 L18.4 40.4 L20.9 34.4 L16.0 22.1 L22.1 16.0 L34.4 20.9 L40.4 18.4Z", + drop: "M50 6C50 6 16 44 16 64C16 82 31 94 50 94C69 94 84 82 84 64C84 44 50 6 50 6Z", + pill: "M30 26H70A24 24 0 0 1 70 74H30A24 24 0 0 1 30 26Z", + triangle: "M50 10L93 86H7Z", + shield: "M50 6L88 20V46C88 70 72 86 50 94C28 86 12 70 12 46V20Z", + burst: + "M50.0 4.0 L58.8 17.2 L73.0 10.2 L74.0 26.0 L89.8 27.0 L82.8 41.2 L96.0 50.0 L82.8 58.8 L89.8 73.0 L74.0 74.0 L73.0 89.8 L58.8 82.8 L50.0 96.0 L41.2 82.8 L27.0 89.8 L26.0 74.0 L10.2 73.0 L17.2 58.8 L4.0 50.0 L17.2 41.2 L10.2 27.0 L26.0 26.0 L27.0 10.2 L41.2 17.2Z", + hexagon: "M50.0 6.0 L88.1 28.0 L88.1 72.0 L50.0 94.0 L11.9 72.0 L11.9 28.0Z", + pumpkin: + "M10.0 60.0a23 30 0 1 0 46 0a23 30 0 1 0 -46 0ZM44.0 60.0a23 30 0 1 0 46 0a23 30 0 1 0 -46 0ZM28.0 60.0a22 32 0 1 0 44 0a22 32 0 1 0 -44 0ZM46 12V30H54V12Z", +}; + +export const BOT_AVATAR_COLOR_HEX: Record = { + white: "#F4F4F5", + brown: "#92643F", + red: "#EF4444", + orange: "#F97316", + amber: "#F5B014", + green: "#22C55E", + teal: "#14B8A6", + blue: "#3B82F6", + purple: "#8B5CF6", + pink: "#EC4899", + gray: "#9CA3AF", +}; + +/** Unknown values from newer servers draw the default rather than nothing. */ +export function resolveBotAvatar(avatar: { readonly shape: string; readonly color: string }) { + const path = (BOT_AVATAR_SHAPE_PATHS as Record)[avatar.shape]; + const color = (BOT_AVATAR_COLOR_HEX as Record)[avatar.color]; + return { + path: path ?? BOT_AVATAR_SHAPE_PATHS.circle, + color: color ?? BOT_AVATAR_COLOR_HEX.gray, + }; +} From d34945d6d1465996703e30335a502e0274f32cf1 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 20:56:38 +0530 Subject: [PATCH 05/40] feat(web): bots sidebar, avatars, and new bot dialog A Bots button in the sidebar footer switches the sidebar to a chat-style bot list (avatar, name, last reply, unread dot, newest first). The button shows the avatar of a bot with an unread reply. New bots pick a shape and color, a connected computer, and a model with reasoning. --- .../src/main/res/drawable/t3_usage.xml | 3 - .../features/workspace/WorkspaceExtraTab.tsx | 14 - apps/web/src/components/AppSidebarLayout.tsx | 9 +- apps/web/src/components/bots/BotAvatar.tsx | 73 +++++ .../src/components/bots/BotAvatarPicker.tsx | 64 ++++ apps/web/src/components/bots/BotsSidebar.tsx | 120 +++++++ .../src/components/bots/CreateBotDialog.tsx | 293 ++++++++++++++++++ .../src/components/sidebar/SidebarChrome.tsx | 37 ++- apps/web/src/state/bots.ts | 16 + 9 files changed, 609 insertions(+), 20 deletions(-) delete mode 100644 apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_usage.xml delete mode 100644 apps/mobile/src/features/workspace/WorkspaceExtraTab.tsx create mode 100644 apps/web/src/components/bots/BotAvatar.tsx create mode 100644 apps/web/src/components/bots/BotAvatarPicker.tsx create mode 100644 apps/web/src/components/bots/BotsSidebar.tsx create mode 100644 apps/web/src/components/bots/CreateBotDialog.tsx create mode 100644 apps/web/src/state/bots.ts diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_usage.xml b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_usage.xml deleted file mode 100644 index da09809d177ff..0000000000000 --- a/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_usage.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/mobile/src/features/workspace/WorkspaceExtraTab.tsx b/apps/mobile/src/features/workspace/WorkspaceExtraTab.tsx deleted file mode 100644 index d24f7c1bb27b8..0000000000000 --- a/apps/mobile/src/features/workspace/WorkspaceExtraTab.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { type NavigationTab } from "./navigation-tab-order"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { UsageRouteScreen } from "../usage/UsageRouteScreen"; - -export function WorkspaceExtraTab(_props: { - tab: Exclude; -}) { - return ( - <> - - - - ); -} diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 91df5616f7f50..ae3a1dcdaf9d1 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -11,6 +11,7 @@ import { useLocation, useRouter, type AnyRouter } from "@tanstack/react-router"; import { moveWorkspaceFocus, workspaceAreaProps } from "../lib/workspaceFocus"; import { isCommandPaletteOpen } from "../commandPaletteBus"; +import { sidebarModeAtom } from "../state/bots"; import { isElectron } from "../env"; import { releaseThreadUndoShortcut, @@ -39,6 +40,7 @@ import { } from "../panelAnimations"; import { BackgroundThreadAnnouncer } from "./BackgroundThreadAnnouncer"; import { HistorySwipeNavigation } from "./HistorySwipeNavigation"; +import BotsSidebar from "./bots/BotsSidebar"; import ThreadSidebar from "./Sidebar"; import { SidebarBrandWidthProbe } from "./sidebar/SidebarChrome"; import { isAppModalPage } from "./sidebar/mainAppLocation"; @@ -403,6 +405,7 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const sidebarArea = workspaceAreaProps("sidebar", 0, "Threads"); + const sidebarMode = useAtomValue(sidebarModeAtom); const [sidebarRouteReveal] = useState(() => ({ observedKey: null, pendingKey: null, @@ -469,7 +472,11 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }} style={windowControlsInsetStyle} > - + {sidebarMode === "bots" ? ( + + ) : ( + + )} {children} diff --git a/apps/web/src/components/bots/BotAvatar.tsx b/apps/web/src/components/bots/BotAvatar.tsx new file mode 100644 index 0000000000000..318c3015b0a80 --- /dev/null +++ b/apps/web/src/components/bots/BotAvatar.tsx @@ -0,0 +1,73 @@ +import { resolveBotAvatar } from "@t3tools/client-runtime/bot-avatar"; +import { assetUrlStateFromResult } from "@t3tools/client-runtime/state/assets"; +import type { BotAvatar as BotAvatarValue, ScopedThreadRef } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { memo } from "react"; + +import { cn } from "../../lib/utils"; +import { assetEnvironment } from "../../state/assets"; +import { useEnvironmentHttpBaseUrl } from "../../state/environments"; + +export function BotAvatarShape({ + shape, + color, + className, +}: { + shape: string; + color: string; + className?: string; +}) { + const resolved = resolveBotAvatar({ shape, color }); + return ( + + + + ); +} + +function BotAvatarImage({ + threadRef, + path, + fallback, +}: { + threadRef: ScopedThreadRef; + path: string; + fallback: React.ReactNode; +}) { + const result = useAtomValue( + assetEnvironment.createUrl({ + environmentId: threadRef.environmentId, + input: { resource: { _tag: "workspace-file", threadId: threadRef.threadId, path } }, + }), + ); + const state = assetUrlStateFromResult(result, useEnvironmentHttpBaseUrl(threadRef.environmentId)); + if (state._tag !== "Success") return fallback; + return ; +} + +/** A generated image when the bot has one, otherwise its drawn shape. */ +export const BotAvatar = memo(function BotAvatar({ + avatar, + threadRef, + className, +}: { + avatar: BotAvatarValue; + threadRef: ScopedThreadRef; + className?: string; +}) { + const shape = ; + return ( + + {avatar.imagePath ? ( + + ) : ( + shape + )} + + ); +}); diff --git a/apps/web/src/components/bots/BotAvatarPicker.tsx b/apps/web/src/components/bots/BotAvatarPicker.tsx new file mode 100644 index 0000000000000..6a254edfa86d6 --- /dev/null +++ b/apps/web/src/components/bots/BotAvatarPicker.tsx @@ -0,0 +1,64 @@ +import { BOT_AVATAR_COLOR_HEX } from "@t3tools/client-runtime/bot-avatar"; +import { + BOT_AVATAR_COLORS, + BOT_AVATAR_SHAPES, + type BotAvatarColor, + type BotAvatarShape, +} from "@t3tools/contracts"; + +import { cn } from "../../lib/utils"; +import { BotAvatarShape as BotAvatarShapeGlyph } from "./BotAvatar"; + +export function BotAvatarPicker({ + shape, + color, + disabled = false, + onChange, +}: { + shape: BotAvatarShape; + color: BotAvatarColor; + disabled?: boolean; + onChange: (next: { shape: BotAvatarShape; color: BotAvatarColor }) => void; +}) { + return ( +
+
+ {BOT_AVATAR_SHAPES.map((option) => ( + + ))} +
+
+ {BOT_AVATAR_COLORS.map((option) => ( +
+
+ ); +} diff --git a/apps/web/src/components/bots/BotsSidebar.tsx b/apps/web/src/components/bots/BotsSidebar.tsx new file mode 100644 index 0000000000000..2a3592f473e7c --- /dev/null +++ b/apps/web/src/components/bots/BotsSidebar.tsx @@ -0,0 +1,120 @@ +import type { BotSummary } from "@t3tools/client-runtime/state/bots"; +import { useAtomValue } from "@effect/atom-react"; +import { useLocation, useNavigate } from "@tanstack/react-router"; +import { PlusIcon } from "lucide-react"; +import { memo, useState } from "react"; + +import { isElectron } from "../../env"; +import { cn } from "../../lib/utils"; +import { botListAtom } from "../../state/bots"; +import { buildThreadRouteParams, isThreadRoutePathname } from "../../threadRoutes"; +import { SidebarChromeFooter, SidebarChromeHeader } from "../sidebar/SidebarChrome"; +import { Button } from "../ui/button"; +import { SidebarContent, SidebarGroup, useSidebar } from "../ui/sidebar"; +import { BotAvatar } from "./BotAvatar"; +import { CreateBotDialog } from "./CreateBotDialog"; + +const BotRow = memo(function BotRow({ + bot, + active, + onOpen, +}: { + bot: BotSummary; + active: boolean; + onOpen: (bot: BotSummary) => void; +}) { + return ( +
  • + +
  • + ); +}); + +export default function BotsSidebar() { + const bots = useAtomValue(botListAtom); + const navigate = useNavigate(); + const pathname = useLocation({ select: (location) => location.pathname }); + const { isMobile, setOpenMobile } = useSidebar(); + const [creating, setCreating] = useState(false); + + const openBot = (bot: BotSummary) => { + if (isMobile) setOpenMobile(false); + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(bot.threadRef), + }); + }; + + return ( + <> + + + Bots + + + } + > + + {bots.length === 0 ? ( +
    + No bots yet + +
    + ) : ( +
      + {bots.map((bot) => ( + + ))} +
    + )} +
    +
    + + + + ); +} diff --git a/apps/web/src/components/bots/CreateBotDialog.tsx b/apps/web/src/components/bots/CreateBotDialog.tsx new file mode 100644 index 0000000000000..aec0523b0d583 --- /dev/null +++ b/apps/web/src/components/bots/CreateBotDialog.tsx @@ -0,0 +1,293 @@ +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + BOT_NAME_MAX_LENGTH, + CommandId, + type BotAvatarColor, + type BotAvatarShape, + type EnvironmentId, + type ModelSelection, + type ServerProvider, +} from "@t3tools/contracts"; +import type { UnifiedSettings } from "@t3tools/contracts/settings"; +import { createModelSelection } from "@t3tools/shared/model"; +import { useAtomSet } from "@effect/atom-react"; +import { useNavigate } from "@tanstack/react-router"; +import { type ReactNode, useId, useState } from "react"; + +import { useEnvironmentSettings } from "../../hooks/useSettings"; +import { randomUUID } from "../../lib/utils"; +import { + getCustomModelOptionsByInstance, + resolveEffectiveDefaultModelSelection, +} from "../../modelSelection"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + resolveDefaultProviderModelSelection, + sortProviderInstanceEntries, +} from "../../providerInstances"; +import { sidebarModeAtom } from "../../state/bots"; +import { + useEnvironmentProviders, + useEnvironments, + usePrimaryEnvironmentId, +} from "../../state/environments"; +import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; +import { useOrchestrationCommand } from "../../state/use-orchestration-command"; +import { buildThreadRouteParams } from "../../threadRoutes"; +import { ProviderModelPicker } from "../chat/ProviderModelPicker"; +import { TraitsPicker } from "../chat/TraitsPicker"; +import { isConnectedWithConfig } from "../settings/scopedSettings"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { toastManager } from "../ui/toast"; +import { BotAvatarShape as BotAvatarShapeGlyph } from "./BotAvatar"; +import { BotAvatarPicker } from "./BotAvatarPicker"; + +// Settings hooks need an id; with no computer connected nothing is created anyway. +const NO_ENVIRONMENT = "" as EnvironmentId; + +export function CreateBotDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + return ( + + + {open ? onOpenChange(false)} /> : null} + + + ); +} + +function defaultBotModel( + settings: UnifiedSettings, + providers: ReadonlyArray, +): ModelSelection | null { + const stored = resolveDefaultProviderModelSelection(providers, settings.defaultModelSelection); + return stored ? resolveEffectiveDefaultModelSelection(settings, providers, stored) : null; +} + +type AvatarChoice = { shape: BotAvatarShape; color: BotAvatarColor }; + +function CreateBotForm({ onDone }: { onDone: () => void }) { + const { environments } = useEnvironments(); + const machines = environments.filter(isConnectedWithConfig); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const [name, setName] = useState(""); + const [avatar, setAvatar] = useState({ shape: "circle", color: "teal" }); + const [chosenEnvironmentId, setChosenEnvironmentId] = useState(null); + const environmentId = + machines.find((machine) => machine.environmentId === chosenEnvironmentId)?.environmentId ?? + machines.find((machine) => machine.environmentId === primaryEnvironmentId)?.environmentId ?? + machines[0]?.environmentId ?? + null; + const machinePicker = + machines.length === 0 ? ( +

    No connected computers

    + ) : ( + + ); + return ( + + ); +} + +function MachineBotForm({ + environmentId, + name, + onNameChange, + avatar, + onAvatarChange, + machinePicker, + onDone, +}: { + environmentId: EnvironmentId | null; + name: string; + onNameChange: (name: string) => void; + avatar: AvatarChoice; + onAvatarChange: (avatar: AvatarChoice) => void; + machinePicker: ReactNode; + onDone: () => void; +}) { + const id = useId(); + const navigate = useNavigate(); + const setSidebarMode = useAtomSet(sidebarModeAtom); + const createBot = useOrchestrationCommand(serverEnvironment.createBot, { reportFailure: false }); + const settings = useEnvironmentSettings(environmentId ?? NO_ENVIRONMENT); + const providersByEnvironment = useEnvironmentProviders(); + const providers = + (environmentId ? providersByEnvironment.get(environmentId) : undefined) ?? + EMPTY_SERVER_PROVIDERS; + const entries = sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings, null), + ); + const [chosenModel, setChosenModel] = useState(null); + const model = + chosenModel && + entries.some((entry) => entry.instanceId === chosenModel.instanceId && entry.enabled) + ? chosenModel + : defaultBotModel(settings, providers); + const activeEntry = entries.find((entry) => entry.instanceId === model?.instanceId); + // One command id per attempt, so a retry after a lost reply returns the same bot. + const [commandId, setCommandId] = useState(() => CommandId.make(randomUUID())); + const [pending, setPending] = useState(false); + const trimmedName = name.trim(); + + const submit = async () => { + if (pending || environmentId === null || model === null || trimmedName.length === 0) return; + setPending(true); + const result = await createBot({ + environmentId, + input: { commandId, name: trimmedName, avatar, modelSelection: model }, + }); + setPending(false); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Could not create bot", + description: error instanceof Error ? error.message : "An error occurred.", + }); + return; + } + setCommandId(CommandId.make(randomUUID())); + setSidebarMode("bots"); + onDone(); + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams({ environmentId, threadId: result.value.threadId }), + }); + }; + + return ( +
    { + event.preventDefault(); + void submit(); + }} + > + + New bot + + +
    +
    + + + +
    + +
    + + onNameChange(event.target.value)} + /> +
    +
    + + {machinePicker} +
    + {environmentId !== null ? ( +
    + + {model === null ? ( +

    No providers on this computer

    + ) : ( +
    + + setChosenModel(createModelSelection(instanceId, slug)) + } + /> + {activeEntry ? ( + + setChosenModel(createModelSelection(model.instanceId, model.model, options)) + } + /> + ) : null} +
    + )} +
    + ) : null} +
    +
    + + + + +
    + ); +} diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index dd41ddd2a0213..39dd7bedaad18 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -1,4 +1,11 @@ -import { ArrowLeftIcon, ArrowRightIcon, ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react"; +import { useAtom, useAtomValue } from "@effect/atom-react"; +import { + ArrowLeftIcon, + ArrowRightIcon, + BotIcon, + ChartNoAxesColumnIcon, + SettingsIcon, +} from "lucide-react"; import type { ReactNode } from "react"; import { memo } from "react"; import { Link, useLocation, useNavigate } from "@tanstack/react-router"; @@ -13,7 +20,9 @@ import { } from "../../navigationHistory"; import { primaryServerKeybindingsAtom } from "../../state/server"; import { cn } from "../../lib/utils"; +import { sidebarModeAtom, unreadBotAtom } from "../../state/bots"; import { usePullRequestsSupported } from "../../state/environments"; +import { BotAvatar } from "../bots/BotAvatar"; import { T3Wordmark } from "../T3Wordmark"; import { resolveEnvironmentIdentificationPillLabel, @@ -196,10 +205,12 @@ function SidebarBrandMark({ onBackdrop }: { onBackdrop: boolean }) { function SidebarUtilityItem({ icon, label, + isActive = false, onClick, }: { icon: ReactNode; label: string; + isActive?: boolean; onClick: () => void; }) { return ( @@ -207,7 +218,7 @@ function SidebarUtilityItem({ + {icon} } @@ -218,6 +229,27 @@ function SidebarUtilityItem({ ); } +/** Switches the sidebar between threads and bots; shows a bot's avatar while it has an unread reply. */ +function SidebarBotsItem() { + const [mode, setMode] = useAtom(sidebarModeAtom); + const unreadBot = useAtomValue(unreadBotAtom); + const showingBots = mode === "bots"; + return ( + + ) : ( + + ) + } + label={showingBots ? "Threads" : unreadBot ? `Bots, ${unreadBot.name} replied` : "Bots"} + isActive={showingBots} + onClick={() => setMode(showingBots ? "threads" : "bots")} + /> + ); +} + const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { const navigate = useNavigate(); const navigateToMainApp = useNavigateToMainApp(); @@ -283,6 +315,7 @@ const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { label="Usage" onClick={handleUsageClick} /> + )} diff --git a/apps/web/src/state/bots.ts b/apps/web/src/state/bots.ts new file mode 100644 index 0000000000000..bae9088d80c79 --- /dev/null +++ b/apps/web/src/state/bots.ts @@ -0,0 +1,16 @@ +import { createBotListAtom } from "@t3tools/client-runtime/state/bots"; +import { Atom } from "effect/reactivity"; + +import { environmentProjects } from "./projects"; +import { environmentThreadShells } from "./threads"; + +export const botListAtom = createBotListAtom({ + botsAtom: environmentProjects.botsAtom, + quietThreadShellAtom: environmentThreadShells.quietThreadShellAtom, +}); + +/** The newest bot with a reply the user has not opened, shown on the sidebar's Bots button. */ +export const unreadBotAtom = Atom.make((get) => get(botListAtom).find((bot) => bot.unread) ?? null); + +export type SidebarMode = "threads" | "bots"; +export const sidebarModeAtom = Atom.make("threads").pipe(Atom.keepAlive); From 11026dd6e17105a09f931fad87fddabf946c846e Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 20:59:39 +0530 Subject: [PATCH 06/40] feat(web): bot details in the thread panel A bot's session shows its avatar, name, and Details, Artifacts, and Computer tabs in place of the workspace controls. The avatar can be redrawn from shapes and colors or generated with Codex when it is set up. Routines are the session's scheduled tasks. --- apps/web/src/components/bots/BotDetails.tsx | 374 ++++++++++++++++++ .../chat/ThreadAutomationsPanel.tsx | 12 +- .../components/chat/ThreadDetailsPanel.tsx | 7 + 3 files changed, 391 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/bots/BotDetails.tsx diff --git a/apps/web/src/components/bots/BotDetails.tsx b/apps/web/src/components/bots/BotDetails.tsx new file mode 100644 index 0000000000000..1add934144cea --- /dev/null +++ b/apps/web/src/components/bots/BotDetails.tsx @@ -0,0 +1,374 @@ +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/models"; +import { + BOT_AVATAR_COLORS, + BOT_AVATAR_SHAPES, + BOT_NAME_MAX_LENGTH, + type BotAvatarColor, + type BotAvatarShape, + type ProjectBot, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { formatModelSlugName } from "@t3tools/shared/model"; +import { useNavigate } from "@tanstack/react-router"; +import { FileIcon, SparklesIcon, Trash2Icon } from "lucide-react"; +import { useState } from "react"; + +import { requestConfirmDialog } from "../../confirmDialog"; +import { useRightPanelStore } from "../../rightPanelStore"; +import { useEnvironmentProviders } from "../../state/environments"; +import { useThreadShell } from "../../state/entities"; +import { useEnvironmentPresentation } from "../../state/presentation"; +import { projectEnvironment } from "../../state/projects"; +import { useEnvironmentQuery } from "../../state/query"; +import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; +import { useOrchestrationCommand } from "../../state/use-orchestration-command"; +import { getDisplayModelName } from "../chat/providerIconUtils"; +import { ThreadAutomationsPanel } from "../chat/ThreadAutomationsPanel"; +import { ThreadDetailsSection } from "../chat/ThreadDetailsSection"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Toggle, ToggleGroup } from "../ui/toggle-group"; +import { toastManager } from "../ui/toast"; +import { BotAvatar } from "./BotAvatar"; +import { BotAvatarPicker } from "./BotAvatarPicker"; + +type BotTab = "details" | "artifacts" | "computer"; + +function reportFailure(title: string, result: Parameters[0]) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title, + description: error instanceof Error ? error.message : "An error occurred.", + }); +} + +function knownShape(value: string): BotAvatarShape { + return BOT_AVATAR_SHAPES.find((shape) => shape === value) ?? "circle"; +} + +function knownColor(value: string): BotAvatarColor { + return BOT_AVATAR_COLORS.find((color) => color === value) ?? "gray"; +} + +function AvatarEditor({ + project, + bot, + threadRef, +}: { + project: EnvironmentProject; + bot: ProjectBot; + threadRef: ScopedThreadRef; +}) { + const updateBot = useOrchestrationCommand(serverEnvironment.updateBot, { reportFailure: false }); + const generateAvatar = useOrchestrationCommand(serverEnvironment.generateBotAvatar, { + reportFailure: false, + }); + const providers = useEnvironmentProviders().get(project.environmentId) ?? EMPTY_SERVER_PROVIDERS; + const canGenerate = providers.some( + (provider) => provider.driver === "codex" && provider.enabled && provider.installed, + ); + const [prompt, setPrompt] = useState(""); + const [generating, setGenerating] = useState(false); + const shape = knownShape(bot.avatar.shape); + const color = knownColor(bot.avatar.color); + const imagePath = bot.avatar.imagePath ?? null; + + const saveAvatar = async (next: { + shape: BotAvatarShape; + color: BotAvatarColor; + imagePath?: null; + }) => { + const result = await updateBot({ + environmentId: project.environmentId, + input: { projectId: project.id, avatar: next }, + }); + if (result._tag === "Failure") reportFailure("Could not update avatar", result); + }; + + const generate = async () => { + setGenerating(true); + const trimmed = prompt.trim(); + const result = await generateAvatar({ + environmentId: project.environmentId, + input: { projectId: project.id, ...(trimmed ? { prompt: trimmed } : {}) }, + }); + setGenerating(false); + if (result._tag === "Failure") reportFailure("Could not generate avatar", result); + }; + + return ( + + + } + > + + + +
    + void saveAvatar(next)} + /> + {canGenerate ? ( +
    { + event.preventDefault(); + void generate(); + }} + > + setPrompt(event.target.value)} + /> + +
    + ) : null} + {imagePath !== null ? ( + + ) : null} +
    +
    +
    + ); +} + +function BotName({ project }: { project: EnvironmentProject }) { + const updateBot = useOrchestrationCommand(serverEnvironment.updateBot, { reportFailure: false }); + const [draft, setDraft] = useState(null); + const commit = async () => { + const name = draft?.trim() ?? ""; + setDraft(null); + if (name.length === 0 || name === project.title) return; + const result = await updateBot({ + environmentId: project.environmentId, + input: { projectId: project.id, name }, + }); + if (result._tag === "Failure") reportFailure("Could not rename bot", result); + }; + if (draft === null) { + return ( + + ); + } + return ( + setDraft(event.target.value)} + onBlur={() => void commit()} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") setDraft(null); + }} + /> + ); +} + +function BotModel({ threadRef }: { threadRef: ScopedThreadRef }) { + const shell = useThreadShell(threadRef); + const providers = + useEnvironmentProviders().get(threadRef.environmentId) ?? EMPTY_SERVER_PROVIDERS; + if (shell === null) return null; + const { instanceId, model } = shell.modelSelection; + const provider = providers.find((entry) => entry.instanceId === instanceId); + const known = provider?.models.find((entry) => entry.slug === model); + return ( +

    + {known ? getDisplayModelName(known) : formatModelSlugName(model)} + {provider?.displayName ? ( + on {provider.displayName} + ) : null} +

    + ); +} + +function BotArtifacts({ + project, + threadRef, +}: { + project: EnvironmentProject; + threadRef: ScopedThreadRef; +}) { + const query = useEnvironmentQuery( + projectEnvironment.listEntries({ + environmentId: project.environmentId, + input: { cwd: project.workspaceRoot, directoryPath: "artifacts" }, + }), + ); + const files = (query.data?.entries ?? []) + .filter((entry) => entry.kind === "file") + .toSorted((left, right) => left.path.localeCompare(right.path)); + if (files.length === 0) { + return ( +

    + {query.isPending && query.data === null ? "Loading" : "No artifacts yet"} +

    + ); + } + return ( +
      + {files.map((file) => ( +
    • + +
    • + ))} +
    + ); +} + +function BotComputer({ project }: { project: EnvironmentProject }) { + const navigate = useNavigate(); + const { presentation } = useEnvironmentPresentation(project.environmentId); + const deleteBot = useOrchestrationCommand(serverEnvironment.deleteBot, { reportFailure: false }); + const remove = async () => { + const confirmed = + (await requestConfirmDialog( + `Delete ${project.title}?\nIts chat and routines stop. Files in its folder stay on disk.`, + { variant: "destructive" }, + )) ?? window.confirm(`Delete ${project.title}?`); + if (!confirmed) return; + const result = await deleteBot({ + environmentId: project.environmentId, + input: { projectId: project.id }, + }); + if (result._tag === "Failure") { + reportFailure("Could not delete bot", result); + return; + } + void navigate({ to: "/" }); + }; + const rows: Array<[string, string]> = [ + ["Computer", presentation?.entry.target.label ?? "Unknown"], + ["Status", presentation?.connection.phase === "connected" ? "Connected" : "Offline"], + ["Folder", project.workspaceRoot], + ]; + return ( +
    +
    + {rows.map(([label, value]) => ( +
    +
    {label}
    +
    {value}
    +
    + ))} +
    + +
    + ); +} + +/** The right panel for a bot's session: who it is, what it made, and where it runs. */ +export function BotDetails({ + project, + bot, + threadRef, +}: { + project: EnvironmentProject; + bot: ProjectBot; + threadRef: ScopedThreadRef; +}) { + const [tab, setTab] = useState("details"); + return ( + <> +
    + +
    + +
    +
    +
    + { + const value = next[0]; + if (value === "details" || value === "artifacts" || value === "computer") setTab(value); + }} + > + Details + Artifacts + Computer + +
    + {tab === "details" ? ( + <> + +

    + {bot.description ?? ( + Written after your first chat + )} +

    +
    + + + + + + ) : tab === "artifacts" ? ( + + + + ) : ( + + + + )} + + ); +} diff --git a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx index 4c3326df8c635..bf97600db08ea 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx @@ -60,6 +60,9 @@ function taskScheduleLine(task: ScheduledTask) { export function ThreadAutomationsPanel(props: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; + readonly title?: string; + /** Shown instead of hiding the section when the thread has no tasks. */ + readonly emptyText?: string; }) { const canOperate = useAtomValue( serverEnvironment.runScheduledTaskNow.permissionAtom(props.environmentId), @@ -80,7 +83,8 @@ export function ThreadAutomationsPanel(props: { (task) => task.threadId === props.threadId, ); - if (tasksQuery.error === null && boundTasks.length === 0) return null; + if (tasksQuery.error === null && boundTasks.length === 0 && props.emptyText === undefined) + return null; const reportFailure = (title: string, error: unknown) => { toastManager.add( @@ -129,7 +133,7 @@ export function ThreadAutomationsPanel(props: { return ( @@ -155,6 +159,10 @@ export function ThreadAutomationsPanel(props: {
    } > + {boundTasks.length === 0 && tasksQuery.error === null ? ( +

    {props.emptyText}

    + ) : null} +
      {boundTasks.map((task) => (
    • {(density) => { + if (project !== null && bot !== null && bot.threadId === props.threadId) { + return ; + } // Section flags follow the rows that will render, so no empty section steals the divider rule. const show = { environment: density === "full" && visible.environment, From ff5c60e6b8022599f74bc45417715e16e5cb2bac Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 21:09:28 +0530 Subject: [PATCH 07/40] fix(server): safer bot creation and Codex image paths Concurrent retries of one create now return a single bot, the bot folder cleanup is armed atomically with the claim, and the CLI Codex snapshot reports its homes so generated avatars can be read. --- apps/server/src/bots/BotService.test.ts | 9 +- apps/server/src/bots/BotService.ts | 167 ++++++++++-------- .../src/provider/Drivers/CodexDriver.test.ts | 5 +- .../src/provider/Drivers/CodexDriver.ts | 12 +- 4 files changed, 115 insertions(+), 78 deletions(-) diff --git a/apps/server/src/bots/BotService.test.ts b/apps/server/src/bots/BotService.test.ts index 7acb9a25f3db4..5a538b8864237 100644 --- a/apps/server/src/bots/BotService.test.ts +++ b/apps/server/src/bots/BotService.test.ts @@ -222,13 +222,18 @@ it.effect("creates a bot as a project in its own folder with one always-on sessi ); it.effect( - "a retried create returns the existing bot and a failed launch leaves nothing behind", + "retried creates, even concurrent ones, return one bot and a failed launch leaves nothing", () => withBots((harness) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const created = yield* createBot("Helper", "bot-retry"); + const [created, concurrent] = yield* Effect.all( + [createBot("Helper", "bot-retry"), createBot("Helper", "bot-retry")], + { concurrency: "unbounded" }, + ); + assert.deepEqual(concurrent, created); + assert.equal(harness.launches.length, 1); harness.failLaunch = true; assert.deepEqual(yield* createBot("Helper", "bot-retry"), created); assert.equal((yield* readProject(created.projectId)).deletedAt, null); diff --git a/apps/server/src/bots/BotService.ts b/apps/server/src/bots/BotService.ts index 2f9538c0d4122..3dc40ab1b5bea 100644 --- a/apps/server/src/bots/BotService.ts +++ b/apps/server/src/bots/BotService.ts @@ -113,6 +113,8 @@ const make = Effect.gen(function* () { // Bot edits read the stored record and write it back whole; serialize them. const mutations = yield* Semaphore.make(1); + // Creation is rare; one at a time keeps a retry from racing its original. + const creations = yield* Semaphore.make(1); const botsRoot = path.resolve(config.baseDir, "bots"); const fail = (message: string, projectId?: ProjectId) => (cause: unknown) => @@ -195,9 +197,8 @@ const make = Effect.gen(function* () { yield* fileSystem.writeFileString(path.join(folder, "CLAUDE.md"), "@AGENTS.md\n"); }); - const create: BotService["Service"]["create"] = Effect.fn("BotService.create")(function* (input) { + const createOnce = Effect.fn("BotService.createOnce")(function* (input: BotCreateInput) { const projectId = ProjectId.make(input.commandId); - const threadId = ThreadId.make(input.commandId); // A retried create returns the bot it already made instead of building a second one. const existing = yield* projects .getById(projectId, { includeDeleted: true }) @@ -210,86 +211,104 @@ const make = Effect.gen(function* () { return { projectId, threadId: bot.threadId }; } - const folder = yield* claimFolder(input.name); - // The folder was claimed by this attempt, so only this attempt's project can own it. - const rollback = Effect.gen(function* () { - const owner = yield* projects.getByWorkspaceRoot(folder).pipe( - Effect.map(Option.getOrUndefined), - Effect.orElseSucceed(() => undefined), - ); - if (owner !== undefined) { - if (owner.id !== projectId) return; - const deleted = yield* projects - .delete({ - commandId: CommandId.make(`${input.commandId}:rollback`), - projectId, - force: true, - }) - .pipe( - Effect.as(true), - Effect.orElseSucceed(() => false), - ); - if (!deleted) return; - } - yield* fileSystem.remove(folder, { recursive: true }).pipe(Effect.ignore); - }); + // Claim the folder and arm its cleanup together, so an interrupt between them cannot leak it. + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const folder = yield* claimFolder(input.name); + return yield* restore(setUp(input, folder)).pipe( + Effect.onExit((exit) => (Exit.isFailure(exit) ? rollback(input, folder) : Effect.void)), + ); + }), + ); + }); - return yield* Effect.gen(function* () { - yield* scaffoldFolder(folder, input.name).pipe( - Effect.mapError(fail("Could not set up the bot's folder.")), - ); - // Its own repository keeps checkpoints out of any checkout the data dir lives in. - yield* git - .execute({ - operation: "BotService.init", - cwd: folder, - args: ["init", "--quiet"], - timeoutMs: 10_000, - }) - .pipe(Effect.mapError(fail("Could not create the bot's repository."))); - yield* projects - .create({ - commandId: CommandId.make(`${input.commandId}:project`), - projectId, - title: input.name, - workspaceRoot: folder, - bot: { - threadId, - avatar: { shape: input.avatar.shape, color: input.avatar.color, imagePath: null }, - description: null, - }, - }) - .pipe(Effect.mapError((error) => fail(error.message)(error))); - yield* launches - .launch({ - commandId: CommandId.make(`${input.commandId}:thread`), - threadId, + // The folder was claimed by this attempt, so only this attempt's project can own it. + const rollback = Effect.fn("BotService.rollback")(function* ( + input: BotCreateInput, + folder: string, + ) { + const projectId = ProjectId.make(input.commandId); + const owner = yield* projects.getByWorkspaceRoot(folder).pipe( + Effect.map(Option.getOrUndefined), + Effect.orElseSucceed(() => undefined), + ); + if (owner !== undefined) { + if (owner.id !== projectId) return; + const deleted = yield* projects + .delete({ + commandId: CommandId.make(`${input.commandId}:rollback`), projectId, - title: input.name, - modelSelection: input.modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - workspaceStrategy: { type: "root" }, - initialMessage: { text: kickoffMessage(input.name), attachments: [] }, - createdBy: "user", - creationSource: "server", + force: true, }) .pipe( - Effect.mapError((error) => fail("Could not start the bot's session.", projectId)(error)), + Effect.as(true), + Effect.orElseSucceed(() => false), ); - // Settled threads drop out of shell snapshots; a bot's session stays in view. - yield* threads - .dispatch({ - type: "thread.auto-settle.set", - commandId: CommandId.make(`${input.commandId}:auto-settle`), + if (!deleted) return; + } + yield* fileSystem.remove(folder, { recursive: true }).pipe(Effect.ignore); + }); + + const setUp = Effect.fn("BotService.setUp")(function* (input: BotCreateInput, folder: string) { + const projectId = ProjectId.make(input.commandId); + const threadId = ThreadId.make(input.commandId); + yield* scaffoldFolder(folder, input.name).pipe( + Effect.mapError(fail("Could not set up the bot's folder.")), + ); + // Its own repository keeps checkpoints out of any checkout the data dir lives in. + yield* git + .execute({ + operation: "BotService.init", + cwd: folder, + args: ["init", "--quiet"], + timeoutMs: 10_000, + }) + .pipe(Effect.mapError(fail("Could not create the bot's repository."))); + yield* projects + .create({ + commandId: CommandId.make(`${input.commandId}:project`), + projectId, + title: input.name, + workspaceRoot: folder, + bot: { threadId, - enabled: false, - }) - .pipe(Effect.ignore); - return { projectId, threadId }; - }).pipe(Effect.onExit((exit) => (Exit.isFailure(exit) ? rollback : Effect.void))); + avatar: { shape: input.avatar.shape, color: input.avatar.color, imagePath: null }, + description: null, + }, + }) + .pipe(Effect.mapError((error) => fail(error.message)(error))); + yield* launches + .launch({ + commandId: CommandId.make(`${input.commandId}:thread`), + threadId, + projectId, + title: input.name, + modelSelection: input.modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + workspaceStrategy: { type: "root" }, + initialMessage: { text: kickoffMessage(input.name), attachments: [] }, + createdBy: "user", + creationSource: "server", + }) + .pipe( + Effect.mapError((error) => fail("Could not start the bot's session.", projectId)(error)), + ); + // Settled threads drop out of shell snapshots; a bot's session stays in view. + yield* threads + .dispatch({ + type: "thread.auto-settle.set", + commandId: CommandId.make(`${input.commandId}:auto-settle`), + threadId, + enabled: false, + }) + .pipe(Effect.ignore); + return { projectId, threadId }; }); + const create: BotService["Service"]["create"] = (input) => + creations.withPermits(1)(createOnce(input)); + const update: BotService["Service"]["update"] = Effect.fn("BotService.update")(function* (input) { return yield* mutations.withPermits(1)( Effect.gen(function* () { diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index c047336ef5dfc..d591ff8a804ac 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -337,7 +337,7 @@ it.layer(layerTest)("CodexDriver", (it) => { ); it.effect.skipIf(windowsHost)( - "runs the standalone updater against the shared home, not the shadow home", + "runs the standalone updater against the shared home and reports both homes", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -371,6 +371,9 @@ it.layer(layerTest)("CodexDriver", (it) => { lockKey: "codex-native", env: { CODEX_HOME: sharedHome }, }); + // Generated images land under these homes, so the snapshot must name both. + const snapshot = yield* instance.snapshot.getSnapshot; + expect(snapshot.runtimePaths).toEqual({ homePath: sharedHome, shadowHomePath: shadowHome }); }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), Effect.scoped, diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 61f26c3f8cc7d..89235f2ad022d 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -161,13 +161,23 @@ export const CodexDriver: ProviderDriver< : config, ); const continuationIdentity = codexContinuationIdentity(homeLayout); - const stampIdentity = withInstanceIdentity({ + const stampInstance = withInstanceIdentity({ instanceId, driverKind: DRIVER_KIND, displayName, accentColor, continuationGroupKey: continuationIdentity.continuationKey, }); + // Image generation saves under these homes; readers check paths against them. + const runtimePaths = { + homePath: homeLayout.sharedHomePath, + shadowHomePath: + homeLayout.mode === "authOverlay" ? (homeLayout.effectiveHomePath ?? null) : null, + }; + const stampIdentity = (draft: Parameters[0]) => ({ + ...stampInstance(draft), + runtimePaths, + }); yield* materializeCodexShadowHome(homeLayout).pipe( Effect.mapError( (cause) => From 0cc5323a0623cd51f1a19a13f6031fc5672d2ecd Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 21:11:46 +0530 Subject: [PATCH 08/40] feat(mobile): new bot form with avatar, model and machine pickers The model picker hides its Runtime row when the caller has no runtime mode, since creating a bot takes only a model selection. --- apps/mobile/src/Stack.tsx | 17 ++ apps/mobile/src/features/bots/BotAvatar.tsx | 70 ++++++ .../src/features/bots/BotAvatarPicker.tsx | 66 ++++++ .../bots/BotModelPickerRouteScreen.tsx | 52 +++++ .../src/features/bots/NewBotRouteScreen.tsx | 221 ++++++++++++++++++ apps/mobile/src/features/bots/bot-draft.ts | 45 ++++ .../features/threads/ThreadSettingsSheet.tsx | 48 ++-- 7 files changed, 500 insertions(+), 19 deletions(-) create mode 100644 apps/mobile/src/features/bots/BotAvatar.tsx create mode 100644 apps/mobile/src/features/bots/BotAvatarPicker.tsx create mode 100644 apps/mobile/src/features/bots/BotModelPickerRouteScreen.tsx create mode 100644 apps/mobile/src/features/bots/NewBotRouteScreen.tsx create mode 100644 apps/mobile/src/features/bots/bot-draft.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 872ca142c8aaa..752a3872e7d7c 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -124,6 +124,8 @@ import { } from "./features/settings/SettingsOpenSourceLicensesRouteScreen"; import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; import { SettingsProjectOverviewRouteScreen } from "./features/settings/SettingsProjectOverviewRouteScreen"; +import { NewBotRouteScreen } from "./features/bots/NewBotRouteScreen"; +import { BotModelPickerRouteScreen } from "./features/bots/BotModelPickerRouteScreen"; import { UsageLimitAccountScreen } from "./features/usage/UsageLimitsPooled"; import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; import { SettingsAboutRouteScreen } from "./features/settings/SettingsAboutRouteScreen"; @@ -726,6 +728,21 @@ const RootStackConfig = createWorkspaceStackNavigator({ linking: { path: "threads/:environmentId?/:threadId?", alias: ["new/draft"] }, options: THREAD_HEADER_OPTIONS, }), + BotNew: createNativeStackScreen({ + screen: NewBotRouteScreen, + linking: "bots/new", + options: { ...SETTINGS_PAGE_OPTIONS, title: "New bot" }, + }), + BotModel: createNativeStackScreen({ + screen: BotModelPickerRouteScreen, + options: { + gestureEnabled: true, + headerShown: false, + ...(Platform.OS === "android" + ? { presentation: "card" as const } + : THREAD_SETTINGS_SHEET_OPTIONS), + }, + }), NewTaskProject: createNativeStackScreen({ screen: NewTaskRouteScreen, linking: { path: "new", alias: ["new/project"] }, diff --git a/apps/mobile/src/features/bots/BotAvatar.tsx b/apps/mobile/src/features/bots/BotAvatar.tsx new file mode 100644 index 0000000000000..e64407428de84 --- /dev/null +++ b/apps/mobile/src/features/bots/BotAvatar.tsx @@ -0,0 +1,70 @@ +import { resolveBotAvatar } from "@t3tools/client-runtime/bot-avatar"; +import type { BotAvatar as BotAvatarValue, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { Image } from "expo-image"; +import { useMemo } from "react"; +import { View } from "react-native"; +import Svg, { Path } from "react-native-svg"; + +import { useAssetUrlState } from "../../state/assets"; + +function BotAvatarShape(props: { readonly avatar: BotAvatarValue; readonly size: number }) { + const { path, color } = resolveBotAvatar(props.avatar); + const inset = props.size * 0.18; + return ( + + + + ); +} + +function BotAvatarImage(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly avatar: BotAvatarValue; + readonly imagePath: string; + readonly size: number; +}) { + const resource = useMemo( + () => ({ _tag: "workspace-file" as const, threadId: props.threadId, path: props.imagePath }), + [props.threadId, props.imagePath], + ); + const asset = useAssetUrlState(props.environmentId, resource); + // The drawn shape stands in while the image loads or when it can't be read. + if (asset._tag !== "Success") return ; + return ( + + ); +} + +/** A bot's drawn shape on a neutral tile, or its generated image when it has one. */ +export function BotAvatar(props: { + readonly avatar: BotAvatarValue; + readonly size: number; + /** Needed to load a generated image; without it the shape is drawn. */ + readonly source?: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId }; +}) { + const imagePath = props.avatar.imagePath ?? null; + return ( + + {imagePath !== null && props.source ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/mobile/src/features/bots/BotAvatarPicker.tsx b/apps/mobile/src/features/bots/BotAvatarPicker.tsx new file mode 100644 index 0000000000000..e43036fba3da0 --- /dev/null +++ b/apps/mobile/src/features/bots/BotAvatarPicker.tsx @@ -0,0 +1,66 @@ +import { BOT_AVATAR_COLOR_HEX } from "@t3tools/client-runtime/bot-avatar"; +import { + BOT_AVATAR_COLORS, + BOT_AVATAR_SHAPES, + type BotAvatarColor, + type BotAvatarShape, +} from "@t3tools/contracts"; +import { Pressable, View } from "react-native"; + +import { AppText as Text } from "../../components/ui/AppText"; +import { cn } from "../../lib/cn"; +import { BotAvatar } from "./BotAvatar"; + +/** Shape and color choices drawn in the chosen color, so each option previews the result. */ +export function BotAvatarPicker(props: { + readonly shape: BotAvatarShape; + readonly color: BotAvatarColor; + readonly disabled?: boolean; + readonly onChange: (avatar: { shape: BotAvatarShape; color: BotAvatarColor }) => void; +}) { + return ( + + Shape + + {BOT_AVATAR_SHAPES.map((shape) => ( + props.onChange({ shape, color: props.color })} + className={cn( + "rounded-xl border-2 p-0.5 active:opacity-70", + shape === props.shape ? "border-primary" : "border-transparent", + )} + > + + + ))} + + Color + + {BOT_AVATAR_COLORS.map((color) => ( + props.onChange({ shape: props.shape, color })} + className={cn( + "rounded-full border-2 p-0.5 active:opacity-70", + color === props.color ? "border-primary" : "border-transparent", + )} + > + + + ))} + + + ); +} diff --git a/apps/mobile/src/features/bots/BotModelPickerRouteScreen.tsx b/apps/mobile/src/features/bots/BotModelPickerRouteScreen.tsx new file mode 100644 index 0000000000000..f8116e2ffb13c --- /dev/null +++ b/apps/mobile/src/features/bots/BotModelPickerRouteScreen.tsx @@ -0,0 +1,52 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { useMemo } from "react"; + +import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; +import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; +import { ThreadSettingsPickerScreen } from "../threads/ThreadSettingsSheet"; +import { updateBotDraft } from "./bot-draft"; +import { useBotDraftModel } from "./NewBotRouteScreen"; + +export function BotModelPickerRouteScreen({ + route, +}: StaticScreenProps<{ readonly environmentId: EnvironmentId }>) { + const navigation = useNavigation(); + const { environmentId } = route.params; + const { config, selection } = useBotDraftModel(environmentId); + const models = useMemo( + () => buildModelOptions(config, selection, undefined, null), + [config, selection], + ); + const providerGroups = useMemo(() => groupByProvider(models), [models]); + const selectedOption = models.find( + (option) => + option.selection.instanceId === selection?.instanceId && + option.selection.model === selection.model, + ); + const optionDescriptors = useMemo( + () => + resolveProviderOptionDescriptors({ + capabilities: selectedOption?.capabilities, + selections: selection?.options, + }), + [selectedOption?.capabilities, selection?.options], + ); + + return ( + undefined} + onSelectModel={(option) => updateBotDraft({ modelSelection: option.selection })} + optionDescriptors={optionDescriptors} + onUpdateOptionSelections={(options) => { + if (selection) updateBotDraft({ modelSelection: { ...selection, options } }); + }} + onClose={() => navigation.goBack()} + /> + ); +} diff --git a/apps/mobile/src/features/bots/NewBotRouteScreen.tsx b/apps/mobile/src/features/bots/NewBotRouteScreen.tsx new file mode 100644 index 0000000000000..b4fcd377f7171 --- /dev/null +++ b/apps/mobile/src/features/bots/NewBotRouteScreen.tsx @@ -0,0 +1,221 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { BOT_NAME_MAX_LENGTH, CommandId, type EnvironmentId } from "@t3tools/contracts"; +import { useNavigation } from "@react-navigation/native"; +import { useMemo, useState } from "react"; +import { Pressable, View } from "react-native"; + +import { AppText as Text, AppTextInput } from "../../components/ui/AppText"; +import { SymbolView } from "../../components/ui/AppSymbol"; +import { Button } from "../../components/ui/Button"; +import * as Dialog from "../../components/ui/Dialog"; +import { ListRow } from "../../components/ui/ListRow"; +import { Menu } from "../../components/ui/Menu"; +import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; +import { buildModelOptions } from "../../lib/modelOptions"; +import { uuidv4 } from "../../lib/uuid"; +import { useEnvironmentServerConfig } from "../../state/entities"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useWorkspaceEnvironments } from "../../state/workspace"; +import { SettingsScreen } from "../settings/components/SettingsScreen"; +import { SettingsSection } from "../settings/components/SettingsSection"; +import { scheduledTaskDefaultModel } from "../settings/scheduledTaskDraft"; +import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; +import { BotAvatar } from "./BotAvatar"; +import { BotAvatarPicker } from "./BotAvatarPicker"; +import { botDraftAtom, updateBotDraft } from "./bot-draft"; + +/** Connected machines a bot can live on, the first being the default. */ +export function useBotMachines() { + return useWorkspaceEnvironments().filter( + (environment) => environment.isEnabled && environment.connectionState === "connected", + ); +} + +/** The draft's model, or the machine's default until the user picks one. */ +export function useBotDraftModel(environmentId: EnvironmentId | null) { + const draft = useAtomValue(botDraftAtom); + const config = useEnvironmentServerConfig(environmentId); + const selection = useMemo( + () => draft.modelSelection ?? scheduledTaskDefaultModel(config, null), + [config, draft.modelSelection], + ); + return { config, selection }; +} + +export function NewBotRouteScreen() { + const draft = useAtomValue(botDraftAtom); + const machines = useBotMachines(); + const machine = + machines.find((entry) => entry.environmentId === draft.environmentId) ?? machines[0] ?? null; + return ( + + {machine ? ( + + ) : ( + Connect a machine first. + )} + + ); +} + +function NewBotForm(props: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; +}) { + const navigation = useNavigation(); + const selectDetail = useSelectWorkspaceDetail(); + const draft = useAtomValue(botDraftAtom); + const machines = useBotMachines(); + const { config, selection } = useBotDraftModel(props.environmentId); + const models = useMemo( + () => buildModelOptions(config, selection, undefined, null), + [config, selection], + ); + const modelLabel = + models.find( + (option) => + option.selection.instanceId === selection?.instanceId && + option.selection.model === selection.model, + )?.label ?? + selection?.model ?? + "No models available"; + const canCreate = useAtomValue(serverEnvironment.createBot.permissionAtom(props.environmentId)); + const createBot = useAtomCommand(serverEnvironment.createBot, { + label: "bot create", + reportFailure: false, + }); + // One command id per form, so a retried submit after a dropped reply is not a second bot. + const [commandId, setCommandId] = useState(() => CommandId.make(uuidv4())); + const [saving, setSaving] = useState(false); + const name = draft.name.trim(); + const ready = name.length > 0 && selection !== null && canCreate && !saving; + + const submit = () => { + if (!ready || selection === null) return; + setSaving(true); + void (async () => { + const result = await createBot({ + environmentId: props.environmentId, + input: { + commandId, + name, + avatar: { shape: draft.shape, color: draft.color }, + modelSelection: selection, + }, + }); + setSaving(false); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + Dialog.alert( + "Couldn't create bot", + error instanceof Error ? error.message : "An error occurred.", + ); + return; + } + setCommandId(CommandId.make(uuidv4())); + selectDetail({ + name: "Thread", + params: { + environmentId: String(props.environmentId), + threadId: String(result.value.threadId), + }, + }); + })(); + }; + + return ( + + + + + + + Name + updateBotDraft({ name: value })} + onSubmitEditing={submit} + /> + + + + + + + ({ + id: machine.environmentId, + title: machine.environmentLabel, + state: machine.environmentId === props.environmentId ? "on" : undefined, + }))} + onPressAction={({ nativeEvent }) => { + const next = machines.find((machine) => machine.environmentId === nativeEvent.event); + // Models belong to a machine, so a new machine starts from its own default. + if (next && next.environmentId !== props.environmentId) + updateBotDraft({ environmentId: next.environmentId, modelSelection: null }); + }} + > + + Machine + + {props.environmentLabel} + + + + + navigation.navigate("BotModel", { environmentId: props.environmentId })} + /> + + ))} -
      +
      {BOT_AVATAR_COLORS.map((option) => ( -
      +

      No bots yet

      ) : (
        {bots.map((bot) => ( diff --git a/apps/web/src/components/bots/CreateBotDialog.tsx b/apps/web/src/components/bots/CreateBotDialog.tsx index aec0523b0d583..864494503f1a5 100644 --- a/apps/web/src/components/bots/CreateBotDialog.tsx +++ b/apps/web/src/components/bots/CreateBotDialog.tsx @@ -1,6 +1,7 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { BOT_NAME_MAX_LENGTH, + BotError, CommandId, type BotAvatarColor, type BotAvatarShape, @@ -11,6 +12,7 @@ import { import type { UnifiedSettings } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; import { useAtomSet } from "@effect/atom-react"; +import { Schema } from "effect"; import { useNavigate } from "@tanstack/react-router"; import { type ReactNode, useId, useState } from "react"; @@ -23,6 +25,7 @@ import { import { applyProviderInstanceSettings, deriveProviderInstanceEntries, + type ProviderInstanceEntry, resolveDefaultProviderModelSelection, sortProviderInstanceEntries, } from "../../providerInstances"; @@ -56,6 +59,7 @@ import { BotAvatarPicker } from "./BotAvatarPicker"; // Settings hooks need an id; with no computer connected nothing is created anyway. const NO_ENVIRONMENT = "" as EnvironmentId; +const isBotError = Schema.is(BotError); export function CreateBotDialog({ open, @@ -64,37 +68,84 @@ export function CreateBotDialog({ open: boolean; onOpenChange: (open: boolean) => void; }) { + const [pending, setPending] = useState(false); return ( - - - {open ? onOpenChange(false)} /> : null} + { + if (!pending) onOpenChange(next); + }} + > + + {open ? ( + onOpenChange(false)} + onDone={() => onOpenChange(false)} + /> + ) : null} ); } +/** The create form on its own, for the Bots page when there are no bots yet. */ +export function CreateBotPanel() { + const [pending, setPending] = useState(false); + return ( +
        + {}} + /> +
        + ); +} + function defaultBotModel( settings: UnifiedSettings, - providers: ReadonlyArray, + entries: ReadonlyArray, ): ModelSelection | null { + // Pick from what this computer's settings allow, not only what the server reports enabled. + const providers: ReadonlyArray = entries.map((entry) => + entry.enabled === entry.snapshot.enabled + ? entry.snapshot + : { ...entry.snapshot, enabled: entry.enabled }, + ); const stored = resolveDefaultProviderModelSelection(providers, settings.defaultModelSelection); return stored ? resolveEffectiveDefaultModelSelection(settings, providers, stored) : null; } type AvatarChoice = { shape: BotAvatarShape; color: BotAvatarColor }; -function CreateBotForm({ onDone }: { onDone: () => void }) { +type FormProps = { + layout: "dialog" | "page"; + pending: boolean; + onPendingChange: (pending: boolean) => void; + onCancel: (() => void) | null; + onDone: () => void; +}; + +function CreateBotForm(props: FormProps) { const { environments } = useEnvironments(); const machines = environments.filter(isConnectedWithConfig); const primaryEnvironmentId = usePrimaryEnvironmentId(); const [name, setName] = useState(""); const [avatar, setAvatar] = useState({ shape: "circle", color: "teal" }); const [chosenEnvironmentId, setChosenEnvironmentId] = useState(null); + // A chosen computer stays chosen even if it drops, so a create never lands somewhere else. const environmentId = - machines.find((machine) => machine.environmentId === chosenEnvironmentId)?.environmentId ?? + chosenEnvironmentId ?? machines.find((machine) => machine.environmentId === primaryEnvironmentId)?.environmentId ?? machines[0]?.environmentId ?? null; + const connected = machines.some((machine) => machine.environmentId === environmentId); const machinePicker = machines.length === 0 ? (

        No connected computers

        @@ -105,7 +156,7 @@ function CreateBotForm({ onDone }: { onDone: () => void }) { items={machines.map((machine) => ({ value: machine.environmentId, label: machine.label }))} > - + {machines.map((machine) => ( @@ -118,35 +169,38 @@ function CreateBotForm({ onDone }: { onDone: () => void }) { ); return ( ); } function MachineBotForm({ + layout, + pending, + onPendingChange, + onCancel, + onDone, environmentId, name, onNameChange, avatar, onAvatarChange, machinePicker, - onDone, -}: { +}: FormProps & { environmentId: EnvironmentId | null; name: string; onNameChange: (name: string) => void; avatar: AvatarChoice; onAvatarChange: (avatar: AvatarChoice) => void; machinePicker: ReactNode; - onDone: () => void; }) { const id = useId(); const navigate = useNavigate(); @@ -165,23 +219,25 @@ function MachineBotForm({ chosenModel && entries.some((entry) => entry.instanceId === chosenModel.instanceId && entry.enabled) ? chosenModel - : defaultBotModel(settings, providers); + : defaultBotModel(settings, entries); const activeEntry = entries.find((entry) => entry.instanceId === model?.instanceId); // One command id per attempt, so a retry after a lost reply returns the same bot. const [commandId, setCommandId] = useState(() => CommandId.make(randomUUID())); - const [pending, setPending] = useState(false); const trimmedName = name.trim(); + const canSubmit = !pending && environmentId !== null && model !== null && trimmedName.length > 0; const submit = async () => { - if (pending || environmentId === null || model === null || trimmedName.length === 0) return; - setPending(true); + if (!canSubmit) return; + onPendingChange(true); const result = await createBot({ environmentId, input: { commandId, name: trimmedName, avatar, modelSelection: model }, }); - setPending(false); + onPendingChange(false); if (result._tag === "Failure") { const error = squashAtomCommandFailure(result); + // The server rejected this attempt for good; only a lost reply should reuse its id. + if (isBotError(error)) setCommandId(CommandId.make(randomUUID())); toastManager.add({ type: "error", title: "Could not create bot", @@ -198,6 +254,87 @@ function MachineBotForm({ }); }; + const fields = ( +
        +
        + + + +
        + +
        + + onNameChange(event.target.value)} + /> +
        +
        + + {machinePicker} +
        + {environmentId !== null ? ( +
        + + {model === null ? ( +

        No providers on this computer

        + ) : ( +
        + + setChosenModel(createModelSelection(instanceId, slug)) + } + /> + {activeEntry ? ( + + setChosenModel(createModelSelection(model.instanceId, model.model, options)) + } + /> + ) : null} +
        + )} +
        + ) : null} +
        + ); + const actions = ( + <> + {onCancel ? ( + + ) : null} + + + ); + return (
        - - New bot - - -
        -
        - - - -
        - -
        - - onNameChange(event.target.value)} - /> -
        -
        - - {machinePicker} -
        - {environmentId !== null ? ( -
        - - {model === null ? ( -

        No providers on this computer

        - ) : ( -
        - - setChosenModel(createModelSelection(instanceId, slug)) - } - /> - {activeEntry ? ( - - setChosenModel(createModelSelection(model.instanceId, model.model, options)) - } - /> - ) : null} -
        - )} -
        - ) : null} -
        -
        - - - - + {layout === "dialog" ? ( + <> + + New bot + + {fields} + {actions} + + ) : ( +
        +

        Create your first bot

        + {fields} +
        {actions}
        +
        + )}
        ); } diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 39dd7bedaad18..4b054105f123d 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -8,7 +8,7 @@ import { } from "lucide-react"; import type { ReactNode } from "react"; import { memo } from "react"; -import { Link, useLocation, useNavigate } from "@tanstack/react-router"; +import { Link, useLocation, useNavigate, useRouter } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; @@ -20,8 +20,10 @@ import { } from "../../navigationHistory"; import { primaryServerKeybindingsAtom } from "../../state/server"; import { cn } from "../../lib/utils"; -import { sidebarModeAtom, unreadBotAtom } from "../../state/bots"; +import { botListAtom, sidebarModeAtom, unreadBotAtom } from "../../state/bots"; +import { isThreadRoutePathname } from "../../threadRoutes"; import { usePullRequestsSupported } from "../../state/environments"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; import { BotAvatar } from "../bots/BotAvatar"; import { T3Wordmark } from "../T3Wordmark"; import { @@ -233,7 +235,25 @@ function SidebarUtilityItem({ function SidebarBotsItem() { const [mode, setMode] = useAtom(sidebarModeAtom); const unreadBot = useAtomValue(unreadBotAtom); + const navigate = useNavigate(); + const router = useRouter(); + const { isMobile, setOpenMobile } = useSidebar(); const showingBots = mode === "bots"; + const toggle = () => { + const { pathname } = router.state.location; + if (showingBots) { + setMode("threads"); + const bots = appAtomRegistry.get(botListAtom); + const onBotPage = + pathname === "/bots" || bots.some((bot) => isThreadRoutePathname(pathname, bot.threadRef)); + if (onBotPage) void navigate({ to: "/" }); + return; + } + setMode("bots"); + // With no bots the create form is the whole page, so the sheet would only cover it. + if (isMobile && appAtomRegistry.get(botListAtom).length === 0) setOpenMobile(false); + void navigate({ to: "/bots" }); + }; return ( setMode(showingBots ? "threads" : "bots")} + onClick={toggle} /> ); } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 0e26ab6edd879..353246595b14b 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -21,6 +21,7 @@ import { Route as ChatUsageRouteImport } from './routes/_chat.usage' import { Route as ChatSettingsRouteImport } from './routes/_chat.settings' import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-requests' import { Route as ChatNewRouteImport } from './routes/_chat.new' +import { Route as ChatBotsRouteImport } from './routes/_chat.bots' import { Route as ChatSettingsWorktreesRouteImport } from './routes/_chat.settings.worktrees' import { Route as ChatSettingsThreadsRouteImport } from './routes/_chat.settings.threads' import { Route as ChatSettingsStorageRouteImport } from './routes/_chat.settings.storage' @@ -109,6 +110,11 @@ const ChatNewRoute = ChatNewRouteImport.update({ path: '/new', getParentRoute: () => ChatRoute, } as any) +const ChatBotsRoute = ChatBotsRouteImport.update({ + id: '/bots', + path: '/bots', + getParentRoute: () => ChatRoute, +} as any) const ChatSettingsWorktreesRoute = ChatSettingsWorktreesRouteImport.update({ id: '/worktrees', path: '/worktrees', @@ -262,6 +268,7 @@ export interface FileRoutesByFullPath { '/guest': typeof GuestRoute '/pair': typeof PairRoute '/welcome': typeof WelcomeRoute + '/bots': typeof ChatBotsRoute '/new': typeof ChatNewRoute '/pull-requests': typeof ChatPullRequestsRoute '/settings': typeof ChatSettingsRouteWithChildren @@ -302,6 +309,7 @@ export interface FileRoutesByTo { '/guest': typeof GuestRoute '/pair': typeof PairRoute '/welcome': typeof WelcomeRoute + '/bots': typeof ChatBotsRoute '/new': typeof ChatNewRoute '/pull-requests': typeof ChatPullRequestsRoute '/settings': typeof ChatSettingsRouteWithChildren @@ -345,6 +353,7 @@ export interface FileRoutesById { '/guest': typeof GuestRoute '/pair': typeof PairRoute '/welcome': typeof WelcomeRoute + '/_chat/bots': typeof ChatBotsRoute '/_chat/new': typeof ChatNewRoute '/_chat/pull-requests': typeof ChatPullRequestsRoute '/_chat/settings': typeof ChatSettingsRouteWithChildren @@ -389,6 +398,7 @@ export interface FileRouteTypes { | '/guest' | '/pair' | '/welcome' + | '/bots' | '/new' | '/pull-requests' | '/settings' @@ -429,6 +439,7 @@ export interface FileRouteTypes { | '/guest' | '/pair' | '/welcome' + | '/bots' | '/new' | '/pull-requests' | '/settings' @@ -471,6 +482,7 @@ export interface FileRouteTypes { | '/guest' | '/pair' | '/welcome' + | '/_chat/bots' | '/_chat/new' | '/_chat/pull-requests' | '/_chat/settings' @@ -603,6 +615,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatNewRouteImport parentRoute: typeof ChatRoute } + '/_chat/bots': { + id: '/_chat/bots' + path: '/bots' + fullPath: '/bots' + preLoaderRoute: typeof ChatBotsRouteImport + parentRoute: typeof ChatRoute + } '/_chat/settings/worktrees': { id: '/_chat/settings/worktrees' path: '/worktrees' @@ -865,6 +884,7 @@ const ChatSettingsRouteWithChildren = ChatSettingsRoute._addFileChildren( ) interface ChatRouteChildren { + ChatBotsRoute: typeof ChatBotsRoute ChatNewRoute: typeof ChatNewRoute ChatPullRequestsRoute: typeof ChatPullRequestsRoute ChatSettingsRoute: typeof ChatSettingsRouteWithChildren @@ -875,6 +895,7 @@ interface ChatRouteChildren { } const ChatRouteChildren: ChatRouteChildren = { + ChatBotsRoute: ChatBotsRoute, ChatNewRoute: ChatNewRoute, ChatPullRequestsRoute: ChatPullRequestsRoute, ChatSettingsRoute: ChatSettingsRouteWithChildren, diff --git a/apps/web/src/routes/_chat.bots.tsx b/apps/web/src/routes/_chat.bots.tsx new file mode 100644 index 0000000000000..394c32ac517f2 --- /dev/null +++ b/apps/web/src/routes/_chat.bots.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { BotsHome } from "../components/bots/BotsHome"; + +export const Route = createFileRoute("/_chat/bots")({ + component: BotsHome, +}); From a5c9b6926b6ce04f437f2f6f64e0b19fc9eaf9f1 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 21:20:23 +0530 Subject: [PATCH 12/40] feat(web): bot avatars with eyes and a Grok style bots sidebar Every avatar shape gets a face that blinks on hover, and one shared timer blinks a random avatar every few seconds so idle avatars never repaint. The bots sidebar gets round search and new bot buttons and roomier rows, and opening /bots directly shows the bots sidebar. --- apps/web/src/components/bots/BotAvatar.tsx | 8 +- .../src/components/bots/BotAvatarPicker.tsx | 2 +- apps/web/src/components/bots/BotsSidebar.tsx | 75 ++++++++++++++----- apps/web/src/components/bots/botBlink.ts | 31 ++++++++ .../src/components/sidebar/SidebarChrome.tsx | 5 ++ apps/web/src/index.css | 25 +++++++ apps/web/src/routes/_chat.bots.tsx | 4 + packages/client-runtime/src/botAvatar.ts | 30 ++++++++ 8 files changed, 160 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/components/bots/botBlink.ts diff --git a/apps/web/src/components/bots/BotAvatar.tsx b/apps/web/src/components/bots/BotAvatar.tsx index 318c3015b0a80..141e3c25e75a3 100644 --- a/apps/web/src/components/bots/BotAvatar.tsx +++ b/apps/web/src/components/bots/BotAvatar.tsx @@ -1,4 +1,4 @@ -import { resolveBotAvatar } from "@t3tools/client-runtime/bot-avatar"; +import { BOT_AVATAR_EYE_COLOR, resolveBotAvatar } from "@t3tools/client-runtime/bot-avatar"; import { assetUrlStateFromResult } from "@t3tools/client-runtime/state/assets"; import type { BotAvatar as BotAvatarValue, ScopedThreadRef } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; @@ -6,6 +6,7 @@ import { memo } from "react"; import { cn } from "../../lib/utils"; import { assetEnvironment } from "../../state/assets"; +import { registerBotEyes } from "./botBlink"; import { useEnvironmentHttpBaseUrl } from "../../state/environments"; export function BotAvatarShape({ @@ -21,6 +22,11 @@ export function BotAvatarShape({ return ( + + {resolved.eyes.map((eye) => ( + + ))} + ); } diff --git a/apps/web/src/components/bots/BotAvatarPicker.tsx b/apps/web/src/components/bots/BotAvatarPicker.tsx index 2f36916003e4a..0968815d29c75 100644 --- a/apps/web/src/components/bots/BotAvatarPicker.tsx +++ b/apps/web/src/components/bots/BotAvatarPicker.tsx @@ -32,7 +32,7 @@ export function BotAvatarPicker({ disabled={disabled} onClick={() => onChange({ shape: option, color })} className={cn( - "flex aspect-square cursor-pointer items-center justify-center rounded-lg bg-muted/60 outline-hidden ring-ring hover:bg-muted focus-visible:ring-2 focus-visible:ring-inset disabled:cursor-default disabled:opacity-60", + "group/bot flex aspect-square cursor-pointer items-center justify-center rounded-lg bg-muted/60 outline-hidden ring-ring hover:bg-muted focus-visible:ring-2 focus-visible:ring-inset disabled:cursor-default disabled:opacity-60", option === shape && "bg-muted ring-2 ring-foreground/70 ring-inset", )} > diff --git a/apps/web/src/components/bots/BotsSidebar.tsx b/apps/web/src/components/bots/BotsSidebar.tsx index b6dc4627c4f27..4076a2c731637 100644 --- a/apps/web/src/components/bots/BotsSidebar.tsx +++ b/apps/web/src/components/bots/BotsSidebar.tsx @@ -1,7 +1,7 @@ import type { BotSummary } from "@t3tools/client-runtime/state/bots"; import { useAtomValue } from "@effect/atom-react"; import { useLocation, useNavigate } from "@tanstack/react-router"; -import { PlusIcon } from "lucide-react"; +import { PlusIcon, SearchIcon } from "lucide-react"; import { memo, useState } from "react"; import { isElectron } from "../../env"; @@ -10,6 +10,7 @@ import { botListAtom } from "../../state/bots"; import { buildThreadRouteParams, isThreadRoutePathname } from "../../threadRoutes"; import { SidebarChromeFooter, SidebarChromeHeader } from "../sidebar/SidebarChrome"; import { Button } from "../ui/button"; +import { Input } from "../ui/input"; import { SidebarContent, SidebarGroup, useSidebar } from "../ui/sidebar"; import { BotAvatar } from "./BotAvatar"; import { CreateBotDialog } from "./CreateBotDialog"; @@ -30,21 +31,26 @@ const BotRow = memo(function BotRow({ aria-current={active ? "page" : undefined} onClick={() => onOpen(bot)} className={cn( - "flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-left outline-hidden ring-ring hover:bg-sidebar-row-hover focus-visible:ring-2 focus-visible:ring-inset", + "group/bot flex w-full cursor-pointer items-center gap-3 rounded-xl px-2.5 py-2.5 text-left outline-hidden ring-ring hover:bg-sidebar-row-hover focus-visible:ring-2 focus-visible:ring-inset", active && "bg-sidebar-row-hover", )} > - - + + {bot.name} - + {bot.working ? "Working" : (bot.preview ?? "No messages yet")} @@ -62,6 +68,10 @@ export default function BotsSidebar() { const pathname = useLocation({ select: (location) => location.pathname }); const { isMobile, setOpenMobile } = useSidebar(); const [creating, setCreating] = useState(false); + const [query, setQuery] = useState(null); + const needle = query?.trim().toLowerCase() ?? ""; + const visibleBots = + needle.length === 0 ? bots : bots.filter((bot) => bot.name.toLowerCase().includes(needle)); const openBot = (bot: BotSummary) => { if (isMobile) setOpenMobile(false); @@ -73,29 +83,58 @@ export default function BotsSidebar() { return ( <> - - - Bots + + - + + } + /> + + setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") setQuery(null); + }} + /> + + ) } > - {bots.length === 0 ? ( -

        No bots yet

        + {visibleBots.length === 0 ? ( +

        + {bots.length === 0 ? "No bots yet" : "No matching bots"} +

        ) : (
          - {bots.map((bot) => ( + {visibleBots.map((bot) => ( (); +let timer: number | null = null; +const reducedMotion = + typeof window === "undefined" ? null : window.matchMedia("(prefers-reduced-motion: reduce)"); + +function blinkOne() { + if (document.hidden || reducedMotion?.matches || eyes.size === 0) return; + const pick = [...eyes][Math.floor(Math.random() * eyes.size)]; + pick?.setAttribute("data-blink", ""); +} + +function stopBlink(event: Event) { + (event.currentTarget as SVGGElement).removeAttribute("data-blink"); +} + +/** A ref callback for an avatar's eye group. */ +export function registerBotEyes(element: SVGGElement) { + eyes.add(element); + element.addEventListener("animationend", stopBlink); + timer ??= window.setInterval(blinkOne, 2600); + return () => { + eyes.delete(element); + element.removeEventListener("animationend", stopBlink); + if (eyes.size === 0 && timer !== null) { + window.clearInterval(timer); + timer = null; + } + }; +} diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 4b054105f123d..ae6e1de275843 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -53,8 +53,10 @@ import { observeResize } from "~/lib/observeResize"; export const SidebarChromeHeader = memo(function SidebarChromeHeader({ isElectron, + actions, }: { isElectron: boolean; + actions?: ReactNode; }) { const stageLabel = useEnvironmentStageLabel(); const environmentIdentificationMode = useEnvironmentIdentificationMode(); @@ -100,6 +102,9 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
      ) : null} + {actions ? ( +
      {actions}
      + ) : null} ); }); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 15dd5e14d33fe..55440c0432dec 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -361,6 +361,31 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } +@keyframes bot-blink { + 0%, + 100% { + transform: scaleY(1); + } + 50% { + transform: scaleY(0.1); + } +} + +/* Eyes blink once on hover or when the shared blink clock picks them; never on a loop. */ +@utility bot-eyes { + transform-box: fill-box; + transform-origin: center; + + &[data-blink], + :where(.group\/bot):hover & { + animation: bot-blink 200ms ease-in-out 1; + } + + @media (prefers-reduced-motion: reduce) { + animation: none !important; + } +} + @utility surface-glass { background: color-mix(in srgb, var(--background) var(--glass-opacity), transparent); -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); diff --git a/apps/web/src/routes/_chat.bots.tsx b/apps/web/src/routes/_chat.bots.tsx index 394c32ac517f2..054edc5e02ec4 100644 --- a/apps/web/src/routes/_chat.bots.tsx +++ b/apps/web/src/routes/_chat.bots.tsx @@ -1,7 +1,11 @@ import { createFileRoute } from "@tanstack/react-router"; import { BotsHome } from "../components/bots/BotsHome"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { sidebarModeAtom } from "../state/bots"; export const Route = createFileRoute("/_chat/bots")({ + // A reload or shared link lands here too, so the sidebar follows the page. + beforeLoad: () => appAtomRegistry.set(sidebarModeAtom, "bots"), component: BotsHome, }); diff --git a/packages/client-runtime/src/botAvatar.ts b/packages/client-runtime/src/botAvatar.ts index f5805ab604574..357a8cd37aa31 100644 --- a/packages/client-runtime/src/botAvatar.ts +++ b/packages/client-runtime/src/botAvatar.ts @@ -23,6 +23,28 @@ export const BOT_AVATAR_SHAPE_PATHS: Record = { "M10.0 60.0a23 30 0 1 0 46 0a23 30 0 1 0 -46 0ZM44.0 60.0a23 30 0 1 0 46 0a23 30 0 1 0 -46 0ZM28.0 60.0a22 32 0 1 0 44 0a22 32 0 1 0 -44 0ZM46 12V30H54V12Z", }; +/** Where each shape's face sits: eye center line and half the distance between the eyes. */ +const BOT_AVATAR_FACES: Record = { + circle: { x: 50, y: 48, gap: 13 }, + cloud: { x: 52, y: 56, gap: 10 }, + diamond: { x: 50, y: 48, gap: 10 }, + star: { x: 50, y: 56, gap: 8 }, + flower: { x: 50, y: 50, gap: 9 }, + heart: { x: 50, y: 42, gap: 12 }, + gear: { x: 50, y: 50, gap: 11 }, + drop: { x: 50, y: 64, gap: 11 }, + pill: { x: 50, y: 50, gap: 14 }, + triangle: { x: 50, y: 66, gap: 10 }, + shield: { x: 50, y: 44, gap: 11 }, + burst: { x: 50, y: 50, gap: 10 }, + hexagon: { x: 50, y: 50, gap: 13 }, + pumpkin: { x: 50, y: 62, gap: 11 }, +}; + +export const BOT_AVATAR_EYE_COLOR = "#18181B"; +const EYE_RX = 5.5; +const EYE_RY = 9; + export const BOT_AVATAR_COLOR_HEX: Record = { white: "#F4F4F5", brown: "#92643F", @@ -41,8 +63,16 @@ export const BOT_AVATAR_COLOR_HEX: Record = { export function resolveBotAvatar(avatar: { readonly shape: string; readonly color: string }) { const path = (BOT_AVATAR_SHAPE_PATHS as Record)[avatar.shape]; const color = (BOT_AVATAR_COLOR_HEX as Record)[avatar.color]; + const face = + (BOT_AVATAR_FACES as Record)[ + avatar.shape + ] ?? BOT_AVATAR_FACES.circle; return { path: path ?? BOT_AVATAR_SHAPE_PATHS.circle, color: color ?? BOT_AVATAR_COLOR_HEX.gray, + eyes: [ + { cx: face.x - face.gap, cy: face.y, rx: EYE_RX, ry: EYE_RY }, + { cx: face.x + face.gap, cy: face.y, rx: EYE_RX, ry: EYE_RY }, + ] as const, }; } From f3dd4fa157f66c90941a4ee8debf911e16e5aa4f Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 21:35:20 +0530 Subject: [PATCH 13/40] fix(bots): build on main and use the composer model picker Rebased onto main: provide the bot service to main's newer server test layers, add the bot migration to the expected list, and raise the MCP tool budget for t3_bot_update. The new bot form now uses the composer's model picker, with the reasoning column and speed footer; permissions are left out because bots always run with full access. --- apps/server/src/mcp/linkOrigin.test.ts | 8 ++- .../src/mcp/toolkits/project/handlers.ts | 1 - apps/server/src/peer/PeerForwarding.test.ts | 15 +++++- apps/server/src/peer/RemoteDelegation.test.ts | 15 +++++- .../reconcileV2PreviewMigration.test.ts | 2 + .../src/provider/AgentContextBudget.test.ts | 2 + .../src/provider/agentContextBudget.json | 2 +- .../src/components/bots/CreateBotDialog.tsx | 51 +++++++++++-------- .../components/chat/ComposerControlsBar.tsx | 8 +-- .../components/chat/ComposerModelEffort.tsx | 23 +++++---- .../src/components/sidebar/SidebarChrome.tsx | 1 - packages/client-runtime/src/botAvatar.ts | 2 +- .../client-runtime/src/state/bots.test.ts | 6 ++- packages/client-runtime/src/state/bots.ts | 6 +-- 14 files changed, 95 insertions(+), 47 deletions(-) diff --git a/apps/server/src/mcp/linkOrigin.test.ts b/apps/server/src/mcp/linkOrigin.test.ts index 253905e1d6373..0f1d2d067b4f1 100644 --- a/apps/server/src/mcp/linkOrigin.test.ts +++ b/apps/server/src/mcp/linkOrigin.test.ts @@ -35,6 +35,7 @@ import * as ProjectCloneTracker from "../project/ProjectCloneTracker.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as Bots from "../bots/BotService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; @@ -130,7 +131,12 @@ const layerTools = Layer.mergeAll( list: () => Effect.succeed([]), }), ), - Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide( + Layer.mergeAll( + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(Bots.BotService)({}), + ), + ), Layer.provide( Layer.mock(ProjectService.ProjectService)({ getById: () => Effect.succeedNone, diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index adb721d4ccf0e..9f45ec44c4f19 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -29,7 +29,6 @@ import * as DateTime from "effect/DateTime"; import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { resolveRuntimeMode } from "../../delegatedTaskModes.ts"; -import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts"; import { ProjectToolkit } from "./tools.ts"; diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index 754170be999c2..3ef71b10158cf 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -31,6 +31,7 @@ import * as ProjectCloneTracker from "../project/ProjectCloneTracker.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as Bots from "../bots/BotService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as RemoteDelegation from "./RemoteDelegation.ts"; @@ -229,7 +230,12 @@ const serveBox = (seen: Ref.Ref, linkSession: Ref.Ref) => Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), ), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), - Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide( + Layer.mergeAll( + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(Bots.BotService)({}), + ), + ), Layer.provide(Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), Layer.provide(ServerSettings.layerTest()), @@ -267,7 +273,12 @@ const makeLaptop = Effect.gen(function* () { Layer.provide(DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer))), Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), - Layer.provide(Layer.mock(ThreadLaunch.ThreadLaunchService)({})), + Layer.provide( + Layer.mergeAll( + Layer.mock(ThreadLaunch.ThreadLaunchService)({}), + Layer.mock(Bots.BotService)({}), + ), + ), Layer.provide( Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), ), diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts index 3cb268ffebf45..f96f569c266a6 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -55,6 +55,7 @@ import * as ProjectService from "../project/ProjectService.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as Bots from "../bots/BotService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; @@ -487,7 +488,12 @@ const boxToolkitLayer = (thread: BoxThread) => { Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), ), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), - Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide( + Layer.mergeAll( + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(Bots.BotService)({}), + ), + ), Layer.provide( Layer.mock(ProjectService.ProjectService)({ getById: () => Effect.succeedNone, @@ -597,7 +603,12 @@ const makeLaptop = ( Layer.provideMerge(layerThreads), Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), - Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide( + Layer.mergeAll( + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(Bots.BotService)({}), + ), + ), Layer.provide(Layer.mock(ProjectService.ProjectService)({})), Layer.provide(Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 4083978758ce0..5ef4d8015b0a4 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -57,6 +57,7 @@ describe("V2 preview upgrade", () => { [75, "SubagentDeliveryState"], [76, "ProjectionProjectsPinArchive"], [77, "RepairBooleanSettledOverride"], + [78, "ProjectionProjectBot"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -157,6 +158,7 @@ describe("V2 preview upgrade", () => { [75, "SubagentDeliveryState"], [76, "ProjectionProjectsPinArchive"], [77, "RepairBooleanSettledOverride"], + [78, "ProjectionProjectBot"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/provider/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts index 885053c7d0f24..1864b885c6ce8 100644 --- a/apps/server/src/provider/AgentContextBudget.test.ts +++ b/apps/server/src/provider/AgentContextBudget.test.ts @@ -7,6 +7,7 @@ import * as Layer from "effect/Layer"; import { McpServer } from "effect/ai"; import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import { BotService } from "../bots/BotService.ts"; import { ServerConfig } from "../config.ts"; import { DeviceService } from "../device/DeviceService.ts"; import { ServerEnvironment } from "../environment/ServerEnvironment.ts"; @@ -46,6 +47,7 @@ import budget from "./agentContextBudget.json" with { type: "json" }; // Registration only stores tool definitions, so the handlers' services are never called. const toolHandlerServices = Layer.mergeAll( + Layer.mock(BotService)({}), Layer.mock(DeviceService)({}), Layer.mock(DocumentText)({}), Layer.mock(GitVcsDriver)({}), diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 1719d3ca6bd19..e95942af319a7 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5153, "codexAdditionalContextBytes": 6883, - "mcpToolListJsonBytes": 47538 + "mcpToolListJsonBytes": 47747 } diff --git a/apps/web/src/components/bots/CreateBotDialog.tsx b/apps/web/src/components/bots/CreateBotDialog.tsx index 864494503f1a5..894611084f174 100644 --- a/apps/web/src/components/bots/CreateBotDialog.tsx +++ b/apps/web/src/components/bots/CreateBotDialog.tsx @@ -23,11 +23,9 @@ import { resolveEffectiveDefaultModelSelection, } from "../../modelSelection"; import { - applyProviderInstanceSettings, - deriveProviderInstanceEntries, + providerInstanceEntriesFor, type ProviderInstanceEntry, resolveDefaultProviderModelSelection, - sortProviderInstanceEntries, } from "../../providerInstances"; import { sidebarModeAtom } from "../../state/bots"; import { @@ -38,8 +36,13 @@ import { import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; import { useOrchestrationCommand } from "../../state/use-orchestration-command"; import { buildThreadRouteParams } from "../../threadRoutes"; +import { + ComposerEffortColumn, + ComposerPickerFooter, + describeComposerEffort, +} from "../chat/ComposerModelEffort"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; -import { TraitsPicker } from "../chat/TraitsPicker"; +import type { TraitsSelectionInput } from "../chat/TraitsPicker"; import { isConnectedWithConfig } from "../settings/scopedSettings"; import { Button } from "../ui/button"; import { @@ -211,9 +214,7 @@ function MachineBotForm({ const providers = (environmentId ? providersByEnvironment.get(environmentId) : undefined) ?? EMPTY_SERVER_PROVIDERS; - const entries = sortProviderInstanceEntries( - applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings, null), - ); + const entries = providerInstanceEntriesFor(providers, settings, null); const [chosenModel, setChosenModel] = useState(null); const model = chosenModel && @@ -221,6 +222,21 @@ function MachineBotForm({ ? chosenModel : defaultBotModel(settings, entries); const activeEntry = entries.find((entry) => entry.instanceId === model?.instanceId); + const traits: TraitsSelectionInput | null = + model && activeEntry + ? { + provider: activeEntry.driverKind, + instanceId: model.instanceId, + models: activeEntry.models, + model: model.model, + modelOptions: model.options ?? [], + allowPromptInjectedEffort: false, + planModeEnabled: settings.planModeEnabled, + onModelOptionsChange: (options) => + setChosenModel(createModelSelection(model.instanceId, model.model, options)), + } + : null; + const effort = describeComposerEffort(traits, settings.effortDisplay, "sm"); // One command id per attempt, so a retry after a lost reply returns the same bot. const [commandId, setCommandId] = useState(() => CommandId.make(randomUUID())); const trimmedName = name.trim(); @@ -283,7 +299,7 @@ function MachineBotForm({ {model === null ? (

      No providers on this computer

      ) : ( -
      +
      } + footer={} onInstanceModelChange={(instanceId, slug) => setChosenModel(createModelSelection(instanceId, slug)) } /> - {activeEntry ? ( - - setChosenModel(createModelSelection(model.instanceId, model.model, options)) - } - /> - ) : null}
      )}
      diff --git a/apps/web/src/components/chat/ComposerControlsBar.tsx b/apps/web/src/components/chat/ComposerControlsBar.tsx index 0002beeaf9fef..2bc02709d6722 100644 --- a/apps/web/src/components/chat/ComposerControlsBar.tsx +++ b/apps/web/src/components/chat/ComposerControlsBar.tsx @@ -231,9 +231,11 @@ export function ComposerControlsBar(props: { footer={ } terminalOpen={terminalOpen} diff --git a/apps/web/src/components/chat/ComposerModelEffort.tsx b/apps/web/src/components/chat/ComposerModelEffort.tsx index 3a863936101b2..0566230f3c1c3 100644 --- a/apps/web/src/components/chat/ComposerModelEffort.tsx +++ b/apps/web/src/components/chat/ComposerModelEffort.tsx @@ -640,18 +640,23 @@ function PermissionsMenu(props: { /** The model picker's footer: speed stays one click away, permissions sit one menu deeper. */ export function ComposerPickerFooter(props: { traits: TraitsSelectionInput | null; - runtimeMode: RuntimeMode; - runtimeModeOptions: ReadonlyArray; - onRuntimeModeChange: (mode: RuntimeMode) => void; + /** Left out where access is fixed, such as a bot's chat. */ + permissions?: { + runtimeMode: RuntimeMode; + runtimeModeOptions: ReadonlyArray; + onRuntimeModeChange: (mode: RuntimeMode) => void; + }; }) { return ( -
      +
      {props.traits ? : null} - + {props.permissions ? ( + + ) : null}
      ); } diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index ae6e1de275843..5b1bdd5033a6e 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -10,7 +10,6 @@ import type { ReactNode } from "react"; import { memo } from "react"; import { Link, useLocation, useNavigate, useRouter } from "@tanstack/react-router"; -import { useAtomValue } from "@effect/atom-react"; import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; import { shortcutLabelForCommand } from "../../keybindings"; import { diff --git a/packages/client-runtime/src/botAvatar.ts b/packages/client-runtime/src/botAvatar.ts index 357a8cd37aa31..90866b69105d5 100644 --- a/packages/client-runtime/src/botAvatar.ts +++ b/packages/client-runtime/src/botAvatar.ts @@ -1,7 +1,7 @@ import type { BotAvatarColor, BotAvatarShape } from "@t3tools/contracts"; /** SVG path data on a 100x100 view box. Overlapping subpaths fill with the nonzero rule. */ -export const BOT_AVATAR_SHAPE_PATHS: Record = { +const BOT_AVATAR_SHAPE_PATHS: Record = { circle: "M8.0 50.0a42 42 0 1 0 84 0a42 42 0 1 0 -84 0Z", cloud: "M12.0 58.0a22 22 0 1 0 44 0a22 22 0 1 0 -44 0ZM30.0 44.0a26 26 0 1 0 52 0a26 26 0 1 0 -52 0ZM54.0 62.0a18 18 0 1 0 36 0a18 18 0 1 0 -36 0ZM34 62V80H72V62Z", diff --git a/packages/client-runtime/src/state/bots.test.ts b/packages/client-runtime/src/state/bots.test.ts index d8d72c37c1954..bddc518489fe3 100644 --- a/packages/client-runtime/src/state/bots.test.ts +++ b/packages/client-runtime/src/state/bots.test.ts @@ -63,7 +63,11 @@ function makeHarness(snapshot: OrchestrationV2ShellSnapshot) { ]), }); const projects = createEnvironmentProjectAtoms({ catalogValueAtom, snapshotAtom }); - const threads = createEnvironmentThreadShellAtoms({ catalogValueAtom, snapshotAtom }); + const threads = createEnvironmentThreadShellAtoms({ + catalogValueAtom, + snapshotAtom, + hiddenThreadTagsAtom: () => Atom.make(null), + }); const bots = createBotListAtom({ botsAtom: projects.botsAtom, quietThreadShellAtom: threads.quietThreadShellAtom, diff --git a/packages/client-runtime/src/state/bots.ts b/packages/client-runtime/src/state/bots.ts index f950922a24566..b42bd55562073 100644 --- a/packages/client-runtime/src/state/bots.ts +++ b/packages/client-runtime/src/state/bots.ts @@ -54,7 +54,7 @@ export interface BotSummary { } /** A reply the user has not opened yet. Opening the thread moves `lastVisitedAt` past it. */ -export function hasUnreadBotReply(shell: EnvironmentThreadShell): boolean { +function hasUnreadBotReply(shell: EnvironmentThreadShell): boolean { const message = shell.source.latestVisibleMessage; if (message === null || message.role !== "assistant") return false; if (shell.lastVisitedAt == null) return true; @@ -66,7 +66,7 @@ function previewText(text: string): string | null { return line.length === 0 ? null : line.slice(0, 160); } -export function summarizeBot( +function summarizeBot( project: EnvironmentProject, shell: EnvironmentThreadShell | null, ): BotSummary | null { @@ -95,7 +95,7 @@ export function summarizeBot( } /** Like a chat list: the most recent activity first. */ -export function compareBots(left: BotSummary, right: BotSummary): number { +function compareBots(left: BotSummary, right: BotSummary): number { const byActivity = Date.parse(right.activityAt) - Date.parse(left.activityAt); if (byActivity !== 0 && !Number.isNaN(byActivity)) return byActivity; return left.name.localeCompare(right.name); From 95b17b0cd5d7ec33d6035e366dc06d7c82855002 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 21:39:28 +0530 Subject: [PATCH 14/40] feat(mobile): bot avatars get eyes that blink Same eye positions as web, from the shared avatar resolver. One shared timer closes a single avatar's eyes every few seconds, so a list never runs an animation per row. Reduced motion turns blinking off. --- apps/mobile/src/features/bots/BotAvatar.tsx | 26 +++++++++++++++++--- apps/mobile/src/features/bots/botBlink.ts | 27 +++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/src/features/bots/botBlink.ts diff --git a/apps/mobile/src/features/bots/BotAvatar.tsx b/apps/mobile/src/features/bots/BotAvatar.tsx index e64407428de84..134b3e98da2b9 100644 --- a/apps/mobile/src/features/bots/BotAvatar.tsx +++ b/apps/mobile/src/features/bots/BotAvatar.tsx @@ -1,18 +1,36 @@ -import { resolveBotAvatar } from "@t3tools/client-runtime/bot-avatar"; +import { BOT_AVATAR_EYE_COLOR, resolveBotAvatar } from "@t3tools/client-runtime/bot-avatar"; import type { BotAvatar as BotAvatarValue, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { Image } from "expo-image"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { View } from "react-native"; -import Svg, { Path } from "react-native-svg"; +import { useReducedMotion } from "react-native-reanimated"; +import Svg, { Ellipse, G, Path } from "react-native-svg"; + +import { registerBotBlink } from "./botBlink"; import { useAssetUrlState } from "../../state/assets"; function BotAvatarShape(props: { readonly avatar: BotAvatarValue; readonly size: number }) { - const { path, color } = resolveBotAvatar(props.avatar); + const { path, color, eyes } = resolveBotAvatar(props.avatar); const inset = props.size * 0.18; + const reducedMotion = useReducedMotion(); + const [closed, setClosed] = useState(false); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- joins the shared blink timer + useEffect(() => (reducedMotion ? undefined : registerBotBlink(setClosed)), [reducedMotion]); return ( + + {eyes.map((eye) => ( + + ))} + ); } diff --git a/apps/mobile/src/features/bots/botBlink.ts b/apps/mobile/src/features/bots/botBlink.ts new file mode 100644 index 0000000000000..749a0dd325009 --- /dev/null +++ b/apps/mobile/src/features/bots/botBlink.ts @@ -0,0 +1,27 @@ +const BLINK_EVERY_MS = 2600; +const BLINK_MS = 160; + +const blinkers = new Set<(closed: boolean) => void>(); +let timer: ReturnType | null = null; + +// One shared timer blinks a single avatar at a time, so a long list costs one +// small re-render every few seconds rather than a running animation per row. +function blinkOne() { + const all = [...blinkers]; + const blink = all[Math.floor(Math.random() * all.length)]; + if (!blink) return; + blink(true); + setTimeout(() => blink(false), BLINK_MS); +} + +export function registerBotBlink(setClosed: (closed: boolean) => void) { + blinkers.add(setClosed); + timer ??= setInterval(blinkOne, BLINK_EVERY_MS); + return () => { + blinkers.delete(setClosed); + if (blinkers.size === 0 && timer !== null) { + clearInterval(timer); + timer = null; + } + }; +} From 90e2b51bb85b292026df0ce7fb9de972a12f2634 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 21:57:32 +0530 Subject: [PATCH 15/40] feat(bots): smooth avatar shapes that turn, glance and spring in Polygon shapes get rounded corners. Avatars now look around with a pseudo 3D head turn (body tilt, face slide and squash), rest in a three quarter pose in the picker grid and face front when picked, and spring in from a side when the shape or color changes. All motion is one-shot from one shared timer, and reduced motion turns it off. --- apps/mobile/src/features/bots/BotAvatar.tsx | 159 +++++++++++++++--- .../src/features/bots/BotAvatarPicker.tsx | 6 +- .../features/bots/BotDetailsRouteScreen.tsx | 2 +- .../src/features/bots/NewBotRouteScreen.tsx | 2 +- apps/mobile/src/features/bots/botBlink.ts | 27 --- apps/mobile/src/features/bots/botMotion.ts | 30 ++++ apps/web/src/components/bots/BotAvatar.tsx | 51 +++++- .../src/components/bots/BotAvatarPicker.tsx | 2 +- apps/web/src/components/bots/BotDetails.tsx | 2 +- .../src/components/bots/CreateBotDialog.tsx | 7 +- apps/web/src/components/bots/botBlink.ts | 31 ---- apps/web/src/components/bots/botMotion.ts | 59 +++++++ apps/web/src/index.css | 109 +++++++++++- packages/client-runtime/src/botAvatar.ts | 43 +++-- 14 files changed, 416 insertions(+), 114 deletions(-) delete mode 100644 apps/mobile/src/features/bots/botBlink.ts create mode 100644 apps/mobile/src/features/bots/botMotion.ts delete mode 100644 apps/web/src/components/bots/botBlink.ts create mode 100644 apps/web/src/components/bots/botMotion.ts diff --git a/apps/mobile/src/features/bots/BotAvatar.tsx b/apps/mobile/src/features/bots/BotAvatar.tsx index 134b3e98da2b9..7e8ed173388b2 100644 --- a/apps/mobile/src/features/bots/BotAvatar.tsx +++ b/apps/mobile/src/features/bots/BotAvatar.tsx @@ -1,37 +1,142 @@ -import { BOT_AVATAR_EYE_COLOR, resolveBotAvatar } from "@t3tools/client-runtime/bot-avatar"; +import { + BOT_AVATAR_EYE_COLOR, + botAvatarLookFor, + resolveBotAvatar, +} from "@t3tools/client-runtime/bot-avatar"; import type { BotAvatar as BotAvatarValue, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { Image } from "expo-image"; import { useEffect, useMemo, useState } from "react"; -import { View } from "react-native"; -import { useReducedMotion } from "react-native-reanimated"; +import { StyleSheet, View } from "react-native"; +import Animated, { + ReduceMotion, + useAnimatedStyle, + useReducedMotion, + useSharedValue, + withDelay, + withSequence, + withSpring, + withTiming, +} from "react-native-reanimated"; import Svg, { Ellipse, G, Path } from "react-native-svg"; -import { registerBotBlink } from "./botBlink"; +import { type BotLook, registerBotMotion } from "./botMotion"; import { useAssetUrlState } from "../../state/assets"; -function BotAvatarShape(props: { readonly avatar: BotAvatarValue; readonly size: number }) { +/** Resting three quarter turn for the picker grid. */ +const SIDE_POSE: BotLook = { x: 8, y: -5, tilt: -8 }; +const motion = { reduceMotion: ReduceMotion.System }; + +type ShapeProps = { + readonly avatar: BotAvatarValue; + readonly size: number; + /** "side" rests turned three quarters until selected. */ + readonly pose?: "side" | "front"; + /** Springs in from a side on mount; callers remount it when the avatar changes. */ + readonly enter?: boolean; +}; + +// The body tilts while the face slides and squashes toward the look, which reads as a +// 3D head turn. Every motion is one-shot; nothing animates while the avatar is idle. +function BotAvatarShapeMotion(props: ShapeProps) { const { path, color, eyes } = resolveBotAvatar(props.avatar); - const inset = props.size * 0.18; + const side = props.size * 0.64; + const unit = side / 100; const reducedMotion = useReducedMotion(); + const enterLook = botAvatarLookFor(`${props.avatar.shape}:${props.avatar.color}`); + const posed = props.pose === "side"; const [closed, setClosed] = useState(false); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- joins the shared blink timer - useEffect(() => (reducedMotion ? undefined : registerBotBlink(setClosed)), [reducedMotion]); + const look = useSharedValue(posed ? SIDE_POSE : enterLook); + const turn = useSharedValue(posed || (props.enter && !reducedMotion) ? 1 : 0); + const enter = useSharedValue(props.enter && !reducedMotion ? 0 : 1); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- plays the one-shot entrance on mount + useEffect(() => { + if (!props.enter || reducedMotion) return; + enter.set(withSpring(1, { damping: 12, stiffness: 170, ...motion })); + turn.set(withDelay(380, withTiming(0, { duration: 420, ...motion }))); + }, [enter, turn, props.enter, reducedMotion]); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- eases between the side pose and facing front + useEffect(() => { + if (props.pose === undefined) return; + look.set(SIDE_POSE); + turn.set(withTiming(posed ? 1 : 0, { duration: 220, ...motion })); + }, [look, turn, posed, props.pose]); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- joins the shared motion timer + useEffect(() => { + if (reducedMotion) return; + return registerBotMotion((next) => { + // A posed avatar keeps its pose, so it only blinks. + if (next._tag === "blink" || props.pose !== undefined) { + setClosed(true); + setTimeout(() => setClosed(false), 160); + return; + } + look.set(next.look); + turn.set( + withSequence( + withTiming(1, { duration: 300, ...motion }), + withDelay(680, withTiming(0, { duration: 420, ...motion })), + ), + ); + }); + }, [look, turn, reducedMotion, props.pose]); + + const bodyStyle = useAnimatedStyle(() => { + const from = 1 - enter.value; + return { + opacity: Math.min(1, enter.value * 2.5), + transform: [ + { translateX: -look.value.x * 2.5 * unit * from }, + { translateY: -look.value.y * 2.5 * unit * from }, + { rotate: `${look.value.tilt * (turn.value - 3 * from)}deg` }, + { scale: 1 - 0.5 * from }, + { scaleX: 1 - 0.05 * turn.value }, + ], + }; + }); + const faceStyle = useAnimatedStyle(() => ({ + transform: [ + { translateX: look.value.x * unit * turn.value }, + { translateY: look.value.y * unit * turn.value }, + { skewX: `${-Math.sign(look.value.x) * 10 * turn.value}deg` }, + { scaleX: 1 - 0.2 * turn.value }, + ], + })); + return ( - - - - {eyes.map((eye) => ( - - ))} - - + + + + + + + + {eyes.map((eye) => ( + + ))} + + + + + ); +} + +function BotAvatarShape(props: ShapeProps) { + // A new key remounts the drawing so a changed avatar springs in again. + return ( + ); } @@ -65,6 +170,9 @@ export function BotAvatar(props: { readonly size: number; /** Needed to load a generated image; without it the shape is drawn. */ readonly source?: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId }; + readonly pose?: ShapeProps["pose"]; + /** Springs in again whenever the shape or color changes. */ + readonly animateChanges?: boolean; }) { const imagePath = props.avatar.imagePath ?? null; return ( @@ -81,7 +189,12 @@ export function BotAvatar(props: { size={props.size} /> ) : ( - + )} ); diff --git a/apps/mobile/src/features/bots/BotAvatarPicker.tsx b/apps/mobile/src/features/bots/BotAvatarPicker.tsx index e43036fba3da0..987f486949bc5 100644 --- a/apps/mobile/src/features/bots/BotAvatarPicker.tsx +++ b/apps/mobile/src/features/bots/BotAvatarPicker.tsx @@ -35,7 +35,11 @@ export function BotAvatarPicker(props: { shape === props.shape ? "border-primary" : "border-transparent", )} > - + ))} diff --git a/apps/mobile/src/features/bots/BotDetailsRouteScreen.tsx b/apps/mobile/src/features/bots/BotDetailsRouteScreen.tsx index 4a9e9a6c8f151..073af1c2c3bf2 100644 --- a/apps/mobile/src/features/bots/BotDetailsRouteScreen.tsx +++ b/apps/mobile/src/features/bots/BotDetailsRouteScreen.tsx @@ -81,7 +81,7 @@ function BotDetails(props: { readonly project: BotProject }) { contentContainerClassName="gap-6 px-5 pb-10 pt-4" > - + {project.title} diff --git a/apps/mobile/src/features/bots/NewBotRouteScreen.tsx b/apps/mobile/src/features/bots/NewBotRouteScreen.tsx index b4fcd377f7171..5f489e0594a90 100644 --- a/apps/mobile/src/features/bots/NewBotRouteScreen.tsx +++ b/apps/mobile/src/features/bots/NewBotRouteScreen.tsx @@ -142,7 +142,7 @@ function NewBotForm(props: { contentContainerClassName="gap-6 px-5 pb-10 pt-4" > - + diff --git a/apps/mobile/src/features/bots/botBlink.ts b/apps/mobile/src/features/bots/botBlink.ts deleted file mode 100644 index 749a0dd325009..0000000000000 --- a/apps/mobile/src/features/bots/botBlink.ts +++ /dev/null @@ -1,27 +0,0 @@ -const BLINK_EVERY_MS = 2600; -const BLINK_MS = 160; - -const blinkers = new Set<(closed: boolean) => void>(); -let timer: ReturnType | null = null; - -// One shared timer blinks a single avatar at a time, so a long list costs one -// small re-render every few seconds rather than a running animation per row. -function blinkOne() { - const all = [...blinkers]; - const blink = all[Math.floor(Math.random() * all.length)]; - if (!blink) return; - blink(true); - setTimeout(() => blink(false), BLINK_MS); -} - -export function registerBotBlink(setClosed: (closed: boolean) => void) { - blinkers.add(setClosed); - timer ??= setInterval(blinkOne, BLINK_EVERY_MS); - return () => { - blinkers.delete(setClosed); - if (blinkers.size === 0 && timer !== null) { - clearInterval(timer); - timer = null; - } - }; -} diff --git a/apps/mobile/src/features/bots/botMotion.ts b/apps/mobile/src/features/bots/botMotion.ts new file mode 100644 index 0000000000000..d067d86dd48f0 --- /dev/null +++ b/apps/mobile/src/features/bots/botMotion.ts @@ -0,0 +1,30 @@ +import { BOT_AVATAR_LOOKS } from "@t3tools/client-runtime/bot-avatar"; + +export type BotLook = { readonly x: number; readonly y: number; readonly tilt: number }; +type Motion = { readonly _tag: "blink" } | { readonly _tag: "look"; readonly look: BotLook }; + +const EVERY_MS = 2200; +const avatars = new Set<(motion: Motion) => void>(); +let timer: ReturnType | null = null; + +// One shared timer moves a single avatar at a time, so a long list costs one +// small animation every few seconds rather than a running animation per row. +function moveOne() { + const all = [...avatars]; + const move = all[Math.floor(Math.random() * all.length)]; + const look = BOT_AVATAR_LOOKS[Math.floor(Math.random() * BOT_AVATAR_LOOKS.length)]; + if (!move || !look) return; + move(Math.random() < 0.5 ? { _tag: "blink" } : { _tag: "look", look }); +} + +export function registerBotMotion(onMotion: (motion: Motion) => void) { + avatars.add(onMotion); + timer ??= setInterval(moveOne, EVERY_MS); + return () => { + avatars.delete(onMotion); + if (avatars.size === 0 && timer !== null) { + clearInterval(timer); + timer = null; + } + }; +} diff --git a/apps/web/src/components/bots/BotAvatar.tsx b/apps/web/src/components/bots/BotAvatar.tsx index 141e3c25e75a3..bcdd1b0377885 100644 --- a/apps/web/src/components/bots/BotAvatar.tsx +++ b/apps/web/src/components/bots/BotAvatar.tsx @@ -1,4 +1,8 @@ -import { BOT_AVATAR_EYE_COLOR, resolveBotAvatar } from "@t3tools/client-runtime/bot-avatar"; +import { + BOT_AVATAR_EYE_COLOR, + botAvatarLookFor, + resolveBotAvatar, +} from "@t3tools/client-runtime/bot-avatar"; import { assetUrlStateFromResult } from "@t3tools/client-runtime/state/assets"; import type { BotAvatar as BotAvatarValue, ScopedThreadRef } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; @@ -6,26 +10,46 @@ import { memo } from "react"; import { cn } from "../../lib/utils"; import { assetEnvironment } from "../../state/assets"; -import { registerBotEyes } from "./botBlink"; +import { botLookStyle, registerBotAvatar } from "./botMotion"; import { useEnvironmentHttpBaseUrl } from "../../state/environments"; export function BotAvatarShape({ shape, color, className, + pose, + animateChanges = false, }: { shape: string; color: string; className?: string; + /** "side" rests turned three quarters and faces front on hover. */ + pose?: "side"; + /** Springs in again whenever the shape or color changes. */ + animateChanges?: boolean; }) { const resolved = resolveBotAvatar({ shape, color }); + const changeKey = `${shape}:${color}`; return ( - - - - {resolved.eyes.map((eye) => ( - - ))} + + + + + + {resolved.eyes.map((eye) => ( + + ))} + + ); @@ -56,12 +80,21 @@ export const BotAvatar = memo(function BotAvatar({ avatar, threadRef, className, + animateChanges = false, }: { avatar: BotAvatarValue; threadRef: ScopedThreadRef; className?: string; + animateChanges?: boolean; }) { - const shape = ; + const shape = ( + + ); return ( - + ))}
      diff --git a/apps/web/src/components/bots/BotDetails.tsx b/apps/web/src/components/bots/BotDetails.tsx index 06fe476f97e28..a13ca19cd8ee1 100644 --- a/apps/web/src/components/bots/BotDetails.tsx +++ b/apps/web/src/components/bots/BotDetails.tsx @@ -110,7 +110,7 @@ function AvatarEditor({ /> } > - +
      diff --git a/apps/web/src/components/bots/CreateBotDialog.tsx b/apps/web/src/components/bots/CreateBotDialog.tsx index 894611084f174..983444ad1db6e 100644 --- a/apps/web/src/components/bots/CreateBotDialog.tsx +++ b/apps/web/src/components/bots/CreateBotDialog.tsx @@ -274,7 +274,12 @@ function MachineBotForm({
      - +
      diff --git a/apps/web/src/components/bots/botBlink.ts b/apps/web/src/components/bots/botBlink.ts deleted file mode 100644 index a1d4774b52c94..0000000000000 --- a/apps/web/src/components/bots/botBlink.ts +++ /dev/null @@ -1,31 +0,0 @@ -// One shared clock blinks a random avatar now and then. Idle avatars run no animation, -// so nothing repaints between blinks. -const eyes = new Set(); -let timer: number | null = null; -const reducedMotion = - typeof window === "undefined" ? null : window.matchMedia("(prefers-reduced-motion: reduce)"); - -function blinkOne() { - if (document.hidden || reducedMotion?.matches || eyes.size === 0) return; - const pick = [...eyes][Math.floor(Math.random() * eyes.size)]; - pick?.setAttribute("data-blink", ""); -} - -function stopBlink(event: Event) { - (event.currentTarget as SVGGElement).removeAttribute("data-blink"); -} - -/** A ref callback for an avatar's eye group. */ -export function registerBotEyes(element: SVGGElement) { - eyes.add(element); - element.addEventListener("animationend", stopBlink); - timer ??= window.setInterval(blinkOne, 2600); - return () => { - eyes.delete(element); - element.removeEventListener("animationend", stopBlink); - if (eyes.size === 0 && timer !== null) { - window.clearInterval(timer); - timer = null; - } - }; -} diff --git a/apps/web/src/components/bots/botMotion.ts b/apps/web/src/components/bots/botMotion.ts new file mode 100644 index 0000000000000..d5e90dc0d915b --- /dev/null +++ b/apps/web/src/components/bots/botMotion.ts @@ -0,0 +1,59 @@ +import { BOT_AVATAR_LOOKS } from "@t3tools/client-runtime/bot-avatar"; + +// One shared clock picks a random avatar now and then to blink or glance around. Each +// motion plays once, so idle avatars run no animation and nothing repaints between picks. +const avatars = new Set(); +let timer: number | null = null; +const reducedMotion = + typeof window === "undefined" ? null : window.matchMedia("(prefers-reduced-motion: reduce)"); + +/** Sets the CSS variables the look and entrance keyframes read. */ +export function botLookStyle(look: (typeof BOT_AVATAR_LOOKS)[number]) { + return { + "--bot-look-x": `${look.x}px`, + "--bot-look-y": `${look.y}px`, + "--bot-look-skew": `${-Math.sign(look.x) * 10}deg`, + "--bot-tilt": `${look.tilt}deg`, + } as React.CSSProperties; +} + +function moveOne() { + if (document.hidden || reducedMotion?.matches || avatars.size === 0) return; + const pick = [...avatars][Math.floor(Math.random() * avatars.size)]; + if (!pick || pick.hasAttribute("data-motion") || pick.hasAttribute("data-enter")) return; + if (Math.random() < 0.5) { + pick.setAttribute("data-motion", "blink"); + return; + } + const look = BOT_AVATAR_LOOKS[Math.floor(Math.random() * BOT_AVATAR_LOOKS.length)]; + if (!look) return; + for (const [name, value] of Object.entries(botLookStyle(look))) { + pick.style.setProperty(name, String(value)); + } + pick.setAttribute("data-motion", "look"); +} + +// Child animations bubble here; only the one that defines each motion's end clears it, +// and clearing the entrance keeps a later look from restarting it. +function endMotion(event: AnimationEvent) { + const element = event.currentTarget as SVGSVGElement; + if (event.animationName === "bot-enter-face") element.removeAttribute("data-enter"); + if (event.animationName === `bot-${element.getAttribute("data-motion")}`) { + element.removeAttribute("data-motion"); + } +} + +/** A ref callback for an avatar's svg. */ +export function registerBotAvatar(element: SVGSVGElement) { + avatars.add(element); + element.addEventListener("animationend", endMotion); + timer ??= window.setInterval(moveOne, 2200); + return () => { + avatars.delete(element); + element.removeEventListener("animationend", endMotion); + if (avatars.size === 0 && timer !== null) { + window.clearInterval(timer); + timer = null; + } + }; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 55440c0432dec..b060bebdb2382 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -371,18 +371,111 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } -/* Eyes blink once on hover or when the shared blink clock picks them; never on a loop. */ -@utility bot-eyes { - transform-box: fill-box; - transform-origin: center; +/* Face slides and squashes toward --bot-look-*, which reads as the head turning. */ +@keyframes bot-look { + 0%, + 100% { + transform: none; + } + 22%, + 70% { + transform: translate(var(--bot-look-x), var(--bot-look-y)) skewX(var(--bot-look-skew)) + scaleX(0.8); + } +} + +@keyframes bot-turn { + 0%, + 100% { + transform: none; + } + 22%, + 70% { + transform: rotate(var(--bot-tilt)) scaleX(0.95); + } +} + +/* Springs in from the side opposite its look, then the face turns to the front. */ +@keyframes bot-enter { + from { + opacity: 0; + transform: translate(calc(var(--bot-look-x) * -2.5), calc(var(--bot-look-y) * -2.5)) + rotate(calc(var(--bot-tilt) * -3)) scale(0.5); + } + 40% { + opacity: 1; + } +} + +@keyframes bot-enter-face { + 0%, + 45% { + transform: translate(var(--bot-look-x), var(--bot-look-y)) skewX(var(--bot-look-skew)) + scaleX(0.8); + } +} - &[data-blink], - :where(.group\/bot):hover & { - animation: bot-blink 200ms ease-in-out 1; +/* Avatars move once when they change, on hover, or when the shared clock picks one; never on a loop. */ +@utility bot-avatar { + & :is(.bot-body, .bot-face, .bot-eyes) { + transform-box: fill-box; + transform-origin: center; + } + + & :is(.bot-body, .bot-face) { + transition: + translate 240ms ease-out, + rotate 240ms ease-out, + scale 240ms ease-out; + } + + &[data-pose="side"] .bot-body { + rotate: -8deg; + } + + &[data-pose="side"] .bot-face { + translate: 8px -5px; + scale: 0.8 1; + } + + :where(.group\/bot):is(:hover, [aria-pressed="true"]) + &[data-pose="side"] + :is(.bot-body, .bot-face) { + translate: 0 0; + rotate: 0deg; + scale: 1; + } + + &[data-enter] .bot-body { + animation: bot-enter 560ms cubic-bezier(0.34, 1.56, 0.64, 1) both; + } + + &[data-enter] .bot-face { + animation: bot-enter-face 900ms ease-in-out both; + } + + &[data-motion="look"] .bot-body { + animation: bot-turn 1400ms ease-in-out; + } + + &[data-motion="look"] .bot-face { + animation: bot-look 1400ms ease-in-out; + } + + &[data-motion="look"] .bot-eyes { + animation: bot-blink 200ms ease-in-out 650ms; + } + + &[data-motion="blink"] .bot-eyes, + :where(.group\/bot):hover & .bot-eyes { + animation: bot-blink 200ms ease-in-out; } @media (prefers-reduced-motion: reduce) { - animation: none !important; + & * { + animation: none !important; + transition: none !important; + } } } diff --git a/packages/client-runtime/src/botAvatar.ts b/packages/client-runtime/src/botAvatar.ts index 90866b69105d5..79c951b584952 100644 --- a/packages/client-runtime/src/botAvatar.ts +++ b/packages/client-runtime/src/botAvatar.ts @@ -5,22 +5,25 @@ const BOT_AVATAR_SHAPE_PATHS: Record = { circle: "M8.0 50.0a42 42 0 1 0 84 0a42 42 0 1 0 -84 0Z", cloud: "M12.0 58.0a22 22 0 1 0 44 0a22 22 0 1 0 -44 0ZM30.0 44.0a26 26 0 1 0 52 0a26 26 0 1 0 -52 0ZM54.0 62.0a18 18 0 1 0 36 0a18 18 0 1 0 -36 0ZM34 62V80H72V62Z", - diamond: "M50 6L92 50L50 94L8 50Z", - star: "M50.0 8.0 L61.8 37.8 L93.7 39.8 L69.0 60.2 L77.0 91.2 L50.0 74.0 L23.0 91.2 L31.0 60.2 L6.3 39.8 L38.2 37.8Z", + diamond: + "M62.9 16.9L83.1 37.1Q96 50 83.1 62.9L62.9 83.1Q50 96 37.1 83.1L16.9 62.9Q4 50 16.9 37.1L37.1 16.9Q50 4 62.9 16.9Z", + star: "M55.9 14L58.2 18.6Q64.1 30.6 77.4 32.5L82.4 33.2Q95.7 35.2 86.1 44.5L82.4 48.1Q72.8 57.4 75.1 70.6L76 75.6Q78.2 88.8 66.4 82.6L61.8 80.2Q50 74 38.2 80.2L33.6 82.6Q21.8 88.8 24 75.6L24.9 70.6Q27.2 57.4 17.6 48.1L13.9 44.5Q4.3 35.2 17.6 33.2L22.6 32.5Q35.9 30.6 41.8 18.6L44.1 14Q50 2 55.9 14Z", flower: "M52.0 50.0a20 20 0 1 0 40 0a20 20 0 1 0 -40 0ZM41.0 69.1a20 20 0 1 0 40 0a20 20 0 1 0 -40 0ZM19.0 69.1a20 20 0 1 0 40 0a20 20 0 1 0 -40 0ZM8.0 50.0a20 20 0 1 0 40 0a20 20 0 1 0 -40 0ZM19.0 30.9a20 20 0 1 0 40 0a20 20 0 1 0 -40 0ZM41.0 30.9a20 20 0 1 0 40 0a20 20 0 1 0 -40 0ZM28.0 50.0a22 22 0 1 0 44 0a22 22 0 1 0 -44 0Z", heart: - "M50 88C22 68 6 52 6 33C6 20 16 10 29 10C38 10 46 15 50 23C54 15 62 10 71 10C84 10 94 20 94 33C94 52 78 68 50 88Z", - gear: "M45.7 6.2 L54.3 6.2 L59.6 18.4 L65.6 20.9 L77.9 16.0 L84.0 22.1 L79.1 34.4 L81.6 40.4 L93.8 45.7 L93.8 54.3 L81.6 59.6 L79.1 65.6 L84.0 77.9 L77.9 84.0 L65.6 79.1 L59.6 81.6 L54.3 93.8 L45.7 93.8 L40.4 81.6 L34.4 79.1 L22.1 84.0 L16.0 77.9 L20.9 65.6 L18.4 59.6 L6.2 54.3 L6.2 45.7 L18.4 40.4 L20.9 34.4 L16.0 22.1 L22.1 16.0 L34.4 20.9 L40.4 18.4Z", - drop: "M50 6C50 6 16 44 16 64C16 82 31 94 50 94C69 94 84 82 84 64C84 44 50 6 50 6Z", + "M44 84C20 66 6 51 6 33C6 20 16 10 29 10C38 10 46 15 50 23C54 15 62 10 71 10C84 10 94 20 94 33C94 51 80 66 56 84C52 87 48 87 44 84Z", + gear: "M56.5 11.3L56.5 11.3Q63 18.6 72.8 18L72.8 18Q82.5 17.5 82 27.2L82 27.2Q81.4 37 88.7 43.5L88.7 43.5Q96 50 88.7 56.5L88.7 56.5Q81.4 63 82 72.8L82 72.8Q82.5 82.5 72.8 82L72.8 82Q63 81.4 56.5 88.7L56.5 88.7Q50 96 43.5 88.7L43.5 88.7Q37 81.4 27.2 82L27.2 82Q17.5 82.5 18 72.8L18 72.8Q18.6 63 11.3 56.5L11.3 56.5Q4 50 11.3 43.5L11.3 43.5Q18.6 37 18 27.2L18 27.2Q17.5 17.5 27.2 18L27.2 18Q37 18.6 43.5 11.3L43.5 11.3Q50 4 56.5 11.3Z", + drop: "M45 9C35 21 16 45 16 64C16 82 31 94 50 94C69 94 84 82 84 64C84 45 65 21 55 9C52 5.5 48 5.5 45 9Z", pill: "M30 26H70A24 24 0 0 1 70 74H30A24 24 0 0 1 30 26Z", - triangle: "M50 10L93 86H7Z", - shield: "M50 6L88 20V46C88 70 72 86 50 94C28 86 12 70 12 46V20Z", + triangle: "M62.2 27.3L84.8 66.7Q97 88 72.6 88L27.4 88Q3 88 15.2 66.7L37.8 27.3Q50 6 62.2 27.3Z", + shield: + "M62 8.2L78 13.8Q90 18 90 27.6L90 40.4Q90 50 78 63.8L62 82.2Q50 96 38 82.2L22 63.8Q10 50 10 40.4L10 27.6Q10 18 22 13.8L38 8.2Q50 4 62 8.2Z", burst: - "M50.0 4.0 L58.8 17.2 L73.0 10.2 L74.0 26.0 L89.8 27.0 L82.8 41.2 L96.0 50.0 L82.8 58.8 L89.8 73.0 L74.0 74.0 L73.0 89.8 L58.8 82.8 L50.0 96.0 L41.2 82.8 L27.0 89.8 L26.0 74.0 L10.2 73.0 L17.2 58.8 L4.0 50.0 L17.2 41.2 L10.2 27.0 L26.0 26.0 L27.0 10.2 L41.2 17.2Z", - hexagon: "M50.0 6.0 L88.1 28.0 L88.1 72.0 L50.0 94.0 L11.9 72.0 L11.9 28.0Z", + "M55.9 11.4L57.2 13.5Q63 23 74 21.7L76.5 21.4Q87.5 20.1 83.8 30.5L83 32.9Q79.2 43.3 87.1 51.1L88.9 52.9Q96.8 60.7 86.3 64.3L84 65.1Q73.5 68.7 72.3 79.7L72 82.2Q70.8 93.2 61.5 87.3L59.4 86Q50 80 40.6 86L38.5 87.3Q29.2 93.2 28 82.2L27.7 79.7Q26.5 68.7 16 65.1L13.7 64.3Q3.2 60.7 11.1 52.9L12.9 51.1Q20.8 43.3 17 32.9L16.2 30.5Q12.5 20.1 23.5 21.4L26 21.7Q37 23 42.8 13.5L44.1 11.4Q50 2 55.9 11.4Z", + hexagon: + "M60 9.8L79.9 21.3Q89.8 27 89.8 38.5L89.8 61.5Q89.8 73 79.9 78.8L60 90.3Q50 96 40 90.3L20.1 78.8Q10.2 73 10.2 61.5L10.2 38.5Q10.2 27 20.1 21.2L40 9.8Q50 4 60 9.8Z", pumpkin: - "M10.0 60.0a23 30 0 1 0 46 0a23 30 0 1 0 -46 0ZM44.0 60.0a23 30 0 1 0 46 0a23 30 0 1 0 -46 0ZM28.0 60.0a22 32 0 1 0 44 0a22 32 0 1 0 -44 0ZM46 12V30H54V12Z", + "M10.0 60.0a23 30 0 1 0 46 0a23 30 0 1 0 -46 0ZM44.0 60.0a23 30 0 1 0 46 0a23 30 0 1 0 -46 0ZM28.0 60.0a22 32 0 1 0 44 0a22 32 0 1 0 -44 0ZM46 16a4 4 0 0 1 8 0V30H46Z", }; /** Where each shape's face sits: eye center line and half the distance between the eyes. */ @@ -59,6 +62,26 @@ export const BOT_AVATAR_COLOR_HEX: Record = { gray: "#9CA3AF", }; +/** + * Ways an avatar can turn its head, in view box units and degrees. The face slides toward + * (x, y) and squashes while the body tilts, which reads as a 3D turn on a flat drawing. + */ +export const BOT_AVATAR_LOOKS = [ + { x: 9, y: -6, tilt: 9 }, + { x: -9, y: -6, tilt: -9 }, + { x: 10, y: 4, tilt: 6 }, + { x: -10, y: 4, tilt: -6 }, + { x: 2, y: -9, tilt: 3 }, + { x: -3, y: 8, tilt: -4 }, +] as const; + +/** A stable look for a given avatar, so its entrance direction is the same on every render. */ +export function botAvatarLookFor(key: string) { + let hash = 0; + for (const char of key) hash = (hash * 31 + char.charCodeAt(0)) | 0; + return BOT_AVATAR_LOOKS[Math.abs(hash) % BOT_AVATAR_LOOKS.length] ?? BOT_AVATAR_LOOKS[0]; +} + /** Unknown values from newer servers draw the default rather than nothing. */ export function resolveBotAvatar(avatar: { readonly shape: string; readonly color: string }) { const path = (BOT_AVATAR_SHAPE_PATHS as Record)[avatar.shape]; From 012535c1930daa6561992bde09cf5785ba4ae1e3 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 21:57:49 +0530 Subject: [PATCH 16/40] style(mobile): format workspace screens --- .../workspace/WorkspaceRootScreen.android.tsx | 6 +--- .../workspace/WorkspaceRootScreen.tsx | 28 +++++++++---------- .../workspace/workspace-tabs-state.test.ts | 1 - 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx b/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx index 6cb62cb0303e0..1a716e531d6e2 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx @@ -50,11 +50,7 @@ export function WorkspaceRootScreen() { ) : null} {enabled.length === 0 ? ( diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx b/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx index 5a6dd22a2c506..89cc886ed7557 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx @@ -110,20 +110,20 @@ function WorkspaceRootContent() { const tab = state.tabs[settingsOpen ? "settings" : state.selectedTab]; const bottomClearance = Platform.OS === "ios" && isDuoDevice(deviceType) - ? // The column's safe area already holds the Duo's search toolbar and keyboard. - Math.max(insets.bottom, keyboardHeight) + 16 - : compactTabs || deviceType === "pad" - ? workspaceDockBottomClearance({ bottomInset: insets.bottom, keyboardHeight }) - : Platform.OS === "ios" - ? Math.max(insets.bottom, keyboardHeight) + - (systemTabBar || sidebarTabs || tab.searchMode !== "resting" - ? sidebarPicker - ? 80 - : 16 - : WORKSPACE_DOCK.height + WORKSPACE_DOCK.bottomGap + 16) - : keyboardHeight > 0 && tab.searchMode !== "resting" - ? keyboardHeight + 64 - : Math.max(insets.bottom, 12) + workspaceToolbarHeight(tab.searchMode) + 16; + ? // The column's safe area already holds the Duo's search toolbar and keyboard. + Math.max(insets.bottom, keyboardHeight) + 16 + : compactTabs || deviceType === "pad" + ? workspaceDockBottomClearance({ bottomInset: insets.bottom, keyboardHeight }) + : Platform.OS === "ios" + ? Math.max(insets.bottom, keyboardHeight) + + (systemTabBar || sidebarTabs || tab.searchMode !== "resting" + ? sidebarPicker + ? 80 + : 16 + : WORKSPACE_DOCK.height + WORKSPACE_DOCK.bottomGap + 16) + : keyboardHeight > 0 && tab.searchMode !== "resting" + ? keyboardHeight + 64 + : Math.max(insets.bottom, 12) + workspaceToolbarHeight(tab.searchMode) + 16; const context = { ...workspace, bottomClearance }; return ( diff --git a/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts b/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts index 3c040c2b06487..c609edd2d59d6 100644 --- a/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts +++ b/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts @@ -153,4 +153,3 @@ describe("workspace root navigation", () => { expect(workspaceTabForNativeSlot(order, "removed")).toBeNull(); }); }); - From 1bc664ded17a55b31b4c7e62eb61f286f86bc404 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 21:59:43 +0530 Subject: [PATCH 17/40] fix(web): first bot form stays put until creation finishes The Bots page redirected to the newest bot as soon as it appeared, which can happen before its launch finishes. A failed launch then left the user on a missing thread without the form. The page now waits while creating. --- apps/web/src/components/bots/BotsHome.tsx | 8 ++++++-- apps/web/src/components/bots/CreateBotDialog.tsx | 11 ++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/bots/BotsHome.tsx b/apps/web/src/components/bots/BotsHome.tsx index 9410d138b0fb0..575e683f8094c 100644 --- a/apps/web/src/components/bots/BotsHome.tsx +++ b/apps/web/src/components/bots/BotsHome.tsx @@ -1,5 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { Navigate } from "@tanstack/react-router"; +import { useState } from "react"; import { botListAtom } from "../../state/bots"; import { useAllEnvironmentShellsBootstrapped } from "../../state/entities"; @@ -13,8 +14,11 @@ import { CreateBotPanel } from "./CreateBotDialog"; export function BotsHome() { const bots = useAtomValue(botListAtom); const bootstrapped = useAllEnvironmentShellsBootstrapped(); + // The new bot can show up before its launch finishes, and launch can still fail. Keep + // the form while creating; a successful submit navigates on its own. + const [creating, setCreating] = useState(false); const newest = bots[0]; - if (newest) { + if (newest && !creating) { return (
      - +
      ); diff --git a/apps/web/src/components/bots/CreateBotDialog.tsx b/apps/web/src/components/bots/CreateBotDialog.tsx index 983444ad1db6e..388606f230d2e 100644 --- a/apps/web/src/components/bots/CreateBotDialog.tsx +++ b/apps/web/src/components/bots/CreateBotDialog.tsx @@ -96,14 +96,19 @@ export function CreateBotDialog({ } /** The create form on its own, for the Bots page when there are no bots yet. */ -export function CreateBotPanel() { - const [pending, setPending] = useState(false); +export function CreateBotPanel({ + pending, + onPendingChange, +}: { + pending: boolean; + onPendingChange: (pending: boolean) => void; +}) { return (
      {}} /> From 00d72903bb3b240292523a5c406c810563225e74 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 22:01:42 +0530 Subject: [PATCH 18/40] fix(mobile): opening a bot keeps the Threads tab's chat With columns on iOS, opening a bot's chat replaced the chat or draft the Threads tab remembered, and the check that rejects bot chats then left Threads on an empty detail column. Bot chats are no longer recorded as the Threads selection. --- .../features/workspace/workspace-selection-store.ts | 6 +++++- .../workspace/workspace-tab-navigation.test.ts | 11 +++++++++++ .../features/workspace/workspace-tab-navigation.ts | 4 ++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/workspace/workspace-selection-store.ts b/apps/mobile/src/features/workspace/workspace-selection-store.ts index 9d12bb8cd1e5f..5e03eac9fbe30 100644 --- a/apps/mobile/src/features/workspace/workspace-selection-store.ts +++ b/apps/mobile/src/features/workspace/workspace-selection-store.ts @@ -89,7 +89,11 @@ export function currentWorkspaceSelections() { */ export function recordWorkspaceSelections(state: NavigationState | undefined) { if (!REMEMBERS || !state) return; - selections = workspaceSelectionsIn(state.routes.slice(0, state.index + 1), selections); + selections = workspaceSelectionsIn( + state.routes.slice(0, state.index + 1), + selections, + isBotThread, + ); } function environmentRemoved(environmentId: string) { diff --git a/apps/mobile/src/features/workspace/workspace-tab-navigation.test.ts b/apps/mobile/src/features/workspace/workspace-tab-navigation.test.ts index 1d30f2c02e6d7..54d635389fbf9 100644 --- a/apps/mobile/src/features/workspace/workspace-tab-navigation.test.ts +++ b/apps/mobile/src/features/workspace/workspace-tab-navigation.test.ts @@ -39,6 +39,17 @@ describe("workspace tab selections", () => { expect(workspaceSelectionsIn([home, thread], afterSettings)).toBe(afterSettings); }); + it("keeps the Threads chat when a bot's chat opens, since bots have their own tab", () => { + const isBot = (_environmentId: string, threadId: string) => threadId === "bot-thread"; + const bot = { + key: "bot", + name: "Thread", + params: { environmentId: "env", threadId: "bot-thread" }, + }; + const afterThread = workspaceSelectionsIn([home, thread], {}); + expect(workspaceSelectionsIn([home, bot], afterThread, isBot)).toBe(afterThread); + }); + it("doesn't remember pages a Settings row doesn't open", () => { for (const name of ["SettingsEnvironmentNew", "SettingsProjectOverview", "SettingsAuth"]) { expect(workspaceSelectionsIn([home, settings, { key: name, name }], {}).settings).toBe( diff --git a/apps/mobile/src/features/workspace/workspace-tab-navigation.ts b/apps/mobile/src/features/workspace/workspace-tab-navigation.ts index f1b243bff5e5c..2df6bce334686 100644 --- a/apps/mobile/src/features/workspace/workspace-tab-navigation.ts +++ b/apps/mobile/src/features/workspace/workspace-tab-navigation.ts @@ -89,14 +89,18 @@ function samePullRequest( export function workspaceSelectionsIn( routes: readonly RouteLike[], previous: WorkspaceSelections, + /** A bot's chat belongs to the Bots tab, so it never replaces the Threads chat or draft. */ + isBotThread: (environmentId: string, threadId: string) => boolean = () => false, ): WorkspaceSelections { let next = previous; const thread = routes.findLast((route) => route.name === "Thread"); const environmentId = stringParam(thread?.params, "environmentId"); const threadId = stringParam(thread?.params, "threadId"); + const botChat = environmentId && threadId ? isBotThread(environmentId, threadId) : false; if ( environmentId && threadId && + !botChat && (next.threads?.environmentId !== environmentId || next.threads.threadId !== threadId || next.threads.key !== thread?.key || From fb505a07241f8239337b52cc5eecd70316170108 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 22:04:01 +0530 Subject: [PATCH 19/40] fix(web): a hovered bot avatar keeps blinking and glancing A timer blink set while hover already ran the same animation never restarted, so animationend never cleared it and that avatar stopped moving for good. The motion flag now clears on a timer matching the CSS durations. --- apps/web/src/components/bots/botMotion.ts | 26 +++++++++++------------ 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/bots/botMotion.ts b/apps/web/src/components/bots/botMotion.ts index d5e90dc0d915b..0cafcb8896dd7 100644 --- a/apps/web/src/components/bots/botMotion.ts +++ b/apps/web/src/components/bots/botMotion.ts @@ -21,25 +21,23 @@ function moveOne() { if (document.hidden || reducedMotion?.matches || avatars.size === 0) return; const pick = [...avatars][Math.floor(Math.random() * avatars.size)]; if (!pick || pick.hasAttribute("data-motion") || pick.hasAttribute("data-enter")) return; - if (Math.random() < 0.5) { - pick.setAttribute("data-motion", "blink"); - return; - } const look = BOT_AVATAR_LOOKS[Math.floor(Math.random() * BOT_AVATAR_LOOKS.length)]; - if (!look) return; - for (const [name, value] of Object.entries(botLookStyle(look))) { - pick.style.setProperty(name, String(value)); + const motion = Math.random() < 0.5 || !look ? "blink" : "look"; + if (motion === "look" && look) { + for (const [name, value] of Object.entries(botLookStyle(look))) { + pick.style.setProperty(name, String(value)); + } } - pick.setAttribute("data-motion", "look"); + pick.setAttribute("data-motion", motion); + // Cleared on a timer, not animationend: a blink set while hover already runs the same + // animation never restarts, so it would never end. Durations match index.css. + window.setTimeout(() => pick.removeAttribute("data-motion"), motion === "blink" ? 220 : 1420); } -// Child animations bubble here; only the one that defines each motion's end clears it, -// and clearing the entrance keeps a later look from restarting it. +// Clearing the entrance keeps a later look from restarting it. function endMotion(event: AnimationEvent) { - const element = event.currentTarget as SVGSVGElement; - if (event.animationName === "bot-enter-face") element.removeAttribute("data-enter"); - if (event.animationName === `bot-${element.getAttribute("data-motion")}`) { - element.removeAttribute("data-motion"); + if (event.animationName === "bot-enter-face") { + (event.currentTarget as SVGSVGElement).removeAttribute("data-enter"); } } From 3f1ee17f1c824de0c6938e74386fcd0357d14c43 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 22:21:22 +0530 Subject: [PATCH 20/40] feat(bots): list shows each bot's last message and unread replies Thread shells carry no message text, so the bots list always showed an empty preview and never marked a reply unread. A bots.previews query returns each bot's newest visible message and refetches only when a bot gets a new message or finishes a run. Unread now follows the thread rule: a run finished after the user last opened the chat. --- apps/mobile/src/state/bots.ts | 7 +- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/bots/BotService.test.ts | 43 +++++- apps/server/src/bots/BotService.ts | 50 ++++++- .../src/observability/RpcInstrumentation.ts | 1 + apps/server/src/ws.ts | 1 + apps/web/src/state/bots.ts | 7 +- .../client-runtime/src/state/bots.test.ts | 99 ++++++++----- packages/client-runtime/src/state/bots.ts | 137 ++++++++++++++---- packages/contracts/src/bot.ts | 15 ++ packages/contracts/src/rpc.ts | 10 ++ 11 files changed, 300 insertions(+), 71 deletions(-) diff --git a/apps/mobile/src/state/bots.ts b/apps/mobile/src/state/bots.ts index f365026055294..2903765422ae0 100644 --- a/apps/mobile/src/state/bots.ts +++ b/apps/mobile/src/state/bots.ts @@ -1,15 +1,20 @@ import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import { createBotListAtom } from "@t3tools/client-runtime/state/bots"; +import { createBotListAtom, createBotPreviewsAtomFamily } from "@t3tools/client-runtime/state/bots"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { appAtomRegistry } from "./atom-registry"; +import { connectionAtomRuntime } from "../connection/runtime"; import { environmentProjects } from "./projects"; import { environmentThreadShells } from "./threads"; const botListAtom = createBotListAtom({ botsAtom: environmentProjects.botsAtom, quietThreadShellAtom: environmentThreadShells.quietThreadShellAtom, + previewsAtom: createBotPreviewsAtomFamily(connectionAtomRuntime, { + botsAtom: environmentProjects.botsAtom, + quietThreadShellAtom: environmentThreadShells.quietThreadShellAtom, + }), }); export function useBotList() { diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index f040dbc459406..5c68897369de8 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -112,6 +112,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverSignalProcess]: AuthEnvironmentMaintainScope, [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, [WS_METHODS.scheduledTasksSubscribe]: AuthOrchestrationReadScope, + [WS_METHODS.botsPreviews]: AuthOrchestrationReadScope, [WS_METHODS.secretsAnswerRequest]: AuthOrchestrationOperateScope, // Delivery logs hold request bodies, so they need the same scope as the URL. [WS_METHODS.scheduledTasksListWebhookDeliveries]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/bots/BotService.test.ts b/apps/server/src/bots/BotService.test.ts index 5a538b8864237..f02a794efc771 100644 --- a/apps/server/src/bots/BotService.test.ts +++ b/apps/server/src/bots/BotService.test.ts @@ -15,6 +15,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import type * as Scope from "effect/Scope"; +import * as SqlClient from "effect/sql/SqlClient"; import * as ServerConfig from "../config.ts"; import * as GitManager from "../git/GitManager.ts"; @@ -147,7 +148,11 @@ const withBots = ( ) => Effect.Effect< A, E, - BotService.BotService | ProjectService.ProjectService | NodeServices.NodeServices | Scope.Scope + | BotService.BotService + | ProjectService.ProjectService + | SqlClient.SqlClient + | NodeServices.NodeServices + | Scope.Scope >, ) => Effect.gen(function* () { @@ -321,3 +326,39 @@ it.effect("lets only the bot's own session edit its profile", () => }), ), ); + +it.effect("previews show each bot's newest visible message", () => + withBots(() => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const chatty = yield* createBot("Chatty", "bot-chatty"); + yield* createBot("Quiet", "bot-quiet"); + const message = ( + id: string, + role: string, + at: string, + payload: Record, + streaming = 0, + ) => sql` + INSERT INTO orchestration_v2_projection_messages + (message_id, thread_id, role, streaming, created_at, updated_at, payload_json) + VALUES (${id}, ${chatty.threadId}, ${role}, ${streaming}, ${at}, ${at}, ${JSON.stringify(payload)}) + `; + yield* message("m1", "assistant", "2026-01-01T00:00:01Z", { text: "Earlier reply" }); + yield* message("m2", "user", "2026-01-01T00:00:02Z", { text: "Find me flights" }); + // Newer, but a notice and a reply still streaming are not what the list shows. + yield* message("m3", "user", "2026-01-01T00:00:03Z", { text: "notice", createdBy: "system" }); + yield* message("m4", "assistant", "2026-01-01T00:00:04Z", { text: "Look" }, 1); + + const bots = yield* BotService.BotService; + assert.deepEqual((yield* bots.previews()).previews, [ + { + threadId: chatty.threadId, + role: "user", + text: "Find me flights", + updatedAt: "2026-01-01T00:00:02Z", + }, + ]); + }), + ), +); diff --git a/apps/server/src/bots/BotService.ts b/apps/server/src/bots/BotService.ts index 3dc40ab1b5bea..fa84e27f68636 100644 --- a/apps/server/src/bots/BotService.ts +++ b/apps/server/src/bots/BotService.ts @@ -6,6 +6,8 @@ import { type BotGenerateAvatarInput, type BotGenerateAvatarResult, type BotMutationResult, + BotPreview, + type BotPreviewsResult, type BotUpdateInput, CommandId, type ModelSelection, @@ -26,10 +28,13 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import * as SqlClient from "effect/sql/SqlClient"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as ServerConfig from "../config.ts"; +import { indexedMessageCondition } from "../orchestration-v2/messageSearchIndex.ts"; import * as ThreadLaunch from "../orchestration-v2/ThreadLaunchService.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; import * as ProjectService from "../project/ProjectService.ts"; @@ -56,6 +61,7 @@ export class BotService extends Context.Service< readonly updateProfile: ( input: BotProfileUpdateInput, ) => Effect.Effect; + readonly previews: () => Effect.Effect; } >()("t3/bots/BotService") {} @@ -110,6 +116,7 @@ const make = Effect.gen(function* () { const providers = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; const settings = yield* ServerSettings.ServerSettingsService; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const sql = yield* SqlClient.SqlClient; // Bot edits read the stored record and write it back whole; serialize them. const mutations = yield* Semaphore.make(1); @@ -507,7 +514,48 @@ const make = Effect.gen(function* () { return { imagePath }; }); - return BotService.of({ create, update, delete: remove, generateAvatar, updateProfile }); + const decodePreviews = Schema.decodeUnknownEffect(Schema.Array(BotPreview)); + + // One indexed lookup per bot walks its thread backwards to the newest visible message, + // so the cost stays flat as a bot's chat grows. Text is cut short; the list shows a line. + const previews: BotService["Service"]["previews"] = Effect.fn("BotService.previews")( + function* () { + const rows = yield* sql` + SELECT + messages.thread_id AS "threadId", + messages.role AS "role", + substr(json_extract(messages.payload_json, '$.text'), 1, 400) AS "text", + messages.updated_at AS "updatedAt" + FROM orchestration_v2_projection_messages AS messages + WHERE messages.message_id IN ( + SELECT ( + SELECT latest.message_id + FROM orchestration_v2_projection_messages AS latest + WHERE latest.thread_id = bots.thread_id + AND ${indexedMessageCondition(sql, "latest")} + ORDER BY latest.created_at DESC, latest.message_id DESC + LIMIT 1 + ) + FROM ( + SELECT json_extract(bot_json, '$.threadId') AS thread_id + FROM projection_projects + WHERE bot_json IS NOT NULL AND deleted_at IS NULL + ) AS bots + ) + `; + return { previews: yield* decodePreviews(rows) }; + }, + Effect.mapError(fail("Could not read bot messages.")), + ); + + return BotService.of({ + create, + update, + delete: remove, + generateAvatar, + updateProfile, + previews, + }); }); export const layer = Layer.effect(BotService, make); diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 55d81924cb57c..804058352a10b 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -91,6 +91,7 @@ const RPC_AGGREGATES = { [WS_METHODS.botsCreate]: "bots", [WS_METHODS.botsUpdate]: "bots", [WS_METHODS.botsDelete]: "bots", + [WS_METHODS.botsPreviews]: "bots", [WS_METHODS.botsGenerateAvatar]: "bots", [WS_METHODS.scheduledTasksSubscribe]: "scheduledTasks", [WS_METHODS.scheduledTasksUpsert]: "scheduledTasks", diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fcd424c31c2c6..62cc1115f41af 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2241,6 +2241,7 @@ export const layerWsRpc = ( Effect.annotateCurrentSpan({ "project.id": input.projectId }).pipe( Effect.andThen(bots.generateAvatar(input)), ), + [WS_METHODS.botsPreviews]: () => bots.previews(), [WS_METHODS.guestShareCreate]: (input) => Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId }).pipe( Effect.andThen(guestShares.create(input)), diff --git a/apps/web/src/state/bots.ts b/apps/web/src/state/bots.ts index bae9088d80c79..ea0422c5f9c9f 100644 --- a/apps/web/src/state/bots.ts +++ b/apps/web/src/state/bots.ts @@ -1,12 +1,17 @@ -import { createBotListAtom } from "@t3tools/client-runtime/state/bots"; +import { createBotListAtom, createBotPreviewsAtomFamily } from "@t3tools/client-runtime/state/bots"; import { Atom } from "effect/reactivity"; +import { connectionAtomRuntime } from "../connection/runtime"; import { environmentProjects } from "./projects"; import { environmentThreadShells } from "./threads"; export const botListAtom = createBotListAtom({ botsAtom: environmentProjects.botsAtom, quietThreadShellAtom: environmentThreadShells.quietThreadShellAtom, + previewsAtom: createBotPreviewsAtomFamily(connectionAtomRuntime, { + botsAtom: environmentProjects.botsAtom, + quietThreadShellAtom: environmentThreadShells.quietThreadShellAtom, + }), }); /** The newest bot with a reply the user has not opened, shown on the sidebar's Bots button. */ diff --git a/packages/client-runtime/src/state/bots.test.ts b/packages/client-runtime/src/state/bots.test.ts index bddc518489fe3..fc2299427c98b 100644 --- a/packages/client-runtime/src/state/bots.test.ts +++ b/packages/client-runtime/src/state/bots.test.ts @@ -1,13 +1,14 @@ import { + type BotPreviewsResult, EnvironmentId, - MessageId, ProjectId, + RunId, ThreadId, type OrchestrationV2ShellSnapshot, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; -import { Atom, AtomRegistry } from "effect/reactivity"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { describe, expect, it } from "vite-plus/test"; import { PrimaryConnectionTarget } from "../connection/model.ts"; @@ -33,14 +34,17 @@ const botProject = { }, }; -const reply = (at: string) => ({ - id: MessageId.make(`reply-${at}`), - role: "assistant" as const, - text: "Found three\nflights.", - updatedAt: DateTime.makeUnsafe(at), +// A finished run, as the shell reports it. Message text comes from the previews query. +const ranUntil = (at: string) => ({ + latestRunId: RunId.make(`run-${at}`), + status: "completed" as const, + latestRunCompletedAt: DateTime.makeUnsafe(at), }); -function makeHarness(snapshot: OrchestrationV2ShellSnapshot) { +function makeHarness( + snapshot: OrchestrationV2ShellSnapshot, + previews: BotPreviewsResult = { previews: [] }, +) { const snapshotAtom = Atom.family((_environmentId: EnvironmentId) => Atom.make(snapshot), ); @@ -71,6 +75,7 @@ function makeHarness(snapshot: OrchestrationV2ShellSnapshot) { const bots = createBotListAtom({ botsAtom: projects.botsAtom, quietThreadShellAtom: threads.quietThreadShellAtom, + previewsAtom: () => Atom.make(AsyncResult.success(previews)), }); return { registry: AtomRegistry.make(), snapshotAtom, projects, threads, bots }; } @@ -113,39 +118,57 @@ describe("bots", () => { registry.dispose(); }); - it("marks a reply unread until the thread is visited after it, newest activity first", () => { - const { registry, snapshotAtom, bots } = makeHarness({ - ...v2ShellSnapshot, - projects: [ - botProject, - { - ...botProject, - id: ProjectId.make("quiet"), - title: "Quiet", - bot: { ...botProject.bot, threadId: ThreadId.make("quiet-thread") }, - }, - ], - threads: [ - { - ...v2ThreadShell, - id: botThreadId, - projectId: botProjectId, - latestVisibleMessage: reply("2026-06-20T10:00:00.000Z"), - lastVisitedAt: DateTime.makeUnsafe("2026-06-20T09:00:00.000Z"), - }, - { - ...v2ThreadShell, - id: ThreadId.make("quiet-thread"), - projectId: ProjectId.make("quiet"), - latestVisibleMessage: reply("2026-06-20T08:00:00.000Z"), - lastVisitedAt: DateTime.makeUnsafe("2026-06-20T08:30:00.000Z"), - }, - ], - }); + it("marks a finished run unread until the thread is visited after it, newest activity first", () => { + const { registry, snapshotAtom, bots } = makeHarness( + { + ...v2ShellSnapshot, + projects: [ + botProject, + { + ...botProject, + id: ProjectId.make("quiet"), + title: "Quiet", + bot: { ...botProject.bot, threadId: ThreadId.make("quiet-thread") }, + }, + ], + threads: [ + { + ...v2ThreadShell, + id: botThreadId, + projectId: botProjectId, + ...ranUntil("2026-06-20T10:00:00.000Z"), + lastVisitedAt: DateTime.makeUnsafe("2026-06-20T09:00:00.000Z"), + }, + { + ...v2ThreadShell, + id: ThreadId.make("quiet-thread"), + projectId: ProjectId.make("quiet"), + ...ranUntil("2026-06-20T08:00:00.000Z"), + lastVisitedAt: DateTime.makeUnsafe("2026-06-20T08:30:00.000Z"), + }, + ], + }, + { + previews: [ + { + threadId: botThreadId, + role: "assistant", + text: "Found three\nflights.", + updatedAt: "2026-06-20T10:00:00.000Z", + }, + { + threadId: ThreadId.make("quiet-thread"), + role: "user", + text: "Thanks", + updatedAt: "2026-06-20T07:59:00.000Z", + }, + ], + }, + ); const [first, second] = registry.get(bots); expect(first).toMatchObject({ name: "Scout", unread: true, preview: "Found three flights." }); - expect(second).toMatchObject({ name: "Quiet", unread: false }); + expect(second).toMatchObject({ name: "Quiet", unread: false, preview: "You: Thanks" }); const visited = registry.get(snapshotAtom(environmentId))!; registry.set(snapshotAtom(environmentId), { diff --git a/packages/client-runtime/src/state/bots.ts b/packages/client-runtime/src/state/bots.ts index b42bd55562073..afd0095a65888 100644 --- a/packages/client-runtime/src/state/bots.ts +++ b/packages/client-runtime/src/state/bots.ts @@ -1,21 +1,24 @@ -import type { - BotAvatar, - EnvironmentId, - OrchestrationProjectShell, - ProjectId, - ScopedThreadRef, - ThreadId, +import { + type BotAvatar, + type BotPreview, + type BotPreviewsResult, + type EnvironmentId, + type OrchestrationProjectShell, + type ProjectId, + type ScopedThreadRef, + type ThreadId, + WS_METHODS, } from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/reactivity"; -import * as DateTime from "effect/DateTime"; -import { Atom } from "effect/reactivity"; - +import type * as EnvironmentRegistry from "../connection/registry.ts"; import { arrayElementsEqual } from "./entities.ts"; import { type EnvironmentProject, type EnvironmentThreadShell, threadRuntimeIsActive, } from "./models.ts"; +import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; const EMPTY_PROJECT_IDS: ReadonlySet = new Set(); @@ -53,27 +56,38 @@ export interface BotSummary { readonly working: boolean; } -/** A reply the user has not opened yet. Opening the thread moves `lastVisitedAt` past it. */ +/** + * Like a thread's unseen completion: a run finished after the user last opened the chat. + * A bot the user never opened counts too, since its first run is its greeting. + */ function hasUnreadBotReply(shell: EnvironmentThreadShell): boolean { - const message = shell.source.latestVisibleMessage; - if (message === null || message.role !== "assistant") return false; - if (shell.lastVisitedAt == null) return true; - return DateTime.toEpochMillis(message.updatedAt) > Date.parse(shell.lastVisitedAt); + const completedAt = shell.latestRun?.completedAt; + if (!completedAt) return false; + if (!shell.lastVisitedAt) return true; + return Date.parse(completedAt) > Date.parse(shell.lastVisitedAt); } -function previewText(text: string): string | null { - const line = text.replace(/\s+/g, " ").trim(); - return line.length === 0 ? null : line.slice(0, 160); +function previewText(preview: BotPreview): string | null { + const line = preview.text.replace(/\s+/g, " ").trim(); + if (line.length === 0) return null; + return (preview.role === "user" ? `You: ${line}` : line).slice(0, 160); +} + +function latestOf(...times: ReadonlyArray): string { + let latest = ""; + for (const time of times) { + if (time && (latest === "" || Date.parse(time) > Date.parse(latest))) latest = time; + } + return latest; } function summarizeBot( project: EnvironmentProject, shell: EnvironmentThreadShell | null, + preview: BotPreview | null, ): BotSummary | null { const bot = project.bot; if (bot == null) return null; - const message = shell?.source.latestVisibleMessage ?? null; - const working = threadRuntimeIsActive(shell?.runtime); return { key: `${project.environmentId}:${project.id}`, environmentId: project.environmentId, @@ -84,13 +98,15 @@ function summarizeBot( avatar: bot.avatar, description: bot.description, workspaceRoot: project.workspaceRoot, - preview: message === null ? null : previewText(message.text), - activityAt: - message === null - ? (shell?.updatedAt ?? project.createdAt) - : DateTime.formatIso(message.updatedAt), + preview: preview === null ? null : previewText(preview), + activityAt: latestOf( + project.createdAt, + shell?.latestUserMessageAt, + shell?.latestRun?.completedAt, + preview?.updatedAt, + ), unread: shell !== null && hasUnreadBotReply(shell), - working, + working: threadRuntimeIsActive(shell?.runtime), }; } @@ -101,6 +117,44 @@ function compareBots(left: BotSummary, right: BotSummary): number { return left.name.localeCompare(right.name); } +type BotPreviewsAtom = ( + environmentId: EnvironmentId, +) => Atom.Atom>; + +/** + * Each environment's bot previews, fetched again only when one of its bots gets a new + * message from the user or finishes a run. Thread shells carry no message text. + */ +export function createBotPreviewsAtomFamily( + runtime: Atom.AtomRuntime, + input: { + readonly botsAtom: Atom.Atom>; + readonly quietThreadShellAtom: ( + ref: ScopedThreadRef, + ) => Atom.Atom; + }, +) { + const activity = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + let key = ""; + for (const project of get(input.botsAtom)) { + if (project.environmentId !== environmentId || project.bot == null) continue; + const shell = get( + input.quietThreadShellAtom({ environmentId, threadId: project.bot.threadId }), + ); + key += `${project.bot.threadId}:${shell?.latestUserMessageAt}:${shell?.latestRun?.completedAt};`; + } + return key; + }), + ); + const family = createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:bots:previews", + tag: WS_METHODS.botsPreviews, + refreshTrigger: ({ environmentId }) => activity(environmentId), + }); + return (environmentId: EnvironmentId) => family({ environmentId, input: {} }); +} + /** * Reads quiet shells, so the list reorders when a reply lands rather than on * every streamed token. Summaries keep their identity while nothing changed. @@ -108,11 +162,26 @@ function compareBots(left: BotSummary, right: BotSummary): number { export function createBotListAtom(input: { readonly botsAtom: Atom.Atom>; readonly quietThreadShellAtom: (ref: ScopedThreadRef) => Atom.Atom; + readonly previewsAtom: BotPreviewsAtom; }) { const cache = new WeakMap< EnvironmentProject, - { shell: EnvironmentThreadShell | null; summary: BotSummary | null } + { + shell: EnvironmentThreadShell | null; + preview: BotPreview | null; + summary: BotSummary | null; + } >(); + const previewMaps = new WeakMap>(); + const previewsIn = (result: BotPreviewsResult | null) => { + if (result === null) return null; + let map = previewMaps.get(result); + if (map === undefined) { + map = new Map(result.previews.map((preview) => [preview.threadId, preview])); + previewMaps.set(result, map); + } + return map; + }; let previous: ReadonlyArray = []; return Atom.make((get) => { const next: BotSummary[] = []; @@ -124,9 +193,19 @@ export function createBotListAtom(input: { threadId: project.bot.threadId, }), ); + const previews = previewsIn( + AsyncResult.getOrElse(get(input.previewsAtom(project.environmentId)), () => null), + ); + const preview = previews?.get(project.bot.threadId) ?? null; let entry = cache.get(project); - if (entry === undefined || entry.shell !== shell) { - entry = { shell, summary: summarizeBot(project, shell) }; + // Compared by value: every refetch decodes new preview objects. + if ( + entry === undefined || + entry.shell !== shell || + entry.preview?.text !== preview?.text || + entry.preview?.updatedAt !== preview?.updatedAt + ) { + entry = { shell, preview, summary: summarizeBot(project, shell, preview) }; cache.set(project, entry); } if (entry.summary !== null) next.push(entry.summary); diff --git a/packages/contracts/src/bot.ts b/packages/contracts/src/bot.ts index 9cc3ce395ee57..bcea3026e51b0 100644 --- a/packages/contracts/src/bot.ts +++ b/packages/contracts/src/bot.ts @@ -122,6 +122,21 @@ export const BotGenerateAvatarResult = Schema.Struct({ }); export type BotGenerateAvatarResult = typeof BotGenerateAvatarResult.Type; +/** Each bot's latest visible message, for the bots list. Thread shells carry no message text. */ +export const BotPreviewsInput = Schema.Struct({}); +export type BotPreviewsInput = typeof BotPreviewsInput.Type; + +export const BotPreview = Schema.Struct({ + threadId: ThreadId, + role: Schema.Literals(["user", "assistant"]), + text: Schema.String, + updatedAt: Schema.String, +}); +export type BotPreview = typeof BotPreview.Type; + +export const BotPreviewsResult = Schema.Struct({ previews: Schema.Array(BotPreview) }); +export type BotPreviewsResult = typeof BotPreviewsResult.Type; + export class BotError extends Schema.TaggedError()("BotError", { message: Schema.String, projectId: Schema.optional(ProjectId), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index fcc571f9ee465..76048300b245c 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -385,6 +385,8 @@ import { BotGenerateAvatarInput, BotGenerateAvatarResult, BotMutationResult, + BotPreviewsInput, + BotPreviewsResult, BotUpdateInput, } from "./bot.ts"; import { @@ -563,6 +565,7 @@ export const WS_METHODS = { botsUpdate: "bots.update", botsDelete: "bots.delete", botsGenerateAvatar: "bots.generateAvatar", + botsPreviews: "bots.previews", cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", @@ -1970,6 +1973,12 @@ const WsBotsGenerateAvatarRpc = Rpc.make(WS_METHODS.botsGenerateAvatar, { error: Schema.Union([BotError, EnvironmentAuthorizationError]), }); +const WsBotsPreviewsRpc = Rpc.make(WS_METHODS.botsPreviews, { + payload: BotPreviewsInput, + success: BotPreviewsResult, + error: Schema.Union([BotError, EnvironmentAuthorizationError]), +}); + const WsGuestShareCreateRpc = Rpc.make(WS_METHODS.guestShareCreate, { payload: GuestShareCreateInput, success: GuestShareCreateResult, @@ -2088,6 +2097,7 @@ export const WsRpcGroup = RpcGroup.make( WsBotsUpdateRpc, WsBotsDeleteRpc, WsBotsGenerateAvatarRpc, + WsBotsPreviewsRpc, WsServerReportClientActivityRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, From 8597aa6e00e1373510844df56cde87507564a161 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 22:38:17 +0530 Subject: [PATCH 21/40] fix(bots): previews skip rolled-back and cancelled queued messages The transcript hides both, so the list kept showing text the chat no longer had after a checkpoint restore or a cancelled queued message. --- apps/server/src/bots/BotService.test.ts | 24 ++++++++++++++++++++++++ apps/server/src/bots/BotService.ts | 20 ++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/apps/server/src/bots/BotService.test.ts b/apps/server/src/bots/BotService.test.ts index f02a794efc771..068a367944c79 100644 --- a/apps/server/src/bots/BotService.test.ts +++ b/apps/server/src/bots/BotService.test.ts @@ -349,6 +349,30 @@ it.effect("previews show each bot's newest visible message", () => // Newer, but a notice and a reply still streaming are not what the list shows. yield* message("m3", "user", "2026-01-01T00:00:03Z", { text: "notice", createdBy: "system" }); yield* message("m4", "assistant", "2026-01-01T00:00:04Z", { text: "Look" }, 1); + // Neither is in the transcript: a restored checkpoint and a queued message the user cancelled. + const hiddenRun = (runId: string, ordinal: number, status: string, messageId: string) => + Effect.all([ + sql` + INSERT INTO orchestration_v2_projection_runs + (run_id, thread_id, ordinal, provider, status, requested_at, payload_json) + VALUES (${runId}, ${chatty.threadId}, ${ordinal}, 'codex', ${status}, '2026-01-01T00:00:05Z', '{}') + `, + sql` + INSERT INTO orchestration_v2_projection_turn_items + (turn_item_id, thread_id, run_id, ordinal, type, status, updated_at, payload_json) + VALUES (${`item-${messageId}`}, ${chatty.threadId}, ${runId}, ${ordinal}, 'user_message', + 'completed', '2026-01-01T00:00:05Z', + ${JSON.stringify({ messageId, inputIntent: "queued_turn" })}) + `, + sql` + INSERT INTO orchestration_v2_projection_messages + (message_id, thread_id, run_id, role, streaming, created_at, updated_at, payload_json) + VALUES (${messageId}, ${chatty.threadId}, ${runId}, 'user', 0, '2026-01-01T00:00:05Z', + '2026-01-01T00:00:05Z', ${JSON.stringify({ text: "Discarded" })}) + `, + ]); + yield* hiddenRun("run-rolled-back", 1, "rolled_back", "m5"); + yield* hiddenRun("run-cancelled", 2, "cancelled", "m6"); const bots = yield* BotService.BotService; assert.deepEqual((yield* bots.previews()).previews, [ diff --git a/apps/server/src/bots/BotService.ts b/apps/server/src/bots/BotService.ts index fa84e27f68636..1530582407fe9 100644 --- a/apps/server/src/bots/BotService.ts +++ b/apps/server/src/bots/BotService.ts @@ -533,6 +533,26 @@ const make = Effect.gen(function* () { FROM orchestration_v2_projection_messages AS latest WHERE latest.thread_id = bots.thread_id AND ${indexedMessageCondition(sql, "latest")} + -- The transcript hides rolled-back runs and cancelled queued messages. + AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_runs AS run + WHERE run.run_id = latest.run_id + AND ( + run.status = 'rolled_back' + OR ( + run.status = 'cancelled' + AND latest.role = 'user' + AND EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items AS item + WHERE item.thread_id = latest.thread_id + AND item.run_id = latest.run_id + AND item.type = 'user_message' + AND json_extract(item.payload_json, '$.messageId') = latest.message_id + AND json_extract(item.payload_json, '$.inputIntent') = 'queued_turn' + ) + ) + ) + ) ORDER BY latest.created_at DESC, latest.message_id DESC LIMIT 1 ) From 9518c6fbbd64466d8309d01228c37fed73f7c689 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 22:38:57 +0530 Subject: [PATCH 22/40] feat(web): bot artifacts browse folders and refresh after each run Artifacts showed only top-level files and stayed stale until reopened. Folders now expand in place and every open folder re-reads after a run finishes. Quick avatar picks build on the newest pick, so a color picked right after a shape no longer undoes the shape. Image generation needs Codex ready, not just installed. --- apps/web/src/components/bots/BotDetails.tsx | 164 +++++++++++++++----- 1 file changed, 124 insertions(+), 40 deletions(-) diff --git a/apps/web/src/components/bots/BotDetails.tsx b/apps/web/src/components/bots/BotDetails.tsx index a13ca19cd8ee1..243e055caa9ec 100644 --- a/apps/web/src/components/bots/BotDetails.tsx +++ b/apps/web/src/components/bots/BotDetails.tsx @@ -7,17 +7,18 @@ import { type BotAvatarColor, type BotAvatarShape, type ProjectBot, + type ProjectEntry, type ScopedThreadRef, } from "@t3tools/contracts"; import { formatModelSlugName } from "@t3tools/shared/model"; import { useNavigate } from "@tanstack/react-router"; -import { FileIcon, SparklesIcon, Trash2Icon } from "lucide-react"; -import { useState } from "react"; +import { FileIcon, FolderIcon, FolderOpenIcon, SparklesIcon, Trash2Icon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { requestConfirmDialog } from "../../confirmDialog"; import { useRightPanelStore } from "../../rightPanelStore"; import { useEnvironmentProviders } from "../../state/environments"; -import { useThreadShell } from "../../state/entities"; +import { useQuietThreadShell, useThreadShell } from "../../state/entities"; import { useEnvironmentPresentation } from "../../state/presentation"; import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; @@ -53,6 +54,8 @@ function knownColor(value: string): BotAvatarColor { return BOT_AVATAR_COLORS.find((color) => color === value) ?? "gray"; } +type AvatarChange = { shape: BotAvatarShape; color: BotAvatarColor; imagePath?: null }; + function AvatarEditor({ project, bot, @@ -68,23 +71,27 @@ function AvatarEditor({ }); const providers = useEnvironmentProviders().get(project.environmentId) ?? EMPTY_SERVER_PROVIDERS; const canGenerate = providers.some( - (provider) => provider.driver === "codex" && provider.enabled && provider.installed, + (provider) => provider.driver === "codex" && provider.enabled && provider.status === "ready", ); const [prompt, setPrompt] = useState(""); const [generating, setGenerating] = useState(false); - const shape = knownShape(bot.avatar.shape); - const color = knownColor(bot.avatar.color); - const imagePath = bot.avatar.imagePath ?? null; + // The newest unconfirmed pick, so quick picks build on each other instead of the stale server avatar. + const [pending, setPending] = useState(null); + const latestSave = useRef(0); + const avatar = pending === null ? bot.avatar : { ...bot.avatar, ...pending }; + const shape = knownShape(avatar.shape); + const color = knownColor(avatar.color); + const imagePath = avatar.imagePath ?? null; - const saveAvatar = async (next: { - shape: BotAvatarShape; - color: BotAvatarColor; - imagePath?: null; - }) => { + const saveAvatar = async (next: AvatarChange) => { + const save = ++latestSave.current; + setPending(next); const result = await updateBot({ environmentId: project.environmentId, input: { projectId: project.id, avatar: next }, }); + if (save !== latestSave.current) return; + setPending(null); if (result._tag === "Failure") reportFailure("Could not update avatar", result); }; @@ -110,7 +117,7 @@ function AvatarEditor({ /> } > - +
      @@ -218,45 +225,122 @@ function BotModel({ threadRef }: { threadRef: ScopedThreadRef }) { ); } -function BotArtifacts({ - project, - threadRef, -}: { +type ArtifactFolderProps = { project: EnvironmentProject; threadRef: ScopedThreadRef; -}) { + directoryPath: string; + depth: number; + runCompletedAt: string | null; +}; + +const ARTIFACT_ROW_CLASS = + "flex w-full cursor-pointer items-center gap-2 rounded-md py-1.5 pr-2.5 text-left text-sm outline-hidden hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"; + +function artifactRowIndent(depth: number) { + return { paddingLeft: `${0.625 + depth}rem` }; +} + +function useArtifactEntries({ + project, + directoryPath, + runCompletedAt, +}: Pick) { const query = useEnvironmentQuery( projectEnvironment.listEntries({ environmentId: project.environmentId, - input: { cwd: project.workspaceRoot, directoryPath: "artifacts" }, + input: { cwd: project.workspaceRoot, directoryPath }, }), ); - const files = (query.data?.entries ?? []) - .filter((entry) => entry.kind === "file") - .toSorted((left, right) => left.path.localeCompare(right.path)); - if (files.length === 0) { + const seenRun = useRef(runCompletedAt); + const { refresh } = query; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- re-reads the folder from disk after a run, since the agent may have written files + useEffect(() => { + if (seenRun.current === runCompletedAt) return; + seenRun.current = runCompletedAt; + if (runCompletedAt !== null) refresh(); + }, [runCompletedAt, refresh]); + const entries = (query.data?.entries ?? []).toSorted( + (left, right) => + (left.kind === "directory" ? 0 : 1) - (right.kind === "directory" ? 0 : 1) || + left.path.localeCompare(right.path), + ); + return { entries, loading: query.isPending && query.data === null }; +} + +function ArtifactEntries({ + entries, + ...props +}: ArtifactFolderProps & { entries: ReadonlyArray }) { + return ( +
        + {entries.map((entry) => { + const name = entry.path.slice(entry.path.lastIndexOf("/") + 1); + return entry.kind === "directory" ? ( + + ) : ( +
      • + +
      • + ); + })} +
      + ); +} + +function ArtifactFolder(props: ArtifactFolderProps) { + const { entries } = useArtifactEntries(props); + return ; +} + +function ArtifactDirectory({ name, ...props }: ArtifactFolderProps & { name: string }) { + const [expanded, setExpanded] = useState(false); + return ( +
    • + + {expanded ? : null} +
    • + ); +} + +function BotArtifacts({ + project, + threadRef, +}: { + project: EnvironmentProject; + threadRef: ScopedThreadRef; +}) { + const runCompletedAt = useQuietThreadShell(threadRef)?.latestRun?.completedAt ?? null; + const props = { project, threadRef, directoryPath: "artifacts", depth: 0, runCompletedAt }; + const { entries, loading } = useArtifactEntries(props); + if (entries.length === 0) { return (

      - {query.isPending && query.data === null ? "Loading" : "No artifacts yet"} + {loading ? "Loading" : "No artifacts yet"}

      ); } - return ( -
        - {files.map((file) => ( -
      • - -
      • - ))} -
      - ); + return ; } function BotComputer({ project }: { project: EnvironmentProject }) { From a1f037908bf365e22adcb8068d0b908f8a0f102d Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 22:40:36 +0530 Subject: [PATCH 23/40] fix(mobile): messages sent to a bot are no longer dropped The outbox looked up a queued message's thread in the thread lists, which leave out bot chats. Once the environment's list was current it read the bot thread as deleted and discarded the message unsent. Lookups now go by thread id. --- .../threadOutboxDelivery.permissions.test.ts | 34 ++++++++++- apps/mobile/src/state/threadOutboxDelivery.ts | 59 +++++++++---------- 2 files changed, 58 insertions(+), 35 deletions(-) diff --git a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts index 0ea64764e5da2..ee0a0330d0372 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts @@ -12,6 +12,8 @@ import { AsyncResult, type Atom } from "effect/reactivity"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import type { QueuedThreadMessage } from "./thread-outbox-model"; +type TestThreadShell = { readonly environmentId: string; readonly id: string; readonly bot?: true }; + const state = vi.hoisted(() => ({ draftText: "Keep this draft separate", cleanups: [] as Array<() => void>, @@ -27,7 +29,7 @@ const state = vi.hoisted(() => ({ checkout: vi.fn(), modelUnavailable: vi.fn(() => false), familyMount: vi.fn(() => () => {}), - threadShells: null as unknown as Atom.Writable>, + threadShells: null as unknown as Atom.Writable>, manager: null as unknown as ReturnType< typeof import("./thread-outbox-manager").createThreadOutboxManager >, @@ -86,7 +88,16 @@ vi.mock("./server", async () => { }); vi.mock("./threads", async () => { const { Atom, AsyncResult } = await import("effect/reactivity"); - state.threadShells = Atom.make>([state.thread]).pipe(Atom.keepAlive); + state.threadShells = Atom.make>([state.thread]).pipe( + Atom.keepAlive, + ); + const threadShell = Atom.family((key: string) => + Atom.make( + (get) => + get(state.threadShells).find((shell) => `${shell.environmentId}:${shell.id}` === key) ?? + null, + ), + ); return { threadEnvironment: { startTurn: state.start, @@ -94,7 +105,12 @@ vi.mock("./threads", async () => { setRuntimeMode: state.runtime, setInteractionMode: state.interaction, }, - environmentThreadShells: { threadShellsAtom: state.threadShells }, + environmentThreadShells: { + // Lists leave bot chats out, as the real atoms do; lookups by id still reach them. + threadShellsAtom: Atom.make((get) => get(state.threadShells).filter((shell) => !shell.bot)), + threadShellAtom: (ref: { readonly environmentId: string; readonly threadId: string }) => + threadShell(`${ref.environmentId}:${ref.threadId}`), + }, threadFamilies: { mount: state.familyMount, familyAtom: () => Atom.make(AsyncResult.success({})), @@ -339,6 +355,18 @@ describe("queued task operation access", () => { expect(remaining()).toEqual([]); }); + it("delivers a queued bot chat message, though thread lists leave bot chats out", async () => { + setOperateGrant("secondary", true); + appAtomRegistry.set(state.threadShells, [ + state.thread, + { ...state.thread, id: "bot", bot: true }, + ]); + await state.manager.enqueue(message({ threadId: ThreadId.make("bot") })); + await runDrain(); + expect(state.start).toHaveBeenCalledOnce(); + expect(remaining()).toEqual([]); + }); + it("backs off instead of redelivering at once when a delivery hits a defect", async () => { setOperateGrant("secondary", true); const queued = message(); diff --git a/apps/mobile/src/state/threadOutboxDelivery.ts b/apps/mobile/src/state/threadOutboxDelivery.ts index 89d7b68b9201b..9c2f5e9bd74b6 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -164,14 +164,9 @@ function finishDispatchingQueuedMessage(queuedMessageId: MessageId): void { appAtomRegistry.set(dispatchingQueuedMessageIdAtom, current === queuedMessageId ? null : current); } -function findThread( - threads: ReadonlyArray, - message: QueuedThreadMessage, -): EnvironmentThreadShell | undefined { - return threads.find( - (candidate) => - candidate.environmentId === message.environmentId && candidate.id === message.threadId, - ); +// By id, not from thread lists, which leave out bot chats. +function findThread(message: QueuedThreadMessage): EnvironmentThreadShell | undefined { + return appAtomRegistry.get(environmentThreadShells.threadShellAtom(message)) ?? undefined; } function findCreationProject( @@ -587,12 +582,17 @@ const deliveryInputsAtom = Atom.make((get) => { operableEnvironments.add(environmentId); } } + const queuedMessagesByThreadKey = get(threadOutboxManager.queuedMessagesByThreadKeyAtom); return { dispatchingQueuedMessageId: get(dispatchingQueuedMessageIdAtom), editingQueuedMessageIds: get(editingQueuedMessageIdsAtom), - queuedMessagesByThreadKey: get(threadOutboxManager.queuedMessagesByThreadKeyAtom), + queuedMessagesByThreadKey, shellStatuses: get(threadOutboxShellStatusesAtom), - threads: get(environmentThreadShells.threadShellsAtom), + // Each queued thread's shell, so a pass re-runs when one arrives or changes. + threads: Object.values(queuedMessagesByThreadKey).flatMap(([message]) => { + const thread = message ? get(environmentThreadShells.threadShellAtom(message)) : null; + return thread ? [thread] : []; + }), projects: get(environmentProjects.projectsAtom), serverConfigs: get(environmentServerConfigsAtom), connectedEnvironments, @@ -604,20 +604,17 @@ const deliveryInputsAtom = Atom.make((get) => { // outcome is recorded, and that entry must still be collected when it does. const settledCreationOutcomeKeysAtom = Atom.make((get): ReadonlyArray => { const outcomes = get(pendingThreadCreationOutcomesAtom); - const threads = get(environmentThreadShells.threadShellsAtom); - return Object.entries(outcomes).flatMap(([threadKey, outcome]) => - outcome.kind === "delivered" && - threads.some( - (thread) => - scopedThreadKey(thread.environmentId, thread.id) === threadKey && - (thread.latestRun !== null || - thread.runtime?.status === "failed" || - thread.runtime?.status === "cancelled" || - thread.runtime?.status === "interrupted"), - ) + return Object.entries(outcomes).flatMap(([threadKey, outcome]) => { + if (outcome.kind !== "delivered") return []; + const thread = get(environmentThreadShells.threadShellAtom(outcome.message)); + return thread !== null && + (thread.latestRun !== null || + thread.runtime?.status === "failed" || + thread.runtime?.status === "cancelled" || + thread.runtime?.status === "interrupted") ? [threadKey] - : [], - ); + : []; + }); }).pipe(Atom.withLabel("mobile:thread-outbox:settled-creation-outcomes")); function deliveredSequence( @@ -1190,7 +1187,11 @@ export function createThreadOutboxDelivery(): { continue; } - let thread = findThread(threads, nextQueuedMessage); + let thread = threads.find( + (candidate) => + candidate.environmentId === nextQueuedMessage.environmentId && + candidate.id === nextQueuedMessage.threadId, + ); if (thread && scopedThreadKey(thread.environmentId, thread.id) !== threadKey) { continue; } @@ -1227,10 +1228,7 @@ export function createThreadOutboxDelivery(): { if (familyRead === "settled") { // The rows read at the start of this pass can lag the family that just settled; // decide from the merged rows as they are now. - thread = findThread( - appAtomRegistry.get(environmentThreadShells.threadShellsAtom), - nextQueuedMessage, - ); + thread = findThread(nextQueuedMessage); } else if (familyRead === "failed") { threadFamilies.refresh( appAtomRegistry, @@ -1330,10 +1328,7 @@ export function createThreadOutboxDelivery(): { return true; } if (deliveryAction === "send") { - const liveThread = findThread( - appAtomRegistry.get(environmentThreadShells.threadShellsAtom), - nextQueuedMessage, - ); + const liveThread = findThread(nextQueuedMessage); const liveDeliveryAction = resolveThreadOutboxDeliveryAction({ isCreation: creation !== undefined, threadExists: liveThread !== undefined, From f471ce8f8e91a63c5de87cb75cd78b5d6aefb662 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 22:42:56 +0530 Subject: [PATCH 24/40] feat(mobile): bot chats drop git and New task, avatars take an idea A bot has no repository and its chat is its only thread, so the header no longer offers git controls or New task there. Generating an avatar takes an optional idea, like web, and quick shape and color picks build on each other instead of undoing one another. --- .../features/bots/BotDetailsRouteScreen.tsx | 37 ++++++++++++++++--- .../features/threads/ThreadGitControls.tsx | 14 +++++-- .../src/features/threads/ThreadHeader.tsx | 15 +++++--- .../features/threads/ThreadRouteScreen.tsx | 1 + .../threads/useThreadHeaderOptions.tsx | 2 +- 5 files changed, 53 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/features/bots/BotDetailsRouteScreen.tsx b/apps/mobile/src/features/bots/BotDetailsRouteScreen.tsx index 073af1c2c3bf2..423121712730a 100644 --- a/apps/mobile/src/features/bots/BotDetailsRouteScreen.tsx +++ b/apps/mobile/src/features/bots/BotDetailsRouteScreen.tsx @@ -17,7 +17,7 @@ import { type ProjectId, } from "@t3tools/contracts"; import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { View } from "react-native"; import { AppText as Text, AppTextInput } from "../../components/ui/AppText"; @@ -118,23 +118,37 @@ function BotAvatarSection(props: { readonly project: BotProject }) { config?.providers.some( (provider) => provider.driver === "codex" && provider.enabled && provider.status === "ready", ) ?? false; + const [prompt, setPrompt] = useState(""); const [generating, setGenerating] = useState(false); + // The newest unconfirmed pick, so quick picks build on each other instead of the stale server avatar. + const [pending, setPending] = useState<{ shape: BotAvatarShape; color: BotAvatarColor } | null>( + null, + ); + const latestSave = useRef(0); + const avatar = pending ?? project.bot.avatar; // Drawn choices fall back to the first option when the server knows a newer one. - const shape = BOT_AVATAR_SHAPES.find((value) => value === project.bot.avatar.shape) ?? "circle"; - const color = BOT_AVATAR_COLORS.find((value) => value === project.bot.avatar.color) ?? "gray"; + const shape = BOT_AVATAR_SHAPES.find((value) => value === avatar.shape) ?? "circle"; + const color = BOT_AVATAR_COLORS.find((value) => value === avatar.color) ?? "gray"; const saveAvatar = (next: { shape: BotAvatarShape; color: BotAvatarColor }) => { + const save = ++latestSave.current; + setPending(next); void updateBot({ environmentId: project.environmentId, // Picking a drawn avatar replaces a generated image. input: { projectId: project.id, avatar: { ...next, imagePath: null } }, - }).then((result) => alertFailure("Couldn't update avatar", result)); + }).then((result) => { + if (save !== latestSave.current) return; + setPending(null); + alertFailure("Couldn't update avatar", result); + }); }; const generate = () => { setGenerating(true); + const trimmed = prompt.trim(); void generateAvatar({ environmentId: project.environmentId, - input: { projectId: project.id }, + input: { projectId: project.id, ...(trimmed ? { prompt: trimmed } : {}) }, }).then((result) => { setGenerating(false); if (result._tag === "Failure") { @@ -155,7 +169,18 @@ function BotAvatarSection(props: { readonly project: BotProject }) { {codexReady && canGenerate ? ( - + + +
      + +
      +
      ); } diff --git a/apps/web/src/components/bots/DeleteBotDialog.tsx b/apps/web/src/components/bots/DeleteBotDialog.tsx new file mode 100644 index 0000000000000..a11b68532779d --- /dev/null +++ b/apps/web/src/components/bots/DeleteBotDialog.tsx @@ -0,0 +1,84 @@ +import { useId, useState } from "react"; + +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; + +/** Deleting a bot ends its chat for good, so the user types its name to confirm. */ +export function DeleteBotDialog({ + name, + open, + onOpenChange, + onDelete, +}: { + name: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onDelete: () => Promise; +}) { + const id = useId(); + const [typed, setTyped] = useState(""); + const [deleting, setDeleting] = useState(false); + const matches = typed.trim() === name; + const confirm = async () => { + if (!matches || deleting) return; + setDeleting(true); + await onDelete(); + setDeleting(false); + }; + return ( + { + if (!next) setTyped(""); + onOpenChange(next); + }} + > + + + Delete {name}? + + Its chat and routines stop for good. Files in its folder stay on disk. + + +
      { + event.preventDefault(); + void confirm(); + }} + > + + setTyped(event.target.value)} + /> +
      + + }>Cancel + + +
      +
      + ); +} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 49f21a1ddc829..bc0edcd4d9612 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -30,6 +30,7 @@ import { canShareWithGuestAtom } from "./guestShareAvailability"; import { useAtomValue } from "@effect/atom-react"; import { useOrchestrationCommand } from "../../state/use-orchestration-command"; import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; +import { BotAvatar } from "../bots/BotAvatar"; import { ProjectFavicon } from "../ProjectFavicon"; import { WorkspaceBreadcrumb, @@ -86,6 +87,7 @@ export const ChatHeader = memo(function ChatHeader({ }: ChatHeaderProps) { const activeProjectName = activeProject?.isScratch ? "No project" : activeProject?.title; const activeProjectCwd = activeProject?.workspaceRoot ?? null; + const bot = activeProject?.bot ?? null; const interfaceFont = useClientSettings((settings) => settings.fontFamilySans); const breadcrumbContainerRef = useRef(null); const [collapseParentTitle, setCollapseParentTitle] = useState(false); @@ -250,7 +252,7 @@ export const ChatHeader = memo(function ChatHeader({ startRename(); }; const handleHeaderContextMenu = (event: ReactMouseEvent) => { - if (renamingTitle !== null) return; + if (renamingTitle !== null || bot !== null) return; if (!isServerThread && onOpenProjectSettings === undefined) return; cancelPendingTitleMenu(); event.preventDefault(); @@ -292,143 +294,155 @@ export const ChatHeader = memo(function ChatHeader({ ariaLabel="Thread breadcrumb" className="flex-1 overflow-clip [overflow-clip-margin:2px]" > - {activeProject ? ( + {bot ? ( + // A bot is its one chat, so the header names the bot rather than a project and thread. + +

      + + {activeProjectName} +

      +
      + ) : ( <> - - - - } - > - - - {activeProjectName} - - - New thread in {activeProjectName} - - - - / - - - ) : null} - {parentThreadLink ? ( - <> - - - onOpenThread(parentThreadLink.threadId)} - className="inline-flex min-w-0 max-w-full cursor-pointer items-center rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" + {activeProject ? ( + <> + + + + } + > + + + {activeProjectName} + + + New thread in {activeProjectName} + + + + / + + + ) : null} + {parentThreadLink ? ( + <> + + + onOpenThread(parentThreadLink.threadId)} + className="inline-flex min-w-0 max-w-full cursor-pointer items-center rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" + /> + } + > + + {parentThreadLink.title} + + {collapseParentTitle ? ( + + ... + + ) : null} + + {parentThreadLink.title} + + + + / + + + ) : null} + + {renamingTitle !== null ? ( + { + if (renameCommittedRef.current) return; + if ( + event.relatedTarget instanceof HTMLElement && + event.relatedTarget.closest("button") + ) { + setRenaming(null); + return; + } + commitRename(event.currentTarget.value); + }} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={handleRenameKeyDown} + /> + ) : isServerThread ? ( + + + } + > +

      + {activeThreadTitle} +

      + - } - > - + {activeThreadTitle} +
      + ) : ( + + + } > - {parentThreadLink.title} - - {collapseParentTitle ? ( - - ... - - ) : null} - - {parentThreadLink.title} - + {activeThreadTitle} +
      + {activeThreadTitle} +
      + )} + {renamingTitle === null && activeThreadLinkOrigin ? ( + + }> + From {activeThreadLinkOrigin} + + + Started by an agent on {activeThreadLinkOrigin} through a linked environment. + + + ) : null}
      - - / - - ) : null} - - {renamingTitle !== null ? ( - { - if (renameCommittedRef.current) return; - if ( - event.relatedTarget instanceof HTMLElement && - event.relatedTarget.closest("button") - ) { - setRenaming(null); - return; - } - commitRename(event.currentTarget.value); - }} - onFocus={(event) => event.currentTarget.select()} - onKeyDown={handleRenameKeyDown} - /> - ) : isServerThread ? ( - - - } - > -

      - {activeThreadTitle} -

      - -
      - {activeThreadTitle} -
      - ) : ( - - - } - > - {activeThreadTitle} - - {activeThreadTitle} - - )} - {renamingTitle === null && activeThreadLinkOrigin ? ( - - }> - From {activeThreadLinkOrigin} - - - Started by an agent on {activeThreadLinkOrigin} through a linked environment. - - - ) : null} -
      + )} {isServerThread ? : null}
      From b5dda1d53d6aa7e89e992c9bdacd432ac942f3d8 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 23:33:17 +0530 Subject: [PATCH 28/40] fix(bots): New bot works on phones, previews drop Markdown syntax At phone width the chat header's controls float over the sidebar sheet's top row, covering Search and New bot. On phones those actions now sit in a row below it, like the threads sidebar. Bot list previews showed raw Markdown, such as a link's local path, which pushed the useful text out of view. Previews now show the rendered text. --- apps/web/src/components/bots/BotsSidebar.tsx | 83 ++++++++++--------- .../client-runtime/src/state/bots.test.ts | 8 +- packages/client-runtime/src/state/bots.ts | 11 ++- 3 files changed, 61 insertions(+), 41 deletions(-) diff --git a/apps/web/src/components/bots/BotsSidebar.tsx b/apps/web/src/components/bots/BotsSidebar.tsx index 4076a2c731637..f3e08d9ec32e9 100644 --- a/apps/web/src/components/bots/BotsSidebar.tsx +++ b/apps/web/src/components/bots/BotsSidebar.tsx @@ -81,52 +81,59 @@ export default function BotsSidebar() { }); }; + const newBotButton = ( + + ); + // On phones the chat header's controls float over the sheet's top row, so actions sit below it. + const searchRow = + isMobile || query !== null ? ( + +
      + setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") setQuery(null); + }} + /> + {isMobile ? newBotButton : null} +
      +
      + ) : null; + return ( <> - - - - } - /> - - + + {newBotButton} + ) } - > + /> + {visibleBots.length === 0 ? (

      diff --git a/packages/client-runtime/src/state/bots.test.ts b/packages/client-runtime/src/state/bots.test.ts index fc2299427c98b..9d9a70ca8d31a 100644 --- a/packages/client-runtime/src/state/bots.test.ts +++ b/packages/client-runtime/src/state/bots.test.ts @@ -153,7 +153,7 @@ describe("bots", () => { { threadId: botThreadId, role: "assistant", - text: "Found three\nflights.", + text: "Saved to [trip.md](/Users/me/bots/scout/artifacts/trip.md):\n- **Found** three\n`flights`.", updatedAt: "2026-06-20T10:00:00.000Z", }, { @@ -167,7 +167,11 @@ describe("bots", () => { ); const [first, second] = registry.get(bots); - expect(first).toMatchObject({ name: "Scout", unread: true, preview: "Found three flights." }); + expect(first).toMatchObject({ + name: "Scout", + unread: true, + preview: "Saved to trip.md: Found three flights.", + }); expect(second).toMatchObject({ name: "Quiet", unread: false, preview: "You: Thanks" }); const visited = registry.get(snapshotAtom(environmentId))!; diff --git a/packages/client-runtime/src/state/bots.ts b/packages/client-runtime/src/state/bots.ts index afd0095a65888..ba181de832a70 100644 --- a/packages/client-runtime/src/state/bots.ts +++ b/packages/client-runtime/src/state/bots.ts @@ -67,8 +67,17 @@ function hasUnreadBotReply(shell: EnvironmentThreadShell): boolean { return Date.parse(completedAt) > Date.parse(shell.lastVisitedAt); } +// A one-line preview reads the text a renderer would show, not its Markdown syntax. +function plainText(markdown: string): string { + return markdown + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/^\s{0,3}(?:#{1,6}\s+|>\s?|[-*+]\s+|\d+[.)]\s+)/gm, "") + .replace(/(\*\*|__|`+)(.+?)\1/g, "$2") + .replace(/(^|\W)[*_](\S(?:.*?\S)?)[*_](?=\W|$)/g, "$1$2"); +} + function previewText(preview: BotPreview): string | null { - const line = preview.text.replace(/\s+/g, " ").trim(); + const line = plainText(preview.text).replace(/\s+/g, " ").trim(); if (line.length === 0) return null; return (preview.role === "user" ? `You: ${line}` : line).slice(0, 160); } From f2107207b027ae7f32e58c214c5ff4a7b8cf1302 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 23:50:32 +0530 Subject: [PATCH 29/40] feat(bots): bot replies push to mobile as messages A finished bot reply sent the same push as any finished task: the bot's name over "Done: ". Bots are chats, so the push should read like one. For a bot's completed run, the server now attaches a plain-text preview of the reply to the activity it publishes, and the relay uses it as the push body. Other threads and phases are unchanged. The field is optional, so older servers and relays keep their current behavior. The Markdown stripping used for bot list previews moves to shared so the list and the push read the same text. --- .../src/relay/AgentAwarenessRelay.test.ts | 93 ++++++++++++++++++- apps/server/src/relay/AgentAwarenessRelay.ts | 60 ++++++++++-- .../agentActivity/agentActivityAggregate.ts | 1 + .../src/agentActivity/agentActivityAlerts.ts | 4 +- .../agentActivity/agentActivityPayloads.ts | 10 +- .../agentActivity/agentActivityPolicy.test.ts | 12 +++ .../client-runtime/src/state/bots.test.ts | 2 +- packages/client-runtime/src/state/bots.ts | 12 +-- packages/contracts/src/relay.ts | 2 + packages/shared/package.json | 4 + packages/shared/src/markdownPreview.test.ts | 13 +++ packages/shared/src/markdownPreview.ts | 10 ++ 12 files changed, 200 insertions(+), 23 deletions(-) create mode 100644 packages/shared/src/markdownPreview.test.ts create mode 100644 packages/shared/src/markdownPreview.ts diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 2a259114b0926..78fb72f31e2e7 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -5,6 +5,7 @@ import { EventId, MessageId, NodeId, + type OrchestrationV2ConversationMessage, type OrchestrationV2DomainEvent, type OrchestrationV2ThreadShell, type OrchestrationV2TurnItem, @@ -92,6 +93,28 @@ function shell(overrides: Partial = {}): Orchestrati }; } +function reply( + text: string, + createdAt: string, + overrides: Partial = {}, +): OrchestrationV2ConversationMessage { + return { + id: MessageId.make(`message:${createdAt}`), + threadId: THREAD_ID, + runId: RunId.make("run:reply"), + nodeId: null, + role: "assistant", + text, + attachments: [], + streaming: false, + createdBy: "agent", + creationSource: "provider", + createdAt: DateTime.makeUnsafe(createdAt), + updatedAt: DateTime.makeUnsafe(createdAt), + ...overrides, + }; +} + const PublishPayload = Schema.Struct({ state: Schema.NullOr(RelayAgentActivityState) }); const decodePublishPayload = Schema.decodeUnknownSync(Schema.fromJsonString(PublishPayload)); const unused = () => Effect.die("Unexpected test dependency call"); @@ -144,6 +167,8 @@ const makeTestRelay = Effect.fnUntraced(function* ( readonly linked?: boolean; readonly readShell?: (threadId: ThreadId) => Effect.Effect; readonly domainEvents?: Stream.Stream; + readonly bot?: boolean; + readonly replies?: ReadonlyArray; } = {}, ) { const values = new Map( @@ -202,7 +227,10 @@ const makeTestRelay = Effect.fnUntraced(function* ( getTimelinePage: () => Effect.die("Unused timeline read"), getMessageCount: () => Effect.die("unused message count"), getTurnItem: () => Effect.die("unused turn item read"), - getThreadRecords: () => Effect.die("unused record read"), + getThreadRecords: () => + options.replies === undefined + ? Effect.die("unused record read") + : Effect.succeed({ messages: options.replies } as never), getThreadProjection: unused, getCheckpointContext: unused, getThreadSnapshot: unused, @@ -281,6 +309,15 @@ const makeTestRelay = Effect.fnUntraced(function* ( workspaceRoot: "/workspace", defaultModelSelection: null, scripts: [], + ...(options.bot + ? { + bot: { + threadId: THREAD_ID, + avatar: { shape: "round", color: "blue" }, + description: null, + }, + } + : {}), createdAt: NOW, updatedAt: NOW, deletedAt: null, @@ -357,6 +394,60 @@ describe("AgentAwarenessRelay", () => { }), ); + it.effect.each([{ bot: true }, { bot: false }])( + "attaches a reply preview to a completed thread only for a bot ($bot)", + ({ bot }) => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay({ + bot, + replies: [ + reply( + "# Newer\nSaved to [trip.md](/tmp/trip.md): **three** flights.", + "2026-09-04T12:00:02Z", + ), + reply("Older reply", "2026-09-04T12:00:01Z"), + reply(" ", "2026-09-04T12:00:03Z"), + ], + }); + yield* Ref.set(currentShell, shell({ latestRunId: RunId.make("run:reply") })); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set( + currentShell, + shell({ status: "completed", latestRunId: RunId.make("run:reply") }), + ); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase), + ["running", "completed"], + ); + assert.isUndefined(publications[0]?.state?.message); + assert.equal( + publications[1]?.state?.message, + bot ? "Newer Saved to trip.md: three flights." : undefined, + ); + }), + ); + + it.effect("caps a long bot reply preview", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay({ + bot: true, + replies: [reply("word ".repeat(100), "2026-09-04T12:00:01Z")], + }); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set( + currentShell, + shell({ status: "completed", latestRunId: RunId.make("run:reply") }), + ); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications[1]?.state?.message, "word ".repeat(31) + "word"); + }), + ); + it.effect("republishes unchanged state with fresh credentials after relinking", () => Effect.gen(function* () { const { relay, secrets, publications } = yield* makeTestRelay(); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 076afc92d733a..5fd986d34d393 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -1,6 +1,7 @@ import { isListedThreadLineage, type EnvironmentId, + type OrchestrationV2ConversationMessage, type OrchestrationV2DomainEvent, type OrchestrationV2ThreadShell, type OrchestrationV2TurnItem, @@ -13,6 +14,7 @@ import { type RelayAgentActivityState, } from "@t3tools/contracts/relay"; import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; +import { markdownPreviewText } from "@t3tools/shared/markdownPreview"; import { turnItemUpdateCanEndBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; @@ -196,12 +198,24 @@ function sanitizeRelayAgentActivityState( if (state === null) { return null; } - const { detail: _detail, ...rest } = state; + const { detail: _detail, message: _message, ...rest } = state; const detail = (state.phase === "failed" ? REDACTED_RELAY_AGENT_FAILURE_DETAIL : state.detail) ?.trim() .slice(0, RELAY_AGENT_ACTIVITY_DETAIL_MAX_LENGTH) .trim(); - return detail ? { ...rest, detail } : rest; + const message = state.message?.trim().slice(0, RELAY_AGENT_ACTIVITY_DETAIL_MAX_LENGTH).trim(); + return { ...rest, ...(detail ? { detail } : {}), ...(message ? { message } : {}) }; +} + +function latestReplyPreview(messages: ReadonlyArray) { + const newestFirst = messages.toSorted( + (a, b) => DateTime.toEpochMillis(b.createdAt) - DateTime.toEpochMillis(a.createdAt), + ); + for (const message of newestFirst) { + const preview = markdownPreviewText(message.text); + if (preview) return preview; + } + return undefined; } function relayEnvironmentClient(token: string) { @@ -523,6 +537,39 @@ export const make = Effect.gen(function* () { }); }); + // A bot's reply is its message; other threads alert on status alone. + const resolveSnapshot = Effect.fn("AgentAwarenessRelay.resolveSnapshot")(function* (input: { + readonly thread: Option.Option; + readonly project: Option.Option; + }) { + const snapshot = resolveAgentAwarenessRelayPublishSnapshot({ environmentId, ...input }); + const latestRunId = Option.isSome(input.thread) ? input.thread.value.latestRunId : null; + if ( + snapshot.state?.phase !== "completed" || + Option.isNone(input.thread) || + Option.isNone(input.project) || + !input.project.value.bot || + latestRunId == null + ) { + return snapshot; + } + const { messages } = yield* threads + .getThreadRecords(input.thread.value.id, ["messages"], { + messageRunIds: [latestRunId], + messageRoles: ["assistant"], + }) + .pipe( + Effect.tapError((cause) => + Effect.logWarning("bot reply unavailable for agent activity", { threadId, cause }), + ), + Effect.orElseSucceed(() => ({ messages: [] })), + ); + const message = latestReplyPreview(messages); + return message === undefined + ? snapshot + : { ...snapshot, state: sanitizeRelayAgentActivityState({ ...snapshot.state, message }) }; + }); + // Read only this thread; full shell reads would make each activity event scale with all threads. const threadShell = yield* threads.getThreadShell(threadId); if ( @@ -540,11 +587,7 @@ export const make = Effect.gen(function* () { const project = Option.isSome(thread) ? yield* projects.getById(thread.value.projectId) : Option.none(); - const snapshot = resolveAgentAwarenessRelayPublishSnapshot({ - environmentId, - thread, - project, - }); + const snapshot = yield* resolveSnapshot({ thread, project }); const publishIdentity = agentAwarenessPublishIdentity(snapshot.state); const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef); if ( @@ -606,8 +649,7 @@ export const make = Effect.gen(function* () { heldCompletions.has(threadId) && Option.isSome(thread) && thread.value.settledOverride === "settled" - ? resolveAgentAwarenessRelayPublishSnapshot({ - environmentId, + ? yield* resolveSnapshot({ thread: Option.some({ ...thread.value, settledOverride: null }), project, }) diff --git a/infra/relay/src/agentActivity/agentActivityAggregate.ts b/infra/relay/src/agentActivity/agentActivityAggregate.ts index 99fdcf71ef90d..ba235d13dc73f 100644 --- a/infra/relay/src/agentActivity/agentActivityAggregate.ts +++ b/infra/relay/src/agentActivity/agentActivityAggregate.ts @@ -40,6 +40,7 @@ function aggregateRowForState(state: RelayAgentActivityState) { modelTitle: state.modelTitle, phase: state.phase, status: statusForPhase(state.phase), + ...(state.message === undefined ? {} : { message: state.message }), updatedAt: state.updatedAt, deepLink: state.deepLink, }; diff --git a/infra/relay/src/agentActivity/agentActivityAlerts.ts b/infra/relay/src/agentActivity/agentActivityAlerts.ts index c6008950e95e8..9cd885656d562 100644 --- a/infra/relay/src/agentActivity/agentActivityAlerts.ts +++ b/infra/relay/src/agentActivity/agentActivityAlerts.ts @@ -6,6 +6,8 @@ import type { import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; +import { alertBodyForActivity } from "./agentActivityPayloads.ts"; + export interface AgentActivityAlert { readonly title: string; readonly body: string; @@ -119,7 +121,7 @@ export function alertForActivityRows( const first = rows[0]; if (!first) return null; if (rows.length === 1) { - return { title: first.threadTitle, body: `${first.status}: ${first.projectTitle}` }; + return { title: first.threadTitle, body: alertBodyForActivity(first) }; } return { title: `${rows.length} agents ${isAttentionPhase(first.phase) ? "need attention" : "finished"}`, diff --git a/infra/relay/src/agentActivity/agentActivityPayloads.ts b/infra/relay/src/agentActivity/agentActivityPayloads.ts index 4195c81171803..0fb8ab9dd3940 100644 --- a/infra/relay/src/agentActivity/agentActivityPayloads.ts +++ b/infra/relay/src/agentActivity/agentActivityPayloads.ts @@ -72,6 +72,9 @@ function sanitizeAgentActivityAggregateRow( threadTitle: truncateText(row.threadTitle, MAX_SUMMARY_TEXT_LENGTH), modelTitle: truncateText(row.modelTitle, MAX_SUMMARY_TEXT_LENGTH), status: truncateText(row.status, MAX_STATUS_TEXT_LENGTH), + ...(row.message === undefined + ? {} + : { message: truncateText(row.message, MAX_SUMMARY_TEXT_LENGTH) }), deepLink: sanitizeDeepLink(row.deepLink), }; } @@ -100,13 +103,18 @@ export function sanitizeApnsNotificationPayload( }; } +/** A bot's reply reads as the message; every other alert names the status and project. */ +export function alertBodyForActivity(row: RelayAgentActivityAggregateRow): string { + return row.message ?? `${row.status}: ${row.projectTitle}`; +} + export function notificationForActivity( row: RelayAgentActivityAggregateRow, ): ApnsNotificationPayload { const activity = sanitizeAgentActivityAggregateRow(row); return sanitizeApnsNotificationPayload({ title: activity.threadTitle, - body: `${activity.status}: ${activity.projectTitle}`, + body: alertBodyForActivity(activity), environmentId: activity.environmentId, threadId: activity.threadId, deepLink: activity.deepLink, diff --git a/infra/relay/src/agentActivity/agentActivityPolicy.test.ts b/infra/relay/src/agentActivity/agentActivityPolicy.test.ts index e73cfd85145a5..72ea90a19bf2c 100644 --- a/infra/relay/src/agentActivity/agentActivityPolicy.test.ts +++ b/infra/relay/src/agentActivity/agentActivityPolicy.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "@effect/vitest"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { RelayAgentActivityState } from "@t3tools/contracts/relay"; import { makeAggregateState } from "./agentActivityAggregate.ts"; +import { notificationForActivity } from "./agentActivityPayloads.ts"; import { + alertForActivityRows, attentionTransitionRows, terminalTransitionRows, shouldAlertForActivity, @@ -53,6 +55,16 @@ describe("shared agent activity policy", () => { }, ); + it("alerts with a reply message when a row has one, and the status otherwise", () => { + const done = { ...state, phase: "completed" as const }; + const [plain] = aggregate([done]).activities; + const [replied] = aggregate([{ ...done, message: "m".repeat(200) }]).activities; + expect(alertForActivityRows([plain!])).toEqual({ title: "Thread", body: "Done: Project" }); + expect(alertForActivityRows([replied!])?.body).toBe("m".repeat(117) + "..."); + expect(notificationForActivity(replied!).body).toBe("m".repeat(117) + "..."); + expect(alertForActivityRows([replied!, plain!])?.body).toBe("Thread, Thread"); + }); + it("allows fresh unobserved completions for events, but not reconciliation or repeats", () => { const previousAggregate = aggregate([ { ...state, threadId: ThreadId.make("old"), phase: "completed" }, diff --git a/packages/client-runtime/src/state/bots.test.ts b/packages/client-runtime/src/state/bots.test.ts index 9d9a70ca8d31a..a1c03902842fb 100644 --- a/packages/client-runtime/src/state/bots.test.ts +++ b/packages/client-runtime/src/state/bots.test.ts @@ -153,7 +153,7 @@ describe("bots", () => { { threadId: botThreadId, role: "assistant", - text: "Saved to [trip.md](/Users/me/bots/scout/artifacts/trip.md):\n- **Found** three\n`flights`.", + text: "Saved to trip.md:\n Found three\nflights.", updatedAt: "2026-06-20T10:00:00.000Z", }, { diff --git a/packages/client-runtime/src/state/bots.ts b/packages/client-runtime/src/state/bots.ts index ba181de832a70..9c3ad49806b1b 100644 --- a/packages/client-runtime/src/state/bots.ts +++ b/packages/client-runtime/src/state/bots.ts @@ -9,6 +9,7 @@ import { type ThreadId, WS_METHODS, } from "@t3tools/contracts"; +import { markdownPreviewText } from "@t3tools/shared/markdownPreview"; import { AsyncResult, Atom } from "effect/reactivity"; import type * as EnvironmentRegistry from "../connection/registry.ts"; @@ -67,17 +68,8 @@ function hasUnreadBotReply(shell: EnvironmentThreadShell): boolean { return Date.parse(completedAt) > Date.parse(shell.lastVisitedAt); } -// A one-line preview reads the text a renderer would show, not its Markdown syntax. -function plainText(markdown: string): string { - return markdown - .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") - .replace(/^\s{0,3}(?:#{1,6}\s+|>\s?|[-*+]\s+|\d+[.)]\s+)/gm, "") - .replace(/(\*\*|__|`+)(.+?)\1/g, "$2") - .replace(/(^|\W)[*_](\S(?:.*?\S)?)[*_](?=\W|$)/g, "$1$2"); -} - function previewText(preview: BotPreview): string | null { - const line = plainText(preview.text).replace(/\s+/g, " ").trim(); + const line = markdownPreviewText(preview.text); if (line.length === 0) return null; return (preview.role === "user" ? `You: ${line}` : line).slice(0, 160); } diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 8ebc9df23a0bb..eb8d22dddfaf9 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -124,6 +124,7 @@ export const RelayAgentActivityState = Schema.Struct({ phase: RelayAgentAwarenessPhase, headline: TrimmedNonEmptyString, detail: Schema.optional(TrimmedNonEmptyString), + message: Schema.optional(TrimmedNonEmptyString), modelTitle: TrimmedNonEmptyString, updatedAt: TrimmedNonEmptyString, deepLink: TrimmedNonEmptyString, @@ -138,6 +139,7 @@ export const RelayAgentActivityAggregateRow = Schema.Struct({ modelTitle: TrimmedNonEmptyString, phase: RelayAgentAwarenessPhase, status: TrimmedNonEmptyString, + message: Schema.optional(TrimmedNonEmptyString), updatedAt: TrimmedNonEmptyString, deepLink: TrimmedNonEmptyString, }); diff --git a/packages/shared/package.json b/packages/shared/package.json index 19542c4459091..bc181630cd779 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -19,6 +19,10 @@ "types": "./src/delimitedPreview.ts", "import": "./src/delimitedPreview.ts" }, + "./markdownPreview": { + "types": "./src/markdownPreview.ts", + "import": "./src/markdownPreview.ts" + }, "./composerPullRequestMatches": { "types": "./src/composerPullRequestMatches.ts", "import": "./src/composerPullRequestMatches.ts" diff --git a/packages/shared/src/markdownPreview.test.ts b/packages/shared/src/markdownPreview.test.ts new file mode 100644 index 0000000000000..8092d80c0af5d --- /dev/null +++ b/packages/shared/src/markdownPreview.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { markdownPreviewText } from "./markdownPreview.ts"; + +describe("markdownPreviewText", () => { + it("keeps the text a renderer would show on one line", () => { + expect( + markdownPreviewText( + "# Trip\nSaved to [trip.md](/Users/me/trip.md):\n- **Found** three\n> `flights`, _soon_.", + ), + ).toBe("Trip Saved to trip.md: Found three flights, soon."); + }); +}); diff --git a/packages/shared/src/markdownPreview.ts b/packages/shared/src/markdownPreview.ts new file mode 100644 index 0000000000000..39395dd03a974 --- /dev/null +++ b/packages/shared/src/markdownPreview.ts @@ -0,0 +1,10 @@ +/** The text a renderer would show for `markdown`, on one line, without its syntax. */ +export function markdownPreviewText(markdown: string): string { + return markdown + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/^\s{0,3}(?:#{1,6}\s+|>\s?|[-*+]\s+|\d+[.)]\s+)/gm, "") + .replace(/(\*\*|__|`+)(.+?)\1/g, "$2") + .replace(/(^|\W)[*_](\S(?:.*?\S)?)[*_](?=\W|$)/g, "$1$2") + .replace(/\s+/g, " ") + .trim(); +} From 3a1d950b83c64a7099139a7aece197987cc2c529 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Mon, 2 Nov 2026 23:52:38 +0530 Subject: [PATCH 30/40] fix(bots): only a bot's own thread gets the bot header Subagent threads share their parent's project, so a bot's subagents showed the bot's name and lost their own title and breadcrumb. The bot header now applies only to the thread the bot owns. Delete bot also follows the delete permission, like mobile does. --- apps/web/src/components/bots/BotDetails.tsx | 3 +++ apps/web/src/components/chat/ChatHeader.tsx | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/bots/BotDetails.tsx b/apps/web/src/components/bots/BotDetails.tsx index 1372cb8c56dd2..35b5fa91cc0f3 100644 --- a/apps/web/src/components/bots/BotDetails.tsx +++ b/apps/web/src/components/bots/BotDetails.tsx @@ -11,6 +11,7 @@ import { type ScopedThreadRef, } from "@t3tools/contracts"; import { formatModelSlugName } from "@t3tools/shared/model"; +import { useAtomValue } from "@effect/atom-react"; import { useNavigate } from "@tanstack/react-router"; import { FileIcon, FolderIcon, FolderOpenIcon, SparklesIcon, Trash2Icon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; @@ -364,6 +365,7 @@ function BotComputer({ project }: { project: EnvironmentProject }) { const navigate = useNavigate(); const { presentation } = useEnvironmentPresentation(project.environmentId); const deleteBot = useOrchestrationCommand(serverEnvironment.deleteBot, { reportFailure: false }); + const canDelete = useAtomValue(serverEnvironment.deleteBot.permissionAtom(project.environmentId)); const [confirming, setConfirming] = useState(false); const remove = async () => { const result = await deleteBot({ @@ -395,6 +397,7 @@ function BotComputer({ project }: { project: EnvironmentProject }) {

    • From 0887434b8af9331fb8eebe126126d7e8078acdd5 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Tue, 3 Nov 2026 00:10:31 +0530 Subject: [PATCH 34/40] fix(mobile): toggle rows forward the leading slot --- apps/mobile/src/components/ui/ListToggleRow.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/src/components/ui/ListToggleRow.tsx b/apps/mobile/src/components/ui/ListToggleRow.tsx index edff3490bb5fe..fd120ce6fd759 100644 --- a/apps/mobile/src/components/ui/ListToggleRow.tsx +++ b/apps/mobile/src/components/ui/ListToggleRow.tsx @@ -13,6 +13,7 @@ export function ListToggleRow( return ( Date: Tue, 3 Nov 2026 00:43:51 +0530 Subject: [PATCH 35/40] fix(bots): New bot form lines up its model field and colours --- .../src/components/bots/BotAvatarPicker.tsx | 10 ++- .../src/components/bots/CreateBotDialog.tsx | 49 ++++++------ .../components/chat/ProviderModelPicker.tsx | 74 +++++++++++-------- 3 files changed, 74 insertions(+), 59 deletions(-) diff --git a/apps/web/src/components/bots/BotAvatarPicker.tsx b/apps/web/src/components/bots/BotAvatarPicker.tsx index ca6695c29e168..526d78d5807ee 100644 --- a/apps/web/src/components/bots/BotAvatarPicker.tsx +++ b/apps/web/src/components/bots/BotAvatarPicker.tsx @@ -40,7 +40,13 @@ export function BotAvatarPicker({ ))} -
      +
      {BOT_AVATAR_COLORS.map((option) => (
      ) : null} diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 2e47b4c2fe3b4..6f6c8a77eb38a 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -5,9 +5,10 @@ import { type ProviderDriverKind, type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; -import { createContext, memo, type ReactNode, useRef, useState } from "react"; +import { createContext, memo, type MouseEvent, type ReactNode, useRef, useState } from "react"; import { Badge } from "../ui/badge"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { SelectButton } from "../ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; import { ModelPickerContent, resolveModelPickerSelectedModel } from "./ModelPickerContent"; @@ -94,6 +95,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { open?: boolean; popupAlign?: "start" | "end"; triggerClassName?: string; + /** `field` draws the trigger as a full-width form select instead of a composer chip. */ + triggerVariant?: "composer" | "field"; triggerLabel?: string; triggerAriaLabel?: string; onOpenChange?: (open: boolean) => void; @@ -287,6 +290,28 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { /> ); + const isField = props.triggerVariant === "field"; + const triggerProps = { + ref: triggerRef, + "aria-label": [ + props.triggerAriaLabel ?? props.triggerLabel ?? allModelNames ?? triggerLabel, + props.triggerDetail, + ] + .filter(Boolean) + .join(", "), + "data-chat-provider-model-picker": "true", + "data-composer-shortcut": props.triggerShortcuts, + disabled: props.disabled, + onClick: (event: MouseEvent) => { + if (!isMenuOpen || !docked) return; + const target = takeOpenTarget(triggerRef.current); + if (!target) return; + // A shortcut aimed at the open picker moves within it instead of closing it. + (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); + openTarget(target); + }, + }; + return ( { - if (!isMenuOpen || !docked) return; - const target = takeOpenTarget(triggerRef.current); - if (!target) return; - // A shortcut aimed at the open picker moves within it instead of closing it. - ( - event as typeof event & { preventBaseUIHandler?: () => void } - ).preventBaseUIHandler?.(); - openTarget(target); - }} - className={cn( - "min-w-0 shrink justify-between whitespace-nowrap", - props.compact - ? "max-w-42 shrink-0" - : !props.isComposerOwned && "max-w-48 sm:max-w-56", - props.triggerClassName, - )} - disabled={props.disabled} - /> + isField ? ( + + ) : ( + + ) } > ) : null} - {props.isComposerOwned ? ( + {props.isComposerOwned || isField ? ( {triggerText} ) : ( From 65afc33c1f486c4aeaa3d10d2f09ecf7c46d4406 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Tue, 3 Nov 2026 00:49:17 +0530 Subject: [PATCH 36/40] fix(bots): colour swatches wrap instead of shrinking in the avatar popover --- apps/web/src/components/bots/BotAvatarPicker.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/bots/BotAvatarPicker.tsx b/apps/web/src/components/bots/BotAvatarPicker.tsx index 526d78d5807ee..db78cc5d6f2d5 100644 --- a/apps/web/src/components/bots/BotAvatarPicker.tsx +++ b/apps/web/src/components/bots/BotAvatarPicker.tsx @@ -43,9 +43,8 @@ export function BotAvatarPicker({
      {BOT_AVATAR_COLORS.map((option) => ( - + ); })}
    ); } +function ArtifactFile({ + path, + name, + ...props +}: ArtifactFolderProps & { path: string; name: string }) { + const { resolvedTheme } = useTheme(); + const [saving, setSaving] = useState(false); + const prepareDownload = useAssetUrlRefresh(props.project.environmentId, { + _tag: "media-file", + threadId: props.threadRef.threadId, + path: resolvePathLinkTarget(path, props.project.workspaceRoot), + disposition: "attachment", + }); + const download = async () => { + setSaving(true); + try { + const url = await prepareDownload(); + if (!url) throw new Error("Reconnect to the computer and try again."); + await saveUrlAsFile(url, name); + } catch (cause) { + toastManager.add({ + type: "error", + title: "Could not download file", + description: cause instanceof Error ? cause.message : "Try again.", + }); + } finally { + setSaving(false); + } + }; + return ( +
  • + + + + +
  • + ); +} + function ArtifactFolder(props: ArtifactFolderProps) { const { entries } = useArtifactEntries(props); return ; diff --git a/apps/web/src/components/files/AttachmentFilePreview.tsx b/apps/web/src/components/files/AttachmentFilePreview.tsx index 8a95cfecb56d5..3c704b512dfc2 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.tsx @@ -14,7 +14,7 @@ import { ChevronRightIcon, DownloadIcon, Trash2Icon, WrapTextIcon, XIcon } from import { Check, Code2, Copy, Eye, Table2 } from "lucide"; import { lazy, Suspense, useEffect, useRef, useState } from "react"; -import { useAssetUrlRefresh } from "~/assets/assetUrls"; +import { saveBlobAsFile, saveUrlAsFile, useAssetUrlRefresh } from "~/assets/assetUrls"; import ChatMarkdown from "~/components/ChatMarkdown"; import { maskMarkdownFrontmatter } from "@t3tools/shared/markdownFrontmatter"; import { MorphIcon } from "~/components/MorphIcon"; @@ -236,22 +236,13 @@ export function AttachmentFilePreview(props: { const save = () => { setSaving(true); void settlePromise(async () => { - let file = props.file; - if (!file) { - const target = await prepareDownload(); - if (!target) throw new Error("Reconnect to the environment and try again."); - const response = await fetch(target); - if (!response.ok) throw new Error("The file could not be loaded. Try again."); - file = await response.blob(); + if (props.file) { + saveBlobAsFile(props.file, props.name); + return; } - // Blob downloads keep desktop's custom app scheme from navigating to an external browser. - const downloadUrl = URL.createObjectURL(file); - // Scheduled before the click so the URL is revoked even if the click throws. - setTimeout(() => URL.revokeObjectURL(downloadUrl), 30_000); - const anchor = document.createElement("a"); - anchor.href = downloadUrl; - anchor.download = props.name; - anchor.click(); + const target = await prepareDownload(); + if (!target) throw new Error("Reconnect to the environment and try again."); + await saveUrlAsFile(target, props.name); }).then((result) => { if (!AsyncResult.isSuccess(result)) { const cause = Cause.squash(result.cause); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index b88d92fc4edcc..e9f4d16ace123 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -50,7 +50,7 @@ import * as Schema from "effect/Schema"; import { lazy, Suspense, useEffect, useRef, useState } from "react"; import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; -import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; +import { saveUrlAsFile, useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer"; import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions"; @@ -1640,14 +1640,7 @@ export function WorkspaceLargeTextPreview(props: { void (async () => { const url = await refreshAssetUrl(); if (!url) throw new Error("Reconnect to the environment and try again."); - const response = await fetch(url); - if (!response.ok) throw new Error("The file could not be loaded. Try again."); - const downloadUrl = URL.createObjectURL(await response.blob()); - setTimeout(() => URL.revokeObjectURL(downloadUrl), 30_000); - const anchor = document.createElement("a"); - anchor.href = downloadUrl; - anchor.download = props.name.split(/[\\/]/).at(-1) || props.name; - anchor.click(); + await saveUrlAsFile(url, props.name.split(/[\\/]/).at(-1) || props.name); })() .catch(showError) .finally(() => setSaving(false)); From 4b2393fcf6e764391ec4827a1e5eb2096fb10d0b Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Tue, 3 Nov 2026 02:03:12 +0530 Subject: [PATCH 38/40] fix(mobile): Settings keeps its workspaceRoot prop The Bots tab change dropped the prop that Settings' split pane and sidebar pass, which broke the mobile typecheck. --- .../src/features/settings/components/SettingsScreen.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/settings/components/SettingsScreen.tsx b/apps/mobile/src/features/settings/components/SettingsScreen.tsx index 776c1e098539e..b61a57c8b7536 100644 --- a/apps/mobile/src/features/settings/components/SettingsScreen.tsx +++ b/apps/mobile/src/features/settings/components/SettingsScreen.tsx @@ -22,6 +22,7 @@ export function SettingsScreen( props: Pick & { readonly children: ReactNode; readonly formSheet?: boolean; + readonly workspaceRoot?: boolean; }, ) { const navigation = useNavigation(); @@ -34,7 +35,8 @@ export function SettingsScreen( const cover = deviceType === "duo-folded"; // Android opens Settings as a workspace tab; its root page has nothing to go back to. const workspaceRoot = - Platform.OS === "android" && workspace !== null && route.name === "Settings"; + props.workspaceRoot || + (Platform.OS === "android" && workspace !== null && route.name === "Settings"); // Settings' grouped cards only lift off the screen surface, so the folded Duo doesn't take // the workspace's drawer surface here. From 88e956831b904378e6daf4cc4cbd7315a58f995a Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Tue, 3 Nov 2026 02:03:27 +0530 Subject: [PATCH 39/40] fix(bots): artifact download handler compiles with React Compiler throw and finally inside the handler made React Compiler skip ArtifactFile. --- apps/web/src/components/bots/BotDetails.tsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/bots/BotDetails.tsx b/apps/web/src/components/bots/BotDetails.tsx index e0ab3f6ef81e9..705afe6b2019b 100644 --- a/apps/web/src/components/bots/BotDetails.tsx +++ b/apps/web/src/components/bots/BotDetails.tsx @@ -323,18 +323,17 @@ function ArtifactFile({ }); const download = async () => { setSaving(true); + let failure: string | null = null; try { const url = await prepareDownload(); - if (!url) throw new Error("Reconnect to the computer and try again."); - await saveUrlAsFile(url, name); + if (url) await saveUrlAsFile(url, name); + else failure = "Reconnect to the computer and try again."; } catch (cause) { - toastManager.add({ - type: "error", - title: "Could not download file", - description: cause instanceof Error ? cause.message : "Try again.", - }); - } finally { - setSaving(false); + failure = cause instanceof Error ? cause.message : "Try again."; + } + setSaving(false); + if (failure) { + toastManager.add({ type: "error", title: "Could not download file", description: failure }); } }; return ( From cd6ac9361291396622dbd0202b688f321da41258 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Tue, 3 Nov 2026 02:17:39 +0530 Subject: [PATCH 40/40] fix(bots): artifact download errors always toast An error with an empty message skipped the toast. --- apps/web/src/components/bots/BotDetails.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/bots/BotDetails.tsx b/apps/web/src/components/bots/BotDetails.tsx index 705afe6b2019b..49c2f4fd732eb 100644 --- a/apps/web/src/components/bots/BotDetails.tsx +++ b/apps/web/src/components/bots/BotDetails.tsx @@ -329,7 +329,7 @@ function ArtifactFile({ if (url) await saveUrlAsFile(url, name); else failure = "Reconnect to the computer and try again."; } catch (cause) { - failure = cause instanceof Error ? cause.message : "Try again."; + failure = (cause instanceof Error && cause.message) || "Try again."; } setSaving(false); if (failure) {