@aimana/web
v0.1.0
Published
AIMana web interface
Readme
@aimana/web
Веб-интерфейс AIMana: React 19 SPA, которую в prod раздаёт демон, а в dev Vite с прокси на
демон. Открывается командой aimana open (см. README @aimana/cli).
Запуск
Prod: pnpm build собирает dist/, демон отдаёт его с SPA-fallback (GET / и любой путь вне
API → index.html). Dev:
pnpm --filter @aimana/web devи в другом терминале aimana open --dev: откроется http://localhost:5173/?token=…&project=….
vite.config.ts проксирует /projects, /runs, /claude, /health и /ws (WebSocket) на
AIMANA_DAEMON_URL или http://127.0.0.1:4817, поэтому браузер работает с одним origin и CORS
не нужен.
Сессия
aimana open передаёт токен демона и id проекта в query. initSession(window) в main.tsx
кладёт их в sessionStorage (aimana.token, aimana.project) и убирает из адресной строки
через history.replaceState. Закрытая вкладка = новый aimana open; перезагрузка работает.
Без проекта приложение уходит на /select-project и показывает реестр демона.
UI-роуты не должны начинаться с API-префиксов демона (/projects, /runs, /claude,
/ws, /users, /audit): полная загрузка такого пути попадёт в API и получит 401. Поэтому страница запуска
живёт на /run/:id, выбор проекта на /select-project; routes/router.test.tsx проверяет
все роуты, хелперы путей в UI_PATHS.
Структура src/
main.tsx initSession + createRoot
app.tsx провайдеры: Theme, Tooltip, QueryClient, Api, Ws, Router
styles.css Tailwind 4, токены shadcn (light/dark, OKLCH), .prose для Markdown
lib/store.ts createStore/useStore на useSyncExternalStore (сессия, тема, статус WS)
lib/utils.ts cn
session/session.ts sessionStore, readSessionFromLocation, initSession, setProject, clearToken
session/context.tsx useSession, useProjectId, useToken
api/types.ts контракты демона (зеркало packages/daemon/README.md) + типы core
api/client.ts API_PREFIXES, ApiError, createApiClient (fetch + Bearer)
api/queries.ts queryKeys (включая specs), taskPath, createQueryClient, ApiContext/useApi,
useProjects, useProjectState, useProjectRuns(id, filters), useRun,
fetchRunEvents, useModels, useTaskSpecs(…, enabled)
api/templates.ts useArchitectureTemplates, useArchitecturePreview, useApplyArchitecture,
useStackPresets, useStackPreview, useApplyStackPreset,
usePolicies, usePolicyPreview, useApplyPolicy
components/architecture-panel ArchitecturePanel: выбор шаблона, дифф правил, подтверждение перезаписи
components/stack-panel StackPanel: выбор пресета стека, что он меняет в PROJECT.md, дифф правил
components/policies-panel PoliciesPanel: во что разворачивается каждая политика, дифф правил
components/diff-view DiffView: построчный дифф, общий для всех трёх панелей
api/run-stream.ts useRunStream: история запуска (query) + живые run.event, пропуски, реконнект
ws/client.ts WsClient: реконнект с backoff, refcount-подписки, ping, on(type | '*')
ws/context.tsx WsProvider (state.changed → инвалидация Query), useWsStatus, useWsEvent,
useProjectSubscription, useRunSubscription
theme/theme.tsx ThemeProvider, useTheme, ThemeToggle (system | light | dark, localStorage aimana.theme)
components/ui/* shadcn/ui (radix, пресет nova): button, card, dialog, tabs, badge, table,
textarea, select, checkbox, input, label, separator, scroll-area, tooltip
components/status-badge StatusBadge для статусов фич, тасок, стейджей, гейтов, запусков
components/progress-bar ProgressBar (done/total, role=progressbar)
components/blockers Blockers: незакрытые зависимости бейджами, feature:/task: без префикса
components/forms/* Field, FormError (текст + issues из 422), AddFeatureDialog, AddTaskDialog
components/forms/model-fields ModelFields (модель + усилие), modelOptions, withModel, useResolvedModel
components/forms/generate-plan-dialog GeneratePlanDialog: модель, усилие, пайплайн, режим ТЗ, замечания
components/forms/task-settings-dialog TaskSettingsDialog: PATCH …/settings только изменённым диффом
components/forms/stage-spec-dialog StageSpecDialog: ТЗ стейджа, запуск, force, перегенерация, правка
api/mutations.ts useCreateFeature, useCreateTask, useStartRun, useCancelRun,
useUpdateFeatureBody/TaskBody/StageBody, useGeneratePlan, useApprovePlan,
useGenerateSpecs, useApproveSpecs, useUpdateTaskSettings, useRunStage
lib/slug.ts slugify (транслитерация → kebab id), isValidId (копия regex core)
lib/run-blocks.ts groupRunEvents (события → блоки консоли), summarizeToolInput
lib/format.ts formatTokens, formatCost, formatDuration, formatTime, formatDate, elapsedMs
lib/run-title.ts runTitle (taskRef · стейдж или первая строка промпта)
components/run/* blocks (RunBlockView: init, text, thinking, tool, usage, result, error),
event-list (виртуализированный список, follow), run-header (статус, usage, отмена)
components/run-list RunList: таблица запусков для истории и compact-список для панели
components/forms/start-run-dialog StartRunDialog: промпт, модель из демона, усилие, режим разрешений
components/markdown Markdown (react-markdown + remark-gfm, .prose)
components/markdown-editor MarkdownEditor (CodeMirror 6), LazyMarkdownEditor для отдельного чанка
components/doc-editor DocEditor: один md-документ .aimana/ — просмотр, правка, сохранение
components/stage-stepper StageStepper: стейджи таски вертикальным степпером; пропы renderActions
(кнопки в строке) и renderGates (точка монтирования панели гейтов T-047)
components/plan-panel PlanPanel: состояние плана, предпросмотр «## План», кнопки по статусу таски
components/specs-panel SpecsPanel: пакет ТЗ из GET …/specs, генерация, подтверждение, перегенерация
components/run-note RunNote: «Идёт запуск #N · открыть консоль»
components/gate-list GateList: гейты стейджа со статусами (только чтение), значение по умолчанию
пропа renderGates у степпера
api/gates.ts хуки гейтов: useGateLog (хвост лога), useRunGates, useRetryStage,
useAcceptGate; gateKeys.log с `at` гейта в ключе
components/gate-panel GatePanel: гейты стейджа с логом по клику, «Прогнать гейты»,
«Повторить стейдж» и «Принять вручную»
api/diff.ts дифф стейджа и таски: diffKeys, useStageDiff, useTaskDiff; оба
включаются флагом «блок открыт», типы — зеркало GitDiffView демона
lib/unified-diff.ts parseUnifiedDiff: unified diff → файлы, куски, строки с номерами;
свой разбор, без зависимости
components/unified-diff-view UnifiedDiffView: unified diff по многим файлам, файлы
сворачиваются; НЕ diff-view (тот — построчный дифф одного текста)
api/task-runs.ts цикл run-all: taskRunKeys, LOOP_STATUS_LABELS/LOOP_REASON_TEXT,
useTaskLoop (404 → null), useProjectTaskLoops, четыре контрола,
writeLoop и useTaskLoopEvents (task-run.changed)
components/run-all-panel RunAllPanel: состояние цикла таски, «Запустить всё / Пауза /
Продолжить / Остановить», причина остановки словами, прошлые циклы
components/forms/run-all-dialog RunAllDialog: модель, усилие и граница until перед стартом
и перед продолжением цикла
api/prompts.ts очередь промптов: promptKeys, useProjectPrompts (по умолчанию только
ожидающие), useRunPrompts, useAnswerPrompt, usePromptEvents,
suggestedRule
components/prompt-dialog PromptDialog: разрешение (команда, аргументы, allow / allow-always /
deny с причиной) и вопрос (варианты подставляются в поле)
components/prompt-queue PromptQueue + usePendingPromptCount: ожидающие в правой панели
lib/use-pending-title.ts usePendingTitle: (N) AIMana в заголовке вкладки
api/project.ts useUpdateProjectBody, useUpdateProjectSettings
components/project-settings ProjectSettings: стек, платформы, дефолты, таблица гейтов;
сохраняет диффом
components/policy-editor PolicyEditor: чекбоксы политик с описаниями из справочника core
components/dependencies Dependencies: «Ждёт» и «Ждут её» ссылками на фичи и таски
components/policies Policies: эффективные политики фичи чекбоксами
lib/deps.ts parseNodeId, featureDependents, taskDependents (обратные рёбра графа)
lib/sections.ts getMarkdownSection: секция `## X` для показа (копия одной функции core)
layout/app-shell.tsx шапка, Sidebar, центр (Outlet с данными проекта), RightPanel; useProjectContext
layout/right-panel.tsx активные запуски проекта, «Запустить Claude», ссылка на историю
routes/router.tsx routes, UI_PATHS, createAppRouter
routes/*.tsx overview (дашборд), project (настройки и правила), feature,
task (стейджи и ТЗ), run (консоль), history (запуски с фильтрами),
projects, errors (ApiErrorView)
test/ setup (jest-dom, cleanup, полифиллы), utils (fakeFetch, renderWithProviders,
renderApp), fake-ws (FakeWebSocket), match-media, fixtures/stateДанные: серверное состояние в TanStack Query (queryKeys), локальное в createStore.
WsProvider по state.changed инвалидирует ['projects', id, 'state'], по
projects.changed список проектов, по run.changed запуски проекта и сам запуск. Страницы
получают { project, tree, state } через useProjectContext(); сам shell держит подписку
на проект.
Дашборд и формы
Обзор (routes/overview.tsx) показывает карточку проекта, прогресс фич и тасок, таблицу фич
(статус, прогресс-бар, блокеры, число тасок, «+ Таска»), «Доступно к старту» из state.available
и «Активные запуски» (useProjectRuns с status=running, ссылки на консоль). Кнопка «Добавить
фичу» открывает AddFeatureDialog, «Запустить Claude» открывает StartRunDialog, на странице
фичи «Добавить таску» открывает AddTaskDialog.
Формы без библиотек: контролируемые поля на UI-kit, Field для разметки, FormError для
ответа демона (error и issues из 422). Id подставляется из названия через lib/slug.ts
(кириллица транслитерируется до NFKD, чтобы «ё» и «й» не распадались на базу + диакритику) и
перестаёт пересчитываться после ручной правки; невалидный id блокирует отправку, формат окончательно
проверяет core на демоне. Id таски предлагается как NNN-slug по числу тасок фичи. Мутации в
api/mutations.ts на useMutation: успех инвалидирует ['projects', id, 'state'] сразу, а
watcher демона чуть позже пришлёт state.changed (повторная инвалидация безвредна). Так новая
фича появляется в сайдбаре и таблице без перезагрузки. Диалог на ошибке остаётся открытым,
закрытие сбрасывает форму и mutation.reset().
Фича и таска
Страница фичи (routes/feature.tsx): крошки, статус, прогресс, чипы (приоритет, теги, бюджет,
даты), зависимости в обе стороны, таблица тасок со ссылками на страницу таски, описание в
DocEditor, карточка эффективных политик (значения PROJECT.md с наложенным override из
FEATURE.md, переопределённые ключи помечены).
Страница таски (routes/task.tsx, /features/:featureId/tasks/:taskId): шапка с прогрессом и
чипами (режим ТЗ, модель, пайплайн, auto_approve_specs, PR), зависимости, стейджи вертикальным
степпером и TASK.md в свёрнутом DocEditor. Степпер строится по TASK.md: stages, а
STAGE-xx.md подмешивается по id стейджа: файла может не быть, пока не сгенерировано ТЗ
(тогда стейдж честно пишет «ТЗ не сгенерировано»). Статус из файла важнее статуса в строке
TASK.md. Запуски стейджа — объединение run_id строки и runs файла, ссылками на консоль.
Запуски таски отбираются на клиенте из GET /projects/:id/runs по taskRef: серверного
фильтра по таске нет. Гейты берутся из состояния проекта (doc.frontmatter.gates), а не отдельным запросом: их
пишет демон в STAGE-xx.md, и state.changed обновляет их вместе со всем остальным.
Содержимое лога — единственное, чего в состоянии нет: его читает useGateLog ручкой
GET …/gates/:name/log по клику, и at гейта входит в ключ запроса, поэтому после
перепрогона хвост перечитывается сам. GatePanel встаёт в проп renderGates степпера;
без него степпер рисует read-only GateList.
RunAllPanel ведёт таску целиком поверх REST цикла из T-033: «Запустить всё» открывает
RunAllDialog (модель, усилие и граница until списком стейджей) и шлёт POST …/run-all,
дальше по статусу цикла показываются «Пауза», «Продолжить» и «Остановить». Про паузу и
остановку честно сказано, что они срабатывают после текущего стейджа: демон намеренно не
убивает идущий запуск, прервать прямо сейчас — это «Отменить» в консоли. Цикл упавший стейдж
сам не повторяет, поэтому пауза с причиной gate-failed, stage-failed или gates-waiting
отправляет читателя к кнопкам гейтов стейджа, а не изображает, что «Продолжить» всё починит.
Причины остановки лежат в LOOP_REASON_TEXT — копия REASON_TEXT демона под
Record<TaskLoopReason, string>, так что новая причина в демоне ломает typecheck здесь.
Цикл обновляется живьём: useTaskLoopEvents слушает task-run.changed (подписка на проект
уже висит в AppShell), а writeLoop не даёт запоздавшему событию откатить более свежую
запись. GET …/run-all отвечает 404, когда циклов не было, — это «цикл ещё не запускали», а
не ошибка.
Обратные рёбра графа («ждут её») core не считает, их даёт lib/deps.ts перебором дерева.
Дифф стейджа и таски
Кнопка «Дифф стейджа» стоит в раскрытом стейдже, рядом с его ТЗ; карточка «Дифф таски» —
внизу страницы таски, за кнопкой «Показать дифф». Обе ходят в GET …/stages/:sid/diff и
GET …/tasks/:tid/diff (api/diff.ts) и только по нажатию: дифф большой таски весит
мегабайты, и тянуть их вместе со страницей незачем. По той же причине ключ запроса лежит вне
['projects', id, 'state'] — state.changed прилетает на каждый чих гейта, а перечитывать
патч на каждое событие никто не просил. Свежий дифф — это закрыть и открыть блок.
Сырой unified diff разбирает lib/unified-diff.ts и показывает UnifiedDiffView: файлы
сворачиваются, добавленное и удалённое подсвечено, номера строк по обе стороны. Разбор
написан здесь, без библиотеки: формат нужен ровно один, а react-diff-view тянет за собой
ещё и парсер. Главное правило разбора — не падать: дифф приезжает урезанным по живому, и
последний файл в нём обрывается на середине куска.
Лимитов два, и оба честные. Демон режет патч по символам (limit, по умолчанию 200 000)
и говорит об этом полями truncated и size; веб пишет над диффом, сколько символов из
скольких показано. Второй лимит — свой: 1500 строк на файл в DOM и свёрнутые по умолчанию
файлы, когда их больше шести или строк больше шестисот. Мегабайт минифицированного JS
проходит символьный лимит десятком строк, а мегабайт обычного кода кладёт вкладку тридцатью
тысячами div.
Когда показывать нечего, место диффа говорит, чего не хватает, а не изображает пустоту:
демон присылает reason и русский note (не репозиторий, нет коммитов, чисто в дереве,
патч не читается), а страница добавляет к ним, что с этим делать — например, «заведи
git-репозиторий и включи коммиты в PROJECT.md: git». Новые файлы вне индекса перечислены
отдельным списком: патча у них нет, и делать вид, что стейдж ничего не создал, нельзя. Если
патч не приехал вовсе, а счётчики приехали, показывается сводка по файлам.
Работа с планом и ТЗ
Страница таски ведёт таску от «есть только название» до запущенного стейджа. PlanPanel
показывает состояние плана и ровно те кнопки, что имеют смысл в текущем статусе: в todo
«Сгенерировать план», в plan-review «Подтвердить план» и «Перегенерировать с замечаниями»,
в planned «Перепланировать» и «Пересинхронизировать план». Отклонения плана как действия нет:
это тот же POST …/plan с комментарием. Правка плана — это правка markdown: approvePlan на
демоне перечитывает секцию ## План из TASK.md, поэтому «Править план» просто раскрывает
DocEditor на TASK.md.
SpecsPanel появляется, когда план подтверждён, и читает пакет ТЗ из GET …/specs, а не из
дерева: «ТЗ есть» — это вердикт core о секции ## ТЗ, повторять этот разбор в браузере нельзя.
Отсюда генерируются и подтверждаются ТЗ (пакетом и по стейджу), отсюда же открываются
настройки таски (TaskSettingsDialog, PATCH …/settings только изменёнными полями).
Кнопка «Запустить» в строке степпера открывает StageSpecDialog: ТЗ стейджа, модель, усилие,
комментарий и разбор всех отказов демона словами — previous-not-done даёт «Запустить всё
равно» (force), spec-not-approved даёт «Подтвердить ТЗ», занятый слот — ссылку на активный
запуск. Все три действия возвращают 202: результат приезжает не в ответе, а через
state.changed, поэтому панели показывают RunNote со ссылкой на консоль и ждут события.
Настройки проекта
Страница /project (в единственном числе: /projects — API-префикс демона, и UI-роут не
может с него начинаться) правит PROJECT.md: frontmatter формой, тело — тем же DocEditor,
что и остальные документы.
Форма сохраняет дифф: в PATCH …/settings уходят только изменившиеся поля. Схема
PROJECT.md — looseObject, у проекта бывают ключи, которых форма не показывает, и стирать
их она не должна. Пустая команда гейта означает удаление гейта, удалённая строка таблицы —
тоже. Списки языков, платформ и имён гейтов берутся из справочника core и остаются
подсказками: своё значение можно вписать руками.
Политика, которой нет в справочнике, показывается с пометкой «дописано руками» — выкинуть чужой ключ из формы значит тихо удалить его при следующем сохранении. Чекбокс в этой форме меняет только флаг; чтобы политика заодно дописала правило и гейт, её включают в карточке «Политики» — под формой так и написано.
Архитектура
Карточка «Архитектура» на той же странице меняет шаблон архитектуры проекта. Выбор — из
того, что реально доступно (GET …/architecture/templates: встроенные, пользовательские и
лежащие в самом репозитории; источник виден прямо в списке). Как только шаблон выбран,
приезжает предпросмотр (GET …/architecture/preview?template=) и показывается дифф правил
PROJECT.md построчно, с числом добавленных и убранных строк, плюс строка о том, что
станет с ARCHITECTURE.md.
До нажатия «Применить шаблон» не пишется ничего: предпросмотр не трогает ни один файл. Если
ARCHITECTURE.md написан руками, кнопка заблокирована, пока не поставлена отдельная галочка
«перезаписать», — иначе POST вернул бы 409, а человек не понял бы, за что. Шаблон, который
уже применён, применить нельзя: кнопка выключена и рядом сказано почему.
Свой шаблон кладётся в ~/.aimana/templates/architectures/<id>/ (виден во всех проектах) или
в <repo>/.aimana/templates/architectures/<id>/ (только в этом); пресеты и политики лежат
рядом файлами — stacks/<id>.yaml, policies/<id>.yaml. Это написано под каждой из трёх
панелей: место, где человек ищет свой шаблон, — то же, где он узнаёт, куда его класть.
Шаблон, который не прошёл схему, из панели не исчезает: GET …/architecture/templates отдаёт
его в поле errors, и под списком видно id, уровень, абсолютный путь файла и поля, на которых
он развалился. В выпадашке его нет — применять нечего. Так же устроены панели стека и политик.
Правила шаблона живут в PROJECT.md между маркерами aimana:template rules, поэтому смена
шаблона меняет только их, а дописанное руками остаётся. Это сказано прямо под панелью: тому,
кто правит правила в редакторе ниже, важно знать, какую часть файла перепишет следующая
смена шаблона.
Стек
Карточка «Стек» на той же странице разворачивает пресет стека. Список приезжает целиком
(GET …/stack/presets), поэтому у выбранного пресета сразу видно всё: язык, фреймворк,
менеджер пакетов, платформы, команды гейтов и правила, которые уедут в PROJECT.md. Гейтов
может не быть вовсе — тогда так и написано: у стека нет команд по умолчанию. Придуманная
команда падала бы на первом запуске, а строка в интерфейсе обещала бы проверку, которой нет.
Дальше — предпросмотр (GET …/stack/preview?stack=): поля frontmatter списком «было → станет»
и построчный дифф правил. До нажатия «Применить пресет» не пишется ничего; пресет, который
уже применён, применить нельзя, и рядом сказано почему.
Правила пресета живут между маркерами aimana:stack rules — отдельно от
aimana:template rules шаблона архитектуры, поэтому смена стека не трогает правила
архитектуры. Гейты с теми же именами пресет перезаписывает, остальные оставляет как есть,
платформы добавляет к уже указанным; это написано под панелью.
Политики
Карточка «Политики» на той же странице показывает, во что разворачивается каждая галочка,
до того как её включили. Список приезжает целиком (GET …/policies) вместе с состоянием
проекта, поэтому у каждой политики сразу видно: правило, которое уедет в PROJECT.md, гейты
с командами для стека этого проекта и дополнительные стейджи с чеклистом. Гейтов может не
быть — тогда так и сказано, и сказано почему: проверить это командой нельзя вовсе или для
стека проекта команды нет. Придуманная команда упала бы на первом гейте.
«Включить» и «Выключить» ничего не пишут: они запрашивают предпросмотр
(GET …/policies/preview?policy=&enabled=) и показывают поля frontmatter «было → станет»
(включая «убрать» для гейта, который снимается) и построчный дифф правил. Запись происходит по
«Применить» — POST …/policies, а дальше core.
Правила включённых политик живут между маркерами aimana:policy rules, отдельно от
aimana:template rules и aimana:stack rules. Под панелью сказано и то, чего пока нет:
дополнительные стейджи доезжают до планировщика текстом правил, отдельной строкой в пайплайне
они ещё не появляются.
Приложения
Монорепозиторий редко состоит из одного стека. /apps показывает приложения сеткой
карточек с разрешёнными настройками — с тем, что реально получит запуск внутри
приложения. Что приложение сказало о себе само, показывает форма на его странице
(/apps/:appId), где это и надо менять.
«Найти приложения» читает манифесты репозитория и ничего не пишет: диалог показывает
уверенность и сигналы словами, а не баллом, чтобы их можно было пойти и проверить на диске.
Id и название правятся до сохранения — id станет ключом в apps.yaml и именем каталога.
Повторный детект не предлагает то, что уже заведено.
На странице приложения четыре вкладки. В «Стеке» у каждого поля есть «как у проекта», и оно
ничего не пишет в apps.yaml: форма отправляет дифф, потому что записать унаследованное
значит превратить умолчание в собственное решение приложения — и следующая правка проекта до
него уже не дойдёт. Смена языка сбрасывает фреймворк. В «Гейтах» над таблицей сказано, что
команды выполняются в каталоге приложения, а не в корне репозитория; гейты от проекта
показаны отдельно и только на чтение. Шаблоны архитектур отфильтрованы по платформам
приложения (теги шаблона и есть платформы); приложение, которое про платформы молчит, видит
все. Удаление сначала называет таски, которые на него ссылаются, и только потом удаляет.
На /project настройки подписаны как умолчания и перечисляют приложения, которые их
переопределяют. Проект без приложений выглядит как раньше: ни подписи, ни списка, ни
селектора приложения в форме новой таски — вопрос с одним ответом «весь проект» никто не
задавал.
Человек в контуре
Запуск, которому нужен неразрешённый инструмент, встаёт и ждёт человека (очередь делает
демон, T-022). В браузере это видно из трёх мест сразу: счётчик «N ждут ответа» в шапке,
карточка «Ожидают ответа» в правой панели и (N) AIMana в заголовке вкладки. Заголовок
вкладки выбран сознательно: он единственный сигнал, который виден при свёрнутом окне и не
требует разрешений браузера.
Диалог не открывается сам. Модалка, всплывшая посреди набора текста, крадёт фокус и
нажатие — а кнопка под этим нажатием выдаёт разрешение на Bash. Промпт зовёт, открывает
человек.
Разрешение показывает готовую фразу от SDK, отдельно команду Bash (решение принимают по
ней, а не по JSON), причину вопроса, путь вне разрешённых и полные аргументы в свёрнутом
блоке. Кнопок три: «Разрешить», «Разрешить навсегда» и «Запретить». У «навсегда» под
кнопкой написано, какое правило появится в .claude/settings.local.json — это запись в
файл проекта навсегда, и suggestedRule считает её тем же способом, что и демон.
«Запретить» сначала раскрывает поле причины: её текст читает модель.
Вопрос показывает варианты кнопками, но отправляется содержимое поля: клик по варианту лишь подставляет его, а дописать можно что угодно, включая «ни то ни другое, сделай X».
Коды отказа разбираются словами: prompt-settled (уже ответили или истёк) блокирует
кнопки — переспрашивать нечего; settings-unwritable кнопки не блокирует, потому что
промпт остался pending: почини файл и ответь снова.
Под очередью — последние отвеченные промпты с именем того, кто ответил: в команде первый вопрос про выданное разрешение — чьё оно.
Команда
/team — доступ к демону: кто я, кто ещё есть и что делали. Роль приходит с /users/me,
и веб не гадает по тому, что ему разрешили: пункт меню «Команда» появляется только у
владельца, а открытая руками страница объясняет, почему список пустой, вместо пустой
таблицы.
Токен нового пользователя показывается один раз — демон хранит только хэш. Поэтому блок с токеном закрывается кнопкой «Скопировал» и не возвращается.
Журнал действий — только изменяющие запросы, вместе с отказами: строка 403 в списке
отвечает на вопрос «кто пытался». Фильтры: пользователь и «только этот проект».
Правка markdown
DocEditor показывает одно тело документа .aimana/ и умеет его править: «Править» открывает
LazyMarkdownEditor, «Сохранить» шлёт PATCH (useUpdateFeatureBody / useUpdateTaskBody /
useUpdateStageBody), работает Cmd/Ctrl+S. Frontmatter из веба не правится вовсе: демон
меняет только тело (и updated), поэтому статусы и стейджи не ломаются. Ошибка сохранения
показывается под редактором и не выкидывает из правки, текст не теряется.
Тела документов приходят целиком в GET /projects/:id/state (это ProjectTree из core),
отдельного GET документа нет. Если файл изменился на диске, пока открыта правка (демон
дописал результат стейджа, кто-то правит руками), DocEditor сравнивает пришедший source с
тем, с которого начали, и показывает предупреждение с кнопкой «Перечитать». Сохранение всё
равно перезапишет: полноценного If-Match в MVP нет намеренно.
Консоль запуска
/run/:id (routes/run.tsx) показывает один запуск: шапка (RunHeader: статус, kind, taskRef,
модель, старт, живая длительность, usage, «Отменить» при running, промпт в <details>) и
EventList. Данные даёт useRunStream(runId) из api/run-stream.ts: история
GET /runs/:id/events живёт в TanStack Query под ключом ['run-events', id] (намеренно не
под ['runs', id], иначе run.changed перечитывал бы всю историю), живые run.event и
дочитанные хвосты копятся в локальном состоянии, mergeEvents сливает всё по seq без
дубликатов. Пропуск seq (пришло 7 после 5), повторное открытие WS и терминальный статус
запуска вызывают GET /runs/:id/events?after=<lastSeq>; один запрос в полёте, повторный
встаёт в очередь. Запись запуска это useRun, WsProvider инвалидирует её по run.changed.
groupRunEvents (lib/run-blocks.ts) превращает события в блоки: соседние assistant.text
сливаются в один markdown-блок, tool.result прикрепляется к своему assistant.tool_use
(результат без пары показывается блоком ?), system.other пропускается. Блоки рендерит
RunBlockView: tool call свёрнут (имя, краткий вход через summarizeToolInput, спиннер,
галочка или крест), раскрытие показывает вход JSON и результат; размышления свёрнуты; итог и
ошибки выделены.
EventList виртуализирован (@tanstack/react-virtual, динамическая высота через
measureElement). Контейнер с нулевой высотой (скрытая вкладка, jsdom) через кастомный
observeElementRect считается за 600 px, иначе virtual-core вернул бы пустой диапазон и в
тестах ничего бы не рендерилось. Режим follow держит список у низа при новых блоках и
выключается, когда пользователь прокрутил вверх больше чем на 48 px; кнопка «Вниз» включает
его обратно. React-компилятор пропускает этот компонент (warning линта про несовместимую
библиотеку), это ожидаемо.
Запуск: StartRunDialog (промпт, модель, усилие, режим разрешений) шлёт POST
/projects/:id/runs через useStartRun и открывает консоль. Список моделей приходит из
GET /claude/models (useModels), дефолт берётся из PROJECT.md defaults.model, если его
нет в списке демона, он добавляется как есть; без списка модель вводится текстом. 409
(активный запуск уже есть) показывает ссылку на него, 503 текст демона. Ctrl/⌘+Enter в
промпте отправляет. Правая панель (layout/right-panel.tsx) показывает running запуски
проекта и кнопку запуска; /history (routes/history.tsx) показывает до 100 последних
запусков с серверными фильтрами по статусу и типу (useProjectRuns(id, filters), фильтры в
ключе query после 'runs', так что инвалидация по run.changed покрывает все варианты) и
поиском по промпту на клиенте.
Компоненты
Добавить компонент shadcn: pnpm dlx shadcn@latest add -y <name> в packages/web, затем
pnpm format. Импорт через @/components/ui/<name>. Tooltip требует TooltipProvider
(есть в app.tsx; в тестах renderWithProviders тоже оборачивает).
Тесты
Vitest в jsdom, проект @aimana/web в корневом vitest.config.ts:
pnpm vitest run --project @aimana/webrenderWithProviders(ui, { session, fetch, ws }) даёт Query + Api (+ Ws); renderApp({
initialEntries, fetch }) рендерит всё приложение на memory-роутере с FakeWebSocket.
fakeFetch({ 'GET /projects/p1/state': data }) отвечает JSON и считает вызовы.
FakeWebSocket.last.open() / message(obj) / serverClose() управляют сокетом. Radix Select в jsdom
требует полифиллы hasPointerCapture и scrollIntoView (есть в setup.ts) и
userEvent.setup({ pointerEventsCheck: 0 }) для клика по опциям.
