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
14 changes: 8 additions & 6 deletions app/src/features/bots/BotView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Callout } from "../../ui/Callout";
import { PanelClosing, PanelRestored } from "../../ui/panelMotion";
import { SidePanel } from "../../ui/SidePanel";
import { type Tab, Tabs, tabId } from "../../ui/Tabs";
import { useStable } from "../../ui/useStable";
import { BrowserPanel } from "../browser/BrowserPanel";
import { ShowBrowser } from "../browser/showBrowser";
import { ChatView } from "../chat/ChatView";
Expand Down Expand Up @@ -86,14 +87,15 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
};
// A tool line in the chat points to its file: read the list first, so the
// file is in it when the panel goes to it.
const showFile = async (path: string) => {
// Stable, so the chat does not re-render when this view does.
const showFile = useStable(async (path: string) => {
if (!filesOpen) {
seen();
}
setSide("files");
await files.refresh();
setShown(path);
};
});
const fresh = filesOpen ? 0 : files.files.filter((file) => file.modifiedAt > seenAt).length;
const routines = useApp(useShallow((state) => routinesOf(state, bot.id)));
// A browser at rest is open, but the bot is not using it (spec 21.2).
Expand All @@ -108,25 +110,25 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
const screens = useScreens(bot);
const screensOpen = side === "screens";
const [screenPath, setScreenPath] = useState<string | null>(null);
const showScreen = (path: string | null) => {
const showScreen = useStable((path: string | null) => {
if (filesOpen) {
seen();
}
setSide("screens");
setScreenPath(path);
void screens.refresh();
};
});
// biome-ignore lint/correctness/useExhaustiveDependencies: another bot's screen is not this bot's
useEffect(() => setScreenPath(null), [bot.id]);
const showBrowser = (options?: { take?: boolean }) => {
const showBrowser = useStable((options?: { take?: boolean }) => {
if (filesOpen) {
seen();
}
setSide("browser");
if (options?.take) {
setTakeBrowser((count) => count + 1);
}
};
});
// The panel follows what the bot starts doing (spec 15.1), over the one
// that came back too.
useFollowBot({
Expand Down
38 changes: 30 additions & 8 deletions app/src/features/chat/BotRun.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
// also shows the reply being written.

import { CircleAlert } from "lucide-react";
import type { ReactNode } from "react";
import { memo, type ReactNode } from "react";
import { useT } from "../../i18n";
import { duration, tokens, usedTokens, when } from "../../lib/format";
import type { Bot, ChatItem, TurnItem } from "../../lib/protocol.gen";
import { useArrival } from "../../ui/motion";
import { BotAvatar, moodOf } from "../bots/BotAvatar";
import { ApprovalCard } from "./ApprovalCard";
import { Markdown } from "./Markdown";
import { runParts } from "./rows";
import { runParts, splitDraft } from "./rows";
import { ToolLines } from "./ToolLines";

export interface Live {
Expand Down Expand Up @@ -70,7 +70,33 @@ function Working() {
);
}

export function BotRun({
function Draft({ text }: { text: string }) {
const [done, writing] = splitDraft(text);
return (
<div aria-live="off" className="flex animate-fade flex-col gap-[0.6em] opacity-90">
{done && <Markdown text={done} />}
<Markdown text={writing} streaming />
</div>
);
}

const sameItems = (a: ChatItem[], b: ChatItem[]) =>
a.length === b.length && a.every((item, index) => item === b[index]);

/**
* Re-renders only when something it shows changed: finished runs stay put
* while the last one streams.
*/
export const BotRun = memo(
BotRunView,
(before, after) =>
before.bot === after.bot &&
before.live?.draft === after.live?.draft &&
before.live?.working === after.live?.working &&
sameItems(before.items, after.items),
);

function BotRunView({
items,
bot,
live,
Expand Down Expand Up @@ -119,11 +145,7 @@ export function BotRun({
return null;
}
})}
{live?.draft && (
<div aria-live="off" className="animate-fade opacity-90">
<Markdown text={live.draft} streaming />
</div>
)}
{live?.draft && <Draft text={live.draft} />}
{live && !live.draft && live.working && <Working />}
</div>
</li>
Expand Down
5 changes: 3 additions & 2 deletions app/src/features/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type ClipboardEvent,
type FormEvent,
type KeyboardEvent,
memo,
useEffect,
useId,
useLayoutEffect,
Expand Down Expand Up @@ -56,7 +57,7 @@ function Chip({ file, onRemove }: { file: PendingFile; onRemove(): void }) {
);
}

export function ChatComposer({
export const ChatComposer = memo(function ChatComposer({
bot,
files,
stopped,
Expand Down Expand Up @@ -241,4 +242,4 @@ export function ChatComposer({
)}
</form>
);
}
});
31 changes: 19 additions & 12 deletions app/src/features/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@
// written, and the composer. Files dropped anywhere on it are attached.

import { LoaderCircle, Paperclip } from "lucide-react";
import { type DragEvent, type ReactNode, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
type DragEvent,
memo,
type ReactNode,
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useT } from "../../i18n";
import { day } from "../../lib/format";
import type { Bot } from "../../lib/protocol.gen";
Expand All @@ -23,7 +32,8 @@ const STICKY_PX = 80;

const hasFiles = (event: DragEvent) => event.dataTransfer.types.includes("Files");

export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) {
/** Skips the renders of the view around it; the text being written re-renders only the last run. */
export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) {
const t = useT();
const chat = useChat(bot.id);
const files = useFiles();
Expand Down Expand Up @@ -55,6 +65,10 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) {
}
}, [end]);

const onSent = useCallback(() => {
atEnd.current = true;
}, []);

const loadOlder = () => {
const element = scroller.current;
if (element) {
Expand All @@ -65,7 +79,7 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) {

const working = bot.state === "busy";
const live: Live | undefined = chat.draft || working ? { draft: chat.draft, working } : undefined;
const rows = chatRows(chat.items);
const rows = useMemo(() => chatRows(chat.items), [chat.items]);
const tail = rows.at(-1);
const openRun = tail?.kind === "run" && tail.items.at(-1)?.body.kind !== "turn";

Expand Down Expand Up @@ -168,14 +182,7 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) {
</div>
</div>
<div className="w-full">
<ChatComposer
bot={bot}
files={files}
stopped={stopped}
onSent={() => {
atEnd.current = true;
}}
/>
<ChatComposer bot={bot} files={files} stopped={stopped} onSent={onSent} />
</div>
{dragging && (
<div className="pointer-events-none absolute inset-3 grid place-items-center rounded-2xl border-2 border-accent border-dashed bg-canvas/85">
Expand All @@ -187,4 +194,4 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) {
)}
</section>
);
}
});
11 changes: 9 additions & 2 deletions app/src/features/chat/InboundRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// another bot's or Botloft's on the left under its name (spec 15.3).

