Skip to content
Open
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ to authenticate. In the same spirit, the handoff panel explains once when a cowo
work on (it runs as its own agent, outside this deployment's loop) instead of offering switches the
server can only refuse; its existing grants stay visible so they can still be revoked.

### A conversation is given a name of its own

A channel was labelled with the names of the Bots in it, so every conversation with the same
coworker read the same on the roster, and the only thing telling two of them apart was a preview of
whatever was said last. That preview is usually the tail of an answer, which says nothing about the
question that prompted it. Once a conversation has an opening exchange, the deployment's own model
is asked for a few words naming what it is about, and the roster draws those words in place of the
preview. A deployment with no model key configured names nothing and looks exactly as it did before.

### A channel stops showing a working indicator once its turn has ended

Sending a message and then opening another channel before the reply arrived left the first channel
Expand Down
14 changes: 8 additions & 6 deletions app/src/components/app-sidebar/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,15 +89,16 @@ const MAX_ANIMATED_ROWS = 60;
/**
* The roster, narrowed to what the person typed.
*
* Matches the channel's name and the last thing said in it, because those are the two things the
* row actually shows — searching against something invisible returns results a person cannot
* account for. Message history beyond the last line is not here to search: it lives in the thread
* store, and reaching for it is a server endpoint rather than a filter.
* Matches the channel's name, its summary, and the last message, because those are the things the
* row can actually show — searching against something invisible returns results a person cannot
* account for. The last message is included because it is still what the second line draws until the
* conversation has been named. Message history beyond that line is not here to search: it lives in
* the thread store, and reaching for it is a server endpoint rather than a filter.
*
* An empty query returns the input array unchanged rather than a copy, so typing and clearing does
* not hand `AnimatePresence` a new array identity and restage the whole list.
*/
function matchingChannels(
export function matchingChannels(
channels: ChannelSummary[] | undefined,
query: string,
): ChannelSummary[] {
Expand All @@ -109,7 +110,7 @@ function matchingChannels(
return channels;
}
return channels.filter((channel) =>
[channel.name, channel.lastMessage].some((field) =>
[channel.name, channel.summary, channel.lastMessage].some((field) =>
field?.toLowerCase().includes(needle),
),
);
Expand Down Expand Up @@ -190,6 +191,7 @@ function ChannelRow({
channelId={channel.id}
participantIds={channel.agentIds}
name={channel.name}
summary={channel.summary ?? undefined}
lastMessage={channel.lastMessage ?? undefined}
lastMessageAt={
channel.lastMessageAt
Expand Down
14 changes: 13 additions & 1 deletion app/src/components/app-sidebar/channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
deleteChannelMutationOptions,
setChannelPinnedMutationOptions,
} from "@/lib/channels/mutations";
import { useTypedReveal } from "@/lib/typed-reveal";
import { ChannelAvatar } from "../channels/avatar";

/**
Expand All @@ -39,6 +40,7 @@ export const Channel = memo(function Channel({
channelId,
participantIds,
name,
summary,
lastMessage,
lastMessageAt,
pinned,
Expand All @@ -48,6 +50,9 @@ export const Channel = memo(function Channel({
channelId: string;
participantIds: string[];
name: string;
/** What the conversation is about, once named. The channel name only says which Bot it is. */
summary?: string;
/** Shown on that same line until the conversation has been named. */
lastMessage?: string;
lastMessageAt?: string;
pinned: boolean;
Expand All @@ -63,6 +68,8 @@ export const Channel = memo(function Channel({
select: (params) =>
(params as { channelId?: string }).channelId === channelId,
});
/* Types in only on arrival; an already-named row draws it outright. */
const revealed = useTypedReveal(summary);
const setPinned = useMutation(setChannelPinnedMutationOptions(queryClient));
const deleteChannel = useMutation(deleteChannelMutationOptions(queryClient));
const [confirming, setConfirming] = useState(false);
Expand Down Expand Up @@ -133,7 +140,12 @@ export const Channel = memo(function Channel({
</div>
<div className="mt-px flex h-4 items-center gap-1.5">
<span className="min-w-0 flex-1 truncate text-[12px] leading-4 text-muted-foreground">
{lastMessage}
{/* Falls back to the last message, so the line never blanks while naming runs. */}
{revealed.text ?? lastMessage}
{revealed.typing ? (
/* Solid, not blinking: this is over in under half a second. */
<span className="ml-0.5 inline-block h-3 w-px translate-y-px bg-muted-foreground/70 align-middle" />
) : null}
</span>
{unread ? (
/* State about the message beats state about the row, so it sits first. */
Expand Down
4 changes: 3 additions & 1 deletion app/src/lib/channels/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ export type AgentChannel = {
active: boolean;
};

/** A channel plus the last thing said in it, which is what the roster renders. */
/** A channel plus what the roster renders about it. */
export type ChannelSummary = AgentChannel & {
/** A few words about the conversation, or null. The roster falls back to `name`. */
summary: string | null;
lastMessage: string | null;
/** ISO-8601, or null for a channel nobody has used yet. */
lastMessageAt: string | null;
Expand Down
12 changes: 12 additions & 0 deletions app/src/lib/channels/use-channel-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export type ChannelActivityEvent = {
lastMessage: string | null;
lastMessageAt: string | null;
lastMessageAgentId: string | null;
/** The channel's newly written summary. Absent on an ordinary activity event. */
summary?: string;
/** The channel is gone from every member's roster. Absent on an ordinary activity event. */
deleted?: true;
/**
Expand Down Expand Up @@ -95,6 +97,16 @@ export function applyChannelEvent(
const previous = page.channels[index];
if (!previous) return data;

/* One field, and no re-sort: naming a conversation is not something anybody said in it. */
if (activity.summary !== undefined) {
if (previous.summary === activity.summary) return data;
const channels = page.channels.slice();
channels[index] = { ...previous, summary: activity.summary };
const pages = data.pages.slice();
pages[holdingPage] = { ...page, channels };
return { ...data, pages };
}

/*
* A pin patches the one field it is about.
*
Expand Down
68 changes: 68 additions & 0 deletions app/src/lib/typed-reveal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";

/**
* Types a conversation's name in as it arrives, so the line reads as learning something rather than
* as a glitch. Only on arrival: otherwise opening the app sets the whole roster typing at once.
*/

/** Per character, not per title, so every length types at the same speed. Capped so none outstays. */
export const TYPING_SECONDS_PER_CHARACTER = 0.014;
export const TYPING_MAX_SECONDS = 0.45;

export function typingSeconds(length: number): number {
return Math.min(length * TYPING_SECONDS_PER_CHARACTER, TYPING_MAX_SECONDS);
}

/** Naming is none-to-some. A name replacing another name is not this, and must not replay. */
export function isNaming(
previous: string | undefined,
next: string | undefined,
): boolean {
return !previous && Boolean(next);
}

export type TypedReveal = {
/** What to draw right now: the whole name, or as much of it as has landed. */
text: string | undefined;
/** Whether the characters are still arriving, so the caller can show a cursor. */
typing: boolean;
};

export function useTypedReveal(summary: string | undefined): TypedReveal {
const reduce = useReducedMotion();
const previous = useRef(summary);
const firstRun = useRef(true);
const [typed, setTyped] = useState<number | null>(null);

useEffect(() => {
const from = previous.current;
previous.current = summary;
// A row that arrives already named is not a naming.
const mounting = firstRun.current;
firstRun.current = false;
if (mounting || !summary || !isNaming(from, summary)) return;
// The gentler version of a reveal made entirely of movement is no movement.
if (reduce) return;

let frame = 0;
const started = performance.now();
const total = typingSeconds(summary.length) * 1000;
// Linear, because this is constant motion. An eased typewriter reads as a machine hesitating.
const step = (now: number) => {
const progress = Math.min((now - started) / total, 1);
setTyped(Math.round(progress * summary.length));
if (progress < 1) {
frame = requestAnimationFrame(step);
return;
}
// Null, not the full length, so later renders draw the string rather than re-slicing it.
setTyped(null);
};
frame = requestAnimationFrame(step);
return () => cancelAnimationFrame(frame);
}, [summary, reduce]);

if (typed === null || !summary) return { text: summary, typing: false };
return { text: summary.slice(0, typed), typing: true };
}
62 changes: 62 additions & 0 deletions app/tests/channel-event-patch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,65 @@ describe("a busy signal", () => {
);
});
});

/**
* A conversation the server has just named.
*
* Written by a sweep some seconds after the message that prompted it, so it arrives on its own long
* after that message was announced.
*/
describe("a summary", () => {
test("patches only the summary, leaving the last message alone", () => {
const data = cache([
channel("a", {
lastMessage: "Said something.",
lastMessageAt: "2024-04-01T00:00:00.000Z",
lastMessageAgentId: "agent-1",
}),
]);

const patched = applyChannelEvent(
data,
event({ channelId: "a", summary: "Expense categories" }),
);

expect(patched).not.toBe("unknown");
if (patched === "unknown") return;
expect(patched.pages[0]?.channels[0]).toEqual({
...(data.pages[0]?.channels[0] as ChannelSummary),
summary: "Expense categories",
});
});

test("does not move the row it names", () => {
// Naming a conversation is not something anybody said in it. A row that jumped to the top
// seconds after the message that put it there would read as a second message arriving.
const data = cache([
channel("recent", { lastMessageAt: "2024-04-02T00:00:00.000Z" }),
channel("older", { lastMessageAt: "2024-04-01T00:00:00.000Z" }),
]);

const patched = applyChannelEvent(
data,
event({ channelId: "older", summary: "Something older" }),
);

expect(patched).not.toBe("unknown");
if (patched === "unknown") return;
expect(patched.pages[0]?.channels.map((row) => row.id)).toEqual([
"recent",
"older",
]);
});

test("returns the same cache when the row already says so", () => {
const data = cache([channel("a", { summary: "Expense categories" })]);

expect(
applyChannelEvent(
data,
event({ channelId: "a", summary: "Expense categories" }),
),
).toBe(data);
});
});
2 changes: 2 additions & 0 deletions app/tests/channel-menu-mutations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ test("marking read PUTs the read route and patches lastReadAt in place", async (
agentIds: ["agent-1"],
threadId: "thread-1",
active: true,
summary: null,
lastMessage: "hello",
lastMessageAt: "2026-08-25T12:00:00.000Z",
lastMessageAgentId: "agent-1",
Expand Down Expand Up @@ -146,6 +147,7 @@ test("a message stamped by a clock ahead of ours still reads as seen after marki
agentIds: ["agent-1"],
threadId: "thread-1",
active: true,
summary: null,
lastMessage: "hello",
lastMessageAt: futureLastMessageAt,
lastMessageAgentId: "agent-1",
Expand Down
26 changes: 25 additions & 1 deletion app/tests/channel-order.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@ import { pinnedFirst } from "../src/components/app-sidebar/app-sidebar";
import type { ChannelSummary } from "../src/lib/channels/queries";

/** A minimal but fully-typed channel summary, so tests build real objects rather than casts. */
function channel(id: string, pinned: boolean): ChannelSummary {
function channel(
id: string,
pinned: boolean,
summary: string | null = null,
): ChannelSummary {
return {
id,
name: id,
agentIds: [],
threadId: `thread-${id}`,
active: true,
summary,
lastMessage: null,
lastMessageAt: null,
lastMessageAgentId: null,
Expand Down Expand Up @@ -51,3 +56,22 @@ test("leaves an all-unpinned roster in its original order", () => {

expect(pinnedFirst(channels).map((c) => c.id)).toEqual(["a", "b", "c"]);
});

test("a title changes nothing about where a row sits", () => {
// Naming a conversation is not activity in it. Whatever the roster order was, it is the same
// order once titles arrive, or rows would appear to jump for no reason anybody could see.
const untitled = [
channel("a", false),
channel("b", true),
channel("c", false),
];
const titled = [
channel("a", false, "Expense categories"),
channel("b", true, "Quarterly revenue"),
channel("c", false, "Vendor onboarding"),
];

expect(pinnedFirst(titled).map((c) => c.id)).toEqual(
pinnedFirst(untitled).map((c) => c.id),
);
});
57 changes: 57 additions & 0 deletions app/tests/channel-search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { expect, test } from "bun:test";
import { matchingChannels } from "../src/components/app-sidebar/app-sidebar";
import type { ChannelSummary } from "../src/lib/channels/queries";

/** A roster row as the search box sees it. */
function channel(overrides: Partial<ChannelSummary>): ChannelSummary {
return {
id: "channel-1",
name: "Knowledge",
agentIds: ["agent-1"],
threadId: "thread-1",
active: true,
summary: null,
lastMessage: "The three flights and the hotel do.",
lastMessageAt: "2026-08-25T12:00:00.000Z",
lastMessageAgentId: "agent-1",
createdAt: "2026-08-25T11:00:00.000Z",
pinned: false,
lastReadAt: null,
...overrides,
};
}

test("an untitled channel is still found by its Bot's name", () => {
// The fallback the row draws is the name, so the name has to stay searchable or a conversation
// that has not been named yet becomes unreachable from the search box.
const rows = [channel({ id: "untitled" })];

expect(matchingChannels(rows, "knowl").map((row) => row.id)).toEqual([
"untitled",
]);
});

test("a titled channel is found by a word in its title", () => {
const rows = [channel({ id: "titled", summary: "Travel receipt rules" })];

expect(matchingChannels(rows, "receipt").map((row) => row.id)).toEqual([
"titled",
]);
});

test("a word from the last message still matches", () => {
// The second line falls back to the last message until a conversation is named, so that text is
// still something the roster can show and therefore still something search must find.
const rows = [channel({ id: "titled", summary: "Travel receipt rules" })];

expect(matchingChannels(rows, "hotel").map((row) => row.id)).toEqual([
"titled",
]);
});

test("an empty query returns the very same array, not a copy", () => {
// Identity matters: a new array on every keystroke restages the whole animated list.
const rows = [channel({})];

expect(matchingChannels(rows, " ")).toBe(rows);
});
Loading