Skip to content

Commit 555b727

Browse files
committed
fix(web): chats are titled by the agent's display name and land without a modal (CL-8439)
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.
1 parent 4b8a563 commit 555b727

5 files changed

Lines changed: 81 additions & 20 deletions

File tree

apps/web/src/chat/threads-api.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -474,11 +474,16 @@ export type ChatThread = {
474474
readonly messages: readonly ChatMessage[];
475475
};
476476

477-
/** The chat title is always the person's own opening turn — never an
478-
* agent reply — so a chat never titles itself off what the agent said. */
479-
function chatTitle(turns: readonly MailTurn[], agentName: string): string {
477+
/** A chat is always titled by its agent's display name — never by mail
478+
* metadata, which can be a run address or other addressing detail nobody
479+
* should have to read. `agentName` is `undefined` only when the agent
480+
* couldn't be resolved at all, in which case the title falls back to the
481+
* person's own opening turn (never an agent reply, so a chat never titles
482+
* itself off what the agent said). */
483+
export function chatTitle(turns: readonly MailTurn[], agentName: string | undefined): string {
484+
if (agentName !== undefined) return agentName;
480485
const first = turns.find((turn) => turn.author === "me");
481-
if (first === undefined) return agentName;
486+
if (first === undefined) return "Untitled chat";
482487
return first.subject.length > 0 ? first.subject : first.body.slice(0, 60);
483488
}
484489

@@ -504,11 +509,12 @@ export async function listChats(tenantId: string): Promise<readonly ChatSummary[
504509
}
505510
return [...byAgent.entries()]
506511
.map(([agentId, rows]) => {
507-
const agentName = agents.find((agent) => agent.id === agentId)?.name ?? agentId;
512+
const resolvedName = agents.find((agent) => agent.id === agentId)?.name;
513+
const agentName = resolvedName ?? agentId;
508514
const newest = rows[rows.length - 1]!;
509515
return {
510516
id: agentId,
511-
title: chatTitle(rows, agentName),
517+
title: chatTitle(rows, resolvedName),
512518
agentName,
513519
preview: newest.body.slice(0, 80),
514520
lastActivityAt: newest.at,

apps/web/src/command-palette-actions.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { WORKBENCH_PATH_PREFIX } from "./workbench-path";
2424
import { NEW_WORKBENCH_PATH } from "./routes";
2525
import { NEW_CHAT_PATH } from "./chat-path";
2626
import { requestLibraryUpload } from "./library-upload";
27+
import { openFirstRunTour } from "./shell/first-run-tour-store";
2728

2829
export const NEW_SKILL_EVENT = "workbench:skills:create";
2930

@@ -45,7 +46,8 @@ export type ActionCommandId =
4546
| "close-canvas"
4647
| "talk-to-myra"
4748
| "go-workbenches"
48-
| "go-insights";
49+
| "go-insights"
50+
| "take-tour";
4951

5052
export type ActionCommand = {
5153
readonly id: ActionCommandId;
@@ -90,6 +92,11 @@ export const ACTION_COMMANDS: readonly ActionCommand[] = [
9092
title: "Go to insights",
9193
subtitle: "Settings · still routable",
9294
},
95+
{
96+
id: "take-tour",
97+
title: "Take the tour",
98+
subtitle: "Guided walkthrough of the shell",
99+
},
93100
];
94101

95102
export type ActionCommandContext = {
@@ -152,5 +159,9 @@ export async function runActionCommand(
152159
ctx.navigate("/insights");
153160
return;
154161
}
162+
case "take-tour": {
163+
openFirstRunTour();
164+
return;
165+
}
155166
}
156167
}

apps/web/src/pages/chat-thread-page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ function ChatTranscript({
238238

239239
return (
240240
<PageShell width="prose" className="page-fill">
241-
<h1 className="chat-thread-title">{chat.title}</h1>
241+
<h1 className="chat-thread-title">{chat.agentName}</h1>
242242
<div className="chat-thread-messages">
243243
{chat.messages.map((message) => {
244244
const { pkg, renderedBody } = resolveMessagePackage(message.attachments, message.body);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// The first-run tour's open state, held outside the React tree like
2+
// `command-palette-open-store.ts`: the tour must never auto-start itself on
3+
// landing (it used to, and its overlay would land right over the chat the
4+
// person was just redirected onto), so the only way in is an explicit call
5+
// to `openFirstRunTour` from a command or menu action.
6+
7+
import { useSyncExternalStore } from "react";
8+
9+
let open = false;
10+
const listeners = new Set<() => void>();
11+
12+
function emit(): void {
13+
for (const listener of listeners) listener();
14+
}
15+
16+
function subscribe(listener: () => void): () => void {
17+
listeners.add(listener);
18+
return () => listeners.delete(listener);
19+
}
20+
21+
export function openFirstRunTour(): void {
22+
if (open) return;
23+
open = true;
24+
emit();
25+
}
26+
27+
export function closeFirstRunTour(): void {
28+
if (!open) return;
29+
open = false;
30+
emit();
31+
}
32+
33+
export function useFirstRunTourOpen(): boolean {
34+
return useSyncExternalStore(
35+
subscribe,
36+
() => open,
37+
() => false,
38+
);
39+
}

apps/web/src/shell/first-run-tour.tsx

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
1-
// A one-time guided tour of the shell, shown the first time a person lands
2-
// here after setup. "Seen" is a localStorage flag keyed by user id (mirrors
1+
// A guided tour of the shell, started only by explicit user action (a
2+
// command or menu item calling `openFirstRunTour`) — never automatically on
3+
// landing, which used to drop its overlay right over the chat `/` redirects
4+
// onto. "Seen" is a localStorage flag keyed by user id (mirrors
35
// `command-palette-recents.ts`'s defensive access) so a shared browser
4-
// profile never re-shows it for the wrong account, and finishing or
5-
// skipping both mark it seen for good — there is no "remind me later".
6+
// profile never re-shows it as "new" for the wrong account, and finishing or
7+
// skipping both mark it seen — there is no "remind me later".
68

79
import Joyride, { ACTIONS, type CallBackProps, STATUS, type Step } from "react-joyride";
8-
import { useState } from "react";
910
import { reportError } from "@corbits/error-sink";
11+
import { closeFirstRunTour, useFirstRunTourOpen } from "./first-run-tour-store";
1012

1113
const STORAGE_PREFIX = "workbench.first-run-tour-seen";
1214

13-
function hasSeenTour(userId: string): boolean {
15+
/** Used only to label the menu item that opens the tour ("Take the tour" vs
16+
* "Replay tour") — no longer gates whether the tour runs. */
17+
export function hasSeenTour(userId: string): boolean {
1418
try {
1519
return window.localStorage.getItem(`${STORAGE_PREFIX}:${userId}`) === "true";
1620
} catch (error) {
1721
reportError(error, { operation: "first_run_tour_read" });
18-
return true; // Storage disabled: never nag with a tour that can't remember itself.
22+
return true; // Storage disabled: default to the less presumptuous label.
1923
}
2024
}
2125

@@ -57,11 +61,12 @@ const STEPS: readonly Step[] = [
5761
];
5862

5963
/**
60-
* Mounted once from `AppShell`. Renders nothing once the tour has already
61-
* been seen for this user, so it costs nothing on every later visit.
64+
* Mounted once from `AppShell`. Renders nothing until `openFirstRunTour` is
65+
* called — never on its own, so a fresh landing on `/` never drops this
66+
* overlay over the chat the person was just redirected onto.
6267
*/
6368
export function FirstRunTour({ userId }: { readonly userId: string }) {
64-
const [run, setRun] = useState(() => !hasSeenTour(userId));
69+
const run = useFirstRunTourOpen();
6570

6671
// Dismissing has to unmount Joyride, not just remember the dismissal:
6772
// a running Joyride keeps two portals appended to `document.body` and
@@ -71,14 +76,14 @@ export function FirstRunTour({ userId }: { readonly userId: string }) {
7176
// The tooltip's close (X) button fires action "close" without ever
7277
// moving status to FINISHED or SKIPPED, so it has to be treated as a
7378
// dismissal in its own right — otherwise closing the tour this way
74-
// never persists and it replays on the next mount.
79+
// never closes it.
7580
if (
7681
data.status === STATUS.FINISHED ||
7782
data.status === STATUS.SKIPPED ||
7883
data.action === ACTIONS.CLOSE
7984
) {
8085
markTourSeen(userId);
81-
setRun(false);
86+
closeFirstRunTour();
8287
}
8388
}
8489

0 commit comments

Comments
 (0)