import { AlarmClock, ListTodo, Reply } from "lucide-react";
import { memo } from "react";
import { useT } from "../../i18n";
import { when } from "../../lib/format";
import type { Bot, Message } from "../../lib/protocol.gen";
Expand Down Expand Up @@ -103,10 +104,16 @@ function OtherMessage({ message }: { message: Message }) {
);
}

export function InboundRow({ message, bot }: { message: Message; bot: Bot }) {
export const InboundRow = memo(function InboundRow({
message,
bot,
}: {
message: Message;
bot: Bot;
}) {
return message.fromKind === "owner" ? (
<OwnerMessage message={message} bot={bot} />
) : (
<OtherMessage message={message} />
);
}
});
11 changes: 9 additions & 2 deletions app/src/features/chat/NoticeRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// sign-in problem, a session that started over (spec 8.2).

import { CircleAlert, Info, TriangleAlert } from "lucide-react";
import { memo } from "react";
import { type Messages, useT } from "../../i18n";
import type { NoticeItem } from "../../lib/protocol.gen";
import { useArrival } from "../../ui/motion";
Expand All @@ -13,7 +14,13 @@ const LOOK = {
} as const;

/** `at` is when the notice came, so a new one animates in. */
export function NoticeRow({ notice, at = 0 }: { notice: NoticeItem; at?: number }) {
export const NoticeRow = memo(function NoticeRow({
notice,
at = 0,
}: {
notice: NoticeItem;
at?: number;
}) {
const t = useT();
const { icon: Icon, tone, frame } = LOOK[notice.level];
const arrival = useArrival(at);
Expand All @@ -28,7 +35,7 @@ export function NoticeRow({ notice, at = 0 }: { notice: NoticeItem; at?: number
</p>
</li>
);
}
});

/** The app's own words for notices it knows; the daemon's text otherwise. */
function noticeText(notice: NoticeItem, words: Messages["chat"]["notice"]): string {
Expand Down
25 changes: 24 additions & 1 deletion app/src/features/chat/rows.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import type { ChatBody, ChatItem } from "../../lib/protocol.gen";
import { chatRows, runParts } from "./rows";
import { chatRows, runParts, splitDraft } from "./rows";

const DAY = 24 * 60 * 60 * 1000;
const start = new Date(2026, 8, 28, 9, 0).getTime();
Expand Down Expand Up @@ -63,3 +63,26 @@ describe("chat rows", () => {
expect(rows.map((row) => row.kind)).toEqual(["day", "run", "day", "run"]);
});
});

