Skip to content
Open
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
124 changes: 124 additions & 0 deletions docs/design/terminal-project-launcher.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Terminal Project Launcher

## Цель

Дать возможность из вкладки **«Терминал» (Workspace)** одним контролом выбрать
зарегистрированный проект и открыть в его папке in-app терминал — либо чистый
(без агента), либо с автозапуском последнего/выбранного агента — по той же
модели, что и лаунчер на вкладке **Projects**.

## Проблема (текущее состояние)

- Новая вкладка терминала открывает pane в **домашней папке** пользователя.
- Per-pane меню `Launch ▾` (`launchAgentInPane`) запускает агента, но **в текущей
папке pane** — то есть для свежей вкладки агент стартует в `~`, а не в проекте
(агенты кёйят историю по `cwd`, поэтому диалог «теряется» — известная ловушка,
см. `msg.cwdFellBack` guard в `workspace.js`).
- Чтобы открыть терминал в папке проекта, надо уйти на вкладку **Projects** и
использовать select «⊞ Terminal ▾» (`spawnProjectTerminals`) — но он открывает
**только чистые** панели, без агента.
- Единого «выбрать проект + (опц.) запустить агента» прямо в Терминале нет.

## Инвентаризация данных (переиспользуем, ничего нового на бэкенде)

| Источник | Что даёт |
|----------|----------|
| `window.manualProjects` (`GET /api/projects/manual`) | реестр проектов: `{id,name,path,source,exists,git,remoteUrl}` |
| `window.installedAgents` (`GET /api/agents/installed`) | установленные агенты `{id,label}` |
| `window.codbashSettings` (`GET /api/settings`) | `defaultAgent`, `lastUsedByPath` |
| `pickPreferredTool(path,null)` (app.js) | last-used → default → первый установленный |
| `agentLabel(id)` (app.js) | человекочитаемое имя агента |
| `WORKSPACE_AGENTS` (workspace.js) | команда запуска агента (`claude`, `codex`, …) |
| `openInWorkspace({name,cwd,cmd})` (workspace.js) | открыть вкладку в папке; `cmd` **авто-запускается** только если папка открылась (иначе fallback в `~` без запуска) |

Все глобальные символы доступны в общем scope (frontend без модулей — app.js и
workspace.js инлайнятся в одну страницу).

## Карта компонентов

- **Потребитель**: только фронтенд вкладки Workspace (`src/frontend/workspace.js`).
- **Бэкенд**: изменений нет — запуск идёт через уже существующий in-app pty
(`openInWorkspace` → WS `/ws/terminal`).
- **Покрытие деплоев**: правка чисто во фронте → одинаково работает в npm-CLI
(браузер) и в подписанном desktop-app (Electron оборачивает тот же сервер).

## Контракт (внутренние функции workspace.js)

```
openWorkspaceProjectLauncher(event) // открыть popover, якорь = кнопка
filterWorkspaceProjectLauncher(value) // перерисовать строки по фильтру
_wsProjectLauncherRowsHtml(filter) // HTML строк проектов
wsLaunchProjectTerminal(projPath, projName) // чистый терминал в папке (без агента)
wsLaunchProjectAgent(projPath, projName, tool) // терминал в папке + автозапуск агента
_wsCloseProjectLauncher() // закрыть + вернуть фокус
```

`tool` → команда через lookup в `WORKSPACE_AGENTS` (по `cmd`/`id`); если агента
нет в списке — используем сам `id` как команду (best-effort, как per-pane launch).

## Поведение выбора агента

- «▶ ‹agent›» использует `pickPreferredTool(projPath, null)` (last-used → default
→ первый установленный).
- Select «Agent ▾» — явный выбор из `window.installedAgents`; при выборе обновляем
**in-memory** `window.codbashSettings.lastUsedByPath[projPath] = tool`, чтобы
метка «▶ ‹agent›» в этой сессии отражала выбор.

> **assumption**: серверную персистентность last-used для Workspace-запусков не
> делаем — `PUT /api/settings` принимает только `defaultAgent`, а `lastUsedByPath`
> пишется сервером лишь через `/api/launch` (нативный терминал). In-app запуски
> эфемерны. Персистентность — follow-up (потребует расширения `PUT /api/settings`).

## Стыки (какие файлы менять)

