feat(share): public-share AI chat reuses internal chat presentation (#41) #51

Merged
Ghost merged 23 commits from feat/share-chat-reuse-internal into develop 2026-06-21 01:29:18 +03:00

Реализация #41 — публичный share-чат переиспользует отлаженный презентационный слой внутреннего чата (стриминг, typing, markdown, tool-cards). Ветка от develop. Closes #41.

Было

Внешний виджет (share-ai-widget.tsx) — отдельная минимальная реализация: ответ plain-text, статичное «Thinking…», без markdown и tool-cards, не переиспользовал ничего из features/ai-chat.

Стало

Share рендерит через общий MessageList/MessageItem/TypingIndicator/ToolCallCard → тот же инкрементальный стриминг, анимированный typing-индикатор до первых токенов, markdown и карточки инструментов, что и внутри. Транспорт share не тронут (анонимный useChat + DefaultChatTransport('/api/shares/ai/stream', credentials:'omit')).

Как

Внутренние компоненты уже были prop-driven (UIMessage[] + isStreaming), без привязки к transport/auth. Новые пропсы (emptyState, showCitations, neutralizeInternalLinks) — аддитивные опциональные с дефолтами «как сейчас», поэтому внутренний чат не изменён (подтверждено ревью: ChatThread зовёт MessageList без новых пропсов).

Безопасность (CRITICAL, пойман ревью)

Рендер markdown ассистента на АНОНИМНОМ share делал кликабельными внутренние ссылки (/p/{id}, /settings/...) — в старом plain-text они были инертны. Фикс: renderChatMarkdown получил neutralizeInternalLinks (true только на share): одноразовый DOMPurify-хук afterSanitizeAttributes (add/remove по ссылке вокруг одного sanitize, в finally) снимает href у внутренних/относительных/не-http(s) ссылок (инертный текст), внешние http(s) сохраняет с rel=noopener noreferrer nofollow target=_blank. Тесты покрывают и нейтрализацию (включая javascript:/data:/#///), и отсутствие утечки глобального хука во внутренние рендеры. Второе ревью — APPROVE.

Проверка

tsc --noEmit — exit 0. vitest — 11/11 (markdown + typing-indicator).

Замечание ревью (неблокирующее): стоит визуально проверить ширину/высоту обёртки MessageList в поповере share (360x480) — это share-only chrome, внутренний чат не затрагивает.

🤖 Generated with Claude Code

Реализация #41 — публичный share-чат переиспользует отлаженный презентационный слой внутреннего чата (стриминг, typing, markdown, tool-cards). Ветка от develop. Closes #41. ## Было Внешний виджет (`share-ai-widget.tsx`) — отдельная минимальная реализация: ответ plain-text, статичное «Thinking…», без markdown и tool-cards, не переиспользовал ничего из `features/ai-chat`. ## Стало Share рендерит через общий `MessageList`/`MessageItem`/`TypingIndicator`/`ToolCallCard` → тот же инкрементальный стриминг, анимированный typing-индикатор до первых токенов, markdown и карточки инструментов, что и внутри. Транспорт share не тронут (анонимный `useChat` + `DefaultChatTransport('/api/shares/ai/stream', credentials:'omit')`). ## Как Внутренние компоненты уже были prop-driven (`UIMessage[]` + `isStreaming`), без привязки к transport/auth. Новые пропсы (`emptyState`, `showCitations`, `neutralizeInternalLinks`) — аддитивные опциональные с дефолтами «как сейчас», поэтому внутренний чат не изменён (подтверждено ревью: `ChatThread` зовёт `MessageList` без новых пропсов). ## Безопасность (CRITICAL, пойман ревью) Рендер markdown ассистента на АНОНИМНОМ share делал кликабельными внутренние ссылки (`/p/{id}`, `/settings/...`) — в старом plain-text они были инертны. Фикс: `renderChatMarkdown` получил `neutralizeInternalLinks` (true только на share): одноразовый DOMPurify-хук `afterSanitizeAttributes` (add/remove **по ссылке** вокруг одного sanitize, в `finally`) снимает `href` у внутренних/относительных/не-http(s) ссылок (инертный текст), внешние http(s) сохраняет с `rel=noopener noreferrer nofollow target=_blank`. Тесты покрывают и нейтрализацию (включая `javascript:`/`data:`/`#`/`//`), и отсутствие утечки глобального хука во внутренние рендеры. Второе ревью — APPROVE. ## Проверка `tsc --noEmit` — exit 0. vitest — 11/11 (markdown + typing-indicator). Замечание ревью (неблокирующее): стоит визуально проверить ширину/высоту обёртки MessageList в поповере share (360x480) — это share-only chrome, внутренний чат не затрагивает. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Ghost added 1 commit 2026-06-21 00:05:00 +03:00
The public-share widget was a separate minimal impl: plain-text answer, static
'Thinking…', no markdown, no tool-cards. Now it renders through the internal
chat's debugged presentational layer (MessageList/MessageItem/TypingIndicator/
ToolCallCard), so a share gets the same incremental streaming, animated typing
indicator, markdown, and tool-call cards. The share keeps its anonymous
transport (useChat + DefaultChatTransport '/api/shares/ai/stream',
credentials:'omit').

The shared components were already prop-driven (UIMessage[] + isStreaming) with
no transport/auth coupling; made the new props additive optionals (emptyState,
showCitations, neutralizeInternalLinks) all defaulting to current behavior, so
the internal chat is unchanged.

Security (review-caught): rendering assistant markdown on the ANONYMOUS share
made internal links (/p/{id}, /settings/...) clickable, which the old plain-text
render didn't. renderChatMarkdown gains neutralizeInternalLinks (true only on
the share): a one-shot DOMPurify afterSanitizeAttributes hook (added/removed by
reference around a single sanitize) strips href from internal/relative/non-http(s)
links (rendered inert) and keeps external http(s) links with
rel=noopener noreferrer nofollow target=_blank. Tests cover both the link
neutralization and the absence of any global-hook leak into internal renders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
vvzvlad added 1 commit 2026-06-21 01:20:23 +03:00
isExternalHttpUrl treated any http(s):// URL as external, so an absolute link
back to the app's own host (e.g. https://self/p/{uuid}, /settings/members)
emitted by the assistant stayed clickable on the anonymous share, leaking
internal UUIDs/structure and pointing at auth-gated routes. Classify a link as
external only when its host differs from window.location.host; unparseable URLs
are treated as internal (fail-closed). Tests cover own-origin absolute (flag
on -> inert), external host (kept with safe rel/target), dangerous schemes, and
no behavior change for the internal chat (flag off).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost merged commit ab51239cab into develop 2026-06-21 01:29:18 +03:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#51