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
32 changes: 32 additions & 0 deletions apps/web/src/chat/threads-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down
18 changes: 12 additions & 6 deletions apps/web/src/chat/threads-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -504,11 +509,12 @@ export async function listChats(tenantId: string): Promise<readonly ChatSummary[
}
return [...byAgent.entries()]
.map(([agentId, rows]) => {
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,
Expand Down
13 changes: 12 additions & 1 deletion apps/web/src/command-palette-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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;
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -142,5 +149,9 @@ export async function runActionCommand(
ctx.navigate(WORKBENCH_PATH_PREFIX);
return;
}
case "take-tour": {
openFirstRunTour();
return;
}
}
}
2 changes: 1 addition & 1 deletion apps/web/src/pages/chat-thread-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ function ChatTranscript({

return (
<PageShell width="prose" className="page-fill">
<h1 className="chat-thread-title">{chat.title}</h1>
<h1 className="chat-thread-title">{chat.agentName}</h1>
<div className="chat-thread-messages">
{chat.messages.map((message) => {
const { pkg, renderedBody } = resolveMessagePackage(message.attachments, message.body);
Expand Down
39 changes: 39 additions & 0 deletions apps/web/src/shell/first-run-tour-store.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
29 changes: 17 additions & 12 deletions apps/web/src/shell/first-run-tour.tsx
Original file line number Diff line number Diff line change
@@ -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.
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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();
}
}

Expand Down
Loading