- `src/frontend/workspace.js` — кнопка «+ Project» в тулбаре `.ws-tools`, popover,
хендлеры запуска. Переиспользует `openInWorkspace`.
- `src/frontend/styles.css` — стили popover (переиспользуем токены `.agent-picker`
/ launcher-карточек для консистентности).

## Риски

| Риск | Митигация |
|------|-----------|
| Агент, запущенный в отсутствующей папке, миспишет историю | `openInWorkspace` уже не авто-запускает `cmd` при `cwdFellBack`; отсутствующие проекты (`exists===false`) показываем disabled |
| Popover перекрывает терминал / не закрывается | Escape + outside-click + close-on-scroll (паттерн `openAgentPicker`) |
| Много проектов — длинный список | Фильтр по имени/пути + скролл-контейнер |
| Нет установленных агентов | Прятать ▶/select, оставлять только ⊞ Terminal |
| XSS через имя/путь проекта | Всё через `escHtml` (как в остальном UI) |

## UX & Accessibility

**Целевой WCAG-уровень**: AA.

**Required UI states**:
- [x] Loading — данные (`manualProjects`/`installedAgents`) уже загружены при init;
если пусто на момент открытия — показываем empty/hint, не спиннер.
- [x] Empty — нет зарегистрированных проектов → «No projects yet — add them on the
Projects tab» + ссылка-переход на Projects.
- [x] Error — папка проекта отсутствует (`exists===false`) → строка disabled с
пометкой «missing»; запуск в fallback-папку не происходит (guard в pty ready).
- [x] Success — новая вкладка терминала открывается и активируется; агент виден в
статус-баре pane.
- [x] Disabled — нет агентов → только ⊞ Terminal; отсутствующая папка → без действий.
- [ ] Partial/Stale — N/A (список читается синхронно из уже загруженного стейта).
- [ ] Optimistic — N/A.

**Клавиатура**:
- Кнопка «+ Project» — обычный таб-стоп; открытие по Enter/Space.
- При открытии фокус уходит в поле фильтра.
- Tab/Shift+Tab циклит по строкам/кнопкам; Escape закрывает и возвращает фокус на
кнопку-якорь.
- Native `<select>` для выбора агента — доступен с клавиатуры из коробки.
- Visible focus ring не убираем.

**Screen reader**:
- Popover — `role="dialog"` `aria-label="Launch in a project"`.
- Список — `role="list"`, строки — `role="listitem"`.
- Кнопки запуска имеют `aria-label` вида «Open terminal in ‹project›» / «Launch
‹agent› in ‹project›».
- Поле фильтра — `<label>`/`aria-label="Filter projects"`.

**Touch targets**: кнопки/строки ≥ 44×44 (используем существующие размеры кнопок).

**Responsive**: popover с `max-width`/`max-height` + внутренний скролл; на узком
экране клампится к вьюпорту (как `agentPicker`).
81 changes: 81 additions & 0 deletions specs/terminal-project-launcher.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
Feature: Launch a project (with or without an agent) from the Terminal tab

As a user on the Terminal (Workspace) tab
I want to pick a registered project and open an in-app terminal in its folder,
optionally auto-running the last-used or a chosen agent,
So that I don't have to leave the Terminal tab or manually cd into the project.

Background:
Given the Terminal (Workspace) view is open
And the "+ Project" launcher button is visible in the toolbar

# 1. Happy path — open a plain terminal in a project
Scenario: Open a plain terminal in a project folder (no agent)
Given at least one registered project whose folder exists
When I open the project launcher and click "⊞ Terminal" on that project
Then a new terminal tab opens named after the project
And the pane's shell starts in the project folder
And no agent command is auto-run

# 1b. Happy path — launch the preferred/last-used agent
Scenario: Launch a project with the last-used agent
Given a registered project whose folder exists
And at least one agent is installed
When I open the project launcher and click the "▶ <agent>" button on that project
Then a new terminal tab opens in the project folder
And the preferred/last-used agent command is auto-run in that folder

# 1c. Happy path — pick a specific agent
Scenario: Launch a project with an explicitly chosen agent
Given a registered project whose folder exists
And two or more agents are installed
When I open the project launcher and choose a specific agent from the "Agent ▾" select on that project
Then a new terminal tab opens in the project folder
And the chosen agent command is auto-run there
And that agent becomes the shown preferred agent for the project this session

