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
12 changes: 12 additions & 0 deletions backend/__tests__/unit/config/scoutWelcome.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,20 @@ describe('Scout welcome locale', () => {

it('keeps the start labels in English, since Scout reads them', () => {
expect(SCOUT_STARTS.map((s) => s.label)).toEqual([
'Show me what you can do',
'Connect an agent I already use',
'Start a pod for a project',
]);
});

// Staging walk 2026-11-02: a start that asks for nothing leads the sent
// starts, and Scout's prompt says how to answer it.
it('leads the sent starts with the one that asks nothing, and tells Scout how to answer it', () => {
expect(SCOUT_STARTS[0]).toEqual({ id: 'ask-scout', label: 'Show me what you can do' });
// eslint-disable-next-line global-require
const { scoutApp } = require('../../../config/native-agents/scout');
expect(scoutApp.systemPrompt).toContain('For "Show me what you can do", answer in two or three short sentences');
expect(scoutApp.systemPrompt).toContain('For "Connect an agent I already use", find out which tool they run');
expect(scoutApp.systemPrompt).toContain('For "Start a pod for a project", ask what the project is');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ describe('workspace onboarding guide identity fork (2026-08-13)', () => {
expect(welcome.payload).toEqual({
kind: 'starts',
starts: [
{ id: 'ask-scout', label: 'Show me what you can do' },
{ id: 'connect-agent', label: 'Connect an agent I already use' },
{ id: 'start-pod', label: 'Start a pod for a project' },
],
Expand Down
16 changes: 13 additions & 3 deletions backend/config/native-agents/scout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,17 @@ import UI_CARD_REVISION from '../uiCardCatalogueRevision.json';
* the start a new person picked (services/onboardingMarks.ts, by id).
*/
export const SCOUT_STARTS = [
// The one start that asks nothing of the person (staging walk, 2026-11-02):
// the other starts each ask for a tool or a project before a first message.
{ id: 'ask-scout', label: 'Show me what you can do' },
{ id: 'connect-agent', label: 'Connect an agent I already use' },
{ id: 'start-pod', label: 'Start a pod for a project' },
] as const;

const startLabel = (id: typeof SCOUT_STARTS[number]['id']): string => (
SCOUT_STARTS.find((start) => start.id === id)?.label ?? id
);

/**
* The start that leads the welcome where the instance can host (QA walk 3,
* 2026-10-16): nothing to install, so it goes first. Unlike SCOUT_STARTS it
Expand Down Expand Up @@ -97,12 +104,15 @@ export const scoutApp = {
+ 'page in between; only if that cannot run does it open Bring your own with '
+ '"Run it here" chosen. Either way it sends you nothing. The others send the start as '
+ 'the person\'s own message: '
+ `"${SCOUT_STARTS[0].label}" and "${SCOUT_STARTS[1].label}". For `
+ `"${SCOUT_STARTS[0].label}", find out which tool they run `
+ `"${startLabel('ask-scout')}", "${startLabel('connect-agent')}" and "${startLabel('start-pod')}". `
+ `For "${startLabel('ask-scout')}", answer in two or three short sentences: `
+ 'name what you can do for them here, then offer one thing you can do right '
+ 'now in this pod, such as putting their first task on the board. '
+ `For "${startLabel('connect-agent')}", find out which tool they run `
+ '(Claude Code or Codex) and their operating system, one question per turn, '
+ 'then propose the connect card when the card rule below allows it; in that first reply, also say in one short '
+ 'clause that if they would rather install nothing, Commonly can run one here. '
+ `For "${SCOUT_STARTS[1].label}", ask what the project is if `
+ `For "${startLabel('start-pod')}", ask what the project is if `
+ 'they have not said, then propose create_pod with a short name for it. '
+ 'Set create_pod params.locale to the person\'s language (en or zh-CN). '
+ 'Once they approve it, reply with the exact one-line wording in the '
Expand Down
26 changes: 17 additions & 9 deletions docs/design/artboards/d215/index.html

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion frontend/src/i18n/__tests__/scoutStartLabels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import i18n, { i18nReady, LANGUAGE_STORAGE_KEY } from '..';
// TASK-550: Scout's welcome starts are sent to Scout by their English labels
// (backend/config/native-agents/scout.ts), so only the displayed text is
// translated, by id. Every id the welcome can offer needs a zh-CN label.
const START_IDS = ['connect-agent', 'start-pod', 'run-hosted'];
const START_IDS = ['ask-scout', 'connect-agent', 'start-pod', 'run-hosted'];

describe('Scout welcome start labels', () => {
beforeAll(async () => {
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,7 @@
"label": "Ways to start",
"openByo": "Open Bring your own",
"labels": {
"ask-scout": "Show me what you can do",
"connect-agent": "Connect an agent I already use",
"start-pod": "Start a pod for a project",
"run-hosted": "Run an agent here"
Expand Down Expand Up @@ -716,6 +717,8 @@
"agents_one": "{{count}} agent",
"agents_other": "{{count}} agents",
"boardOpen": "board {{count}} open",
"boardTasks_one": "{{count}} to do",
"boardTasks_other": "{{count}} to do",
"backToPods": "Back to pods",
"invite": "Invite",
"dm": "DM",
Expand Down Expand Up @@ -746,6 +749,7 @@
},
"composer": {
"placeholder": "Message {{podName}}…",
"placeholderAgent": "Tell {{agent}}: I'm working on…",
"uploading": "Uploading…",
"attachFile": "Attach file",
"sending": "Sending…",
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,7 @@
"label": "开始方式",
"openByo": "去接入自己的智能体",
"labels": {
"ask-scout": "看看你能做什么",
"connect-agent": "接入我在用的智能体",
"start-pod": "新建项目 Pod",
"run-hosted": "在这里运行智能体"
Expand Down Expand Up @@ -715,6 +716,7 @@
"agents_one": "{{count}}个智能体",
"agents_other": "{{count}}个智能体",
"boardOpen": "看板{{count}}项待办",
"boardTasks_other": "{{count}}项待办",
"backToPods": "返回 Pod 列表",
"invite": "邀请",
"dm": "私信",
Expand Down Expand Up @@ -745,6 +747,7 @@
},
"composer": {
"placeholder": "发消息到 {{podName}}…",
"placeholderAgent": "告诉 {{agent}}:我在做…",
"uploading": "正在上传…",
"attachFile": "添加文件",
"sending": "正在发送…",
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/v2/__tests__/V2ThreadPodRename.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@ describe('V2Thread — owner-only pod rename (TASK-419)', () => {
expect(chips).toHaveLength(2);
chips.forEach((chip) => {
expect(chip).toHaveAttribute('title', 'board 2 open');
expect(chip).toHaveTextContent(/^2$/);
// The count carries its noun: a bare "2" read as an unread count (walk 11-02).
expect(chip).toHaveTextContent(/^2 to do$/);
expect(chip.querySelector('svg')).toBeTruthy();
});
fireEvent.click(chips[0]);
Expand Down
59 changes: 55 additions & 4 deletions frontend/src/v2/__tests__/V2ThreadStarts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ describe("V2Thread — Scout's starts", () => {

test('a click sends the start as a root message, leaves the draft alone, and that start goes away', async () => {
const sendSpy = renderThread([welcome]);
const composer = screen.getByPlaceholderText(/message my workspace/i);
const composer = screen.getByPlaceholderText(/tell scout: i'm working on/i);
fireEvent.change(composer, { target: { value: 'half typed' } });

fireEvent.click(within(startsGroup()).getByRole('button', { name: POD_START }));
Expand All @@ -171,7 +171,7 @@ describe("V2Thread — Scout's starts", () => {

test('typing something else leaves the starts in place, so connecting stays one click away', async () => {
const sendSpy = renderThread([welcome]);
fireEvent.change(screen.getByPlaceholderText(/message my workspace/i), { target: { value: 'hello' } });
fireEvent.change(screen.getByPlaceholderText(/tell scout: i'm working on/i), { target: { value: 'hello' } });
fireEvent.click(screen.getByRole('button', { name: /send message/i }));

expect(await screen.findByText('hello', { selector: '.v2-msg *' })).toBeInTheDocument();
Expand Down Expand Up @@ -213,6 +213,57 @@ describe("V2Thread — Scout's starts", () => {
// message, from the moment it is sent.
const OPEN_BYO = 'Open Bring your own';

// Staging walk 2026-11-02: every start asked for a tool or a project, and the
// composer named the pod rather than the agent who had just spoken.
describe('V2Thread — a first message that asks nothing', () => {
const ASK = 'Show me what you can do';
const withAsk = (extra = []) => ({
...welcome,
payload: { kind: 'starts', starts: [...extra, { id: 'ask-scout', label: ASK }, ...welcome.payload.starts] },
});

test('where no hosted start is offered, the ask start is the one ink key', () => {
renderThread([withAsk()]);
const primary = startsGroup().querySelectorAll('.v2-chat__start--primary');
expect(primary).toHaveLength(1);
expect(primary[0]).toHaveTextContent(ASK);
});

test('where the hosted start is offered, it keeps the ink key and the ask start is bordered', () => {
renderThread([withAsk([{ id: 'run-hosted', label: 'Run an agent here', href: '/v2/agents/byo?pod=pod-1&mode=hosted', action: 'run-hosted' }])]);
const primary = startsGroup().querySelectorAll('.v2-chat__start--primary');
expect(primary).toHaveLength(1);
expect(primary[0]).toHaveTextContent('Run an agent here');
});

test('one tap sends it as the person\'s own message', async () => {
const sendSpy = renderThread([withAsk()]);
fireEvent.click(screen.getByRole('button', { name: ASK }));
await waitFor(() => expect(sendSpy).toHaveBeenCalled());
expect(sendSpy.mock.calls[0][0]).toBe(ASK);
});

test('the composer names the agent who welcomed them until they send anything', () => {
renderThread([withAsk()]);
expect(screen.getByPlaceholderText("Tell scout: I'm working on…")).toBeInTheDocument();
});

test('after their first message the composer names the pod again', () => {
renderThread([withAsk(), message('mine', 'u1', 'maya', 'hello', 3)]);
expect(screen.getByPlaceholderText('Message My Workspace…')).toBeInTheDocument();
});

test('an older welcome without the ask start still gets the agent composer', () => {
renderThread([welcome]);
expect(screen.getByPlaceholderText("Tell scout: I'm working on…")).toBeInTheDocument();
});

test("in a pod the viewer didn't create, the composer names the pod", () => {
renderThread([withAsk()], jest.fn(), { pod: { ...OWN_POD, createdBy: 'someone-else' } });
expect(screen.getByPlaceholderText('Message My Workspace…')).toBeInTheDocument();
});
});

describe('V2Thread — the connect start links Bring your own', () => {
test("clicking the connect start shows the link under the person's message at once", async () => {
const send = renderThread([welcome]);
Expand Down Expand Up @@ -440,7 +491,7 @@ describe('Scout starts and the composer after a hosted agent goes live', () => {
</MemoryRouter>
</AuthContext.Provider>,
);
const composer = await screen.findByPlaceholderText(/message my workspace/i);
const composer = await screen.findByPlaceholderText(/tell scout: i'm working on/i);
await waitFor(() => expect(composer).toHaveValue('@maya-agent '));
});

Expand All @@ -452,7 +503,7 @@ describe('Scout starts and the composer after a hosted agent goes live', () => {
</MemoryRouter>
</AuthContext.Provider>,
);
const composer = await screen.findByPlaceholderText(/message my workspace/i);
const composer = await screen.findByPlaceholderText(/tell scout: i'm working on/i);
await waitFor(() => expect(composer).toHaveValue('@maya-agent '));
});
});
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3259,7 +3259,9 @@ describe('v2 layout invariants (CSS rule presence)', () => {
// row under Scout's message, in the slot the delivery cue and the
// no-agent row share, so they land on the message text column too.
expect(threadMessages).toContain('className="v2-chat__starts"');
expect(threadMessages).toContain('v2-chat__start${start.action === WELCOME_HOSTED_ACTION ? \' v2-chat__start--primary\' : \'\'}');
expect(threadMessages).toContain('v2-chat__start${primary ? \' v2-chat__start--primary\' : \'\'}');
// One ink key: the hosted start where offered, otherwise the start that asks nothing (walk 11-02).
expect(threadMessages).toMatch(/const primary = start\.action === WELCOME_HOSTED_ACTION\s*\|\| \(start\.id === ASK_START_ID && !starts\.some/);
// The 44px phone minimum must come AFTER the 32px base in the sheet: at
// equal specificity the later rule wins, and a base rule placed after
// the media block would silently render 32 on a phone.
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/v2/components/V2Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface V2ComposerWarning {

interface V2ComposerProps {
podName: string;
/** Replaces "Message <pod>" when nothing is being replied to. */
placeholder?: string;
authorName: string;
draft: string;
sending: boolean;
Expand Down Expand Up @@ -61,6 +63,7 @@ const FILE_ACCEPT = 'image/*,.pdf,.md,.txt,.csv,.json,.doc,.docx,.xls,.xlsx,.ppt
/** The one bounded input surface for a workspace thread (direction C). */
const V2Composer: React.FC<V2ComposerProps> = ({
podName,
placeholder,
authorName,
draft,
sending,
Expand Down Expand Up @@ -171,7 +174,7 @@ const V2Composer: React.FC<V2ComposerProps> = ({
<div className="v2-composer__field">
<textarea
ref={inputRef}
placeholder={threadTarget ? t('podChat.thread.replyInThread') : replyTarget ? t('podChat.composer.replyPlaceholder', { author: replyTarget.user?.username || t('podChat.messageFallback') }) : t('podChat.composer.placeholder', { podName })}
placeholder={threadTarget ? t('podChat.thread.replyInThread') : replyTarget ? t('podChat.composer.replyPlaceholder', { author: replyTarget.user?.username || t('podChat.messageFallback') }) : placeholder || t('podChat.composer.placeholder', { podName })}
value={draft}
rows={1}
onChange={(event) => onDraftChange(event.target.value, event.target.selectionStart)}
Expand Down
20 changes: 17 additions & 3 deletions frontend/src/v2/components/V2Thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import V2Hint from './V2Hint';
import V2CatchUpStrip from './V2CatchUpStrip';
import V2Composer from './V2Composer';
import { type V2DecisionCardData, type V2DecisionRuling } from './V2DecisionCard';
import V2ThreadMessages, { V2ThreadHistoryStatus } from './V2ThreadMessages';
import V2ThreadMessages, { ASK_START_ID, V2ThreadHistoryStatus } from './V2ThreadMessages';
import { landOnMessage } from './V2MessageRow';
import V2ThreadStarter from './V2ThreadStarter';
import V2BringTeammate from './V2BringTeammate';
Expand Down Expand Up @@ -43,7 +43,7 @@ import {
slugifyAgentHandle,
} from '../utils/threadAgentIdentity';

// The connect start's label, as SCOUT_STARTS[0] sends it
// The connect start's label, as SCOUT_STARTS sends it (id connect-agent)
// (backend/config/native-agents/scout.ts). A click posts exactly this text.
const CONNECT_START_LABEL = 'Connect an agent I already use';
// How long a smooth follow may run before its scroll events count as the reader's.
Expand Down Expand Up @@ -1328,6 +1328,18 @@ const V2Thread: React.FC<V2ThreadProps> = ({ detail, firstRunVisible = false, in
}
return map;
}, [agents]);
// Staging walk 2026-11-02: until the pod's creator sends anything after
// Scout's welcome, the composer names the agent who just spoke, with an
// example, instead of the pod. Older welcomes carry no ask start, so the
// connect start identifies them too.
const welcomeAgentName = useMemo(() => {
if (!viewerOwnsPod || !viewerId) return null;
const index = messages.findIndex((message) => message.payload?.kind === 'starts'
&& message.payload.starts?.some((start) => start.id === ASK_START_ID || start.id === 'connect-agent'));
if (index < 0 || messages.slice(index + 1).some((later) => later.user_id === viewerId)) return null;
const raw = messages[index].user?.username || '';
return agentDisplayNames.get(raw) || agentDisplayNames.get(raw.toLowerCase()) || raw || null;
}, [agentDisplayNames, messages, viewerId, viewerOwnsPod]);

// username/displayName → agent key (instanceId or agentName) so a click on
// a chat author byline can drive the inspector to the right member sub-page.
Expand Down Expand Up @@ -1900,7 +1912,8 @@ const V2Thread: React.FC<V2ThreadProps> = ({ detail, firstRunVisible = false, in
title={boardLabel}
>
<ShellGlyph name="board" size={13} />
<span>{headerMeta.boardOpen}</span>
{/* The count carries its noun: a bare "3" read as an unread count (walk 11-02). */}
<span>{headerMeta.boardOpen !== null ? t('podChat.header.boardTasks', { count: headerMeta.boardOpen }) : null}</span>
</button>
);
// A 1:1 room is a pod without the pod furniture (d215 frame): no board
Expand Down Expand Up @@ -2332,6 +2345,7 @@ const V2Thread: React.FC<V2ThreadProps> = ({ detail, firstRunVisible = false, in
)}
<V2Composer
podName={pod.name}
placeholder={welcomeAgentName ? t('podChat.composer.placeholderAgent', { agent: welcomeAgentName }) : undefined}
authorName={currentUser?.username || t('common.you')}
draft={draft}
sending={sending}
Expand Down
12 changes: 10 additions & 2 deletions frontend/src/v2/components/V2ThreadMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import { formatRelativeTime } from '../utils/grouping';
import { V2DecisionCardData, V2DecisionRuling } from './V2DecisionCard';
import V2Fold from './V2Fold';

// Scout's start that asks nothing of the person (id in SCOUT_STARTS,
// backend/config/native-agents/scout.ts). It sends itself as their first message.
export const ASK_START_ID = 'ask-scout';

// Expanded threads show the newest eight; a `N more replies` line reveals the rest.
const MAX_EXPANDED_REPLIES = 8;
// A transcript load shorter than this never shows a spinner.
Expand Down Expand Up @@ -341,15 +345,19 @@ const V2ThreadMessages: React.FC<V2ThreadMessagesProps> = ({
/>
{onStart && (startsByMessageId?.get(String(message.id))?.length ?? 0) > 0 && (
<div className="v2-chat__starts" role="group" aria-label={t('podChat.starts.label')}>
{startsByMessageId?.get(String(message.id))?.map((start) => {
{startsByMessageId?.get(String(message.id))?.map((start, _index, starts) => {
// One ink key per welcome: the hosted start where it is
// offered, otherwise the start that asks nothing of you.
const primary = start.action === WELCOME_HOSTED_ACTION
|| (start.id === ASK_START_ID && !starts.some((other) => other.action === WELCOME_HOSTED_ACTION));
const hostedHere = start.action === WELCOME_HOSTED_ACTION
&& hostedStart?.messageId === String(message.id) ? hostedStart : null;
const starting = hostedHere?.status === 'starting';
return (
<React.Fragment key={start.id}>
<button
type="button"
className={`v2-chat__start${start.action === WELCOME_HOSTED_ACTION ? ' v2-chat__start--primary' : ''}`}
className={`v2-chat__start${primary ? ' v2-chat__start--primary' : ''}`}
disabled={startsDisabled || starting}
aria-busy={starting || undefined}
onClick={() => onStart(start, String(message.id))}
Expand Down
Loading