Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
29 changes: 29 additions & 0 deletions __tests__/components/AIAssistant.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ function mockChat(overrides: Partial<ReturnType<typeof useAIChat>> = {}) {
newConversation: jest.fn().mockResolvedValue(undefined),
loadConversation: jest.fn().mockResolvedValue(undefined),
removeConversation: jest.fn().mockResolvedValue(undefined),
renameConversation: jest.fn().mockResolvedValue(undefined),
...overrides,
};
mockUseAIChat.mockReturnValue(base);
Expand Down Expand Up @@ -187,5 +188,33 @@ describe("AIAssistantScreen", () => {
fireEvent.press(getByLabelText("Delete conversation"));
expect(chat.removeConversation).toHaveBeenCalledWith("c1");
});

it("renames a conversation via the dialog", () => {
const chat = mockChat({
serverBacked: true,
conversations: [{ id: "c1", title: "Old name" }],
});
const { getByLabelText, getByRole } = renderScreen();
fireEvent.press(getByLabelText("Conversation history"));
fireEvent.press(getByLabelText("Rename conversation"));
// The dialog opens prefilled with the current title.
const input = getByLabelText("Conversation title");
fireEvent.changeText(input, "New name");
fireEvent.press(getByRole("button", { name: "Save" }));
expect(chat.renameConversation).toHaveBeenCalledWith("c1", "New name");
});
});

it("renders a reasoning block when the message has reasoning", () => {
mockChat({
messages: [
{ role: "user", content: "why?" },
{ role: "assistant", content: "Because.", reasoning: "Considered the trade-offs." },
],
});
const { getByText, getByLabelText } = renderScreen();
expect(getByText("Reasoning")).toBeTruthy();
fireEvent.press(getByLabelText("Reasoning"));
expect(getByText("Considered the trade-offs.")).toBeTruthy();
});
});
20 changes: 20 additions & 0 deletions __tests__/components/Reasoning.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from "react";
import { render, fireEvent } from "@testing-library/react-native";
import { Reasoning } from "~/components/ui/Reasoning";

describe("Reasoning", () => {
it("renders nothing when reasoning is empty", () => {
expect(render(<Reasoning reasoning="" />).toJSON()).toBeNull();
expect(render(<Reasoning reasoning=" " />).toJSON()).toBeNull();
});

it("is collapsed by default and expands on tap", () => {
const { getByLabelText, getByText, queryByText } = render(
<Reasoning reasoning="First I considered the options." />,
);
expect(getByText("Reasoning")).toBeTruthy();
expect(queryByText("First I considered the options.")).toBeNull();
fireEvent.press(getByLabelText("Reasoning"));
expect(getByText("First I considered the options.")).toBeTruthy();
});
});
15 changes: 14 additions & 1 deletion __tests__/lib/ai-chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,19 @@ describe("parseAiSdkStream", () => {
expect(parseAiSdkStream(raw).toolCalls).toEqual(["list_objects", "query_data"]);
});

it("accumulates reasoning-delta events into the reasoning field", () => {
const raw = [
`data: {"type":"reasoning-start","id":"r0"}`,
`data: {"type":"reasoning-delta","id":"r0","delta":"Let me "}`,
`data: {"type":"reasoning-delta","id":"r0","delta":"think."}`,
`data: {"type":"reasoning-end","id":"r0"}`,
`data: {"type":"text-delta","delta":"Answer."}`,
].join("\n");
const parsed = parseAiSdkStream(raw);
expect(parsed.reasoning).toBe("Let me think.");
expect(parsed.text).toBe("Answer.");
});

