diff --git a/__tests__/components/AIAssistant.test.tsx b/__tests__/components/AIAssistant.test.tsx index 2644a53..1c9762a 100644 --- a/__tests__/components/AIAssistant.test.tsx +++ b/__tests__/components/AIAssistant.test.tsx @@ -34,6 +34,7 @@ function mockChat(overrides: Partial> = {}) { newConversation: jest.fn().mockResolvedValue(undefined), loadConversation: jest.fn().mockResolvedValue(undefined), removeConversation: jest.fn().mockResolvedValue(undefined), + renameConversation: jest.fn().mockResolvedValue(undefined), ...overrides, }; mockUseAIChat.mockReturnValue(base); @@ -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(); }); }); diff --git a/__tests__/components/Reasoning.test.tsx b/__tests__/components/Reasoning.test.tsx new file mode 100644 index 0000000..23d63c2 --- /dev/null +++ b/__tests__/components/Reasoning.test.tsx @@ -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().toJSON()).toBeNull(); + expect(render().toJSON()).toBeNull(); + }); + + it("is collapsed by default and expands on tap", () => { + const { getByLabelText, getByText, queryByText } = render( + , + ); + expect(getByText("Reasoning")).toBeTruthy(); + expect(queryByText("First I considered the options.")).toBeNull(); + fireEvent.press(getByLabelText("Reasoning")); + expect(getByText("First I considered the options.")).toBeTruthy(); + }); +}); diff --git a/__tests__/lib/ai-chat.test.ts b/__tests__/lib/ai-chat.test.ts index c5882f8..d02976c 100644 --- a/__tests__/lib/ai-chat.test.ts +++ b/__tests__/lib/ai-chat.test.ts @@ -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"}}`, @@ -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: [] }); }); }); diff --git a/__tests__/stores/ai-chat-store.test.ts b/__tests__/stores/ai-chat-store.test.ts index 5b33fdc..3eca530 100644 --- a/__tests__/stores/ai-chat-store.test.ts +++ b/__tests__/stores/ai-chat-store.test.ts @@ -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"; @@ -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" }]); + }); }); }); diff --git a/app/ai.tsx b/app/ai.tsx index 0518778..95ea632 100644 --- a/app/ai.tsx +++ b/app/ai.tsx @@ -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"; @@ -71,7 +75,8 @@ function MessageBubble({ message }: { message: AIChatMessage }) { return ( - {/* Structured tool activity (assistant only) */} + {/* Reasoning trace, then tool activity (assistant only) */} + {!isUser && message.reasoning ? : null} {!isUser && message.tools && message.tools.length > 0 && ( )} @@ -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(null); // Probe the server + restore the last conversation on first mount. @@ -225,6 +232,17 @@ export default function AIAssistantScreen() { {c.title ?? "New conversation"} + { + 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" + > + + void removeConversation(c.id)} accessibilityRole="button" @@ -238,6 +256,46 @@ export default function AIAssistantScreen() { )} + {/* Rename dialog */} + !o && setRenaming(null)} + title="Rename conversation" + > + {renaming && ( + + 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" + /> + + + + + + )} + + + setOpen((o) => !o)} + accessibilityRole="button" + accessibilityLabel="Reasoning" + accessibilityState={{ expanded: open }} + > + + Reasoning + {open ? ( + + ) : ( + + )} + + {open && ( + + {reasoning.trim()} + + )} + + ); +} diff --git a/hooks/useAIChat.ts b/hooks/useAIChat.ts index 28b33bd..20dab91 100644 --- a/hooks/useAIChat.ts +++ b/hooks/useAIChat.ts @@ -29,6 +29,8 @@ export interface UseAIChatResult { loadConversation: (id: string) => Promise; /** Delete a saved conversation. */ removeConversation: (id: string) => Promise; + /** Rename a saved conversation. */ + renameConversation: (id: string, title: string) => Promise; } /** @@ -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, @@ -68,5 +71,6 @@ export function useAIChat(): UseAIChatResult { newConversation, loadConversation, removeConversation, + renameConversation, }; } diff --git a/lib/ai-chat.ts b/lib/ai-chat.ts index ee69b87..6f34f9e 100644 --- a/lib/ai-chat.ts +++ b/lib/ai-chat.ts @@ -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). */ @@ -75,6 +77,12 @@ function applyEvent(event: Record, 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); @@ -124,7 +132,7 @@ function parseEventLine(line: string): Record | 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); @@ -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; } @@ -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 { diff --git a/stores/ai-chat-store.ts b/stores/ai-chat-store.ts index b7b5752..9fed92a 100644 --- a/stores/ai-chat-store.ts +++ b/stores/ai-chat-store.ts @@ -9,6 +9,7 @@ import { deleteConversation, addMessage, deriveConversationTitle, + renameConversation as renameConversationApi, type ConversationSummary, } from "~/lib/ai-conversations"; @@ -18,6 +19,8 @@ export interface AIChatMessage { content: string; /** Structured tool invocations the agent ran (assistant messages only). */ tools?: ToolInvocation[]; + /** The model's reasoning/thinking text, if it streamed any. */ + reasoning?: string; } /* ------------------------------------------------------------------ */ @@ -110,6 +113,8 @@ interface AIChatState { loadConversation: (id: string) => Promise; /** Delete a server conversation; resets the view if it was active. */ removeConversation: (id: string) => Promise; + /** Rename a server conversation. */ + renameConversation: (id: string, title: string) => Promise; /** Refresh the conversation list from the server. */ refreshConversations: () => Promise; /** Reload the local cached thread (cold-start restore in local mode). */ @@ -169,12 +174,17 @@ export const useAIChatStore = create((set, get) => ({ set({ messages: [...history, { role: "assistant", content: "" }], isLoading: true, error: null }); - const patchAssistant = (content: string, tools: ToolInvocation[]) => { + const patchAssistant = (content: string, tools: ToolInvocation[], reasoning = "") => { set((state) => { const next = [...state.messages]; const last = next.length - 1; if (last >= 0 && next[last].role === "assistant") { - next[last] = { role: "assistant", content, ...(tools.length > 0 ? { tools } : {}) }; + next[last] = { + role: "assistant", + content, + ...(tools.length > 0 ? { tools } : {}), + ...(reasoning.trim() !== "" ? { reasoning } : {}), + }; } return { messages: next }; }); @@ -201,11 +211,11 @@ export const useAIChatStore = create((set, get) => ({ const result = await streamAiChat(wire, { signal: controller.signal, conversationId: conversationId ?? undefined, - onUpdate: (t, tools) => { + onUpdate: (t, tools, reasoning) => { const now = Date.now(); if (now - lastPatch >= 80) { lastPatch = now; - patchAssistant(t, tools); + patchAssistant(t, tools, reasoning); } }, }); @@ -217,7 +227,7 @@ export const useAIChatStore = create((set, get) => ({ : stopped ? "(stopped)" : "I couldn't generate a response."); - patchAssistant(content, result.tools); + patchAssistant(content, result.tools, result.reasoning); lastFailed = null; if (serverBacked && conversationId) { @@ -302,6 +312,16 @@ export const useAIChatStore = create((set, get) => ({ } }, + renameConversation: async (id: string, title: string) => { + const trimmed = title.trim(); + if (trimmed === "") return; + // Optimistic local update, then persist. + set((s) => ({ + conversations: s.conversations.map((c) => (c.id === id ? { ...c, title: trimmed } : c)), + })); + await renameConversationApi(id, trimmed); + }, + refreshConversations: async () => { if (!get().serverBacked) return; try {