Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
b63bb61
feat(server): bots as projects with one long-lived session
vkpdeveloper Nov 2, 2026
2563251
fix(server): bot creation is retry-safe and cleans up on any failure
vkpdeveloper Nov 2, 2026
4fa3968
feat(client): bot list state and bot commands
vkpdeveloper Nov 2, 2026
3297679
feat(client): shared bot avatar shapes and colors
vkpdeveloper Nov 2, 2026
d34945d
feat(web): bots sidebar, avatars, and new bot dialog
vkpdeveloper Nov 2, 2026
11026dd
feat(web): bot details in the thread panel
vkpdeveloper Nov 2, 2026
ff5c60e
fix(server): safer bot creation and Codex image paths
vkpdeveloper Nov 2, 2026
0cc5323
feat(mobile): new bot form with avatar, model and machine pickers
vkpdeveloper Nov 2, 2026
4030bc9
feat(mobile): Bots tab replaces Usage
vkpdeveloper Nov 2, 2026
0b68c56
feat(mobile): bot details from the bot chat header
vkpdeveloper Nov 2, 2026
5720c96
feat(web): bots open on their own page
vkpdeveloper Nov 2, 2026
a5c9b69
feat(web): bot avatars with eyes and a Grok style bots sidebar
vkpdeveloper Nov 2, 2026
f3dd4fa
fix(bots): build on main and use the composer model picker
vkpdeveloper Nov 2, 2026
95b17b0
feat(mobile): bot avatars get eyes that blink
vkpdeveloper Nov 2, 2026
90e2b51
feat(bots): smooth avatar shapes that turn, glance and spring in
vkpdeveloper Nov 2, 2026
012535c
style(mobile): format workspace screens
vkpdeveloper Nov 2, 2026
1bc664d
fix(web): first bot form stays put until creation finishes
vkpdeveloper Nov 2, 2026
00d7290
fix(mobile): opening a bot keeps the Threads tab's chat
vkpdeveloper Nov 2, 2026
fb505a0
fix(web): a hovered bot avatar keeps blinking and glancing
vkpdeveloper Nov 2, 2026
3f1ee17
feat(bots): list shows each bot's last message and unread replies
vkpdeveloper Nov 2, 2026
8597aa6
fix(bots): previews skip rolled-back and cancelled queued messages
vkpdeveloper Nov 2, 2026
9518c6f
feat(web): bot artifacts browse folders and refresh after each run
vkpdeveloper Nov 2, 2026
a1f0379
fix(mobile): messages sent to a bot are no longer dropped
vkpdeveloper Nov 2, 2026
f471ce8
feat(mobile): bot chats drop git and New task, avatars take an idea
vkpdeveloper Nov 2, 2026
aa234ab
fix(bots): avatar picks and reopened artifact folders stay current
vkpdeveloper Nov 2, 2026
f88c01a
feat(bots): a round face with two eyes marks Bots everywhere
vkpdeveloper Nov 2, 2026
24e9c7e
feat(bots): bot chats show the bot's face and name, deleting asks for…
vkpdeveloper Nov 2, 2026
b5dda1d
fix(bots): New bot works on phones, previews drop Markdown syntax
vkpdeveloper Nov 2, 2026
f210720
feat(bots): bot replies push to mobile as messages
vkpdeveloper Nov 2, 2026
3a1d950
fix(bots): only a bot's own thread gets the bot header
vkpdeveloper Nov 2, 2026
2be0de7
fix(bots): previews keep code spans and nested links intact
vkpdeveloper Nov 2, 2026
22aa487
fix(bots): bot pushes show the final reply and leave Live Activities …
vkpdeveloper Nov 2, 2026
1c581b4
feat(bots): artifacts show file type icons
vkpdeveloper Nov 2, 2026
0887434
fix(mobile): toggle rows forward the leading slot
vkpdeveloper Nov 2, 2026
2555ea3
fix(bots): New bot form lines up its model field and colours
vkpdeveloper Nov 2, 2026
65afc33
fix(bots): colour swatches wrap instead of shrinking in the avatar po…
vkpdeveloper Nov 2, 2026
df671a1
feat(bots): download artifacts from the bot's Artifacts tab
vkpdeveloper Nov 2, 2026
4b2393f
fix(mobile): Settings keeps its workspaceRoot prop
vkpdeveloper Nov 2, 2026
88e9568
fix(bots): artifact download handler compiles with React Compiler
vkpdeveloper Nov 2, 2026
cd6ac93
fix(bots): artifact download errors always toast
vkpdeveloper Nov 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added apps/mobile/assets/icons/bot-face-fill.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/mobile/assets/icons/bot-face-fill@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/mobile/assets/icons/bot-face-fill@3x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/mobile/assets/icons/bot-face.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/mobile/assets/icons/bot-face@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/mobile/assets/icons/bot-face@3x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -414,8 +414,8 @@ class T3WorkspaceNavigationView(

private fun tabTitle(tab: String) = when (tab) {
"threads" -> "Chats"
"bots" -> "Bots"
"pullRequests" -> "Pulls"
"usage" -> "Usage"
else -> "Settings"
}

Expand All @@ -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
}
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#000000" android:fillType="evenOdd" android:pathData="M4,20V10H8V20ZM10,20V4H14V20ZM16,20V7H20V20Z" />
<path android:fillColor="#000000" android:fillType="evenOdd" android:pathData="M12,2a10,10 0,1 1,0 20a10,10 0,1 1,0 -20ZM7.4,10a1.6,1.6 0,0 1,3.2 0v3.2a1.6,1.6 0,0 1,-3.2 0ZM13.4,10a1.6,1.6 0,0 1,3.2 0v3.2a1.6,1.6 0,0 1,-3.2 0Z" />
</vector>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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() } }
Expand Down Expand Up @@ -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
}
}
Expand Down
22 changes: 22 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"] },
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/components/ui/ListRowLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,6 +17,7 @@ export interface ListRowContentProps {

export function ListRowLayout({
icon,
leading,
label,
description,
detail,
Expand All @@ -32,7 +35,9 @@ export function ListRowLayout({
disabled && "opacity-45",
)}
>
{icon ? (
{leading ? (
<View className="size-6 items-center justify-center">{leading}</View>
) : icon ? (
<View className="size-6 items-center justify-center">
<SymbolView
name={icon}
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/components/ui/ListToggleRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export function ListToggleRow(
return (
<ListRowLayout
icon={props.icon}
leading={props.leading}
label={props.label}
description={props.description}
disabled={props.disabled}
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/archive/archivedThreadList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type EnvironmentProject,
type EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/shell";
import { isBotProject } from "@t3tools/client-runtime/state/bots";
import { threadSearchSubject } from "@t3tools/client-runtime/state/thread-search";
import type { EnvironmentId } from "@t3tools/contracts";
import { foldSearchQuery, rankSearchItems, scoreThreadSearch } from "@t3tools/shared/paletteSearch";
Expand Down Expand Up @@ -58,6 +59,7 @@ export function buildArchivedThreadGroups(input: {
}

for (const rawProject of entry.snapshot.projects) {
if (isBotProject(rawProject)) continue;
const project = scopeProject(entry.environmentId, rawProject);
const projectThreads = threadsByProjectId.get(project.id) ?? [];
const groupMatches =
Expand Down
201 changes: 201 additions & 0 deletions apps/mobile/src/features/bots/BotAvatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import {
BOT_AVATAR_EYE_COLOR,
botAvatarLookFor,
resolveBotAvatar,
} from "@t3tools/client-runtime/bot-avatar";
import type { BotAvatar as BotAvatarValue, EnvironmentId, ThreadId } from "@t3tools/contracts";
import { Image } from "expo-image";
import { useEffect, useMemo, useState } from "react";
import { StyleSheet, View } from "react-native";
import Animated, {
ReduceMotion,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withDelay,
withSequence,
withSpring,
withTiming,
} from "react-native-reanimated";
import Svg, { Ellipse, G, Path } from "react-native-svg";

import { type BotLook, registerBotMotion } from "./botMotion";

import { useAssetUrlState } from "../../state/assets";

/** Resting three quarter turn for the picker grid. */
const SIDE_POSE: BotLook = { x: 8, y: -5, tilt: -8 };
const motion = { reduceMotion: ReduceMotion.System };

type ShapeProps = {
readonly avatar: BotAvatarValue;
readonly size: number;
/** "side" rests turned three quarters until selected. */
readonly pose?: "side" | "front";
/** Springs in from a side on mount; callers remount it when the avatar changes. */
readonly enter?: boolean;
};

// The body tilts while the face slides and squashes toward the look, which reads as a
// 3D head turn. Every motion is one-shot; nothing animates while the avatar is idle.
function BotAvatarShapeMotion(props: ShapeProps) {
const { path, color, eyes } = resolveBotAvatar(props.avatar);
const side = props.size * 0.64;
const unit = side / 100;
const reducedMotion = useReducedMotion();
const enterLook = botAvatarLookFor(`${props.avatar.shape}:${props.avatar.color}`);
const posed = props.pose === "side";
const [closed, setClosed] = useState(false);
const look = useSharedValue<BotLook>(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 (
<Animated.View style={[{ width: side, height: side }, bodyStyle]}>
<Svg width={side} height={side} viewBox="0 0 100 100" style={StyleSheet.absoluteFill}>
<Path d={path} fill={color} fillRule="nonzero" />
</Svg>
<Animated.View style={[StyleSheet.absoluteFill, faceStyle]}>
<Svg width={side} height={side} viewBox="0 0 100 100">
<G fill={BOT_AVATAR_EYE_COLOR}>
{eyes.map((eye) => (
<Ellipse
key={eye.cx}
cx={eye.cx}
cy={eye.cy}
rx={eye.rx}
ry={closed ? eye.ry * 0.12 : eye.ry}
/>
))}
</G>
</Svg>
</Animated.View>
</Animated.View>
);
}

function BotAvatarShape(props: ShapeProps) {
// A new key remounts the drawing so a changed avatar springs in again.
return (
<BotAvatarShapeMotion
key={props.enter ? `${props.avatar.shape}:${props.avatar.color}` : undefined}
{...props}
/>
);
}

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 <BotAvatarShape avatar={props.avatar} size={props.size} />;
return (
<Image
source={{ uri: asset.url }}
style={{ width: props.size, height: props.size }}
contentFit="cover"
accessibilityIgnoresInvertColors
/>
);
}

/** 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 (
<View
className="items-center justify-center overflow-hidden border-continuous bg-subtle"
style={{ width: props.size, height: props.size, borderRadius: props.size * 0.28 }}
>
{imagePath !== null && props.source ? (
<BotAvatarImage
environmentId={props.source.environmentId}
threadId={props.source.threadId}
avatar={props.avatar}
imagePath={imagePath}
size={props.size}
/>
) : (
<BotAvatarShape
avatar={props.avatar}
size={props.size}
{...(props.pose ? { pose: props.pose } : {})}
enter={props.animateChanges ?? false}
/>
)}
</View>
);
}
Loading
Loading