diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index f6617a62a..511ca246f 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -630,10 +630,10 @@ "agentRoomLiveness": { "neverConnected": "{{agentName}} isn't connected yet. Messages sent here will wait until it runs.", "goneDark": "{{agentName}} hasn't checked in recently. A reply may take longer.", - "unknown": "{{agentName}}'s availability is unknown. A reply may take longer.", "waiting": "Waiting for {{agentName}} to reply…", "timedOut": "{{agentName}} hasn't replied yet. Your message is still in this conversation.", - "runCommand": "To connect it, run:" + "runCommand": "To connect it, run:", + "copyCommand": "Copy command" }, "podAgentLiveness": { "notConnected": "{{agentName}} isn't connected yet, so it won't answer here until it's running.", @@ -713,7 +713,9 @@ "agents_other": "{{count}} agents", "boardOpen": "board {{count}} open", "backToPods": "Back to pods", - "invite": "Invite" + "invite": "Invite", + "dm": "DM", + "readOnly": "read only" }, "team": { "view": "View pod team", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index da78fef33..ae5d3ff64 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -630,10 +630,10 @@ "agentRoomLiveness": { "neverConnected": "{{agentName}} 尚未连接。发送到这里的消息会等待该席位连接。", "goneDark": "{{agentName}} 最近没有接入,回复可能需要更长时间。", - "unknown": "{{agentName}} 的可用状态未知,回复可能需要更长时间。", "waiting": "正在等待 {{agentName}} 回复…", "timedOut": "{{agentName}} 还没有回复。你的消息仍在此对话中。", - "runCommand": "要连接它,请运行:" + "runCommand": "要连接它,请运行:", + "copyCommand": "复制命令" }, "podAgentLiveness": { "notConnected": "{{agentName}} 还没有连接,运行起来之前不会在这里回复。", @@ -712,7 +712,9 @@ "agents_other": "{{count}}个智能体", "boardOpen": "看板{{count}}项待办", "backToPods": "返回 Pod 列表", - "invite": "邀请" + "invite": "邀请", + "dm": "私信", + "readOnly": "只读" }, "team": { "view": "查看 Pod 团队", diff --git a/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx b/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx index 3401eb93b..a28a91f65 100644 --- a/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx +++ b/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx @@ -138,6 +138,38 @@ describe('V2Thread teaching empty states', () => { expect(screen.getByText('no messages yet')).toBeInTheDocument(); }); + test('a 1:1 room header carries a DM chip and none of the pod furniture', () => { + renderChat(makeAgentRoom()); + + const meta = document.querySelector('.v2-pod-header__meta:not(.v2-pod-header__meta--compact)'); + expect(meta).toHaveTextContent('DM'); + expect(document.querySelector('.v2-pod-header__faces')).toBeNull(); + expect(document.querySelector('.v2-pod-header__board')).toBeNull(); + expect(screen.queryByText('read only')).not.toBeInTheDocument(); + }); + + test('an observer of an agent DM sees a read-only chip and note, and no composer', () => { + renderChat(makeDetail({ + pod: { _id: 'agent-dm-1', name: 'Aria and Pixel', type: 'agent-dm' }, + members: [ + { _id: 'agent-1', username: 'Aria', isBot: true }, + { _id: 'agent-2', username: 'Pixel', isBot: true }, + ], + })); + + const meta = document.querySelector('.v2-pod-header__meta:not(.v2-pod-header__meta--compact)'); + expect(meta).toHaveTextContent('DM'); + expect(meta).toHaveTextContent('read only'); + // The phone head draws "DM" alone (d215 390 frame). + const compact = document.querySelector('.v2-pod-header__meta--compact'); + expect(compact).toHaveTextContent('DM'); + expect(compact).not.toHaveTextContent('read only'); + const note = screen.getByRole('note', { name: 'Read-only conversation' }); + expect(note).toHaveTextContent('Aria and Pixel are talking directly'); + expect(note.querySelector('.v2-chat__readonly-icon svg')).not.toBeNull(); + expect(screen.queryByPlaceholderText(/Message/)).not.toBeInTheDocument(); + }); + test('keeps the agent-to-agent empty state unchanged', () => { renderChat(makeDetail({ pod: { _id: 'agent-dm-1', name: 'Aria and Pixel', type: 'agent-dm' }, @@ -207,19 +239,34 @@ describe('V2Thread agent-room liveness', () => { axios.get.mockResolvedValue({ data: { agents: [] } }); }); - test('shows an unknown direct seat before the person sends a message', async () => { + test('an unknown reach state shows no row, so a failed status read never reads as offline', async () => { axios.get.mockResolvedValue({ data: { agents: [{ - agentName: 'openclaw', instanceId: 'aria', displayName: 'Aria', state: 'unknown', isOwner: false, + agentName: 'openclaw', instanceId: 'aria', displayName: 'Aria', state: 'unknown', isOwner: true, fixCommand: 'commonly agent run aria', }], }, }); renderChat(makeAgentRoom()); - expect(await screen.findByTestId('agent-room-liveness')).toHaveTextContent( - "Aria's availability is unknown", - ); + await waitFor(() => expect(axios.get).toHaveBeenCalled()); + await act(async () => { await Promise.resolve(); }); + expect(screen.queryByTestId('agent-room-liveness')).not.toBeInTheDocument(); + }); + + test('a gone-dark direct seat gets the line with no command, even for its owner', async () => { + axios.get.mockResolvedValue({ + data: { + agents: [{ + agentName: 'openclaw', instanceId: 'aria', displayName: 'Aria', state: 'gone-dark', isOwner: true, fixCommand: 'commonly agent run aria', + }], + }, + }); + renderChat(makeAgentRoom()); + + expect(await screen.findByTestId('agent-room-liveness')).toHaveTextContent("Aria hasn't checked in recently"); + expect(screen.queryByTestId('agent-room-fix-command')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Copy command' })).not.toBeInTheDocument(); }); test('a never-connected direct seat says so and shows the owner the command to run', async () => { @@ -237,6 +284,22 @@ describe('V2Thread agent-room liveness', () => { expect(screen.getByTestId('agent-room-fix-command')).toHaveTextContent('commonly agent run aria'); }); + test('the owner copies the fix command with one key, which confirms the copy', async () => { + navigator.clipboard.writeText.mockClear(); + axios.get.mockResolvedValue({ + data: { + agents: [{ + agentName: 'openclaw', instanceId: 'aria', displayName: 'Aria', state: 'never-connected', isOwner: true, fixCommand: 'commonly agent run aria', + }], + }, + }); + renderChat(makeAgentRoom()); + + fireEvent.click(await screen.findByRole('button', { name: 'Copy command' })); + await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith('commonly agent run aria')); + expect(await screen.findByRole('button', { name: 'Copied!' })).toBeInTheDocument(); + }); + test('a never-connected direct seat shows a non-owner no command', async () => { axios.get.mockResolvedValue({ data: { @@ -325,8 +388,14 @@ describe('V2Thread agent-room liveness', () => { }); expect(screen.getByTestId('agent-reply-wait')).toHaveTextContent('Waiting for Aria to reply'); + expect(screen.getByTestId('agent-reply-wait')).toHaveClass('v2-chat__agent-room-status--wait'); + act(() => { jest.advanceTimersByTime(120_000); }); - expect(screen.getByTestId('agent-reply-wait')).toHaveTextContent("Aria hasn't replied yet"); + const timedOut = screen.getByTestId('agent-reply-wait'); + expect(timedOut).toHaveTextContent("Aria hasn't replied yet"); + // Timed out drops the pulsing dot and the mono face (d215 frame). + expect(timedOut.querySelector('.v2-chat__agent-room-status-dot')).toBeNull(); + expect(timedOut).not.toHaveClass('v2-chat__agent-room-status--wait'); jest.useRealTimers(); }); }); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 084c4edce..0a74a573c 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -648,11 +648,18 @@ describe('v2 layout invariants (CSS rule presence)', () => { // liveness and the bounded reply wait outside the scrollable transcript // and outside the textarea, so both remain visible while composing. const status = ruleBody(v2, '.v2-chat__agent-room-status'); - expect(status).toContain('margin: 8px 24px 0'); - expect(status).toContain('font-size: 12px'); + expect(status).toContain('margin: 6px 18px 0'); + expect(status).toContain('flex: none'); const waiting = ruleBody(v2, '.v2-chat__agent-room-status--wait'); - expect(waiting).toContain('background: var(--v2-surface-hover)'); - expect(waiting).toContain('font-family: var(--v2-font-mono)'); + expect(waiting).toContain('var(--v2-font-mono)'); + // The waiting dot is ink, never the success green (d215 frame). + expect(ruleBody(v2, '.v2-chat__agent-room-status-dot')).toContain('background: var(--v2-ink)'); + // The copy key is a 44px target on a phone (an exception to the 32px desktop key). + const phone = mediaBlockContaining(v2, '.v2-root button.v2-chat__agent-room-copy { width: 44px'); + expect(phone).toContain('.v2-root button.v2-chat__agent-room-copy { width: 44px; height: 44px; }'); + // The read-only note replaces the composer, so on a phone it clears the + // fixed 54px tab bar as the composer does. + expect(mediaBlockContaining(v2, '.v2-chat__readonly { margin')).toContain('.v2-chat__readonly { margin: 4px 12px 66px; }'); }); test('runtime vocabulary stays off Your Team cards (ADR-022 D1, ratified)', () => { diff --git a/frontend/src/v2/chat/chat-pane.css b/frontend/src/v2/chat/chat-pane.css index e35df35c5..759f126e8 100644 --- a/frontend/src/v2/chat/chat-pane.css +++ b/frontend/src/v2/chat/chat-pane.css @@ -386,6 +386,20 @@ white-space: nowrap; } +/* A 1:1 room's header chips, "DM" and "read only" (d215 .chip). */ +.v2-pod-header__chip { + display: inline-flex; + align-items: center; + height: 24px; + padding: 0 8px; + border-radius: 7px; + font: 500 11.5px/1 var(--v2-font-mono); + color: var(--v2-text-secondary); + background: var(--v2-surface); + box-shadow: inset 0 0 0 1px var(--v2-border-soft); + white-space: nowrap; +} + /* The compact meta is the phone's; both carry the base class, so the compact rule must out-rank the base `display: flex` above it. */ .v2-pod-header__meta.v2-pod-header__meta--compact { display: none; } diff --git a/frontend/src/v2/chat/chat-states.css b/frontend/src/v2/chat/chat-states.css index 2f656e027..d087b92cd 100644 --- a/frontend/src/v2/chat/chat-states.css +++ b/frontend/src/v2/chat/chat-states.css @@ -271,50 +271,122 @@ border: 1px solid rgba(239, 68, 68, 0.16); } -/* A 1:1 agent room must not make a successful POST look like an active - agent. The liveness row is deliberately adjacent to, but outside, the - composer: it remains visible while someone writes and never becomes part - of the scrollable transcript. */ +/* Agent room state rows (d215 .rstat): the direct agent's reach, the bounded + reply wait, and a group pod's not-running agents. Each row is adjacent to, + but outside, the composer, so it stays visible while someone writes and + never becomes part of the scrollable transcript. */ .v2-chat__agent-room-status { - margin: 8px 24px 0; - padding: 8px 10px; - border: 1px solid var(--v2-border); - border-radius: var(--v2-radius-sm); + display: flex; + align-items: flex-start; + gap: 10px; + margin: 6px 18px 0; + padding: 10px 12px; + border-radius: 10px; background: var(--v2-bg-subtle); + box-shadow: inset 0 0 0 1px var(--v2-border-soft); color: var(--v2-text-secondary); - font-size: 12px; - line-height: 1.4; + font-size: 12.5px; + line-height: 1.5; + flex: none; } -.v2-chat__agent-room-command { - display: block; - margin-top: 4px; - font-family: var(--v2-font-mono, ui-monospace, monospace); - overflow-wrap: anywhere; - user-select: all; +.v2-chat__agent-room-status-icon { + display: flex; + flex: none; + width: 16px; + height: 16px; + margin-top: 2px; + color: var(--v2-text-muted); +} + +.v2-chat__agent-room-status-body { + flex: 1 1 auto; + min-width: 0; } .v2-chat__agent-room-status--wait { - border-color: var(--v2-border); - background: var(--v2-surface-hover); - color: var(--v2-text-secondary); - font-family: var(--v2-font-mono); - font-weight: 500; + font: 500 12px/1.5 var(--v2-font-mono); } +/* The waiting dot is ink, not green: a reply that has not come is not a + success. It pulses like the "is thinking" presence. */ .v2-chat__agent-room-status-dot { - width: 6px; - height: 6px; - flex: 0 0 auto; + position: relative; + display: inline-block; + flex: none; + width: 7px; + height: 7px; + margin: 7px 3px 0; border-radius: 50%; - background: var(--v2-success); + background: var(--v2-ink); } -.v2-chat__agent-room-status--timeout { - border-color: var(--v2-border-strong); - background: var(--v2-bg-subtle); +.v2-chat__agent-room-status-dot::after { + content: ''; + position: absolute; + inset: -4px; + border-radius: 50%; + border: 2px solid var(--v2-ink); + opacity: 0; + animation: v2-presence-pulse var(--motion-pulse) var(--motion-ease-state) infinite; +} + +@media (prefers-reduced-motion: reduce) { + .v2-chat__agent-room-status-dot::after { animation: none; } +} + +.v2-chat__agent-room-lede { + margin-top: 6px; +} + +/* The owner's one next step: a mono command with a copy key (d215 .rcmd). */ +.v2-chat__agent-room-command { + display: flex; + align-items: center; + gap: 8px; + margin-top: 6px; + padding: 4px 4px 4px 10px; + border-radius: 8px; + background: var(--v2-surface-tint); + box-shadow: inset 0 0 0 1px var(--v2-border-soft); +} + +.v2-chat__agent-room-command code { + flex: 1; + min-width: 0; + font: 500 12.5px/1.4 var(--v2-font-mono); + color: var(--v2-ink); + overflow-wrap: anywhere; + user-select: all; +} + +.v2-root button.v2-chat__agent-room-copy { + all: unset; + box-sizing: border-box; + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 8px; color: var(--v2-text-secondary); - font-weight: 500; + cursor: pointer; +} + +.v2-root button.v2-chat__agent-room-copy:hover { + background: var(--v2-surface-hover); + color: var(--v2-ink); +} + +.v2-root button.v2-chat__agent-room-copy:focus-visible { + outline: none; + box-shadow: var(--v2-focus-ring); +} + +@media (max-width: 760px) { + .v2-chat__agent-room-status { margin: 6px 12px 0; } + .v2-root button.v2-chat__agent-room-copy { width: 44px; height: 44px; } } .v2-chat__starter-prompts { @@ -507,51 +579,59 @@ } } -/* Read-only banner shown in place of the composer when the viewer can read - an agent-dm pod (§3.7) but isn't a formal member. Styled as a soft note, - not an error — being a read-only observer is the intended state. */ +/* Read-only note in place of the composer when the viewer can read an + agent-dm pod (§3.7) but isn't a member (d215 .rro). A quiet note, not an + error: observing is the intended state, and there is no box that would + refuse a post. */ .v2-chat__readonly { display: flex; align-items: flex-start; gap: 12px; + margin: 4px 18px 16px; padding: 14px 16px; - margin: 12px 16px 16px; + border-radius: 12px; background: var(--v2-bg-subtle); - border: 1px solid var(--v2-border-soft); - border-radius: var(--v2-radius); + box-shadow: inset 0 0 0 1px var(--v2-border-soft); + flex: none; } .v2-chat__readonly-icon { - flex: 0 0 auto; - display: flex; - align-items: center; - justify-content: center; + display: grid; + place-items: center; + flex: none; width: 28px; height: 28px; - border-radius: var(--v2-radius-pill); - background: var(--v2-surface-hover); + border-radius: 50%; + background: var(--v2-surface-tint); color: var(--v2-ink); } .v2-chat__readonly-body { - flex: 1 1 auto; - display: flex; - flex-direction: column; - gap: 2px; + min-width: 0; } .v2-chat__readonly-title { + display: block; font-size: 13px; font-weight: 600; - color: var(--v2-text-primary); + color: var(--v2-ink); } .v2-chat__readonly-text { + display: block; + margin-top: 2px; font-size: 12.5px; line-height: 1.45; color: var(--v2-text-secondary); } +/* The frame's 12px below the note, plus the fixed 54px phone tab bar, which + the composer clears the same way (an exception to the lifted frame, whose + tab bar is in flow). */ +@media (max-width: 760px) { + .v2-chat__readonly { margin: 4px 12px 66px; } +} + /* GitHub PR preview card. Rendered below a message body when the content contains a `https://github.com///pull/` URL. Card fetches from GitHub REST API on mount; failure mode is silent (parent's clickable diff --git a/frontend/src/v2/components/V2Thread.tsx b/frontend/src/v2/components/V2Thread.tsx index 76dc1945e..76721dd1d 100644 --- a/frontend/src/v2/components/V2Thread.tsx +++ b/frontend/src/v2/components/V2Thread.tsx @@ -247,6 +247,7 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in const { socket, connected } = useSocket(); const { currentUser } = useAuth(); const [draft, setDraft] = useState(''); + const [fixCommandCopied, setFixCommandCopied] = useState(false); const [sending, setSending] = useState(false); // Owner-only pod rename (TASK-419). The draft lives here so the header // can swap the title for an input without touching the message state. @@ -705,6 +706,13 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in return () => clearTimeout(timeout); }, [awaitingAgentReply, pod?._id, messages, directAgent?.username]); + // The copy key on the fix command shows a check for two seconds. + useEffect(() => { + if (!fixCommandCopied) return undefined; + const timeout = window.setTimeout(() => setFixCommandCopied(false), 2000); + return () => window.clearTimeout(timeout); + }, [fixCommandCopied]); + useEffect(() => { setAgentDeliveryHint(null); // The no-agent row belongs to the pod it was earned in: carrying it into @@ -1551,14 +1559,17 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in ? botMembers.slice(0, 2).map((m) => m.username || t('common.agent')) : null; const directAgentReach = directAgent?.state?.state; + // Only a reach state the server measured gets a row. An unknown state shows + // nothing, so a failed status read never reads as offline (d215 frame). const showDirectAgentLiveness = Boolean( - isAgentRoom && directAgent && directAgentReach && !REACH_IS_ACTIVE.has(directAgentReach), + isAgentRoom && directAgent && (directAgentReach === 'never-connected' || directAgentReach === 'gone-dark'), ); const directAgentLivenessKey = directAgentReach === 'never-connected' ? 'podChat.agentRoomLiveness.neverConnected' - : directAgentReach === 'gone-dark' - ? 'podChat.agentRoomLiveness.goneDark' - : 'podChat.agentRoomLiveness.unknown'; + : 'podChat.agentRoomLiveness.goneDark'; + const directAgentFixCommand = directAgentReach === 'never-connected' && directAgent?.state?.isOwner + ? directAgent.state.fixCommand || '' + : ''; // Group pods name each agent that isn't running, so nobody waits on a reply // that can't come: 5 of 7 people who heard nothing in their first pod had an // agent that never connected (silence census, 10-30). Only reach states the @@ -1571,6 +1582,13 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in isAgentRoom && awaitingAgentReply && awaitingAgentReply.podId === pod._id, ); + const copyFixCommand = async (command: string) => { + try { + await navigator.clipboard.writeText(command); + setFixCommandCopied(true); + } catch { /* clipboard unavailable: the command stays selectable */ } + }; + // `override` is a start from Scout's welcome: it posts its own text as a // root message and leaves the composer's draft and targets alone. const handleSend = async (override?: string) => { @@ -1864,10 +1882,21 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in {headerMeta.boardOpen} ); - if (headerMeta.boardOpen !== null) { + // A 1:1 room is a pod without the pod furniture (d215 frame): no board + // count and no member stack. "DM" sits in a mono chip, and an observer of an + // agent DM sees "read only" before reaching the banner. + const isDirectPod = isAgentRoom || isAgentDm; + const directChips = isDirectPod ? [ + {t('podChat.header.dm')}, + ...(isReadOnly ? [{t('podChat.header.readOnly')}] : []), + ] : []; + if (isDirectPod) { + metaParts.push(...directChips); + } + if (!isDirectPod && headerMeta.boardOpen !== null) { metaParts.push(boardChip(false)); } - if (faceMembers.length > 0) { + if (!isDirectPod && faceMembers.length > 0) { metaParts.push( , ); } - headerMeta.channels.forEach((channel) => { - metaParts.push({channel}); - }); + if (!isDirectPod) { + headerMeta.channels.forEach((channel) => { + metaParts.push({channel}); + }); + } return (
@@ -1943,7 +1974,12 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in {/* The phone head carries the board chip alone (D215 phone frame); the member faces live in the panel there. */} - {headerMeta.boardOpen !== null && ( + {/* The phone head draws "DM" alone (d215 390 frame). */} + {isDirectPod ? ( + + {directChips[0]} + + ) : headerMeta.boardOpen !== null && ( {boardChip(true)} @@ -2136,34 +2172,45 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in {isReadOnly ? (
- +
-
{t('podChat.readOnly.title')}
-
+ {t('podChat.readOnly.title')} + {isBotToBot && botPair ? t('podChat.readOnly.botPair', { first: botPair[0], second: botPair[1] }) : t('podChat.readOnly.agentDm')} -
+
) : ( <> {showDirectAgentLiveness && directAgent && (
- {t(directAgentLivenessKey, { agentName: directAgent.displayName })} - {directAgentReach === 'never-connected' && directAgent.state?.isOwner && directAgent.state?.fixCommand && ( - <> - {' '}{t('podChat.agentRoomLiveness.runCommand')} - - {directAgent.state.fixCommand} - - - )} + +
+ {t(directAgentLivenessKey, { agentName: directAgent.displayName })} + {directAgentFixCommand && ( + <> +
{t('podChat.agentRoomLiveness.runCommand')}
+
+ {directAgentFixCommand} + +
+ + )} +
)} {podAgentLiveness.map((row) => ( @@ -2173,24 +2220,32 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in data-testid="pod-agent-liveness" role="status" > - {row.state === 'never-connected' || !row.lastUsedAt - ? t('podChat.podAgentLiveness.notConnected', { agentName: row.displayName || row.agentName }) - : t('podChat.podAgentLiveness.offlineSince', { - agentName: row.displayName || row.agentName, - age: localizeRelativeTime(row.lastUsedAt, t), - })} +
+ {row.state === 'never-connected' || !row.lastUsedAt + ? t('podChat.podAgentLiveness.notConnected', { agentName: row.displayName || row.agentName }) + : t('podChat.podAgentLiveness.offlineSince', { + agentName: row.displayName || row.agentName, + age: localizeRelativeTime(row.lastUsedAt, t), + })} +
))} {showAwaitingAgentReply && awaitingAgentReply && (
- {!awaitingAgentReply.timedOut &&
)} = ({ name, s } }; -export type ShellGlyphName = 'pencil' | 'people' | 'gear' | 'board' | 'pin' | 'inbox' | 'agents' | 'file' | 'lock' | 'search' | 'plus' | 'reply' | 'send' | 'chevron'; +export type ShellGlyphName = 'pencil' | 'people' | 'gear' | 'board' | 'pin' | 'inbox' | 'agents' | 'file' | 'lock' | 'search' | 'plus' | 'reply' | 'send' | 'chevron' | 'copy' | 'check' | 'info' | 'alert'; /** D215 sidebar controls (TASK-691): the icon carries the act, aria-label and title carry the word. */ export const ShellGlyph: React.FC<{ name: ShellGlyphName; size?: number }> = ({ name, size }) => { @@ -92,6 +92,14 @@ export const ShellGlyph: React.FC<{ name: ShellGlyphName; size?: number }> = ({ return ; case 'reply': return ; + case 'copy': + return ; + case 'check': + return ; + case 'info': + return ; + case 'alert': + return ; default: return ; }