feat: BooCode 2.0 UI — Ember theme, brand banner, coder tabs, model-attribution chips
- Ember theme (Obsidian charcoal + #ff7a18 orange), now DEFAULT_THEME_ID; server theme_id whitelist gains 'ember' - Brand banner: transparent Westie mascot + >_BooCode wordmark, big/edge-to-edge (flood-filled to transparency + cropped) - Coder panes are multi-tab: + opens a BooCode tab, split opens a pane (shared ChatTabBar via tabKind + createCoderTab; closeOtherTabs/tab-numbering extended to coder) - Model-attribution: new messages.model column stamped at finalizeCompletion (BooChat/native coder) + dispatcher assistant-row creation (external coder); surfaced via view + wire types + live frame; rendered as a subtle shortened-name chip (shortenModelName) - Composer Web toggle moved into a boxed focus-ringed input; glowing accent dot on tool rows - Claude SDK follow-ups (1M context, follow-up-message fix, collapsed thinking/tool chips) + CLAUDE_SDK_BACKEND=1 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
|
||||
import { Check, ListPlus, Plus, Send, Square } from 'lucide-react';
|
||||
import { Globe, ListPlus, Send, Square } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
flattenToMessage,
|
||||
inferLanguage,
|
||||
@@ -598,39 +592,9 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
onChange={onAgentChange}
|
||||
/>
|
||||
)}
|
||||
{sessionId && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Quick toggles"
|
||||
title="Quick toggles"
|
||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onSelect={async () => {
|
||||
// v1.9: tri-state collapses to two on the wire when toggled
|
||||
// here. null (inherit) treated as off; click flips to true.
|
||||
// To restore "inherit" the user opens SettingsPane.
|
||||
const next = webSearchEnabled === true ? false : true;
|
||||
try {
|
||||
await api.sessions.update(sessionId, { web_search_enabled: next });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'failed to toggle web search');
|
||||
}
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={`size-3 ${webSearchEnabled === true ? 'opacity-100' : 'opacity-0'}`} />
|
||||
Enable web search and fetch
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{/* BooCode 2.0: the web-search toggle moved out of this top toolbar
|
||||
into the composer box's bottom controls row (the Web pill below),
|
||||
leaving the top row as just the agent picker + context bar. */}
|
||||
{/* v1.11.5.1: ContextBar fills the remaining horizontal space.
|
||||
`flex-1 min-w-0` is set inside the component. Mounts only when
|
||||
the caller passes `messages` so older call sites (without the
|
||||
@@ -640,54 +604,86 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-4 py-3 flex items-end gap-2">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={onPaste}
|
||||
placeholder={
|
||||
isMobile
|
||||
? 'Ask about this project. Tap send to submit.'
|
||||
: 'Ask about this project. Enter to send · Shift+Enter for newline.'
|
||||
}
|
||||
disabled={disabled || busy}
|
||||
rows={3}
|
||||
className="resize-none min-h-[68px] max-h-[240px]"
|
||||
/>
|
||||
{(() => {
|
||||
const hasContent = value.trim().length > 0 || attachments.length > 0;
|
||||
// While generating with an empty draft, the button stops generation.
|
||||
if (generating && onStop && !hasContent) {
|
||||
return (
|
||||
<Button
|
||||
onClick={() => void onStop()}
|
||||
size="icon-lg"
|
||||
variant="outline"
|
||||
aria-label="Stop generating"
|
||||
title="Stop generating"
|
||||
{/* BooCode 2.0 composer: textarea + a bottom controls row live INSIDE one
|
||||
bordered, focus-ringed message box (Refreshed direction). */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="rounded-xl border bg-card transition-colors focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/15">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={onPaste}
|
||||
placeholder={
|
||||
isMobile
|
||||
? 'Ask about this project. Tap send to submit.'
|
||||
: 'Ask about this project. Enter to send · Shift+Enter for newline.'
|
||||
}
|
||||
disabled={disabled || busy}
|
||||
rows={3}
|
||||
className="resize-none min-h-[56px] max-h-[240px] border-0 bg-transparent px-3 pt-2.5 shadow-none focus-visible:ring-0 dark:bg-transparent"
|
||||
/>
|
||||
{/* bottom controls row: Web toggle on the left, Send/Stop on the right */}
|
||||
<div className="flex items-center gap-1.5 px-2 pb-2 pt-0.5">
|
||||
{sessionId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
// v1.9 tri-state collapses to two on toggle; null (inherit) → on.
|
||||
const next = webSearchEnabled === true ? false : true;
|
||||
try {
|
||||
await api.sessions.update(sessionId, { web_search_enabled: next });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'failed to toggle web search');
|
||||
}
|
||||
}}
|
||||
aria-pressed={webSearchEnabled === true}
|
||||
title="Web search & fetch"
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors max-md:min-h-[36px] ${
|
||||
webSearchEnabled === true
|
||||
? 'border-primary/40 bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Square className="fill-current size-3.5" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
// With a draft, submit. While generating the caller queues it, so the
|
||||
// button reads as Queue; otherwise it's a normal Send.
|
||||
const queueing = !!generating && hasContent;
|
||||
return (
|
||||
<Button
|
||||
onClick={() => void submit()}
|
||||
disabled={disabled || busy || !hasContent}
|
||||
size="icon-lg"
|
||||
variant={queueing ? 'secondary' : 'default'}
|
||||
aria-label={queueing ? 'Queue message' : 'Send'}
|
||||
title={queueing ? 'Queue message' : 'Send'}
|
||||
>
|
||||
{queueing ? <ListPlus /> : <Send />}
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
<Globe className="size-3.5" />
|
||||
Web
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{(() => {
|
||||
const hasContent = value.trim().length > 0 || attachments.length > 0;
|
||||
// While generating with an empty draft, the button stops generation.
|
||||
if (generating && onStop && !hasContent) {
|
||||
return (
|
||||
<Button
|
||||
onClick={() => void onStop()}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
aria-label="Stop generating"
|
||||
title="Stop generating"
|
||||
>
|
||||
<Square className="fill-current size-3.5" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
// With a draft, submit. While generating the caller queues it, so the
|
||||
// button reads as Queue; otherwise it's a normal Send.
|
||||
const queueing = !!generating && hasContent;
|
||||
return (
|
||||
<Button
|
||||
onClick={() => void submit()}
|
||||
disabled={disabled || busy || !hasContent}
|
||||
size="icon"
|
||||
variant={queueing ? 'secondary' : 'default'}
|
||||
aria-label={queueing ? 'Queue message' : 'Send'}
|
||||
title={queueing ? 'Queue message' : 'Send'}
|
||||
>
|
||||
{queueing ? <ListPlus /> : <Send />}
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AttachmentPreviewModal
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { History, MessageSquare, X } from 'lucide-react';
|
||||
import { Code, History, MessageSquare, X } from 'lucide-react';
|
||||
import type { Chat, WorkspacePane } from '@/api/types';
|
||||
import { StatusDot } from '@/components/StatusDot';
|
||||
import { PaneHeaderActions } from '@/components/PaneHeaderActions';
|
||||
@@ -17,6 +17,9 @@ import { cn } from '@/lib/utils';
|
||||
interface Props {
|
||||
pane: WorkspacePane;
|
||||
tabs: Chat[];
|
||||
// Host pane kind — 'coder' shows the Code glyph + routes the "+" to a new
|
||||
// BooCode tab. Defaults to 'chat' (the BooChat tab bar).
|
||||
tabKind?: 'chat' | 'coder';
|
||||
// v2.6.x (Batch 3a): stable session-scoped tab number per chat id. Keyed by
|
||||
// chat.id, NEVER by tab position.
|
||||
tabNumbers: Record<string, number>;
|
||||
@@ -36,6 +39,7 @@ interface Props {
|
||||
export function ChatTabBar({
|
||||
pane,
|
||||
tabs,
|
||||
tabKind = 'chat',
|
||||
tabNumbers,
|
||||
onSwitchTab,
|
||||
onRemoveTab,
|
||||
@@ -51,6 +55,8 @@ export function ChatTabBar({
|
||||
}: Props) {
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const TabIcon = tabKind === 'coder' ? Code : MessageSquare;
|
||||
const newLabel = tabKind === 'coder' ? 'New BooCode' : 'New chat';
|
||||
|
||||
// Long-press: dispatch a synthetic contextmenu event on the tab so the
|
||||
// existing Radix ContextMenuTrigger opens at the touch coordinates. Works
|
||||
@@ -104,7 +110,7 @@ export function ChatTabBar({
|
||||
: 'bg-muted/30 text-muted-foreground hover:bg-muted/60'
|
||||
)}
|
||||
>
|
||||
<MessageSquare size={12} className="shrink-0" />
|
||||
<TabIcon size={12} className="shrink-0" />
|
||||
<StatusDot chatId={chat.id} />
|
||||
{renamingId === chat.id ? (
|
||||
<input
|
||||
@@ -142,7 +148,7 @@ export function ChatTabBar({
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={onNewTab}>
|
||||
New chat
|
||||
{newLabel}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={() =>
|
||||
@@ -189,6 +195,7 @@ export function ChatTabBar({
|
||||
<PaneHeaderActions
|
||||
className="ml-auto px-1"
|
||||
onNewTab={onNewTab}
|
||||
tabKind={tabKind}
|
||||
onSplitPane={onSplitPane}
|
||||
onReopenPane={onReopenPane}
|
||||
onShowHistory={onShowHistory}
|
||||
|
||||
@@ -19,18 +19,24 @@ interface Props {
|
||||
// the same boundaries the server's auto-compaction triggers.
|
||||
const COMPACTION_BUFFER = 20_000;
|
||||
|
||||
// Walk newest-first; first message with both ctx_used and ctx_max non-null
|
||||
// AND ctx_max > 0 wins. Older messages may have ctx_used but missing ctx_max
|
||||
// (early v1 before llama-swap's n_ctx capture worked) — skip them and keep
|
||||
// walking. Returns null when no usable pair exists in the chat.
|
||||
// Take the latest ctx_used and the latest ctx_max INDEPENDENTLY (newest-first).
|
||||
// They needn't be on the same message: ctx_max is the model's context window — a
|
||||
// constant per model — while some agents report it only intermittently (the claude
|
||||
// SDK populates modelUsage.contextWindow on some turns, not all) yet report
|
||||
// ctx_used every turn. Pairing the latest of each gives a correct used/max even
|
||||
// when the most recent turn omitted the window. Native BooChat sets both on the
|
||||
// same assistant message, so this is identical there. Returns null until BOTH a
|
||||
// used and a positive max have been seen at least once.
|
||||
function latestPair(messages: Message[]): { used: number; max: number } | null {
|
||||
let used: number | null = null;
|
||||
let max: number | null = null;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
if (m.ctx_used == null || m.ctx_max == null) continue;
|
||||
if (m.ctx_max <= 0) continue;
|
||||
return { used: m.ctx_used, max: m.ctx_max };
|
||||
if (used === null && m.ctx_used != null) used = m.ctx_used;
|
||||
if (max === null && m.ctx_max != null && m.ctx_max > 0) max = m.ctx_max;
|
||||
if (used !== null && max !== null) break;
|
||||
}
|
||||
return null;
|
||||
return used !== null && max !== null ? { used, max } : null;
|
||||
}
|
||||
|
||||
interface ColorTier {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Chat, ErrorReason, Message } from '@/api/types';
|
||||
import { api } from '@/api/client';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
|
||||
import { shortenModelName } from '@/lib/modelName';
|
||||
import { CapHitSentinel } from './CapHitSentinel';
|
||||
import { DoomLoopSentinel } from './DoomLoopSentinel';
|
||||
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||
@@ -608,12 +609,12 @@ function SummaryCard({ message }: { message: Message }) {
|
||||
|
||||
// Collapsible "Thinking" block for assistant reasoning. Fed by either
|
||||
// reasoning_text (coder wire / live reasoning_delta stream) or reasoning_parts
|
||||
// (native inference, persisted from message_parts). Auto-expands while the turn
|
||||
// is still streaming so the user watches it think (Paseo-style), then stays
|
||||
// where the user left it once the turn completes — initial state is captured
|
||||
// once at mount, so we never fight a manual collapse on later re-renders.
|
||||
// (native inference, persisted from message_parts). Starts COLLAPSED to start
|
||||
// (a quiet chip) — for native BooChat/BooCode and the external agents (opencode,
|
||||
// claude SDK) alike — so the transcript stays tidy; click to expand. The
|
||||
// `streaming` pulse still animates while the turn runs.
|
||||
function ReasoningBlock({ text, streaming }: { text: string; streaming: boolean }) {
|
||||
const [expanded, setExpanded] = useState(() => streaming);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
return (
|
||||
<div className="max-w-[90%] rounded-lg border bg-muted/30 text-sm">
|
||||
<button
|
||||
@@ -768,7 +769,7 @@ export function MessageBubble({
|
||||
return (
|
||||
<div className="group flex flex-col items-end gap-1">
|
||||
<SendToTerminalMenu>
|
||||
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
|
||||
<div className="boo-user-bubble max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
|
||||
{message.content}
|
||||
</div>
|
||||
</SendToTerminalMenu>
|
||||
@@ -782,6 +783,8 @@ export function MessageBubble({
|
||||
// v1.13.7: match the MessageList.flatten trim guard so a whitespace-only
|
||||
// assistant turn doesn't render an empty bubble + dangling ActionRow.
|
||||
const hasContent = message.content.trim().length > 0;
|
||||
// model-attribution chip: short label for the model that produced this turn.
|
||||
const modelLabel = shortenModelName(message.model);
|
||||
// Reasoning arrives as a pre-joined string (coder wire) or as parts (native
|
||||
// inference). Read whichever is present; loose ?? chain tolerates the coder
|
||||
// shape where reasoning_parts is undefined (see CLAUDE.md null-guard note).
|
||||
@@ -823,6 +826,14 @@ export function MessageBubble({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isStreaming && (modelLabel || null) && (
|
||||
<span
|
||||
className="inline-flex w-fit items-center rounded-full border border-primary/25 bg-primary/10 px-2 py-0.5 text-[10px] font-mono text-primary/90"
|
||||
title={message.model ?? undefined}
|
||||
>
|
||||
{modelLabel}
|
||||
</span>
|
||||
)}
|
||||
{!isStreaming && <StatsLine message={message} />}
|
||||
{!isStreaming && hasContent && (
|
||||
<ActionRow
|
||||
|
||||
@@ -12,10 +12,14 @@ import { cn } from '@/lib/utils';
|
||||
// desktop coder + terminal pane headers (Workspace) so all pane kinds share one
|
||||
// control set. Extracted to avoid a divergent copy per header.
|
||||
interface Props {
|
||||
// When provided (chat panes), the "+" menu's New BooChat opens an in-pane
|
||||
// tab. When omitted (coder/terminal panes, which can't host tabs), New BooChat
|
||||
// splits into a new pane instead.
|
||||
// When provided, the "+" menu item matching `tabKind` opens an in-pane tab
|
||||
// (e.g. chat panes: New BooChat → tab; coder panes: New BooCode → tab). Every
|
||||
// OTHER kind splits into a new pane. When onNewTab is omitted (terminal
|
||||
// panes, which can't host tabs) all three items split.
|
||||
onNewTab?: () => void;
|
||||
// The host pane's own kind — the "+" item of this kind becomes "new tab".
|
||||
// Defaults to 'chat' for back-compat with the chat tab bar.
|
||||
tabKind?: 'chat' | 'terminal' | 'coder';
|
||||
onSplitPane: (kind: 'chat' | 'terminal' | 'coder') => void;
|
||||
onReopenPane?: () => void;
|
||||
onShowHistory: () => void;
|
||||
@@ -31,6 +35,7 @@ const BTN =
|
||||
|
||||
export function PaneHeaderActions({
|
||||
onNewTab,
|
||||
tabKind = 'chat',
|
||||
onSplitPane,
|
||||
onReopenPane,
|
||||
onShowHistory,
|
||||
@@ -38,6 +43,10 @@ export function PaneHeaderActions({
|
||||
historyActive,
|
||||
className,
|
||||
}: Props) {
|
||||
// The "+" item of the host pane's own kind adds a tab; every other kind
|
||||
// splits into a new pane. Falls back to split when onNewTab is absent.
|
||||
const newOrSplit = (kind: 'chat' | 'terminal' | 'coder') =>
|
||||
onNewTab && tabKind === kind ? onNewTab : () => onSplitPane(kind);
|
||||
return (
|
||||
<div className={cn('flex items-center gap-0.5 shrink-0', className)}>
|
||||
<DropdownMenu>
|
||||
@@ -53,15 +62,15 @@ export function PaneHeaderActions({
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-fit">
|
||||
{/* Chat panes: New BooChat opens a tab in THIS pane. Coder/terminal
|
||||
panes can't host tabs, so it splits into a new pane. */}
|
||||
<DropdownMenuItem onSelect={onNewTab ?? (() => onSplitPane('chat'))}>
|
||||
{/* The item matching the host pane's kind opens an in-pane tab; the
|
||||
others split into a new pane. (tabKind defaults to 'chat'.) */}
|
||||
<DropdownMenuItem onSelect={newOrSplit('chat')}>
|
||||
<MessageSquare size={14} /> New BooChat
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSplitPane('terminal')}>
|
||||
<DropdownMenuItem onSelect={newOrSplit('terminal')}>
|
||||
<Terminal size={14} /> New BooTerm
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSplitPane('coder')}>
|
||||
<DropdownMenuItem onSelect={newOrSplit('coder')}>
|
||||
<Code size={14} /> New BooCode
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus, Settings as SettingsIcon, X, Code } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import mascot from '@/assets/brand/banner-mascot.png';
|
||||
import wordmark from '@/assets/brand/banner-wordmark.png';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -307,9 +309,22 @@ export function ProjectSidebar() {
|
||||
|
||||
return (
|
||||
<aside className={asideCls}>
|
||||
<div className="px-4 py-3 border-b flex items-center justify-between">
|
||||
<NavLink to="/" className="font-semibold tracking-tight text-base">
|
||||
BooCode
|
||||
<div className="px-2 py-1 border-b flex items-center justify-between gap-1">
|
||||
{/* BooCode brand banner: mascot badge + >_BooCode wordmark, big and
|
||||
visible, on transparent backgrounds (no chip, no blend). */}
|
||||
<NavLink to="/" aria-label="BooCode home" className="flex items-center gap-0.5 min-w-0 flex-1">
|
||||
<img
|
||||
src={mascot}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="h-12 w-auto select-none shrink-0"
|
||||
/>
|
||||
<img
|
||||
src={wordmark}
|
||||
alt="BooCode"
|
||||
draggable={false}
|
||||
className="h-12 w-auto select-none min-w-0 flex-1 object-contain object-left"
|
||||
/>
|
||||
</NavLink>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="icon-sm" variant="ghost" onClick={() => setAddOpen(true)} aria-label="Add project">
|
||||
|
||||
@@ -149,8 +149,13 @@ export function ToolCallLine({ run, insideGroup }: Props) {
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-1.5 w-full text-left hover:bg-muted/40 rounded px-1 py-0.5 -mx-1"
|
||||
>
|
||||
{/* BooCode 2.0: glowing activity indicator (was ↳ / >_) */}
|
||||
{!insideGroup && (
|
||||
<span className="text-muted-foreground/60 select-none shrink-0">↳</span>
|
||||
<span
|
||||
className="size-1.5 rounded-full bg-primary shrink-0"
|
||||
style={{ boxShadow: '0 0 6px var(--primary)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<ChevronRight
|
||||
className={`size-3 text-muted-foreground/60 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Terminal, Code, Clipboard } from 'lucide-react';
|
||||
import { Terminal, Clipboard } from 'lucide-react';
|
||||
import { api } from '@/api/client';
|
||||
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
|
||||
import { MAX_PANES, activePaneChatId, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
|
||||
@@ -60,6 +60,7 @@ export function Workspace({
|
||||
closeAllTabs,
|
||||
showLandingPage,
|
||||
addSplitPane,
|
||||
createCoderTab,
|
||||
removePane,
|
||||
reopenPane,
|
||||
hasClosedPanes,
|
||||
@@ -214,18 +215,27 @@ export function Workspace({
|
||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||
/>
|
||||
)}
|
||||
{/* Coder panes host BooCode tabs (one chat = one agent context,
|
||||
all sharing the session worktree). "+" adds a tab; the split
|
||||
button adds a pane. Same tab strip as chat panes (tabKind). */}
|
||||
{isCoder && !isMobile && (
|
||||
<div className="flex items-center gap-1 border-b border-border px-2 py-1 shrink-0">
|
||||
<Code size={12} className="text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">BooCode</span>
|
||||
<PaneHeaderActions
|
||||
className="ml-auto"
|
||||
onSplitPane={onAddPane}
|
||||
onReopenPane={hasClosedPanes ? reopenPane : undefined}
|
||||
onShowHistory={() => showLandingPage(idx)}
|
||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||
/>
|
||||
</div>
|
||||
<ChatTabBar
|
||||
pane={pane}
|
||||
tabs={chatsForPane(pane)}
|
||||
tabKind="coder"
|
||||
tabNumbers={tabNumbers}
|
||||
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
|
||||
onRemoveTab={(chatId) => removeTab(idx, chatId)}
|
||||
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
|
||||
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
|
||||
onCloseAll={() => closeAllTabs(idx)}
|
||||
onNewTab={() => void createCoderTab(idx)}
|
||||
onSplitPane={(kind) => onAddPane(kind)}
|
||||
onReopenPane={hasClosedPanes ? reopenPane : undefined}
|
||||
onShowHistory={() => showLandingPage(idx)}
|
||||
onRename={renameChat}
|
||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||
/>
|
||||
)}
|
||||
{isTerminal && (
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
|
||||
|
||||
Reference in New Issue
Block a user