describe("splitDraft", () => {
test("cuts after the last finished block", () => {
expect(splitDraft("One.\n\nTwo is still")).toEqual(["One.\n\n", "Two is still"]);
expect(splitDraft("# Title\n\nFirst.\n\nSecond")).toEqual(["# Title\n\nFirst.\n\n", "Second"]);
});

test("keeps everything together until a block ends", () => {
expect(splitDraft("Only one paragraph so far")).toEqual(["", "Only one paragraph so far"]);
expect(splitDraft("One.\n\n")).toEqual(["", "One.\n\n"]);
});

test("does not cut inside a code fence", () => {
const open = "Look:\n\n```ts\nconst a = 1;\n\nconst b = 2;";
expect(splitDraft(open)).toEqual(["Look:\n\n", "```ts\nconst a = 1;\n\nconst b = 2;"]);
const closed = `${open}\n\`\`\`\n\nAfter.`;
expect(splitDraft(closed)[1]).toBe("After.");
});

test("does not cut before an indented line, which may belong to a list item", () => {
expect(splitDraft("- item\n\n more of it")).toEqual(["", "- item\n\n more of it"]);
});
});
31 changes: 31 additions & 0 deletions app/src/features/chat/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,34 @@ export function runParts(items: ChatItem[]): ChatItem[][] {
}
return parts;
}

/**
* Splits the reply being written where its last finished block ends: the
* part before no longer changes, so only the rest is parsed again as text
* arrives. A blank line inside a code fence does not end a block.
*/
export function splitDraft(text: string): [string, string] {
let fence: string | null = null;
let blank = false;
let cut = 0;
let offset = 0;
for (const line of text.split("\n")) {
if (fence === null && blank && /^\S/.test(line)) {
cut = offset;
}
const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
if (marker && fence === null) {
fence = marker;
} else if (
marker &&
fence !== null &&
marker[0] === fence[0] &&
marker.length >= fence.length
) {
fence = null;
}
blank = line.trim() === "";
offset += line.length + 1;
}
return [text.slice(0, cut), text.slice(cut)];
}
44 changes: 44 additions & 0 deletions app/src/features/chat/stream.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { act, cleanup, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import type { ChatItem } from "../../lib/protocol.gen";
import { crewOpened, openBot, renderApp } from "../../test/app";

// Counts how often finished tool lines render.
const renders = vi.hoisted(() => ({ tools: 0 }));
vi.mock("./ToolLines", () => ({
ToolLines: ({ items }: { items: ChatItem[] }) => {
renders.tools += 1;
return <p>{items.length} tool calls</p>;
},
}));

afterEach(cleanup);

describe("streaming", () => {
test("text being written re-renders only the run it belongs to", async () => {
const fake = new FakeBotloft();
const ops = fake.addCrew("Ops");
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
fake.setBotState(scout.id, "idle");
renderApp(fake);
await crewOpened("Ops");
openBot("Scout");
const chat = await screen.findByRole("list", { name: "Messages" });
act(() => {
fake.chat.tool(scout.id, "Bash", { summary: "npm test", input: "{}" });
fake.chat.turn(scout.id);
fake.setBotState(scout.id, "busy");
});
const before = renders.tools;

const pieces = ["The first ", "paragraph.\n\n", "The second ", "one."];
for (const piece of pieces) {
act(() => fake.chat.delta(scout.id, piece));
}

expect(within(chat).getByText("The second one.")).toBeDefined();
expect(within(chat).getByText("The first paragraph.")).toBeDefined();
expect(renders.tools).toBe(before);
});
});
Loading