npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.mdlooseObject, у проекта бывают ключи, которых форма не показывает, и стирать их она не должна. Пустая команда гейта означает удаление гейта, удалённая строка таблицы — тоже. Списки языков, платформ и имён гейтов берутся из справочника 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/web

renderWithProviders(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 }) для клика по опциям.