feat(v3): implement Mission Control MVP (Phases 1-5)

Phase 1: Data model - groups.rs (Rust structs + load/save groups.json),
groups.ts (TypeScript interfaces), groups-bridge.ts (IPC adapter),
workspace.svelte.ts (replaces layout store), SQLite migrations
(agent_messages, project_agent_state tables, project_id column),
--group CLI argument.

Phase 2: Project shell layout - GlobalTabBar, ProjectGrid, ProjectBox,
ProjectHeader, CommandPalette, DocsTab, ContextTab, SettingsTab,
App.svelte full rewrite (no sidebar/TilingGrid).

Phase 3: ClaudeSession.svelte wrapping AgentPane per-project.
Phase 4: TerminalTabs.svelte with shell/SSH/agent tab types.
Phase 5: TeamAgentsPanel + AgentCard for compact subagent view.

Also fixes AgentPane Svelte 5 event modifier (on:click -> onclick).
This commit is contained in:
Hibryda 2026-03-07 16:06:07 +01:00
parent 293bed6dc5
commit ab79dac4b3
20 changed files with 2296 additions and 65 deletions

View file

@ -302,7 +302,7 @@
<button
class="skill-item"
class:active={i === skillMenuIndex}
onmousedown|preventDefault={() => handleSkillSelect(skill)}
onmousedown={(e) => { e.preventDefault(); handleSkillSelect(skill); }}
>
<span class="skill-name">/{skill.name}</span>
<span class="skill-desc">{skill.description}</span>

View file

