diff --git a/README.md b/README.md index 18008517f..157748180 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,41 @@ graph TB **AI Provider** — The AI backend that powers Alice. Claude (via Agent SDK, supports OAuth login or API key) or Vercel AI SDK (Anthropic, OpenAI, Google). Switchable at runtime — no restart needed. +## Two kinds of chat + +OpenAlice ships two paths for chatting with Alice. They have very different performance characteristics, and picking the right one matters more than it looks. + +### Workspace chat (recommended when available) + +A chat-type **workspace** is a directory + git repo + a persistent terminal session running the native CLI of your chosen agent (`claude`, `codex`, or `shell`). The CLI process handles all model interaction, prompt caching, and rendering — OpenAlice's job is to plumb its MCP server into the workspace and surface the terminal in the UI. + +- **Native prompt cache.** Claude Code, Codex, and the other agent CLIs implement vendor-specific cache control we can't replicate. On a long conversation this is often a 10× cost reduction. +- **Native frontend.** TUI rendering, syntax highlighting, diff display — the CLI vendor has already tuned these for their model. +- **Full tool surface.** The CLI sees the workspace's local files plus OpenAlice's MCP tools. No "greatest-common-denominator" trimming. +- **Stable.** No `ChatHook` protocol layer between you and the model. + +The only requirement: the CLI binary has to be installed on the host running OpenAlice. + +### Traditional chat + +The original `/chat` page. OpenAlice's `ChatHook` calls the agent SDK (Vercel AI SDK or Anthropic Agent SDK) directly, normalizes events through its own layer, and renders them. + +- **No shell required.** Works in any environment OpenAlice runs in. +- **Reachable by connectors.** Telegram, MCP Ask, and webhook-pushed messages all land here because those surfaces have no terminal to host a CLI in. + +The cost: token usage is high (cache control is OpenAlice's responsibility and still incomplete), capability is constrained (each new CLI feature has to be re-implemented at the `ChatHook` layer), and `ChatHook` itself is still maturing. + +### Which one should I use? + +| Scenario | Use | +| --- | --- | +| Interactive UI chats with Alice on this machine | **Workspace chat** | +| Connector-pushed messages (Telegram bot, webhook callbacks) | Traditional chat | +| Long sessions where token cost matters | **Workspace chat** | +| Environment with no shell / no CLI installed | Traditional chat | + +Today the connectors are wired to traditional chat. If shell-bridged connectors arrive later, they can opt into workspace chat too — the two paths are designed to coexist, not replace each other. + ## Quick Start Prerequisites: Node.js 22+, pnpm 10+, [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated. diff --git a/TODO.md b/TODO.md index 948554bb8..43729f506 100644 --- a/TODO.md +++ b/TODO.md @@ -107,6 +107,39 @@ the item when done — git log is the history. third parties (Together, Groq, vLLM, LM Studio, Ollama) a proper light chat path. Same structural shape as the Anthropic one. ~3-4h. +- [ ] **Profile + AI Provider model needs structural rethink.** + Surfaced 2026-05-13 during workspaces' per-workspace codex + override testing (commit `6b52853`). `ai-provider-manager.json` + profiles (Kimi/MiniMax/DeepSeek/Claude Pro) are **claude-shaped** + — each profile's `baseUrl` is the vendor's Anthropic-compat + endpoint (e.g. `https://api.moonshot.ai/anthropic`) and `model` + is the Anthropic-side model name. Applying the same profile to + codex via `WorkspaceAIConfigModal`'s shared Apply-from-profile + quick-pick silently produces invalid configs: codex speaks + OpenAI Responses shape, POSTs against an Anthropic endpoint → + `POST /anthropic/responses` → 404. + + The shape mismatch isn't a quick-fix item — disabling + Apply-in-codex-tab or adding "no `/anthropic` in baseUrl" + sanity checks just covers one foot-gun each. "AI Provider" + today conflates four concepts that have started to diverge: + Anthropic-API endpoints, OpenAI-API endpoints, **vendor + identity** (Moonshot/DeepSeek/etc., one credential, multiple + endpoint shapes), and the `(baseUrl, model)` triple stored + per-profile. Each consumer (chat path / workspace claude / + workspace codex / future Anthropic-native / future + OpenAI-native) needs a different slice. Bolting more + `(baseUrl, apiKey, model)` triples onto the profile struct + keeps stacking the conflation. + + Adjacent work that should design together (don't ship in + isolation): the **Native Anthropic / Native OpenAI provider** + TODOs above — their concrete client-side input shapes + clarify what profile dimensions actually need to exist. The + per-workspace override modal (just shipped) hits the same + foot-gun and would benefit from the redesigned profile model + directly. Land all three in one focused pass. + - [ ] Unified config hot-reload. Right now every consumer of a config section has to solve "did the user edit this?" on its own — Telegram/MCP-Ask via `reconnectConnectors`, opentypebb via lazy diff --git a/package.json b/package.json index 4a5fd34ab..611925f67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-alice", - "version": "0.10.0-beta.4", + "version": "0.10.0-beta.5", "description": "File-based trading agent engine", "type": "module", "scripts": { diff --git a/src/webui/routes/workspaces.ts b/src/webui/routes/workspaces.ts index fbef38c0a..9b2d94a11 100644 --- a/src/webui/routes/workspaces.ts +++ b/src/webui/routes/workspaces.ts @@ -8,8 +8,8 @@ import { Hono } from 'hono'; import { randomUUID } from 'node:crypto'; -import { readFile } from 'node:fs/promises'; -import { resolve as resolvePath } from 'node:path'; +import { readFile, rm } from 'node:fs/promises'; +import { join, resolve as resolvePath } from 'node:path'; import { listDir, PathTraversal, readWorkspaceFile, writeWorkspaceFile } from '../../workspaces/file-service.js'; import { gitLog, gitStatus } from '../../workspaces/git-service.js'; @@ -29,6 +29,8 @@ export function createWorkspaceRoutes(svc: WorkspaceService): Hono { templates: svc.templates.list().map((t) => ({ name: t.name, ...(t.description !== undefined ? { description: t.description } : {}), + ...(t.displayName !== undefined ? { displayName: t.displayName } : {}), + ...(t.groupOrder !== undefined ? { groupOrder: t.groupOrder } : {}), defaultAgents: t.defaultAgents, })), }); @@ -546,8 +548,11 @@ async function readClaudeConfig(workspaceDir: string): Promise { const hasAny = cfg.baseUrl || cfg.apiKey || cfg.model; if (!hasAny) { - // Reset: empty settings.local.json so claude falls back to global. - await writeWorkspaceFile(workspaceDir, CLAUDE_SETTINGS_PATH, '{}\n'); + // Reset: delete the settings file so claude falls back to its global + // OAuth / settings. We don't leave an empty `{}` behind — workspace + // files exist only when there's an actual override. + const filePath = join(workspaceDir, CLAUDE_SETTINGS_PATH); + await rm(filePath, { force: true }); return; } const out: Record = {}; @@ -598,37 +603,44 @@ async function readCodexConfig(workspaceDir: string): Promise { - // Always preserve the MCP block (constant content, OpenAlice infrastructure). - // Provider block + top-level `model` / `model_provider` only when configured. - const mcpBlock = - '[mcp_servers.openalice]\n' + - 'url = "${OPENALICE_MCP_URL:-http://127.0.0.1:3001/mcp}"\n'; - const hasProvider = !!(cfg.baseUrl || cfg.model); - let toml = mcpBlock; - if (hasProvider) { + + if (!hasProvider) { + // Reset: tear down the workspace's entire `.codex/` directory. The + // adapter's `composeEnv` won't set `CODEX_HOME` when the directory is + // absent, so codex falls back to the user's global `~/.codex/`. We + // don't leave empty stubs behind — workspace files exist only when + // there's an actual override. Note: `CODEX_HOME` is exclusive (not a + // merge layer), so a half-empty `.codex/` would *shadow* the user's + // global login and break auth. Full teardown is the only safe reset. + const codexDir = join(workspaceDir, '.codex'); + await rm(codexDir, { recursive: true, force: true }); + return; + } + + // Provider override. config.toml carries only model / model_provider / + // [model_providers.*] — the OpenAlice MCP server entry is wired per-spawn + // via the codex adapter's `-c mcp_servers.openalice.url=...` flag, so we + // don't repeat it here. + let toml = ''; + if (cfg.model) toml += `model = ${tomlString(cfg.model)}\n`; + if (cfg.baseUrl) toml += `model_provider = "${CODEX_PROVIDER_NAME}"\n`; + if (cfg.baseUrl) { toml += '\n'; - if (cfg.model) toml += `model = ${tomlString(cfg.model)}\n`; - if (cfg.baseUrl) toml += `model_provider = "${CODEX_PROVIDER_NAME}"\n`; - if (cfg.baseUrl) { - toml += '\n'; - toml += `[model_providers.${CODEX_PROVIDER_NAME}]\n`; - toml += `name = "OpenAlice workspace provider"\n`; - toml += `base_url = ${tomlString(cfg.baseUrl)}\n`; - toml += `env_key = "${CODEX_KEY_ENV_NAME}"\n`; - toml += `wire_api = "${cfg.wireApi ?? 'chat'}"\n`; - } + toml += `[model_providers.${CODEX_PROVIDER_NAME}]\n`; + toml += `name = "OpenAlice workspace provider"\n`; + toml += `base_url = ${tomlString(cfg.baseUrl)}\n`; + toml += `env_key = "${CODEX_KEY_ENV_NAME}"\n`; + toml += `wire_api = "${cfg.wireApi ?? 'chat'}"\n`; } await writeWorkspaceFile(workspaceDir, CODEX_CONFIG_PATH, toml); // env.json: holds the per-workspace API key codex picks up via env_key. - // Adapter's composeEnv reads this and exports at spawn. Empty / missing - // file = no workspace key (codex falls back to ~/.codex/auth.json OAuth). + // Adapter's composeEnv reads this and exports at spawn. if (cfg.apiKey) { const envObj: Record = { [CODEX_KEY_ENV_NAME]: cfg.apiKey }; await writeWorkspaceFile(workspaceDir, CODEX_ENV_PATH, JSON.stringify(envObj, null, 2) + '\n'); } else { - // Reset key: write empty object so the adapter sees nothing to inject. await writeWorkspaceFile(workspaceDir, CODEX_ENV_PATH, '{}\n'); } } diff --git a/src/workspaces/adapters/codex.ts b/src/workspaces/adapters/codex.ts index f52197753..cba0ccbe0 100644 --- a/src/workspaces/adapters/codex.ts +++ b/src/workspaces/adapters/codex.ts @@ -24,16 +24,28 @@ import type { BootstrapContext, CliAdapter, SpawnContext } from '../cli-adapter. * pre-writes that entry so the launcher's spawn doesn't stall on the * prompt. * - * AI provider config: each workspace owns its own `.codex/` (we set - * `CODEX_HOME=/.codex` via `composeEnv` below). The workspace's - * `config.toml` carries the OpenAlice MCP server entry + any - * UI-configured `[model_providers.*]` blocks; `auth.json` starts as a - * symlink to `~/.codex/auth.json` (graceful fallback to global login) - * and gets replaced by the UI with a real file when the user picks a - * workspace-specific provider. The MCP-flag translation that the - * launcher originally did via `mcpJsonToCodexFlags` is gone — codex - * reads MCP entries directly from the workspace's `config.toml` now. + * AI provider model — two modes, mutually exclusive: + * + * 1. **Default (no override).** Workspace has no `.codex/` directory. + * Adapter doesn't set `CODEX_HOME`. Codex reads the user's global + * `~/.codex/auth.json` + `~/.codex/config.toml` — exactly what a + * vanilla `codex` invocation in any project does. The OpenAlice MCP + * server is wired via the per-invocation `-c mcp_servers.openalice.url=...` + * flag in `composeCommand` below, so MCP is visible without + * polluting the user's global config. + * + * 2. **Override (user-configured via OpenAlice UI).** Workspace has its + * own `.codex/{config.toml, env.json[, auth.json]}`. Adapter sets + * `CODEX_HOME=/.codex`. Codex reads workspace files only, + * isolated from global state. + * + * No symlinks, no global-fallback inheritance. The `-c` flag is OpenAlice's + * "local MCP registration" — analogous to claude's `.mcp.json` cwd + * discovery, but driven via codex's CLI override flag since codex has no + * cwd-MCP convention of its own. */ +const OPENALICE_MCP_URL_DEFAULT = 'http://127.0.0.1:3001/mcp'; + export const codexAdapter: CliAdapter = { id: 'codex', displayName: 'Codex', @@ -45,41 +57,40 @@ export const codexAdapter: CliAdapter = { transcriptDiscovery: 'none', }, + /** + * Always prepends `-c mcp_servers.openalice.url="..."` so OpenAlice MCP + * is visible per-spawn without writing to `~/.codex/config.toml`. The + * flag overrides any same-key entry in the read config.toml (verified + * empirically), and adds a new key when none exists — safe in both + * default and override modes. + */ composeCommand(_base: readonly string[], ctx: SpawnContext): readonly string[] { - const head = ['codex']; + const mcpUrl = process.env['OPENALICE_MCP_URL'] ?? OPENALICE_MCP_URL_DEFAULT; + const head = ['codex', '-c', `mcp_servers.openalice.url="${mcpUrl}"`]; if (ctx.resume === undefined) return head; if (ctx.resume === 'last') return [...head, 'resume', '--last']; return [...head, 'resume', ctx.resume.sessionId]; }, /** - * Point codex at the workspace's own `.codex/` and inject any - * UI-configured env vars (api keys named by `[model_providers.X].env_key` - * in the workspace's `config.toml`). + * Set `CODEX_HOME` only when workspace has its own `.codex/` directory + * (override mode). Otherwise codex falls back to its own `~/.codex/`, + * which is its normal behavior in any uninvolved project. The "reset + * to default" UI action deletes the entire `.codex/` directory so the + * adapter naturally falls back here. * - * Defensive: only set `CODEX_HOME` when `/.codex/auth.json` exists. - * Codex *crashes* ("Codex requires a login") if `CODEX_HOME` points at a - * dir without `auth.json`. Workspaces created by the current bootstrap - * always have it (real file or symlink to `~/.codex/auth.json`); legacy - * workspaces from before this change don't — those silently fall back - * to the global `~/.codex/` and lose workspace-MCP wiring until - * recreated. That's the migration story. - * - * `.codex/env.json` is the workspace's per-CLI env contribution. Codex - * has no notion of "literal api key in config" — its `env_key` field - * indirects through an env var. The OpenAlice UI writes the user's - * chosen key into `env.json` (e.g. `{"OPENALICE_WORKSPACE_KEY":"sk-..."}`) - * and the adapter exports those at spawn so codex's `env_key` lookup - * resolves. This is the one place we DO bridge a file → env (because - * codex requires env), but the source of truth is still the workspace - * file, not OpenAlice's internal state. + * `.codex/env.json` is OpenAlice's per-workspace key bridge. Codex's + * `[model_providers.X].env_key` field indirects through an env var; the + * UI writes the chosen key into `env.json` and the adapter exports it + * at spawn so codex's `env_key` lookup resolves. This is the only place + * we bridge file → env, and the source of truth is still the workspace + * file (not OpenAlice's internal state). */ composeEnv(ctx: SpawnContext): Record { const result: Record = {}; const workspaceCodex = join(ctx.cwd, '.codex'); - if (existsSync(join(workspaceCodex, 'auth.json'))) { - result['CODEX_HOME'] = workspaceCodex; - } + if (!existsSync(workspaceCodex)) return result; + result['CODEX_HOME'] = workspaceCodex; const envFile = join(workspaceCodex, 'env.json'); if (existsSync(envFile)) { try { diff --git a/src/workspaces/service.ts b/src/workspaces/service.ts index 17da29a4d..7a363c6e6 100644 --- a/src/workspaces/service.ts +++ b/src/workspaces/service.ts @@ -8,6 +8,7 @@ * Lifecycle: `createWorkspaceService()` at plugin start; `dispose()` at stop. */ +import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { claudeAdapter } from './adapters/claude.js'; @@ -172,7 +173,14 @@ export async function createWorkspaceService(): Promise { startedAt: liveEntry?.startedAt ?? null, }; }); - return { ...w, sessions }; + // Workspace AI provider override signals — read by the Overview + // dashboard for the "⚙ Workspace override" footer per card. Cheap + // (single statSync each) so it's safe on the regular list poll. + const agentOverride = { + claude: existsSync(join(w.dir, '.claude', 'settings.local.json')), + codex: existsSync(join(w.dir, '.codex')), + }; + return { ...w, sessions, agentOverride }; }; const dispose = async (reason: string): Promise => { diff --git a/src/workspaces/template-registry.ts b/src/workspaces/template-registry.ts index fe40a07ff..42b1ba0bf 100644 --- a/src/workspaces/template-registry.ts +++ b/src/workspaces/template-registry.ts @@ -7,6 +7,20 @@ import type { Logger } from './logger.js'; export interface TemplateMeta { readonly name: string; readonly description?: string; + /** + * Human-readable name surfaced in the UI (dashboard section headers, + * etc.). Sourced from `template.json`'s `displayName` key. Falls back + * to a humanized form of `name` on the client when missing. + */ + readonly displayName?: string; + /** + * Sort key for grouping workspaces by template type in the dashboard. + * Lower = earlier. Sourced from `template.json`'s `groupOrder` key. + * Templates without a declared `groupOrder` sort after declared ones, + * by name. New template types just need to add this field to their + * `template.json` — no frontend code change required. + */ + readonly groupOrder?: number; /** Absolute path to the template's `bootstrap.sh`. */ readonly bootstrapScript: string; /** Absolute path to the template's `files/` directory (may not exist). */ @@ -55,6 +69,8 @@ export class TemplateRegistry { const meta: TemplateMeta = { name, ...(tplMeta.description !== undefined ? { description: tplMeta.description } : {}), + ...(tplMeta.displayName !== undefined ? { displayName: tplMeta.displayName } : {}), + ...(tplMeta.groupOrder !== undefined ? { groupOrder: tplMeta.groupOrder } : {}), bootstrapScript, filesDir, defaultAgents: tplMeta.defaultAgents, @@ -96,6 +112,8 @@ export class TemplateRegistry { interface ParsedTemplateMeta { readonly description?: string; + readonly displayName?: string; + readonly groupOrder?: number; readonly defaultAgents: readonly string[]; } @@ -112,11 +130,18 @@ async function readTemplateMeta(path: string): Promise { if (typeof parsed !== 'object' || parsed === null) return fallback; const obj = parsed as Record; const description = typeof obj['description'] === 'string' ? obj['description'] : undefined; + const displayName = typeof obj['displayName'] === 'string' ? obj['displayName'] : undefined; + const groupOrder = + typeof obj['groupOrder'] === 'number' && Number.isFinite(obj['groupOrder']) + ? obj['groupOrder'] + : undefined; const defaultAgents = Array.isArray(obj['defaultAgents']) ? obj['defaultAgents'].filter((a): a is string => typeof a === 'string') : null; return { ...(description !== undefined ? { description } : {}), + ...(displayName !== undefined ? { displayName } : {}), + ...(groupOrder !== undefined ? { groupOrder } : {}), defaultAgents: defaultAgents && defaultAgents.length > 0 ? defaultAgents : ['claude'], }; } catch { diff --git a/src/workspaces/templates/auto-quant/bootstrap.sh b/src/workspaces/templates/auto-quant/bootstrap.sh index 2f149606b..597f166d1 100755 --- a/src/workspaces/templates/auto-quant/bootstrap.sh +++ b/src/workspaces/templates/auto-quant/bootstrap.sh @@ -73,16 +73,12 @@ cd "$OUT_DIR" # 2. autoresearch branch from whatever master/main the source points at. git checkout -b "autoresearch/$TAG" >/dev/null -# ── Codex workspace skeleton + agent-config excludes ───────────────────── -# Mirror the chat template's setup so codex's CODEX_HOME=$cwd/.codex works -# and the UI-saved secrets never leak to upstream Auto-Quant pushes. See -# chat/bootstrap.sh for the full rationale. -mkdir -p .codex -ln -sf "$HOME/.codex/auth.json" .codex/auth.json -cat > .codex/config.toml <<'TOML' -[mcp_servers.openalice] -url = "${OPENALICE_MCP_URL:-http://127.0.0.1:3001/mcp}" -TOML +# ── Agent-config excludes ──────────────────────────────────────────────── +# Preemptive defense: if the user later configures workspace-specific AI +# provider via the OpenAlice UI (writing `.claude/settings.local.json` / +# `.codex/auth.json`), the per-clone exclude keeps those secrets out of any +# push to upstream Auto-Quant. Claude itself auto-ignores its file; this +# entry is defense-in-depth. { echo '.claude/settings.local.json' echo '.codex/auth.json' diff --git a/src/workspaces/templates/auto-quant/template.json b/src/workspaces/templates/auto-quant/template.json index 136cbc964..2b8dd85be 100644 --- a/src/workspaces/templates/auto-quant/template.json +++ b/src/workspaces/templates/auto-quant/template.json @@ -1,4 +1,6 @@ { + "displayName": "Auto-Quant", + "groupOrder": 20, "description": "Auto-Quant autoresearch workspace — clones the public Auto-Quant repo on first use (~/.openalice/workspaces/auto-quant-mirror), then makes per-workspace local clones with an autoresearch/ branch and symlinked .feather data. Set AQ_TEMPLATE_DIR to override the source location.", "defaultAgents": ["claude"] } diff --git a/src/workspaces/templates/chat/bootstrap.sh b/src/workspaces/templates/chat/bootstrap.sh index ec694a083..483a5e46e 100755 --- a/src/workspaces/templates/chat/bootstrap.sh +++ b/src/workspaces/templates/chat/bootstrap.sh @@ -56,35 +56,18 @@ fi # drift; this is a literal copy, not a separate compose pass. cp CLAUDE.md AGENTS.md -# ── Codex workspace skeleton ──────────────────────────────────────────────── -# Each workspace is its own VS-Code-style "open folder" — claude reads -# `.claude/settings*.json` from cwd, codex reads `$CODEX_HOME` (which the -# codex adapter points at this `.codex/` dir at spawn). We seed: -# - `.codex/config.toml` with the OpenAlice MCP block so codex sees the -# OpenAlice tool surface from day 1. The OpenAlice UI later patches -# `[model_providers.*]` + `model` / `model_provider` keys when the user -# picks a provider; we deliberately do not write provider config at -# create time (workspaces inherit the user's global CLI auth until -# explicitly configured). -# - `.codex/auth.json` symlinked to the user's global codex login so a -# fresh-config workspace still has a valid auth. The UI replaces this -# symlink with a real file when the user assigns a workspace-specific -# key (so global rotation doesn't leak into configured workspaces). -mkdir -p .codex -ln -sf "$HOME/.codex/auth.json" .codex/auth.json -cat > .codex/config.toml <<'TOML' -[mcp_servers.openalice] -url = "${OPENALICE_MCP_URL:-http://127.0.0.1:3001/mcp}" -TOML - git init -q -# `.git/info/exclude` is per-clone, untracked. Belt-and-suspenders against -# UI-saved secrets ever entering a push: +# `.git/info/exclude` is per-clone, untracked. Preemptive defense for +# files the OpenAlice UI may later write when the user configures +# workspace-specific AI provider overrides: # - .claude/settings.local.json — claude itself auto-ignores this, but # the entry here defends against any future tooling reading from # `.gitignore` instead of trusting claude's runtime behaviour. -# - .codex/auth.json — codex's auth (symlink or real file). Never push. +# - .codex/auth.json — workspace-local codex auth (real file written +# by the UI when user configures a workspace-specific provider). +# Bootstrap doesn't create this; it's listed here so a configured +# workspace's secret never enters a push. { echo '.claude/settings.local.json' echo '.codex/auth.json' diff --git a/src/workspaces/templates/chat/template.json b/src/workspaces/templates/chat/template.json index 15bf5bd59..8f5fd77ad 100644 --- a/src/workspaces/templates/chat/template.json +++ b/src/workspaces/templates/chat/template.json @@ -1,4 +1,6 @@ { + "displayName": "Chat", + "groupOrder": 10, "description": "Chat workspace wired to OpenAlice's MCP server — full trading/market/brain tool surface available to the agent.", "defaultAgents": ["claude", "codex"] } diff --git a/ui/src/components/ChatChannelListContainer.tsx b/ui/src/components/ChatChannelListContainer.tsx index 5836c2413..b3cc373e5 100644 --- a/ui/src/components/ChatChannelListContainer.tsx +++ b/ui/src/components/ChatChannelListContainer.tsx @@ -1,25 +1,26 @@ -import { Bell, Notebook } from 'lucide-react' +import { Bell, HelpCircle, Notebook } from 'lucide-react' import { useChannels } from '../contexts/ChannelsContext' import { useWorkspace } from '../tabs/store' import { getFocusedTab } from '../tabs/types' import { useUnreadNotificationsCount } from '../live/notifications-read' import { ChatChannelList } from './ChatChannelList' +import { ChatWorkspaceSection } from './workspace/ChatWorkspaceSection' import { SidebarRow } from './SidebarRow' /** - * Connects ChatChannelList to ChannelsContext + the workspace store. + * Chat activity sidebar. Two conceptual sections: * - * Layout reflects the framing of "Chat" as the catch-all activity for - * interactions with Alice — not strictly chat: + * - **Workspace chat** (recommended) — chat-template workspaces, each + * wrapping a native CLI session (claude / codex / shell). Native + * prompt cache + native frontend; the path most users should default + * to. See README "Two kinds of chat". + * - **Traditional chat** — the original /chat channels backed by + * OpenAlice's ChatHook. Required for connectors (Telegram / MCP Ask + * / webhook) which have no PTY to host a CLI in. * - * - Notifications (inbound system pushes; unread badge) - * - Diary (Alice's first-person output stream — read-only) - * ───── - * - Channels (the chat conversations the user opens) - * - * The two upper rows are "Alice surfaces"; the channel list is "user - * actions". They share this sidebar because the unifying mental model - * is "everything Alice-shaped" rather than "places to type messages". + * Above both: Notifications + Diary — system-push surfaces that share + * this sidebar because the unifying mental model is "everything + * Alice-shaped". * * Active row tracking is derived from the focused tab — switching tabs * naturally shifts the highlight without bespoke wiring. @@ -69,17 +70,32 @@ export function ChatChannelListContainer() { /> -
- Channels -
-
- openOrFocus({ kind: 'chat', params: { channelId: id } })} - onEdit={openEditDialog} - onDelete={deleteChannel} - /> +
+ + +
+ Traditional +
+
+ openOrFocus({ kind: 'chat', params: { channelId: id } })} + onEdit={openEditDialog} + onDelete={deleteChannel} + /> +
+ + +
) diff --git a/ui/src/components/TabStrip.tsx b/ui/src/components/TabStrip.tsx index c28433d65..727b89c4a 100644 --- a/ui/src/components/TabStrip.tsx +++ b/ui/src/components/TabStrip.tsx @@ -1,6 +1,7 @@ import { useState, type MouseEvent, type WheelEvent } from 'react' import { X } from 'lucide-react' import { useChannels } from '../contexts/ChannelsContext' +import { useWorkspaces } from '../contexts/WorkspacesContext' import { useWorkspace } from '../tabs/store' import { getView } from '../tabs/registry' import { ContextMenu, type ContextMenuItem } from './ContextMenu' @@ -22,6 +23,7 @@ import { ContextMenu, type ContextMenuItem } from './ContextMenu' */ export function TabStrip() { const { channels } = useChannels() + const { workspaces } = useWorkspaces() const tabIds = useWorkspace((state) => state.tree.kind === 'leaf' ? state.tree.group.tabIds : [], ) @@ -101,7 +103,7 @@ export function TabStrip() { const tab = tabsMap[id] if (!tab) return null const view = getView(tab.spec.kind) - const title = view.title(tab.spec as never, { channels }) + const title = view.title(tab.spec as never, { channels, workspaces }) const isActive = id === activeTabId return (