From 676987bbb0e6d7342cb681ae4b7b2b746c54a8e7 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 12:21:19 -0700 Subject: [PATCH 1/2] test(web): pin chat titles to the agent's display name (CL-8439) Covers the new priority in chatTitle: agent name first, opening turn subject/body only when the agent can't be resolved. --- apps/web/src/chat/threads-api.test.ts | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/apps/web/src/chat/threads-api.test.ts b/apps/web/src/chat/threads-api.test.ts index 08d4e17eb..88b13e2f7 100644 --- a/apps/web/src/chat/threads-api.test.ts +++ b/apps/web/src/chat/threads-api.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { agentDeploySourceAssetName } from "../agent-deploy"; import { MYRA_SOURCE_CONFIG } from "../myra-source"; import { + chatTitle, displayAgentName, listWorkbenchParticipants, resolveAvatarName, @@ -33,6 +34,37 @@ describe("displayAgentName", () => { }); }); +function meTurn(subject: string, body: string) { + return { + id: "Sent:1", + messageId: "m1", + address: "run_alice@example.com", + author: "me" as const, + subject, + body, + at: "2026-01-01T00:00:00Z", + attachments: [], + }; +} + +describe("chatTitle", () => { + test("titles a chat by the agent's display name, never mail metadata", () => { + expect(chatTitle([meTurn("Deploy the thing", "hi")], "Echo Bot")).toBe("Echo Bot"); + }); + + test("falls back to the opening turn's subject only when the agent can't be resolved", () => { + expect(chatTitle([meTurn("Deploy the thing", "hi")], undefined)).toBe("Deploy the thing"); + }); + + test("falls back further to the opening turn's body when it has no subject", () => { + expect(chatTitle([meTurn("", "hello there")], undefined)).toBe("hello there"); + }); + + test("falls back to a generic label when there is no opening turn and no agent name", () => { + expect(chatTitle([], undefined)).toBe("Untitled chat"); + }); +}); + describe("listWorkbenchParticipants", () => { test("a person's address is their refId at the workbench's own domain, never email or bare refId", async () => { globalThis.fetch = ((input: RequestInfo | URL) => { From 95954a92481ca7b0ed4b294c5d69eabde8c86377 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 12:21:26 -0700 Subject: [PATCH 2/2] fix(web): chats are titled by the agent's display name and land without a modal (CL-8439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat heading, sidebar row, and composer placeholder all derived from mail metadata that could surface a run address; title them by the agent's display name instead, falling back to the opening turn's subject only when the agent itself can't be resolved. Landing on `/` could also drop the first-run tour's overlay over the chat it just redirected onto — the tour now only opens on an explicit "Take the tour" command, never automatically. --- apps/web/src/chat/threads-api.ts | 18 ++++++---- apps/web/src/command-palette-actions.ts | 13 +++++++- apps/web/src/pages/chat-thread-page.tsx | 2 +- apps/web/src/shell/first-run-tour-store.ts | 39 ++++++++++++++++++++++ apps/web/src/shell/first-run-tour.tsx | 29 +++++++++------- 5 files changed, 81 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/shell/first-run-tour-store.ts diff --git a/apps/web/src/chat/threads-api.ts b/apps/web/src/chat/threads-api.ts index 9419a680e..b86d8f9ce 100644 --- a/apps/web/src/chat/threads-api.ts +++ b/apps/web/src/chat/threads-api.ts @@ -474,11 +474,16 @@ export type ChatThread = { readonly messages: readonly ChatMessage[]; }; -/** The chat title is always the person's own opening turn — never an - * agent reply — so a chat never titles itself off what the agent said. */ -function chatTitle(turns: readonly MailTurn[], agentName: string): string { +/** A chat is always titled by its agent's display name — never by mail + * metadata, which can be a run address or other addressing detail nobody + * should have to read. `agentName` is `undefined` only when the agent + * couldn't be resolved at all, in which case the title falls back to the + * person's own opening turn (never an agent reply, so a chat never titles + * itself off what the agent said). */ +export function chatTitle(turns: readonly MailTurn[], agentName: string | undefined): string { + if (agentName !== undefined) return agentName; const first = turns.find((turn) => turn.author === "me"); - if (first === undefined) return agentName; + if (first === undefined) return "Untitled chat"; return first.subject.length > 0 ? first.subject : first.body.slice(0, 60); } @@ -504,11 +509,12 @@ export async function listChats(tenantId: string): Promise { - const agentName = agents.find((agent) => agent.id === agentId)?.name ?? agentId; + const resolvedName = agents.find((agent) => agent.id === agentId)?.name; + const agentName = resolvedName ?? agentId; const newest = rows[rows.length - 1]!; return { id: agentId, - title: chatTitle(rows, agentName), + title: chatTitle(rows, resolvedName), agentName, preview: newest.body.slice(0, 80), lastActivityAt: newest.at, diff --git a/apps/web/src/command-palette-actions.ts b/apps/web/src/command-palette-actions.ts index 803b17363..c2df3b060 100644 --- a/apps/web/src/command-palette-actions.ts +++ b/apps/web/src/command-palette-actions.ts @@ -24,6 +24,7 @@ import { WORKBENCH_PATH_PREFIX } from "./workbench-path"; import { NEW_WORKBENCH_PATH } from "./routes"; import { NEW_CHAT_PATH } from "./chat-path"; import { requestLibraryUpload } from "./library-upload"; +import { openFirstRunTour } from "./shell/first-run-tour-store"; export const NEW_SKILL_EVENT = "workbench:skills:create"; @@ -44,7 +45,8 @@ export type ActionCommandId = | "toggle-theme" | "close-canvas" | "talk-to-myra" - | "go-workbenches"; + | "go-workbenches" + | "take-tour"; export type ActionCommand = { readonly id: ActionCommandId; @@ -84,6 +86,11 @@ export const ACTION_COMMANDS: readonly ActionCommand[] = [ title: "Go to workbenches", subtitle: "Home · conversation list", }, + { + id: "take-tour", + title: "Take the tour", + subtitle: "Guided walkthrough of the shell", + }, ]; export type ActionCommandContext = { @@ -142,5 +149,9 @@ export async function runActionCommand( ctx.navigate(WORKBENCH_PATH_PREFIX); return; } + case "take-tour": { + openFirstRunTour(); + return; + } } } diff --git a/apps/web/src/pages/chat-thread-page.tsx b/apps/web/src/pages/chat-thread-page.tsx index 6ad0a30eb..e30e60fb2 100644 --- a/apps/web/src/pages/chat-thread-page.tsx +++ b/apps/web/src/pages/chat-thread-page.tsx @@ -238,7 +238,7 @@ function ChatTranscript({ return ( -

{chat.title}

+

{chat.agentName}

{chat.messages.map((message) => { const { pkg, renderedBody } = resolveMessagePackage(message.attachments, message.body); diff --git a/apps/web/src/shell/first-run-tour-store.ts b/apps/web/src/shell/first-run-tour-store.ts new file mode 100644 index 000000000..fdeda615d --- /dev/null +++ b/apps/web/src/shell/first-run-tour-store.ts @@ -0,0 +1,39 @@ +// The first-run tour's open state, held outside the React tree like +// `command-palette-open-store.ts`: the tour must never auto-start itself on +// landing (it used to, and its overlay would land right over the chat the +// person was just redirected onto), so the only way in is an explicit call +// to `openFirstRunTour` from a command or menu action. + +import { useSyncExternalStore } from "react"; + +let open = false; +const listeners = new Set<() => void>(); + +function emit(): void { + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function openFirstRunTour(): void { + if (open) return; + open = true; + emit(); +} + +export function closeFirstRunTour(): void { + if (!open) return; + open = false; + emit(); +} + +export function useFirstRunTourOpen(): boolean { + return useSyncExternalStore( + subscribe, + () => open, + () => false, + ); +} diff --git a/apps/web/src/shell/first-run-tour.tsx b/apps/web/src/shell/first-run-tour.tsx index c71e7b499..ca49a8b4d 100644 --- a/apps/web/src/shell/first-run-tour.tsx +++ b/apps/web/src/shell/first-run-tour.tsx @@ -1,21 +1,25 @@ -// A one-time guided tour of the shell, shown the first time a person lands -// here after setup. "Seen" is a localStorage flag keyed by user id (mirrors +// A guided tour of the shell, started only by explicit user action (a +// command or menu item calling `openFirstRunTour`) — never automatically on +// landing, which used to drop its overlay right over the chat `/` redirects +// onto. "Seen" is a localStorage flag keyed by user id (mirrors // `command-palette-recents.ts`'s defensive access) so a shared browser -// profile never re-shows it for the wrong account, and finishing or -// skipping both mark it seen for good — there is no "remind me later". +// profile never re-shows it as "new" for the wrong account, and finishing or +// skipping both mark it seen — there is no "remind me later". import Joyride, { ACTIONS, type CallBackProps, STATUS, type Step } from "react-joyride"; -import { useState } from "react"; import { reportError } from "@corbits/error-sink"; +import { closeFirstRunTour, useFirstRunTourOpen } from "./first-run-tour-store"; const STORAGE_PREFIX = "workbench.first-run-tour-seen"; -function hasSeenTour(userId: string): boolean { +/** Used only to label the menu item that opens the tour ("Take the tour" vs + * "Replay tour") — no longer gates whether the tour runs. */ +export function hasSeenTour(userId: string): boolean { try { return window.localStorage.getItem(`${STORAGE_PREFIX}:${userId}`) === "true"; } catch (error) { reportError(error, { operation: "first_run_tour_read" }); - return true; // Storage disabled: never nag with a tour that can't remember itself. + return true; // Storage disabled: default to the less presumptuous label. } } @@ -51,11 +55,12 @@ const STEPS: readonly Step[] = [ ]; /** - * Mounted once from `AppShell`. Renders nothing once the tour has already - * been seen for this user, so it costs nothing on every later visit. + * Mounted once from `AppShell`. Renders nothing until `openFirstRunTour` is + * called — never on its own, so a fresh landing on `/` never drops this + * overlay over the chat the person was just redirected onto. */ export function FirstRunTour({ userId }: { readonly userId: string }) { - const [run, setRun] = useState(() => !hasSeenTour(userId)); + const run = useFirstRunTourOpen(); // Dismissing has to unmount Joyride, not just remember the dismissal: // a running Joyride keeps two portals appended to `document.body` and @@ -65,14 +70,14 @@ export function FirstRunTour({ userId }: { readonly userId: string }) { // The tooltip's close (X) button fires action "close" without ever // moving status to FINISHED or SKIPPED, so it has to be treated as a // dismissal in its own right — otherwise closing the tour this way - // never persists and it replays on the next mount. + // never closes it. if ( data.status === STATUS.FINISHED || data.status === STATUS.SKIPPED || data.action === ACTIONS.CLOSE ) { markTourSeen(userId); - setRun(false); + closeFirstRunTour(); } }