# 2. Empty state
Scenario: No registered projects
Given the projects registry is empty
When I open the project launcher
Then it shows an empty-state message inviting me to add projects on the Projects tab
And no project rows are rendered

# 3. Loading / not-yet-ready data
Scenario: Agent list not loaded yet
Given the installed-agents data has not loaded
When I open the project launcher
Then each existing project still shows the "⊞ Terminal" action
And the agent launch actions are hidden or disabled until agents are known

# 4. Error state — missing folder
Scenario: Project folder was moved or deleted
Given a registered project whose folder no longer exists on disk
When I open the project launcher
Then that project row is shown as disabled with a "missing" note
And it offers no launch actions
And clicking elsewhere never spawns a terminal in a fallback home folder for it

# 4b. Error state — no agents installed
Scenario: No agent CLI is installed
Given no agents are installed
When I open the project launcher
Then existing projects show only the "⊞ Terminal" action
And no agent launch controls are shown

# 5. Keyboard-only navigation
Scenario: Operate the launcher with the keyboard only
Given the project launcher is closed
When I focus the "+ Project" button and press Enter
Then the launcher opens and focus moves into the filter field
And Tab / Shift+Tab cycles through the project rows and their actions
And pressing Escape closes the launcher and returns focus to the "+ Project" button

# 6. Edge data
Scenario: Long project names, many projects, and special characters
Given a project whose name is very long and contains "<script>" and emoji
And more registered projects than fit on screen
When I open the project launcher and type into the filter field
Then the list scrolls within the popover without breaking the page layout
And the name is rendered as text (escaped), never as markup
And the filter narrows the visible rows by name or path
100 changes: 100 additions & 0 deletions src/frontend/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -4748,6 +4748,106 @@ body[data-view="overview"] .toolbar { display: none; }
color: var(--text-muted);
}

/* "+ Project" launcher popover (Terminal/Workspace toolbar) */
.ws-proj-launcher {
position: fixed;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 250;
display: none;
overflow: hidden;
}
.ws-proj-launcher.open { display: flex; flex-direction: column; }
.ws-proj-launcher-head {
padding: 8px;
border-bottom: 1px solid var(--border);
}
.ws-proj-launcher-filter {
width: 100%;
box-sizing: border-box;
padding: 6px 8px;
font-size: 13px;
color: var(--text-primary);
background: var(--bg-input, var(--bg-card-hover));
border: 1px solid var(--border);
border-radius: 6px;
}
.ws-proj-launcher-filter:focus-visible {
outline: 2px solid var(--accent-blue);
outline-offset: -1px;
}
.ws-proj-launcher-list {
flex: 1 1 auto; /* fill the clamped popover height, scroll within */
min-height: 0;
max-height: 340px;
overflow-y: auto;
padding: 4px;
}
.ws-proj-launcher-empty {
padding: 14px 12px;
font-size: 12px;
color: var(--text-muted);
text-align: center;
}
.ws-proj-launcher-empty a { color: var(--accent-blue); }
.ws-proj-row {
display: flex;
flex-direction: column; /* name/path on top, actions below — names stay readable */
align-items: stretch;
gap: 6px;
padding: 7px 8px;
border-radius: 6px;
}
.ws-proj-row:hover { background: var(--bg-card-hover); }
.ws-proj-meta {
min-width: 0; /* allow the name/path to truncate at the row width */
display: flex;
flex-direction: column;
gap: 1px;
}
.ws-proj-name {
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ws-proj-path {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ws-proj-acts {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.ws-proj-acts .toolbar-btn { padding: 4px 8px; font-size: 12px; }
.ws-proj-acts .ws-proj-run-btn.primary {
background: var(--accent-blue);
color: #fff;
border-color: var(--accent-blue);
max-width: 130px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ws-proj-acts .ws-proj-agent { max-width: 96px; }
.ws-proj-row-missing { opacity: 0.55; }
.ws-proj-row-missing .ws-proj-name { text-decoration: line-through; }
.ws-proj-missing {
font-size: 11px;
color: var(--accent-red, #f87171);
flex-shrink: 0;
white-space: nowrap;
}

/* Projects settings modal */
.projects-settings-box {
max-width: 440px !important;
Expand Down
Loading
Loading