@ -0,0 +1,100 @@
<script lang="ts">
import type { AgentSession } from '../../stores/agents.svelte';
interface Props {
session: AgentSession;
onclick?: () => void;
}
let { session, onclick }: Props = $props();
let statusColor = $derived(
session.status === 'running' ? 'var(--ctp-green)' :
session.status === 'done' ? 'var(--ctp-blue)' :
session.status === 'error' ? 'var(--ctp-red)' :
'var(--ctp-overlay0)'
);
let truncatedPrompt = $derived(
session.prompt.length > 60
? session.prompt.slice(0, 60) + '...'
: session.prompt
);
</script>
<div class="agent-card" role="button" tabindex="0" {onclick} onkeydown={e => e.key === 'Enter' && onclick?.()}>
<div class="card-header">
<span class="status-dot" style="background: {statusColor}"></span>
<span class="agent-status">{session.status}</span>
{#if session.costUsd > 0}
<span class="agent-cost">${session.costUsd.toFixed(4)}</span>
{/if}
</div>
<div class="card-prompt">{truncatedPrompt}</div>
{#if session.status === 'running'}
<div class="card-progress">
<span class="turns">{session.numTurns} turns</span>
</div>
{/if}
</div>
<style>
.agent-card {
padding: 6px 8px;
background: var(--ctp-surface0);
border-radius: 4px;
cursor: pointer;
transition: background 0.1s;
border-left: 2px solid transparent;
}
.agent-card:hover {
background: var(--ctp-surface1);
}
.card-header {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 3px;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.agent-status {
font-size: 0.65rem;
color: var(--ctp-overlay1);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.agent-cost {
margin-left: auto;
font-size: 0.65rem;
color: var(--ctp-overlay0);
font-family: monospace;
}
.card-prompt {
font-size: 0.72rem;
color: var(--ctp-subtext0);
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-progress {
margin-top: 3px;
}
.turns {
font-size: 0.65rem;
color: var(--ctp-overlay0);
}
</style>

View file

@ -0,0 +1,82 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { ProjectConfig } from '../../types/groups';
import {
loadProjectAgentState,
saveProjectAgentState,
loadAgentMessages,
saveAgentMessages,
type ProjectAgentState,
type AgentMessageRecord,
} from '../../adapters/groups-bridge';
import AgentPane from '../Agent/AgentPane.svelte';
interface Props {
project: ProjectConfig;
onsessionid?: (id: string) => void;
}
let { project, onsessionid }: Props = $props();
// Per-project session ID (stable across renders, changes with project)
let sessionId = $state(crypto.randomUUID());
let lastState = $state<ProjectAgentState | null>(null);
let resumeSessionId = $state<string | undefined>(undefined);
let loading = $state(true);
// Load previous session state when project changes
$effect(() => {
const pid = project.id;
loadPreviousState(pid);
});
async function loadPreviousState(projectId: string) {
loading = true;
try {
const state = await loadProjectAgentState(projectId);
lastState = state;
if (state?.sdk_session_id) {
resumeSessionId = state.sdk_session_id;
sessionId = state.last_session_id;
} else {
resumeSessionId = undefined;
sessionId = crypto.randomUUID();
}
} catch (e) {
console.warn('Failed to load project agent state:', e);
sessionId = crypto.randomUUID();
} finally {
loading = false;
onsessionid?.(sessionId);
}
}
</script>
<div class="claude-session">
{#if loading}
<div class="loading-state">Loading session...</div>
{:else}
<AgentPane
{sessionId}
cwd={project.cwd}
/>
{/if}
</div>
<style>
.claude-session {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--ctp-overlay0);
font-size: 0.85rem;
}
</style>

View file

@ -0,0 +1,159 @@
<script lang="ts">
import { getAllGroups, switchGroup, getActiveGroupId } from '../../stores/workspace.svelte';
interface Props {
open: boolean;
onclose: () => void;
}
let { open, onclose }: Props = $props();
let query = $state('');
let inputEl: HTMLInputElement | undefined = $state();
let groups = $derived(getAllGroups());
let filtered = $derived(
groups.filter(g =>
g.name.toLowerCase().includes(query.toLowerCase()),
),
);
let activeGroupId = $derived(getActiveGroupId());
$effect(() => {
if (open) {
query = '';
// Focus input after render
requestAnimationFrame(() => inputEl?.focus());
}
});
function selectGroup(groupId: string) {
switchGroup(groupId);
onclose();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onclose();
}
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="palette-backdrop" onclick={onclose} onkeydown={handleKeydown}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="palette" onclick={(e) => e.stopPropagation()} onkeydown={handleKeydown}>
<input
bind:this={inputEl}
bind:value={query}
class="palette-input"
placeholder="Switch group..."
onkeydown={handleKeydown}
/>
<ul class="palette-results">
{#each filtered as group}
<li>
<button
class="palette-item"
class:active={group.id === activeGroupId}
onclick={() => selectGroup(group.id)}
>
<span class="group-name">{group.name}</span>
<span class="project-count">{group.projects.length} projects</span>
</button>
</li>
{/each}
{#if filtered.length === 0}
<li class="no-results">No groups match "{query}"</li>
{/if}
</ul>
</div>
</div>
{/if}
<style>
.palette-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
padding-top: 15vh;
z-index: 1000;
}
.palette {
width: 460px;
max-height: 360px;
background: var(--ctp-mantle);
border: 1px solid var(--ctp-surface1);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
overflow: hidden;
align-self: flex-start;
}
.palette-input {
padding: 12px 16px;
background: transparent;
border: none;
border-bottom: 1px solid var(--ctp-surface0);
color: var(--ctp-text);
font-size: 0.95rem;
outline: none;
}
.palette-input::placeholder {
color: var(--ctp-overlay0);
}
.palette-results {
list-style: none;
margin: 0;
padding: 4px;
overflow-y: auto;
}
.palette-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 12px;
background: transparent;
border: none;
color: var(--ctp-text);
font-size: 0.85rem;
cursor: pointer;
border-radius: 4px;
transition: background 0.1s;
}
.palette-item:hover {
background: var(--ctp-surface0);
}
.palette-item.active {
background: var(--ctp-surface0);
border-left: 3px solid var(--ctp-blue);
}
.group-name {
font-weight: 600;
}
.project-count {
color: var(--ctp-overlay0);
font-size: 0.75rem;
}
.no-results {
padding: 12px;
color: var(--ctp-overlay0);
font-size: 0.85rem;
text-align: center;
}
</style>

View file

@ -0,0 +1,14 @@
<script lang="ts">
import ContextPane from '../Context/ContextPane.svelte';
</script>
<div class="context-tab">
<ContextPane onExit={() => {}} />
</div>
<style>
.context-tab {
height: 100%;
overflow: hidden;
}
</style>

View file

@ -0,0 +1,159 @@
<script lang="ts">
import { getActiveProjectId, getActiveGroup } from '../../stores/workspace.svelte';
import { discoverMarkdownFiles, type MdFileEntry } from '../../adapters/groups-bridge';
import MarkdownPane from '../Markdown/MarkdownPane.svelte';
let files = $state<MdFileEntry[]>([]);
let selectedPath = $state<string | null>(null);
let loading = $state(false);
let activeProjectId = $derived(getActiveProjectId());
let activeGroup = $derived(getActiveGroup());
let activeProject = $derived(
activeGroup?.projects.find(p => p.id === activeProjectId),
);
$effect(() => {
const project = activeProject;
if (project) {
loadFiles(project.cwd);
} else {
files = [];
selectedPath = null;
}
});
async function loadFiles(cwd: string) {
loading = true;
try {
files = await discoverMarkdownFiles(cwd);
// Auto-select first priority file
const priority = files.find(f => f.priority);
selectedPath = priority?.path ?? files[0]?.path ?? null;
} catch (e) {
console.warn('Failed to discover markdown files:', e);
files = [];
} finally {
loading = false;
}
}
</script>
<div class="docs-tab">
<aside class="file-picker">
<h3 class="picker-title">
{activeProject?.name ?? 'No project'} — Docs
</h3>
{#if loading}
<div class="loading">Scanning...</div>
{:else if files.length === 0}
<div class="empty">No markdown files found</div>
{:else}
<ul class="file-list">
{#each files as file}
<li>
<button
class="file-btn"
class:active={selectedPath === file.path}
class:priority={file.priority}
onclick={() => (selectedPath = file.path)}
>
{file.name}
</button>
</li>
{/each}
</ul>
{/if}
</aside>
<main class="doc-content">
{#if selectedPath}
<MarkdownPane paneId="docs-viewer" filePath={selectedPath} />
{:else}
<div class="no-selection">Select a document from the sidebar</div>
{/if}
</main>
</div>
<style>
.docs-tab {
display: flex;
height: 100%;
overflow: hidden;
}
.file-picker {
width: 220px;
flex-shrink: 0;
background: var(--ctp-mantle);
border-right: 1px solid var(--ctp-surface0);
overflow-y: auto;
padding: 8px 0;
}
.picker-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--ctp-subtext0);
padding: 4px 12px 8px;
margin: 0;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.file-list {
list-style: none;
margin: 0;
padding: 0;
}
.file-btn {
display: block;
width: 100%;
padding: 5px 12px;
background: transparent;
border: none;
color: var(--ctp-subtext0);
font-size: 0.8rem;
text-align: left;
cursor: pointer;
transition: color 0.1s, background 0.1s;
}
.file-btn:hover {
background: var(--ctp-surface0);
color: var(--ctp-text);
}
.file-btn.active {
background: var(--ctp-surface0);
color: var(--ctp-text);
font-weight: 600;
}
.file-btn.priority {
color: var(--ctp-blue);
}
.file-btn.priority.active {
color: var(--ctp-blue);
}
.doc-content {
flex: 1;
overflow: auto;
}
.loading, .empty, .no-selection {
display: flex;
align-items: center;
justify-content: center;
color: var(--ctp-overlay0);
font-size: 0.85rem;
padding: 20px;
}
.no-selection {
height: 100%;
}
</style>

View file

@ -0,0 +1,56 @@
<script lang="ts">
import { getActiveTab, setActiveTab, type WorkspaceTab } from '../../stores/workspace.svelte';
const tabs: { id: WorkspaceTab; label: string; shortcut: string }[] = [
{ id: 'sessions', label: 'Sessions', shortcut: 'Alt+1' },
{ id: 'docs', label: 'Docs', shortcut: 'Alt+2' },
{ id: 'context', label: 'Context', shortcut: 'Alt+3' },
{ id: 'settings', label: 'Settings', shortcut: 'Alt+4' },
];
</script>
<nav class="global-tab-bar">
{#each tabs as tab}
<button
class="tab-btn"
class:active={getActiveTab() === tab.id}
onclick={() => setActiveTab(tab.id)}
title={tab.shortcut}
>
{tab.label}
</button>
{/each}
</nav>
<style>
.global-tab-bar {
display: flex;
gap: 2px;
padding: 4px 8px;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.tab-btn {
padding: 4px 14px;
background: transparent;
border: none;
color: var(--ctp-subtext0);
font-size: 0.8rem;
cursor: pointer;
border-radius: 4px 4px 0 0;
transition: color 0.15s, background 0.15s;
}
.tab-btn:hover {
color: var(--ctp-text);
background: var(--ctp-surface0);
}
.tab-btn.active {
color: var(--ctp-text);
background: var(--ctp-base);
border-bottom: 2px solid var(--ctp-blue);
}
</style>

View file

@ -0,0 +1,77 @@
<script lang="ts">
import type { ProjectConfig } from '../../types/groups';
import { PROJECT_ACCENTS } from '../../types/groups';
import ProjectHeader from './ProjectHeader.svelte';
import ClaudeSession from './ClaudeSession.svelte';
import TerminalTabs from './TerminalTabs.svelte';
import TeamAgentsPanel from './TeamAgentsPanel.svelte';
interface Props {
project: ProjectConfig;
slotIndex: number;
active: boolean;
onactivate: () => void;
}
let { project, slotIndex, active, onactivate }: Props = $props();
let accentVar = $derived(PROJECT_ACCENTS[slotIndex % PROJECT_ACCENTS.length]);
let mainSessionId = $state<string | null>(null);
</script>
<div
class="project-box"
class:active
style="--accent: var({accentVar})"
>
<ProjectHeader
{project}
{slotIndex}
{active}
onclick={onactivate}
/>
<div class="project-session-area">
<ClaudeSession {project} onsessionid={(id) => mainSessionId = id} />
{#if mainSessionId}
<TeamAgentsPanel {mainSessionId} />
{/if}
</div>
<div class="project-terminal-area">
<TerminalTabs {project} />
</div>
</div>
<style>
.project-box {
display: flex;
flex-direction: column;
min-width: 480px;
scroll-snap-align: start;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface0);
border-radius: 6px;
overflow: hidden;
transition: border-color 0.15s;
}
.project-box.active {
border-color: var(--accent);
}
.project-session-area {
flex: 1;
min-height: 200px;
overflow: hidden;
position: relative;
display: flex;
}
.project-terminal-area {
flex-shrink: 0;
min-height: 120px;
border-top: 1px solid var(--ctp-surface0);
}
</style>

View file

@ -0,0 +1,85 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getEnabledProjects, getActiveProjectId, setActiveProject } from '../../stores/workspace.svelte';
import ProjectBox from './ProjectBox.svelte';
let containerEl: HTMLDivElement | undefined = $state();
let containerWidth = $state(0);
let projects = $derived(getEnabledProjects());
let activeProjectId = $derived(getActiveProjectId());
let visibleCount = $derived(
Math.min(projects.length, Math.max(1, Math.floor(containerWidth / 520))),
);
let observer: ResizeObserver | undefined;
onMount(() => {
if (containerEl) {
containerWidth = containerEl.clientWidth;
observer = new ResizeObserver(entries => {
for (const entry of entries) {
containerWidth = entry.contentRect.width;
}
});
observer.observe(containerEl);
}
});
onDestroy(() => {
observer?.disconnect();
});
</script>
<div
class="project-grid"
bind:this={containerEl}
style="--visible-count: {visibleCount}"
>
{#each projects as project, i (project.id)}
<div class="project-slot">
<ProjectBox
{project}
slotIndex={i}
active={activeProjectId === project.id}
onactivate={() => setActiveProject(project.id)}
/>
</div>
{/each}
{#if projects.length === 0}
<div class="empty-state">
No enabled projects in this group. Go to Settings to add projects.
</div>
{/if}
</div>
<style>
.project-grid {
display: flex;
gap: 4px;
height: 100%;
overflow-x: auto;
scroll-snap-type: x mandatory;
padding: 4px;
}
.project-slot {
flex: 0 0 calc((100% - (var(--visible-count) - 1) * 4px) / var(--visible-count));
min-width: 480px;
display: flex;
}
.project-slot > :global(*) {
flex: 1;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
color: var(--ctp-overlay0);
font-size: 0.9rem;
}
</style>

View file

@ -0,0 +1,74 @@
<script lang="ts">
import type { ProjectConfig } from '../../types/groups';
import { PROJECT_ACCENTS } from '../../types/groups';
interface Props {
project: ProjectConfig;
slotIndex: number;
active: boolean;
onclick: () => void;
}
let { project, slotIndex, active, onclick }: Props = $props();
let accentVar = $derived(PROJECT_ACCENTS[slotIndex % PROJECT_ACCENTS.length]);
</script>
<button
class="project-header"
class:active
style="--accent: var({accentVar})"
{onclick}
>
<span class="project-icon">{project.icon || '\uf120'}</span>
<span class="project-name">{project.name}</span>
<span class="project-id">({project.identifier})</span>
</button>
<style>
.project-header {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
height: 28px;
background: var(--ctp-mantle);
border: none;
border-bottom: 2px solid transparent;
color: var(--ctp-subtext0);
font-size: 0.78rem;
cursor: pointer;
flex-shrink: 0;
width: 100%;
text-align: left;
transition: color 0.15s, border-color 0.15s;
}
.project-header:hover {
color: var(--ctp-text);
}
.project-header.active {
color: var(--ctp-text);
border-bottom-color: var(--accent);
}
.project-icon {
font-family: 'NerdFontsSymbols Nerd Font', 'Symbols Nerd Font Mono', monospace;
font-size: 0.9rem;
color: var(--accent);
}
.project-name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.project-id {
color: var(--ctp-overlay0);
font-size: 0.7rem;
white-space: nowrap;
}
</style>

View file

@ -0,0 +1,275 @@
<script lang="ts">
import {
getActiveProjectId,
getActiveGroup,
getActiveGroupId,
getAllGroups,
updateProject,
addProject,
removeProject,
addGroup,
removeGroup,
switchGroup,
} from '../../stores/workspace.svelte';
import { deriveIdentifier } from '../../types/groups';
let activeGroupId = $derived(getActiveGroupId());
let activeGroup = $derived(getActiveGroup());
let activeProjectId = $derived(getActiveProjectId());
let groups = $derived(getAllGroups());
let editingProject = $derived(
activeGroup?.projects.find(p => p.id === activeProjectId),
);
// New project form
let newName = $state('');
let newCwd = $state('');
function handleAddProject() {
if (!newName.trim() || !newCwd.trim() || !activeGroupId) return;
const id = crypto.randomUUID();
addProject(activeGroupId, {
id,
name: newName.trim(),
identifier: deriveIdentifier(newName.trim()),
description: '',
icon: '\uf120',
cwd: newCwd.trim(),
profile: 'default',
enabled: true,
});
newName = '';
newCwd = '';
}
// New group form
let newGroupName = $state('');
function handleAddGroup() {
if (!newGroupName.trim()) return;
const id = crypto.randomUUID();
addGroup({ id, name: newGroupName.trim(), projects: [] });
newGroupName = '';
}
</script>
<div class="settings-tab">
<section class="settings-section">
<h2>Groups</h2>
<div class="group-list">
{#each groups as group}
<div class="group-row" class:active={group.id === activeGroupId}>
<button class="group-name" onclick={() => switchGroup(group.id)}>
{group.name}
</button>
<span class="group-count">{group.projects.length} projects</span>
{#if groups.length > 1}
<button class="btn-danger" onclick={() => removeGroup(group.id)}>Remove</button>
{/if}
</div>
{/each}
</div>
<div class="add-form">
<input bind:value={newGroupName} placeholder="New group name" />
<button class="btn-primary" onclick={handleAddGroup} disabled={!newGroupName.trim()}>
Add Group
</button>
</div>
</section>
{#if activeGroup}
<section class="settings-section">
<h2>Projects in "{activeGroup.name}"</h2>
{#each activeGroup.projects as project}
<div class="project-settings-row">
<div class="project-field">
<label>Name</label>
<input
value={project.name}
onchange={e => updateProject(activeGroupId, project.id, { name: (e.target as HTMLInputElement).value })}
/>
</div>
<div class="project-field">
<label>CWD</label>
<input
value={project.cwd}
onchange={e => updateProject(activeGroupId, project.id, { cwd: (e.target as HTMLInputElement).value })}
/>
</div>
<div class="project-field">
<label>Icon</label>
<input
value={project.icon}
onchange={e => updateProject(activeGroupId, project.id, { icon: (e.target as HTMLInputElement).value })}
style="width: 60px"
/>
</div>
<div class="project-field">
<label>Enabled</label>
<input
type="checkbox"
checked={project.enabled}
onchange={e => updateProject(activeGroupId, project.id, { enabled: (e.target as HTMLInputElement).checked })}
/>
</div>
<button class="btn-danger" onclick={() => removeProject(activeGroupId, project.id)}>
Remove
</button>
</div>
{/each}
{#if activeGroup.projects.length < 5}
<div class="add-form">
<input bind:value={newName} placeholder="Project name" />
<input bind:value={newCwd} placeholder="/path/to/project" />
<button class="btn-primary" onclick={handleAddProject} disabled={!newName.trim() || !newCwd.trim()}>
Add Project
</button>
</div>
{:else}
<p class="limit-notice">Maximum 5 projects per group reached.</p>
{/if}
</section>
{/if}
</div>
<style>
.settings-tab {
padding: 16px 24px;
overflow-y: auto;
height: 100%;
max-width: 900px;
}
h2 {
font-size: 1rem;
font-weight: 600;
color: var(--ctp-text);
margin: 0 0 12px;
}
.settings-section {
margin-bottom: 24px;
}
.group-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
.group-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--ctp-surface0);
border-radius: 4px;
}
.group-row.active {
border-left: 3px solid var(--ctp-blue);
}
.group-name {
background: transparent;
border: none;
color: var(--ctp-text);
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
flex: 1;
text-align: left;
}
.group-count {
color: var(--ctp-overlay0);
font-size: 0.75rem;
}
.project-settings-row {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 8px 10px;
background: var(--ctp-surface0);
border-radius: 4px;
margin-bottom: 4px;
flex-wrap: wrap;
}
.project-field {
display: flex;
flex-direction: column;
gap: 2px;
}
.project-field label {
font-size: 0.7rem;
color: var(--ctp-overlay0);
text-transform: uppercase;
}
.project-field input[type="text"],
.project-field input:not([type]) {
padding: 4px 8px;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 3px;
color: var(--ctp-text);
font-size: 0.8rem;
}
.add-form {
display: flex;
gap: 8px;
align-items: center;
margin-top: 8px;
}
.add-form input {
padding: 5px 10px;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 3px;
color: var(--ctp-text);
font-size: 0.8rem;
flex: 1;
}
.btn-primary {
padding: 5px 14px;
background: var(--ctp-blue);
color: var(--ctp-base);
border: none;
border-radius: 3px;
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-danger {
padding: 4px 10px;
background: transparent;
color: var(--ctp-red);
border: 1px solid var(--ctp-red);
border-radius: 3px;
font-size: 0.75rem;
cursor: pointer;
}
.limit-notice {
color: var(--ctp-overlay0);
font-size: 0.8rem;
font-style: italic;
}
</style>

View file

@ -0,0 +1,91 @@
<script lang="ts">
import { getAgentSessions, getChildSessions, type AgentSession } from '../../stores/agents.svelte';
import AgentCard from './AgentCard.svelte';
interface Props {
/** The main Claude session ID for this project */
mainSessionId: string;
}
let { mainSessionId }: Props = $props();
// Get subagent sessions spawned by the main session
let childSessions = $derived(getChildSessions(mainSessionId));
let hasAgents = $derived(childSessions.length > 0);
let expanded = $state(true);
</script>
{#if hasAgents}
<div class="team-agents-panel">
<button class="panel-header" onclick={() => expanded = !expanded}>
<span class="header-icon">{expanded ? '▾' : '▸'}</span>
<span class="header-title">Team Agents</span>
<span class="agent-count">{childSessions.length}</span>
</button>
{#if expanded}
<div class="agent-list">
{#each childSessions as child (child.id)}
<AgentCard session={child} />
{/each}
</div>
{/if}
</div>
{/if}
<style>
.team-agents-panel {
border-left: 1px solid var(--ctp-surface0);
background: var(--ctp-mantle);
display: flex;
flex-direction: column;
overflow: hidden;
width: 220px;
flex-shrink: 0;
}
.panel-header {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 8px;
background: transparent;
border: none;
color: var(--ctp-subtext0);
font-size: 0.72rem;
cursor: pointer;
border-bottom: 1px solid var(--ctp-surface0);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.panel-header:hover {
color: var(--ctp-text);
}
.header-icon {
font-size: 0.65rem;
}
.header-title {
font-weight: 600;
}
.agent-count {
margin-left: auto;
background: var(--ctp-surface0);
padding: 0 5px;
border-radius: 8px;
font-size: 0.65rem;
color: var(--ctp-overlay1);
}
.agent-list {
display: flex;
flex-direction: column;
gap: 3px;
padding: 4px;
overflow-y: auto;
flex: 1;
}
</style>

View file

@ -0,0 +1,217 @@
<script lang="ts">
import type { ProjectConfig } from '../../types/groups';
import {
getTerminalTabs,
addTerminalTab,
removeTerminalTab,
type TerminalTab,
} from '../../stores/workspace.svelte';
import TerminalPane from '../Terminal/TerminalPane.svelte';
interface Props {
project: ProjectConfig;
}
let { project }: Props = $props();
let tabs = $derived(getTerminalTabs(project.id));
let activeTabId = $state<string | null>(null);
// Auto-select first tab
$effect(() => {
if (tabs.length > 0 && (!activeTabId || !tabs.find(t => t.id === activeTabId))) {
activeTabId = tabs[0].id;
}
if (tabs.length === 0) {
activeTabId = null;
}
});
function addShellTab() {
const id = crypto.randomUUID();
const num = tabs.filter(t => t.type === 'shell').length + 1;
addTerminalTab(project.id, {
id,
title: `Shell ${num}`,
type: 'shell',
});
activeTabId = id;
}
function closeTab(tabId: string) {
removeTerminalTab(project.id, tabId);
}
function handleTabExit(tabId: string) {
closeTab(tabId);
}
</script>
<div class="terminal-tabs">
<div class="tab-bar">
{#each tabs as tab (tab.id)}
<div
class="tab"
class:active={activeTabId === tab.id}
role="tab"
tabindex="0"
onclick={() => (activeTabId = tab.id)}
onkeydown={e => e.key === 'Enter' && (activeTabId = tab.id)}
>
<span class="tab-title">{tab.title}</span>
<button
class="tab-close"
onclick={(e) => { e.stopPropagation(); closeTab(tab.id); }}
title="Close"
>×</button>
</div>
{/each}
<button class="tab-add" onclick={addShellTab} title="New shell (Ctrl+N)">+</button>
</div>
<div class="tab-content">
{#each tabs as tab (tab.id)}
<div class="tab-pane" class:visible={activeTabId === tab.id}>
{#if activeTabId === tab.id}
<TerminalPane
cwd={project.cwd}
shell={tab.type === 'ssh' ? '/usr/bin/ssh' : undefined}
onExit={() => handleTabExit(tab.id)}
/>
{/if}
</div>
{/each}
{#if tabs.length === 0}
<div class="empty-terminals">
<button class="add-first" onclick={addShellTab}>
+ Open terminal
</button>
</div>
{/if}
</div>
</div>
<style>
.terminal-tabs {
display: flex;
flex-direction: column;
height: 100%;
}
.tab-bar {
display: flex;
align-items: center;
gap: 1px;
height: 26px;
padding: 0 4px;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
overflow-x: auto;
flex-shrink: 0;
}
.tab {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: transparent;
border: none;
color: var(--ctp-overlay1);
font-size: 0.72rem;
cursor: pointer;
border-radius: 3px 3px 0 0;
white-space: nowrap;
transition: color 0.1s, background 0.1s;
}
.tab:hover {
color: var(--ctp-text);
background: var(--ctp-surface0);
}
.tab.active {
color: var(--ctp-text);
background: var(--ctp-base);
border-bottom: 1px solid var(--ctp-blue);
}
.tab-icon {
font-family: 'NerdFontsSymbols Nerd Font', 'Symbols Nerd Font Mono', monospace;
font-size: 0.75rem;
}
.tab-title {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-close {
background: transparent;
border: none;
color: var(--ctp-overlay0);
font-size: 0.8rem;
cursor: pointer;
padding: 0 2px;
line-height: 1;
}
.tab-close:hover {
color: var(--ctp-red);
}
.tab-add {
background: transparent;
border: none;
color: var(--ctp-overlay0);
font-size: 0.85rem;
cursor: pointer;
padding: 2px 6px;
border-radius: 3px;
}
.tab-add:hover {
color: var(--ctp-text);
background: var(--ctp-surface0);
}
.tab-content {
flex: 1;
position: relative;
overflow: hidden;
}
.tab-pane {
position: absolute;
inset: 0;
display: none;
}
.tab-pane.visible {
display: block;
}
.empty-terminals {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.add-first {
padding: 6px 16px;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 4px;
color: var(--ctp-subtext0);
font-size: 0.8rem;
cursor: pointer;
}
.add-first:hover {
background: var(--ctp-surface1);
color: var(--ctp-text);
}
</style>