diff --git a/apps/mobile/assets/icons/bot-face-fill.png b/apps/mobile/assets/icons/bot-face-fill.png new file mode 100644 index 0000000000000..9a7555017351f Binary files /dev/null and b/apps/mobile/assets/icons/bot-face-fill.png differ diff --git a/apps/mobile/assets/icons/bot-face-fill@2x.png b/apps/mobile/assets/icons/bot-face-fill@2x.png new file mode 100644 index 0000000000000..c1019dcdfa8f7 Binary files /dev/null and b/apps/mobile/assets/icons/bot-face-fill@2x.png differ diff --git a/apps/mobile/assets/icons/bot-face-fill@3x.png b/apps/mobile/assets/icons/bot-face-fill@3x.png new file mode 100644 index 0000000000000..349990bd8aea1 Binary files /dev/null and b/apps/mobile/assets/icons/bot-face-fill@3x.png differ diff --git a/apps/mobile/assets/icons/bot-face.png b/apps/mobile/assets/icons/bot-face.png new file mode 100644 index 0000000000000..1474c032158e8 Binary files /dev/null and b/apps/mobile/assets/icons/bot-face.png differ diff --git a/apps/mobile/assets/icons/bot-face@2x.png b/apps/mobile/assets/icons/bot-face@2x.png new file mode 100644 index 0000000000000..d20eebaf23cb7 Binary files /dev/null and b/apps/mobile/assets/icons/bot-face@2x.png differ diff --git a/apps/mobile/assets/icons/bot-face@3x.png b/apps/mobile/assets/icons/bot-face@3x.png new file mode 100644 index 0000000000000..efd3b559e8513 Binary files /dev/null and b/apps/mobile/assets/icons/bot-face@3x.png differ diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt index 736f7cd3019f0..18b5790947998 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt @@ -126,7 +126,7 @@ class T3WorkspaceNavigationView( private val onTabSelected by EventDispatcher() private val onNewThread by EventDispatcher() private val onOverlayChange by EventDispatcher() - var tabs by mutableStateOf(listOf("threads", "pullRequests", "usage", "settings")) + var tabs by mutableStateOf(listOf("threads", "bots", "pullRequests", "settings")) var selectedTab by mutableStateOf("threads") var rail by mutableStateOf(false) var floating by mutableStateOf(false) @@ -414,8 +414,8 @@ class T3WorkspaceNavigationView( private fun tabTitle(tab: String) = when (tab) { "threads" -> "Chats" + "bots" -> "Bots" "pullRequests" -> "Pulls" - "usage" -> "Usage" else -> "Settings" } @@ -426,8 +426,8 @@ class T3WorkspaceNavigationView( private fun tabIconPainter(tab: String) = painterResource( when (tab) { "threads" -> R.drawable.t3_chats + "bots" -> R.drawable.t3_bots "pullRequests" -> R.drawable.t3_pull_requests - "usage" -> R.drawable.t3_usage else -> R.drawable.t3_settings } ) 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_bots.xml similarity index 58% rename from apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_usage.xml rename to apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_bots.xml index da09809d177ff..c6774a1d1d1e0 100644 --- 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_bots.xml @@ -1,3 +1,3 @@ - + diff --git a/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/bot-face-fill.png b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/bot-face-fill.png new file mode 100644 index 0000000000000..9a7555017351f Binary files /dev/null and b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/bot-face-fill.png differ diff --git a/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/bot-face-fill@2x.png b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/bot-face-fill@2x.png new file mode 100644 index 0000000000000..c1019dcdfa8f7 Binary files /dev/null and b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/bot-face-fill@2x.png differ diff --git a/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/bot-face-fill@3x.png b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/bot-face-fill@3x.png new file mode 100644 index 0000000000000..349990bd8aea1 Binary files /dev/null and b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/bot-face-fill@3x.png differ diff --git a/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/bot-face.png b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/bot-face.png new file mode 100644 index 0000000000000..1474c032158e8 Binary files /dev/null and b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/bot-face.png differ diff --git a/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/bot-face@2x.png b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/bot-face@2x.png new file mode 100644 index 0000000000000..d20eebaf23cb7 Binary files /dev/null and b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/bot-face@2x.png differ diff --git a/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/bot-face@3x.png b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/bot-face@3x.png new file mode 100644 index 0000000000000..efd3b559e8513 Binary files /dev/null and b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/bot-face@3x.png differ diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec b/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec index c32f88b0dacb3..6c8ffdeb9c083 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec @@ -15,6 +15,6 @@ Pod::Spec.new do |s| s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', } - s.resource_bundles = { 'T3NativeControlIcons' => ['Resources/Icons/pull-request*.png'] } + s.resource_bundles = { 'T3NativeControlIcons' => ['Resources/Icons/pull-request*.png', 'Resources/Icons/bot-face*.png'] } s.source_files = '**/*.{h,m,mm,swift,hpp,cpp}' end diff --git a/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift index f96c00962682c..5dc05724119a0 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift @@ -33,7 +33,7 @@ final class T3SidebarTabBarView: ExpoView, UITabBarDelegate { private let composeButton = UIButton(type: .system) private lazy var tabBarTrailing = tabBar.trailingAnchor.constraint(equalTo: trailingAnchor) private var itemsByTab: [String: UITabBarItem] = [:] - var tabs = ["threads", "pullRequests", "usage", "settings"] { + var tabs = ["threads", "bots", "pullRequests", "settings"] { didSet { if tabs != oldValue { updateItems() } } } var selectedTab = "threads" { didSet { updateSelection() } } @@ -99,14 +99,18 @@ final class T3SidebarTabBarView: ExpoView, UITabBarDelegate { private func prepareItems() { let iconBundle = Bundle(for: Self.self).url(forResource: "T3NativeControlIcons", withExtension: "bundle").flatMap(Bundle.init(url:)) let pullRequestIcon = UIImage(named: "pull-request", in: iconBundle, compatibleWith: nil)?.withRenderingMode(.alwaysTemplate) - let labels = ["threads": "Chats", "pullRequests": "PRs", "usage": "Usage", "settings": "Settings"] - let symbols = ["threads": "text.bubble", "usage": "chart.bar.xaxis", "settings": "gearshape"] + let labels = ["threads": "Chats", "bots": "Bots", "pullRequests": "PRs", "settings": "Settings"] + let botIcon = UIImage(named: "bot-face", in: iconBundle, compatibleWith: nil)?.withRenderingMode(.alwaysTemplate) + let symbols = ["threads": "text.bubble", "settings": "gearshape"] for (tab, label) in labels { - let icon = tab == "pullRequests" ? pullRequestIcon : UIImage(systemName: symbols[tab] ?? "circle") + let icon = tab == "pullRequests" ? pullRequestIcon : tab == "bots" ? botIcon : UIImage(systemName: symbols[tab] ?? "circle") let item = UITabBarItem(title: nil, image: tabIcon(icon), tag: 0) item.accessibilityLabel = label item.accessibilityIdentifier = "workspace-tab-\(tab)" if tab == "threads" { item.selectedImage = tabIcon(UIImage(systemName: "text.bubble.fill")) } + if tab == "bots" { + item.selectedImage = tabIcon(UIImage(named: "bot-face-fill", in: iconBundle, compatibleWith: nil)?.withRenderingMode(.alwaysTemplate)) + } itemsByTab[tab] = item } } diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 872ca142c8aaa..0fc8a3f87d89e 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -124,6 +124,9 @@ 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 { BotDetailsRouteScreen } from "./features/bots/BotDetailsRouteScreen"; import { UsageLimitAccountScreen } from "./features/usage/UsageLimitsPooled"; import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; import { SettingsAboutRouteScreen } from "./features/settings/SettingsAboutRouteScreen"; @@ -726,6 +729,25 @@ 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), + }, + }), + BotDetails: createNativeStackScreen({ + screen: BotDetailsRouteScreen, + options: { ...SETTINGS_PAGE_OPTIONS, title: "Bot" }, + }), NewTaskProject: createNativeStackScreen({ screen: NewTaskRouteScreen, linking: { path: "new", alias: ["new/project"] }, diff --git a/apps/mobile/src/components/ui/ListRowLayout.tsx b/apps/mobile/src/components/ui/ListRowLayout.tsx index 8564e7d13accf..ca369ef58a12f 100644 --- a/apps/mobile/src/components/ui/ListRowLayout.tsx +++ b/apps/mobile/src/components/ui/ListRowLayout.tsx @@ -7,6 +7,8 @@ import { SymbolView, type AppSymbolName } from "./AppSymbol"; export interface ListRowContentProps { readonly icon?: AppSymbolName; + /** Replaces `icon` when the row needs a non-symbol glyph, such as a file-type icon. */ + readonly leading?: ReactNode; readonly label: string; readonly description?: string; readonly detail?: string; @@ -15,6 +17,7 @@ export interface ListRowContentProps { export function ListRowLayout({ icon, + leading, label, description, detail, @@ -32,7 +35,9 @@ export function ListRowLayout({ disabled && "opacity-45", )} > - {icon ? ( + {leading ? ( + {leading} + ) : icon ? ( (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) => ( + + ))} + + + + + ); +} + +function BotAvatarShape(props: ShapeProps) { + // A new key remounts the drawing so a changed avatar springs in again. + 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 }; + readonly pose?: ShapeProps["pose"]; + /** Springs in again whenever the shape or color changes. */ + readonly animateChanges?: boolean; +}) { + 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..987f486949bc5 --- /dev/null +++ b/apps/mobile/src/features/bots/BotAvatarPicker.tsx @@ -0,0 +1,70 @@ +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/BotDetailsRouteScreen.tsx b/apps/mobile/src/features/bots/BotDetailsRouteScreen.tsx new file mode 100644 index 0000000000000..3f7c2fa35c23e --- /dev/null +++ b/apps/mobile/src/features/bots/BotDetailsRouteScreen.tsx @@ -0,0 +1,404 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { + BOT_AVATAR_COLORS, + BOT_AVATAR_SHAPES, + BOT_DESCRIPTION_MAX_LENGTH, + BOT_NAME_MAX_LENGTH, + type BotAvatarColor, + type BotAvatarShape, + type EnvironmentId, + type ProjectBot, + type ProjectId, +} from "@t3tools/contracts"; +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { useRef, useState } from "react"; +import { View } from "react-native"; + +import { AppText as Text, AppTextInput } from "../../components/ui/AppText"; +import { Button } from "../../components/ui/Button"; +import * as Dialog from "../../components/ui/Dialog"; +import { PierreEntryIcon } from "../../components/PierreEntryIcon"; +import { ListRow } from "../../components/ui/ListRow"; +import { ListRowLayout } from "../../components/ui/ListRowLayout"; +import { ListToggleRow } from "../../components/ui/ListToggleRow"; +import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; +import { useBotProject } from "../../state/bots"; +import { useEnvironmentServerConfig } from "../../state/entities"; +import { projectEnvironment } from "../../state/projects"; +import { useEnvironmentQuery } from "../../state/query"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useWorkspaceEnvironments } from "../../state/workspace"; +import { fileRoutePathSegments } from "../files/filePath"; +import { SettingsScreen } from "../settings/components/SettingsScreen"; +import { SettingsSection } from "../settings/components/SettingsSection"; +import { describeSchedule } from "../settings/scheduledTaskPresentation"; +import { useSelectWorkspaceTab } from "../workspace/useSelectWorkspaceTab"; +import { BotAvatar } from "./BotAvatar"; +import { BotAvatarPicker } from "./BotAvatarPicker"; + +const ARTIFACTS_DIRECTORY = "artifacts"; + +type BotProject = EnvironmentProject & { readonly bot: ProjectBot }; + +function alertFailure(title: string, result: AtomCommandResult) { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + Dialog.alert(title, error instanceof Error ? error.message : "An error occurred."); +} + +export function BotDetailsRouteScreen({ + route, +}: StaticScreenProps<{ readonly environmentId: EnvironmentId; readonly projectId: ProjectId }>) { + const project = useBotProject(route.params.environmentId, route.params.projectId); + const bot = project?.bot; + return ( + + {project && bot ? ( + + ) : ( + This bot no longer exists. + )} + + ); +} + +function BotDetails(props: { readonly project: BotProject }) { + const { project } = props; + const source = { environmentId: project.environmentId, threadId: project.bot.threadId }; + return ( + + + + + {project.title} + + + + + + + + + + ); +} + +function sameAvatar(left: ProjectBot["avatar"], right: ProjectBot["avatar"]) { + return ( + left.shape === right.shape && + left.color === right.color && + (left.imagePath ?? null) === (right.imagePath ?? null) + ); +} + +function BotAvatarSection(props: { readonly project: BotProject }) { + const { project } = props; + const updateBot = useAtomCommand(serverEnvironment.updateBot, { + label: "bot avatar update", + reportFailure: false, + }); + const generateAvatar = useAtomCommand(serverEnvironment.generateBotAvatar, { + label: "bot avatar generate", + reportFailure: false, + }); + const canUpdate = useAtomValue(serverEnvironment.updateBot.permissionAtom(project.environmentId)); + const canGenerate = useAtomValue( + serverEnvironment.generateBotAvatar.permissionAtom(project.environmentId), + ); + const config = useEnvironmentServerConfig(project.environmentId); + const codexReady = + config?.providers.some( + (provider) => provider.driver === "codex" && provider.enabled && provider.status === "ready", + ) ?? false; + const [prompt, setPrompt] = useState(""); + const [generating, setGenerating] = useState(false); + // The newest pick, shown until the bot's avatar changes from the one it was picked over, so quick + // picks build on each other even when a save returns before the project update arrives. + const [pending, setPending] = useState<{ + readonly change: { shape: BotAvatarShape; color: BotAvatarColor }; + readonly base: ProjectBot["avatar"]; + } | null>(null); + const latestSave = useRef(0); + const live = pending !== null && sameAvatar(pending.base, project.bot.avatar) ? pending : null; + const avatar = live?.change ?? 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 === 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({ change: next, base: live?.base ?? project.bot.avatar }); + void updateBot({ + environmentId: project.environmentId, + // Picking a drawn avatar replaces a generated image. + input: { projectId: project.id, avatar: { ...next, imagePath: null } }, + }).then((result) => { + if (result._tag === "Failure" && save === latestSave.current) 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, ...(trimmed ? { prompt: trimmed } : {}) }, + }).then((result) => { + setGenerating(false); + if (result._tag === "Failure") { + alertFailure("Couldn't generate image", result); + return; + } + void updateBot({ + environmentId: project.environmentId, + input: { + projectId: project.id, + avatar: { shape, color, imagePath: result.value.imagePath }, + }, + }).then((update) => alertFailure("Couldn't update avatar", update)); + }); + }; + + return ( + + + {codexReady && canGenerate ? ( + + + + ))} + +
+ {BOT_AVATAR_COLORS.map((option) => ( +
+ + ); +} diff --git a/apps/web/src/components/bots/BotDetails.tsx b/apps/web/src/components/bots/BotDetails.tsx new file mode 100644 index 0000000000000..49c2f4fd732eb --- /dev/null +++ b/apps/web/src/components/bots/BotDetails.tsx @@ -0,0 +1,534 @@ +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 ProjectEntry, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { formatModelSlugName } from "@t3tools/shared/model"; +import { useAtomValue } from "@effect/atom-react"; +import { useNavigate } from "@tanstack/react-router"; +import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; +import { DownloadIcon, FolderIcon, FolderOpenIcon, SparklesIcon, Trash2Icon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { saveUrlAsFile, useAssetUrlRefresh } from "~/assets/assetUrls"; +import { useTheme } from "~/hooks/useTheme"; +import { cn } from "~/lib/utils"; +import { useRightPanelStore } from "../../rightPanelStore"; +import { useEnvironmentProviders } from "../../state/environments"; +import { useQuietThreadShell, 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 { PierreEntryIcon } from "../chat/PierreEntryIcon"; +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"; +import { DeleteBotDialog } from "./DeleteBotDialog"; + +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"; +} + +type AvatarChange = { shape: BotAvatarShape; color: BotAvatarColor; imagePath?: null }; + +function sameAvatar(left: ProjectBot["avatar"], right: ProjectBot["avatar"]) { + return ( + left.shape === right.shape && + left.color === right.color && + (left.imagePath ?? null) === (right.imagePath ?? null) + ); +} + +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.status === "ready", + ); + const [prompt, setPrompt] = useState(""); + const [generating, setGenerating] = useState(false); + // The newest pick, shown until the bot's avatar changes from the one it was picked over, so quick + // picks build on each other even when a save returns before the project update arrives. + const [pending, setPending] = useState<{ + change: AvatarChange; + base: ProjectBot["avatar"]; + } | null>(null); + const latestSave = useRef(0); + const live = pending !== null && sameAvatar(pending.base, bot.avatar) ? pending : null; + const avatar = live === null ? bot.avatar : { ...bot.avatar, ...live.change }; + const shape = knownShape(avatar.shape); + const color = knownColor(avatar.color); + const imagePath = avatar.imagePath ?? null; + + const saveAvatar = async (next: AvatarChange) => { + const save = ++latestSave.current; + setPending({ change: next, base: live?.base ?? bot.avatar }); + const result = await updateBot({ + environmentId: project.environmentId, + input: { projectId: project.id, avatar: next }, + }); + if (result._tag !== "Failure") return; + if (save === latestSave.current) setPending(null); + 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} +

+ ); +} + +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 }, + }), + ); + // A listing fetched before the latest run finished may miss files the agent wrote, including a + // cached one shown when a folder reopens. Once per run, so a skewed server clock can't loop it. + const refreshedFor = useRef(null); + const { refresh, dataUpdatedAt, isPending } = query; + const loaded = query.data !== null; + // 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 (runCompletedAt === null || !loaded || isPending) return; + if (refreshedFor.current === runCompletedAt) return; + refreshedFor.current = runCompletedAt; + if (dataUpdatedAt < Date.parse(runCompletedAt)) refresh(); + }, [runCompletedAt, loaded, isPending, dataUpdatedAt, 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 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); + let failure: string | null = null; + try { + const url = await prepareDownload(); + 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."; + } + setSaving(false); + if (failure) { + toastManager.add({ type: "error", title: "Could not download file", description: failure }); + } + }; + return ( +
  • + + + + +
  • + ); +} + +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 ( +

    + {loading ? "Loading" : "No artifacts yet"} +

    + ); + } + return ; +} + +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({ + environmentId: project.environmentId, + input: { projectId: project.id }, + }); + if (result._tag === "Failure") { + reportFailure("Could not delete bot", result); + return; + } + setConfirming(false); + void navigate({ to: "/bots" }); + }; + const rows: Array<[string, string]> = [ + ["Computer", presentation?.entry.target.label ?? "Unknown"], + ["Status", presentation?.connection.phase === "connected" ? "Connected" : "Offline"], + ]; + 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/bots/BotFaceIcon.tsx b/apps/web/src/components/bots/BotFaceIcon.tsx new file mode 100644 index 0000000000000..9e5398da75ca8 --- /dev/null +++ b/apps/web/src/components/bots/BotFaceIcon.tsx @@ -0,0 +1,13 @@ +import type { SVGProps } from "react"; + +/** The Bots glyph: a round face with two pill eyes, drawn in currentColor like a lucide icon. */ +export function BotFaceIcon(props: SVGProps) { + return ( + + + + ); +} diff --git a/apps/web/src/components/bots/BotsHome.tsx b/apps/web/src/components/bots/BotsHome.tsx new file mode 100644 index 0000000000000..575e683f8094c --- /dev/null +++ b/apps/web/src/components/bots/BotsHome.tsx @@ -0,0 +1,40 @@ +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"; +import { buildThreadRouteParams } from "../../threadRoutes"; +import { SidebarInset } from "../ui/sidebar"; +import { WorkspacePageHeader } from "../WorkspacePageHeader"; +import { isElectron } from "../../env"; +import { CreateBotPanel } from "./CreateBotDialog"; + +/** Opens the newest bot, or the create form when there are none. */ +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 && !creating) { + return ( + + ); + } + // A pending environment cannot prove there are no bots. + if (!bootstrapped) return null; + return ( + + +
    + +
    +
    + ); +} diff --git a/apps/web/src/components/bots/BotsSidebar.tsx b/apps/web/src/components/bots/BotsSidebar.tsx new file mode 100644 index 0000000000000..f3e08d9ec32e9 --- /dev/null +++ b/apps/web/src/components/bots/BotsSidebar.tsx @@ -0,0 +1,160 @@ +import type { BotSummary } from "@t3tools/client-runtime/state/bots"; +import { useAtomValue } from "@effect/atom-react"; +import { useLocation, useNavigate } from "@tanstack/react-router"; +import { PlusIcon, SearchIcon } 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 { Input } from "../ui/input"; +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 [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); + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(bot.threadRef), + }); + }; + + 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 ? ( +

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

    + ) : ( +
      + {visibleBots.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..484bcdbce084d --- /dev/null +++ b/apps/web/src/components/bots/CreateBotDialog.tsx @@ -0,0 +1,379 @@ +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + BOT_NAME_MAX_LENGTH, + BotError, + 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 { Schema } from "effect"; +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 { + providerInstanceEntriesFor, + type ProviderInstanceEntry, + resolveDefaultProviderModelSelection, +} 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 { + ComposerEffortColumn, + ComposerPickerFooter, + describeComposerEffort, +} from "../chat/ComposerModelEffort"; +import { ProviderModelPicker } from "../chat/ProviderModelPicker"; +import type { TraitsSelectionInput } 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; +const isBotError = Schema.is(BotError); + +export function CreateBotDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [pending, setPending] = useState(false); + return ( + { + 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({ + pending, + onPendingChange, +}: { + pending: boolean; + onPendingChange: (pending: boolean) => void; +}) { + return ( +
    + {}} + /> +
    + ); +} + +function defaultBotModel( + settings: UnifiedSettings, + 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 }; + +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 = + 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

    + ) : ( + + ); + return ( + + ); +} + +function MachineBotForm({ + layout, + pending, + onPendingChange, + onCancel, + onDone, + environmentId, + name, + onNameChange, + avatar, + onAvatarChange, + machinePicker, +}: FormProps & { + environmentId: EnvironmentId | null; + name: string; + onNameChange: (name: string) => void; + avatar: AvatarChoice; + onAvatarChange: (avatar: AvatarChoice) => void; + machinePicker: ReactNode; +}) { + 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 = providerInstanceEntriesFor(providers, settings, null); + const [chosenModel, setChosenModel] = useState(null); + const model = + chosenModel && + entries.some((entry) => entry.instanceId === chosenModel.instanceId && entry.enabled) + ? 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(); + const canSubmit = !pending && environmentId !== null && model !== null && trimmedName.length > 0; + + const submit = async () => { + if (!canSubmit) return; + onPendingChange(true); + const result = await createBot({ + environmentId, + input: { commandId, name: trimmedName, avatar, modelSelection: model }, + }); + 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", + 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 }), + }); + }; + + const fields = ( +
    +
    + + + +
    + +
    + + onNameChange(event.target.value)} + /> +
    +
    + + {machinePicker} +
    + {environmentId !== null ? ( +
    + + {model === null ? ( +

    No providers on this computer

    + ) : ( + } + footer={} + onInstanceModelChange={(instanceId, slug) => + setChosenModel(createModelSelection(instanceId, slug)) + } + /> + )} +
    + ) : null} +
    + ); + const actions = ( + <> + {onCancel ? ( + + ) : null} + + + ); + + return ( +
    { + event.preventDefault(); + void submit(); + }} + > + {layout === "dialog" ? ( + <> + + New bot + + {fields} + {actions} + + ) : ( +
    +

    Create your first bot

    + {fields} +
    {actions}
    +
    + )} +
    + ); +} 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/bots/botMotion.ts b/apps/web/src/components/bots/botMotion.ts new file mode 100644 index 0000000000000..0cafcb8896dd7 --- /dev/null +++ b/apps/web/src/components/bots/botMotion.ts @@ -0,0 +1,57 @@ +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; + const look = BOT_AVATAR_LOOKS[Math.floor(Math.random() * BOT_AVATAR_LOOKS.length)]; + 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", 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); +} + +// Clearing the entrance keeps a later look from restarting it. +function endMotion(event: AnimationEvent) { + if (event.animationName === "bot-enter-face") { + (event.currentTarget as SVGSVGElement).removeAttribute("data-enter"); + } +} + +/** 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/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 49f21a1ddc829..dbe1b2d6e5bc3 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,8 @@ export const ChatHeader = memo(function ChatHeader({ }: ChatHeaderProps) { const activeProjectName = activeProject?.isScratch ? "No project" : activeProject?.title; const activeProjectCwd = activeProject?.workspaceRoot ?? null; + // Subagents share their parent's project, so only the bot's own thread gets the bot header. + const bot = activeProject?.bot?.threadId === activeThreadId ? activeProject.bot : null; const interfaceFont = useClientSettings((settings) => settings.fontFamilySans); const breadcrumbContainerRef = useRef(null); const [collapseParentTitle, setCollapseParentTitle] = useState(false); @@ -250,7 +253,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 +295,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} 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/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} ) : ( 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, 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)); diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index dd41ddd2a0213..eda556fc15d78 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -1,9 +1,9 @@ +import { useAtom, useAtomValue } from "@effect/atom-react"; import { ArrowLeftIcon, ArrowRightIcon, ChartNoAxesColumnIcon, SettingsIcon } 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"; import { shortcutLabelForCommand } from "../../keybindings"; import { @@ -13,7 +13,12 @@ import { } from "../../navigationHistory"; import { primaryServerKeybindingsAtom } from "../../state/server"; import { cn } from "../../lib/utils"; +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 { BotFaceIcon } from "../bots/BotFaceIcon"; import { T3Wordmark } from "../T3Wordmark"; import { resolveEnvironmentIdentificationPillLabel, @@ -42,8 +47,10 @@ import { observeResize } from "~/lib/observeResize"; export const SidebarChromeHeader = memo(function SidebarChromeHeader({ isElectron, + actions, }: { isElectron: boolean; + actions?: ReactNode; }) { const stageLabel = useEnvironmentStageLabel(); const environmentIdentificationMode = useEnvironmentIdentificationMode(); @@ -89,6 +96,9 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
    ) : null} + {actions ? ( +
    {actions}
    + ) : null} ); }); @@ -196,10 +206,12 @@ function SidebarBrandMark({ onBackdrop }: { onBackdrop: boolean }) { function SidebarUtilityItem({ icon, label, + isActive = false, onClick, }: { icon: ReactNode; label: string; + isActive?: boolean; onClick: () => void; }) { return ( @@ -207,7 +219,7 @@ function SidebarUtilityItem({ + {icon} } @@ -218,6 +230,45 @@ 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 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 ( + + ) : ( + + ) + } + label={showingBots ? "Threads" : unreadBot ? `Bots, ${unreadBot.name} replied` : "Bots"} + isActive={showingBots} + onClick={toggle} + /> + ); +} + const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { const navigate = useNavigate(); const navigateToMainApp = useNavigateToMainApp(); @@ -283,6 +334,7 @@ const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { label="Usage" onClick={handleUsageClick} /> + )} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 15dd5e14d33fe..b060bebdb2382 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -361,6 +361,124 @@ 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); + } +} + +/* 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); + } +} + +/* 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; + transition: 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/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..054edc5e02ec4 --- /dev/null +++ b/apps/web/src/routes/_chat.bots.tsx @@ -0,0 +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/apps/web/src/state/bots.ts b/apps/web/src/state/bots.ts new file mode 100644 index 0000000000000..ea0422c5f9c9f --- /dev/null +++ b/apps/web/src/state/bots.ts @@ -0,0 +1,21 @@ +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. */ +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); diff --git a/infra/relay/src/agentActivity/ApnsClient.test.ts b/infra/relay/src/agentActivity/ApnsClient.test.ts index 81e76b0391aae..40847e842f0d7 100644 --- a/infra/relay/src/agentActivity/ApnsClient.test.ts +++ b/infra/relay/src/agentActivity/ApnsClient.test.ts @@ -183,6 +183,7 @@ describe("ApnsClient", () => { threadId: ThreadId.make(id), projectTitle: "界".repeat(200), threadTitle: "界".repeat(200), + message: "界".repeat(200), })), }); const request = apns.makeLiveActivityRequest({ @@ -201,6 +202,8 @@ describe("ApnsClient", () => { const sent = JSON.parse(payload.aps["content-state"].props) as typeof longState; expect(sent.activities.map((row) => row.threadId)).toEqual(["a", "b", "c", "d", "e"]); expect(sent.activities[0]?.threadTitle).toMatch(/^界+\.\.\.$/); + // The widget never shows a bot's reply, so it cannot crowd out rows. + expect(sent.activities.some((row) => row.message !== undefined)).toBe(false); }).pipe(Effect.provide(layerTest)), ); diff --git a/infra/relay/src/agentActivity/ApnsClient.ts b/infra/relay/src/agentActivity/ApnsClient.ts index b21953c59eecc..054c384f529f5 100644 --- a/infra/relay/src/agentActivity/ApnsClient.ts +++ b/infra/relay/src/agentActivity/ApnsClient.ts @@ -79,9 +79,11 @@ const decodeApnsErrorResponseJson = Schema.decodeUnknownOption( ), ); function contentState(state: RelayAgentActivityAggregateState) { + // The widget never shows a bot's reply, so it stays out of the 4 KB budget. + const activities = state.activities.map(({ message: _message, ...row }) => row); return { name: LIVE_ACTIVITY_NAME, - props: JSON.stringify(state), + props: JSON.stringify({ ...state, activities }), }; } 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/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..79c951b584952 --- /dev/null +++ b/packages/client-runtime/src/botAvatar.ts @@ -0,0 +1,101 @@ +import type { BotAvatarColor, BotAvatarShape } from "@t3tools/contracts"; + +/** SVG path data on a 100x100 view box. Overlapping subpaths fill with the nonzero rule. */ +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: + "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: + "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: "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: + "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 16a4 4 0 0 1 8 0V30H46Z", +}; + +/** 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", + red: "#EF4444", + orange: "#F97316", + amber: "#F5B014", + green: "#22C55E", + teal: "#14B8A6", + blue: "#3B82F6", + purple: "#8B5CF6", + pink: "#EC4899", + 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]; + 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, + }; +} 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..a1c03902842fb --- /dev/null +++ b/packages/client-runtime/src/state/bots.test.ts @@ -0,0 +1,189 @@ +import { + type BotPreviewsResult, + EnvironmentId, + ProjectId, + RunId, + ThreadId, + type OrchestrationV2ShellSnapshot, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import { AsyncResult, 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, + }, +}; + +// 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, + previews: BotPreviewsResult = { previews: [] }, +) { + 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, + hiddenThreadTagsAtom: () => Atom.make(null), + }); + const bots = createBotListAtom({ + botsAtom: projects.botsAtom, + quietThreadShellAtom: threads.quietThreadShellAtom, + previewsAtom: () => Atom.make(AsyncResult.success(previews)), + }); + 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 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: "Saved to trip.md:\n 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: "Saved to trip.md: Found three flights.", + }); + expect(second).toMatchObject({ name: "Quiet", unread: false, preview: "You: Thanks" }); + + 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..9c3ad49806b1b --- /dev/null +++ b/packages/client-runtime/src/state/bots.ts @@ -0,0 +1,219 @@ +import { + type BotAvatar, + type BotPreview, + type BotPreviewsResult, + type EnvironmentId, + type OrchestrationProjectShell, + type ProjectId, + type ScopedThreadRef, + 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"; +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(); + +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; +} + +/** + * 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 completedAt = shell.latestRun?.completedAt; + if (!completedAt) return false; + if (!shell.lastVisitedAt) return true; + return Date.parse(completedAt) > Date.parse(shell.lastVisitedAt); +} + +function previewText(preview: BotPreview): string | null { + const line = markdownPreviewText(preview.text); + 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; + 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: preview === null ? null : previewText(preview), + activityAt: latestOf( + project.createdAt, + shell?.latestUserMessageAt, + shell?.latestRun?.completedAt, + preview?.updatedAt, + ), + unread: shell !== null && hasUnreadBotReply(shell), + working: threadRuntimeIsActive(shell?.runtime), + }; +} + +/** Like a chat list: the most recent activity first. */ +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); +} + +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. + */ +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; + 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[] = []; + for (const project of get(input.botsAtom)) { + if (project.bot == null) continue; + const shell = get( + input.quietThreadShellAtom({ + environmentId: project.environmentId, + 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); + // 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); + } + 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) || 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..bcea3026e51b0 --- /dev/null +++ b/packages/contracts/src/bot.ts @@ -0,0 +1,144 @@ +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; + +/** 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), + 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/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/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 01989ff8a474f..76048300b245c 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -377,6 +377,18 @@ import { ScheduledTaskMutationResult, } from "./scheduledTask.ts"; import { SecretRequestAnswerInput, SecretRequestError } from "./secretRequest.ts"; +import { + BotCreateInput, + BotCreateResult, + BotDeleteInput, + BotError, + BotGenerateAvatarInput, + BotGenerateAvatarResult, + BotMutationResult, + BotPreviewsInput, + BotPreviewsResult, + BotUpdateInput, +} from "./bot.ts"; import { ProjectCloneActionInput, ProjectCloneActionResult, @@ -549,6 +561,12 @@ export const WS_METHODS = { scheduledTasksListWebhookDeliveries: "scheduledTasks.listWebhookDeliveries", scheduledTasksGetWebhookDelivery: "scheduledTasks.getWebhookDelivery", + botsCreate: "bots.create", + botsUpdate: "bots.update", + botsDelete: "bots.delete", + botsGenerateAvatar: "bots.generateAvatar", + botsPreviews: "bots.previews", + cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", @@ -1931,6 +1949,36 @@ 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 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, @@ -2045,6 +2093,11 @@ export const WsRpcGroup = RpcGroup.make( WsSecretsAnswerRequestRpc, WsScheduledTasksListWebhookDeliveriesRpc, WsScheduledTasksGetWebhookDeliveryRpc, + WsBotsCreateRpc, + WsBotsUpdateRpc, + WsBotsDeleteRpc, + WsBotsGenerateAvatarRpc, + WsBotsPreviewsRpc, 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/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..467a5dff94aea --- /dev/null +++ b/packages/shared/src/markdownPreview.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { markdownPreviewText } from "./markdownPreview.ts"; + +describe("markdownPreviewText", () => { + it.each([ + [ + "# Trip\nSaved to [trip.md](/Users/me/trip.md):\n- **Found** three\n> `flights`, _soon_.", + "Trip Saved to trip.md: Found three flights, soon.", + ], + [ + "Run `__init__.py` on `src/*/index.*` and **ship**", + "Run __init__.py on src/*/index.* and ship", + ], + ["See [docs](https://example.com/a_(b)) now", "See docs now"], + ["Read [API [v2]](https://example.com/docs)", "Read API [v2]"], + ["Keep snake_case_name and 2 * 3 * 4", "Keep snake_case_name and 2 * 3 * 4"], + ])("shows %j as the rendered text", (markdown, text) => { + expect(markdownPreviewText(markdown)).toBe(text); + }); +}); diff --git a/packages/shared/src/markdownPreview.ts b/packages/shared/src/markdownPreview.ts new file mode 100644 index 0000000000000..a2370f5ee6b84 --- /dev/null +++ b/packages/shared/src/markdownPreview.ts @@ -0,0 +1,26 @@ +// Link text and destinations allow one level of nested brackets or parentheses. +const LINK = /!?\[((?:[^[\]]|\[[^[\]]*\])*)\]\((?:[^()\s]|\([^()\s]*\))*(?:\s+"[^"]*")?\)/g; +const LINE_MARKER = /^\s{0,3}(?:#{1,6}\s+|>\s?|[-*+]\s+|\d+[.)]\s+)/gm; +const PREVIEW_SOURCE_LIMIT = 2_000; +const CODE_SPAN = /(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/g; + +function stripEmphasis(text: string): string { + return text + .replace(/(\*\*|__)(.+?)\1/g, "$2") + .replace(/(^|\W)[*_](\S(?:.*?\S)?)[*_](?=\W|$)/g, "$1$2"); +} + +/** The text a renderer would show for `markdown`, on one line, without its syntax. */ +export function markdownPreviewText(markdown: string): string { + // Previews are short; bounding the input keeps unmatched backticks from scanning a whole reply. + const text = markdown.slice(0, PREVIEW_SOURCE_LIMIT).replace(LINE_MARKER, "").replace(LINK, "$1"); + // Code spans show their contents literally, so emphasis is stripped only between them. + let plain = ""; + let index = 0; + for (const match of text.matchAll(CODE_SPAN)) { + plain += stripEmphasis(text.slice(index, match.index)) + match[2]; + index = match.index + match[0].length; + } + plain += stripEmphasis(text.slice(index)); + return plain.replace(/\s+/g, " ").trim(); +} 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",