it("captures structured tool invocations (input, output, state) by toolCallId", () => {
const raw = [
`data: {"type":"tool-input-available","toolCallId":"tc1","toolName":"query_data","input":{"request":"count"}}`,
Expand All @@ -109,7 +122,7 @@ describe("parseAiSdkStream", () => {
});

it("returns an empty result for empty input", () => {
expect(parseAiSdkStream("")).toEqual({ text: "", toolCalls: [], tools: [] });
expect(parseAiSdkStream("")).toEqual({ text: "", reasoning: "", toolCalls: [], tools: [] });
});
});

Expand Down
16 changes: 16 additions & 0 deletions __tests__/stores/ai-chat-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ jest.mock("~/lib/ai-conversations", () => ({
getConversation: jest.fn(),
deleteConversation: jest.fn(),
addMessage: jest.fn(),
renameConversation: jest.fn(),
}));
import { streamAiChat } from "~/lib/ai-chat";
import * as conv from "~/lib/ai-conversations";
Expand Down Expand Up @@ -206,5 +207,20 @@ describe("ai-chat-store", () => {
expect(get().conversationId).toBeNull();
expect(get().messages).toEqual([]);
});

it("renameConversation optimistically updates the list then PATCHes", async () => {
useAIChatStore.setState({ conversations: [{ id: "c5", title: "Old" }] });
(conv.renameConversation as jest.Mock).mockResolvedValue(undefined);
await get().renameConversation("c5", " Brand New ");
expect(get().conversations).toEqual([{ id: "c5", title: "Brand New" }]);
expect(conv.renameConversation).toHaveBeenCalledWith("c5", "Brand New");
});

it("renameConversation ignores a blank title", async () => {
useAIChatStore.setState({ conversations: [{ id: "c5", title: "Old" }] });
await get().renameConversation("c5", " ");
expect(conv.renameConversation).not.toHaveBeenCalled();
expect(get().conversations).toEqual([{ id: "c5", title: "Old" }]);
});
});
});
60 changes: 59 additions & 1 deletion app/ai.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,16 @@ import {
Square,
SquarePen,
MessagesSquare,
Pencil,
} from "lucide-react-native";
import { ScreenHeader } from "~/components/common/ScreenHeader";
import { EmptyState } from "~/components/common/EmptyState";
import { BottomSheet } from "~/components/ui/BottomSheet";
import { Dialog } from "~/components/ui/Dialog";
import { Button } from "~/components/ui/Button";
import { MarkdownText } from "~/components/ui/MarkdownText";
import { ToolInvocations } from "~/components/ui/ToolInvocations";
import { Reasoning } from "~/components/ui/Reasoning";
import { cn } from "~/lib/utils";
import { useAIChat, type AIChatMessage } from "~/hooks/useAIChat";

Expand Down Expand Up @@ -71,7 +75,8 @@ function MessageBubble({ message }: { message: AIChatMessage }) {

return (
<View className={cn("mb-3 max-w-[85%]", isUser ? "self-end" : "self-start")}>
{/* Structured tool activity (assistant only) */}
{/* Reasoning trace, then tool activity (assistant only) */}
{!isUser && message.reasoning ? <Reasoning reasoning={message.reasoning} /> : null}
{!isUser && message.tools && message.tools.length > 0 && (
<ToolInvocations tools={message.tools} />
)}
Expand Down Expand Up @@ -118,9 +123,11 @@ export default function AIAssistantScreen() {
newConversation,
loadConversation,
removeConversation,
renameConversation,
} = useAIChat();
const [draft, setDraft] = useState("");
const [drawerOpen, setDrawerOpen] = useState(false);
const [renaming, setRenaming] = useState<{ id: string; title: string } | null>(null);
const scrollRef = useRef<ScrollView>(null);

// Probe the server + restore the last conversation on first mount.
Expand Down Expand Up @@ -225,6 +232,17 @@ export default function AIAssistantScreen() {
{c.title ?? "New conversation"}
</Text>
</Pressable>
<Pressable
onPress={() => {
setDrawerOpen(false);
setRenaming({ id: c.id, title: c.title ?? "" });
}}
accessibilityRole="button"
accessibilityLabel="Rename conversation"
className="h-9 w-9 items-center justify-center rounded-lg active:bg-muted"
>
<Pencil size={15} color="#94a3b8" />
</Pressable>
<Pressable
onPress={() => void removeConversation(c.id)}
accessibilityRole="button"
Expand All @@ -238,6 +256,46 @@ export default function AIAssistantScreen() {
)}
</BottomSheet>

{/* Rename dialog */}
<Dialog
open={renaming !== null}
onOpenChange={(o) => !o && setRenaming(null)}
title="Rename conversation"
>
{renaming && (
<View className="gap-3">
<TextInput
className="rounded-lg border border-input bg-background px-3 py-2.5 text-base text-foreground"
value={renaming.title}
onChangeText={(t) => setRenaming({ ...renaming, title: t })}
placeholder="Conversation title"
placeholderTextColor="#9ca3af"
autoFocus
onSubmitEditing={() => {
const r = renaming;
setRenaming(null);
void renameConversation(r.id, r.title);
}}
accessibilityLabel="Conversation title"
/>
<View className="flex-row justify-end gap-2">
<Button variant="outline" onPress={() => setRenaming(null)}>
Cancel
</Button>
<Button
onPress={() => {
const r = renaming;
setRenaming(null);
void renameConversation(r.id, r.title);
}}
>
Save
</Button>
</View>
</View>
)}
</Dialog>

<KeyboardAvoidingView
className="flex-1"
behavior={Platform.OS === "ios" ? "padding" : undefined}
Expand Down
48 changes: 48 additions & 0 deletions components/ui/Reasoning.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useState } from "react";
import { View, Text, Pressable } from "react-native";
import { ChevronRight, ChevronDown, Brain } from "lucide-react-native";
import { cn } from "~/lib/utils";

/**
* Collapsible "thinking" block for a model's reasoning trace — a React Native
* take on Vercel AI Elements' `Reasoning` component (web/DOM-only). Collapsed
* by default (reasoning is secondary to the answer); tap to expand.
*
* Only renders when the agent actually streamed reasoning; the current
* ObjectStack agent does not, so this stays invisible until a reasoning-capable
* model is wired up server-side.
*/
export function Reasoning({
reasoning,
className,
}: {
reasoning: string;
className?: string;
}) {
const [open, setOpen] = useState(false);
if (!reasoning || reasoning.trim() === "") return null;
return (
<View className={cn("mb-1 overflow-hidden rounded-lg border border-border bg-muted/40", className)}>
<Pressable
className="flex-row items-center gap-2 px-2.5 py-2 active:bg-muted"
onPress={() => setOpen((o) => !o)}
accessibilityRole="button"
accessibilityLabel="Reasoning"
accessibilityState={{ expanded: open }}
>
<Brain size={13} color="#64748b" />
<Text className="flex-1 text-xs font-medium text-muted-foreground">Reasoning</Text>
{open ? (
<ChevronDown size={14} color="#94a3b8" />
) : (
<ChevronRight size={14} color="#94a3b8" />
)}
</Pressable>
{open && (
<View className="border-t border-border px-2.5 py-2">
<Text className="text-xs leading-5 text-muted-foreground">{reasoning.trim()}</Text>
</View>
)}
</View>
);
}
4 changes: 4 additions & 0 deletions hooks/useAIChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface UseAIChatResult {
loadConversation: (id: string) => Promise<void>;
/** Delete a saved conversation. */
removeConversation: (id: string) => Promise<void>;
/** Rename a saved conversation. */
renameConversation: (id: string, title: string) => Promise<void>;
}

/**
Expand All @@ -53,6 +55,7 @@ export function useAIChat(): UseAIChatResult {
const newConversation = useAIChatStore((s) => s.newConversation);
const loadConversation = useAIChatStore((s) => s.loadConversation);
const removeConversation = useAIChatStore((s) => s.removeConversation);
const renameConversation = useAIChatStore((s) => s.renameConversation);
return {
messages,
isLoading,
Expand All @@ -68,5 +71,6 @@ export function useAIChat(): UseAIChatResult {
newConversation,
loadConversation,
removeConversation,
renameConversation,
};
}
27 changes: 18 additions & 9 deletions lib/ai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export interface ToolInvocation {
export interface ParsedAiStream {
/** Concatenated assistant text (from `text-delta` / `text` events). */
text: string;
/** Concatenated reasoning/thinking text (from `reasoning-delta` events). */
reasoning: string;
/** Names of tools the agent invoked (back-compat; see `tools` for detail). */
toolCalls: string[];
/** Structured tool invocations (name + input + output + state). */
Expand Down Expand Up @@ -75,6 +77,12 @@ function applyEvent(event: Record<string, unknown>, acc: ParsedAiStream): void {
// Some servers emit a single full-text event instead of deltas.
if (typeof event.text === "string") acc.text += event.text;
break;
case "reasoning-delta":
if (typeof event.delta === "string") acc.reasoning += event.delta;
break;
case "reasoning":
if (typeof event.text === "string") acc.reasoning += event.text;
break;
case "tool-input-available":
if (typeof event.toolName === "string") {
acc.toolCalls.push(event.toolName);
Expand Down Expand Up @@ -124,7 +132,7 @@ function parseEventLine(line: string): Record<string, unknown> | null {
}

export function parseAiSdkStream(raw: string): ParsedAiStream {
const result: ParsedAiStream = { text: "", toolCalls: [], tools: [] };
const result: ParsedAiStream = { text: "", reasoning: "", toolCalls: [], tools: [] };
if (!raw) return result;
for (const line of raw.split("\n")) {
const event = parseEventLine(line);
Expand Down Expand Up @@ -188,8 +196,8 @@ function errorMessageFromBody(raw: string, status: number): string {

export interface StreamAiChatOptions {
signal?: AbortSignal;
/** Called as text accumulates, with the full reply so far + tools seen. */
onUpdate?: (text: string, tools: ToolInvocation[]) => void;
/** Called as text accumulates, with the full reply so far + tools + reasoning. */
onUpdate?: (text: string, tools: ToolInvocation[], reasoning: string) => void;
/** Bind the turn to a server conversation (for server-side persistence). */
conversationId?: string;
}
Expand Down Expand Up @@ -232,24 +240,25 @@ export async function streamAiChat(
// Streaming not supported in this runtime — read it whole.
const raw = await res.text();
const parsed = parseAiSdkStream(raw);
onUpdate?.(parsed.text, parsed.tools);
onUpdate?.(parsed.text, parsed.tools, parsed.reasoning);
return parsed;
}

const reader = body.getReader();
const decoder = new TextDecoder();
const acc: ParsedAiStream = { text: "", toolCalls: [], tools: [] };
const acc: ParsedAiStream = { text: "", reasoning: "", toolCalls: [], tools: [] };
let buffer = "";

// A cheap signature so onUpdate fires on text deltas, new tool calls, and a
// tool's state flipping to `done` (output arriving).
const sig = () => `${acc.text.length}|${acc.tools.map((t) => t.id + t.state).join(",")}`;
// A cheap signature so onUpdate fires on text/reasoning deltas, new tool
// calls, and a tool's state flipping to `done` (output arriving).
const sig = () =>
`${acc.text.length}|${acc.reasoning.length}|${acc.tools.map((t) => t.id + t.state).join(",")}`;
const drainLine = (line: string) => {
const event = parseEventLine(line);
if (!event) return;
const before = sig();
applyEvent(event, acc);
if (sig() !== before) onUpdate?.(acc.text, acc.tools);
if (sig() !== before) onUpdate?.(acc.text, acc.tools, acc.reasoning);
};

try {
Expand Down
Loading
Loading