feat(agents): role-specific tabs + bttask Tauri backend
- TaskBoardTab: kanban board (5 columns, CRUD, comments, 5s poll) for Manager - ArchitectureTab: PlantUML viewer/editor (4 templates, plantuml.com) for Architect - TestingTab: Selenium screenshots + test file discovery for Tester - bttask.rs: Rust backend (list, create, update_status, delete, comments) - bttask-bridge.ts: TypeScript IPC adapter - ProjectBox: conditional role tabs (isAgent && agentRole), PERSISTED-LAZY
This commit is contained in:
parent
0c28f204c7
commit
2ca7756a74
9 changed files with 1812 additions and 2 deletions
169
v2/src-tauri/src/bttask.rs
Normal file
169
v2/src-tauri/src/bttask.rs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
// bttask — Read access to task board SQLite tables in btmsg.db
|
||||
// Tasks table created by bttask CLI, shared DB with btmsg
|
||||
|
||||
use rusqlite::{params, Connection, OpenFlags};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn db_path() -> PathBuf {
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("bterminal")
|
||||
.join("btmsg.db")
|
||||
}
|
||||
|
||||
fn open_db() -> Result<Connection, String> {
|
||||
let path = db_path();
|
||||
if !path.exists() {
|
||||
return Err("btmsg database not found".into());
|
||||
}
|
||||
Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_WRITE)
|
||||
.map_err(|e| format!("Failed to open btmsg.db: {e}"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Task {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub status: String,
|
||||
pub priority: String,
|
||||
pub assigned_to: Option<String>,
|
||||
pub created_by: String,
|
||||
pub group_id: String,
|
||||
pub parent_task_id: Option<String>,
|
||||
pub sort_order: i32,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TaskComment {
|
||||
pub id: String,
|
||||
pub task_id: String,
|
||||
pub agent_id: String,
|
||||
pub content: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// Get all tasks for a group
|
||||
pub fn list_tasks(group_id: &str) -> Result<Vec<Task>, String> {
|
||||
let db = open_db()?;
|
||||
let mut stmt = db
|
||||
.prepare(
|
||||
"SELECT id, title, description, status, priority, assigned_to,
|
||||
created_by, group_id, parent_task_id, sort_order,
|
||||
created_at, updated_at
|
||||
FROM tasks WHERE group_id = ?1
|
||||
ORDER BY sort_order ASC, created_at DESC",
|
||||
)
|
||||
.map_err(|e| format!("Query error: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![group_id], |row| {
|
||||
Ok(Task {
|
||||
id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
description: row.get::<_, String>(2).unwrap_or_default(),
|
||||
status: row.get::<_, String>(3).unwrap_or_else(|_| "todo".into()),
|
||||
priority: row.get::<_, String>(4).unwrap_or_else(|_| "medium".into()),
|
||||
assigned_to: row.get(5)?,
|
||||
created_by: row.get(6)?,
|
||||
group_id: row.get(7)?,
|
||||
parent_task_id: row.get(8)?,
|
||||
sort_order: row.get::<_, i32>(9).unwrap_or(0),
|
||||
created_at: row.get::<_, String>(10).unwrap_or_default(),
|
||||
updated_at: row.get::<_, String>(11).unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Query error: {e}"))?;
|
||||
|
||||
rows.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("Row error: {e}"))
|
||||
}
|
||||
|
||||
/// Get comments for a task
|
||||
pub fn task_comments(task_id: &str) -> Result<Vec<TaskComment>, String> {
|
||||
let db = open_db()?;
|
||||
let mut stmt = db
|
||||
.prepare(
|
||||
"SELECT id, task_id, agent_id, content, created_at
|
||||
FROM task_comments WHERE task_id = ?1
|
||||
ORDER BY created_at ASC",
|
||||
)
|
||||
.map_err(|e| format!("Query error: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![task_id], |row| {
|
||||
Ok(TaskComment {
|
||||
id: row.get(0)?,
|
||||
task_id: row.get(1)?,
|
||||
agent_id: row.get(2)?,
|
||||
content: row.get(3)?,
|
||||
created_at: row.get::<_, String>(4).unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Query error: {e}"))?;
|
||||
|
||||
rows.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("Row error: {e}"))
|
||||
}
|
||||
|
||||
/// Update task status
|
||||
pub fn update_task_status(task_id: &str, status: &str) -> Result<(), String> {
|
||||
let valid = ["todo", "progress", "review", "done", "blocked"];
|
||||
if !valid.contains(&status) {
|
||||
return Err(format!("Invalid status '{}'. Valid: {:?}", status, valid));
|
||||
}
|
||||
let db = open_db()?;
|
||||
db.execute(
|
||||
"UPDATE tasks SET status = ?1, updated_at = datetime('now') WHERE id = ?2",
|
||||
params![status, task_id],
|
||||
)
|
||||
.map_err(|e| format!("Update error: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a comment to a task
|
||||
pub fn add_comment(task_id: &str, agent_id: &str, content: &str) -> Result<String, String> {
|
||||
let db = open_db()?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
db.execute(
|
||||
"INSERT INTO task_comments (id, task_id, agent_id, content) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![id, task_id, agent_id, content],
|
||||
)
|
||||
.map_err(|e| format!("Insert error: {e}"))?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Create a new task
|
||||
pub fn create_task(
|
||||
title: &str,
|
||||
description: &str,
|
||||
priority: &str,
|
||||
group_id: &str,
|
||||
created_by: &str,
|
||||
assigned_to: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let db = open_db()?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
db.execute(
|
||||
"INSERT INTO tasks (id, title, description, priority, group_id, created_by, assigned_to)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![id, title, description, priority, group_id, created_by, assigned_to],
|
||||
)
|
||||
.map_err(|e| format!("Insert error: {e}"))?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Delete a task
|
||||
pub fn delete_task(task_id: &str) -> Result<(), String> {
|
||||
let db = open_db()?;
|
||||
db.execute("DELETE FROM task_comments WHERE task_id = ?1", params![task_id])
|
||||
.map_err(|e| format!("Delete comments error: {e}"))?;
|
||||
db.execute("DELETE FROM tasks WHERE id = ?1", params![task_id])
|
||||
.map_err(|e| format!("Delete task error: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
38
v2/src-tauri/src/commands/bttask.rs
Normal file
38
v2/src-tauri/src/commands/bttask.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
use crate::bttask;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn bttask_list(group_id: String) -> Result<Vec<bttask::Task>, String> {
|
||||
bttask::list_tasks(&group_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn bttask_comments(task_id: String) -> Result<Vec<bttask::TaskComment>, String> {
|
||||
bttask::task_comments(&task_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn bttask_update_status(task_id: String, status: String) -> Result<(), String> {
|
||||
bttask::update_task_status(&task_id, &status)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn bttask_add_comment(task_id: String, agent_id: String, content: String) -> Result<String, String> {
|
||||
bttask::add_comment(&task_id, &agent_id, &content)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn bttask_create(
|
||||
title: String,
|
||||
description: String,
|
||||
priority: String,
|
||||
group_id: String,
|
||||
created_by: String,
|
||||
assigned_to: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
bttask::create_task(&title, &description, &priority, &group_id, &created_by, assigned_to.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn bttask_delete(task_id: String) -> Result<(), String> {
|
||||
bttask::delete_task(&task_id)
|
||||
}
|
||||
|
|
@ -10,3 +10,4 @@ pub mod files;
|
|||
pub mod remote;
|
||||
pub mod misc;
|
||||
pub mod btmsg;
|
||||
pub mod bttask;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
mod btmsg;
|
||||
mod bttask;
|
||||
mod commands;
|
||||
mod ctx;
|
||||
mod event_sink;
|
||||
|
|
@ -139,6 +140,13 @@ pub fn run() {
|
|||
commands::btmsg::btmsg_channel_send,
|
||||
commands::btmsg::btmsg_create_channel,
|
||||
commands::btmsg::btmsg_add_channel_member,
|
||||
// bttask (task board)
|
||||
commands::bttask::bttask_list,
|
||||
commands::bttask::bttask_comments,
|
||||
commands::bttask::bttask_update_status,
|
||||
commands::bttask::bttask_add_comment,
|
||||
commands::bttask::bttask_create,
|
||||
commands::bttask::bttask_delete,
|
||||
// Misc
|
||||
commands::misc::cli_get_group,
|
||||
commands::misc::open_url,
|
||||
|
|
|
|||
57
v2/src/lib/adapters/bttask-bridge.ts
Normal file
57
v2/src/lib/adapters/bttask-bridge.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// bttask Bridge — Tauri IPC adapter for task board
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: 'todo' | 'progress' | 'review' | 'done' | 'blocked';
|
||||
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||
assignedTo: string | null;
|
||||
createdBy: string;
|
||||
groupId: string;
|
||||
parentTaskId: string | null;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TaskComment {
|
||||
id: string;
|
||||
taskId: string;
|
||||
agentId: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function listTasks(groupId: string): Promise<Task[]> {
|
||||
return invoke<Task[]>('bttask_list', { groupId });
|
||||
}
|
||||
|
||||
export async function getTaskComments(taskId: string): Promise<TaskComment[]> {
|
||||
return invoke<TaskComment[]>('bttask_comments', { taskId });
|
||||
}
|
||||
|
||||
export async function updateTaskStatus(taskId: string, status: string): Promise<void> {
|
||||
return invoke('bttask_update_status', { taskId, status });
|
||||
}
|
||||
|
||||
export async function addTaskComment(taskId: string, agentId: string, content: string): Promise<string> {
|
||||
return invoke<string>('bttask_add_comment', { taskId, agentId, content });
|
||||
}
|
||||
|
||||
export async function createTask(
|
||||
title: string,
|
||||
description: string,
|
||||
priority: string,
|
||||
groupId: string,
|
||||
createdBy: string,
|
||||
assignedTo?: string,
|
||||
): Promise<string> {
|
||||
return invoke<string>('bttask_create', { title, description, priority, groupId, createdBy, assignedTo });
|
||||
}
|
||||
|
||||
export async function deleteTask(taskId: string): Promise<void> {
|
||||
return invoke('bttask_delete', { taskId });
|
||||
}
|
||||
493
v2/src/lib/components/Workspace/ArchitectureTab.svelte
Normal file
493
v2/src/lib/components/Workspace/ArchitectureTab.svelte
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { listDirectoryChildren, readFileContent, writeFileContent, type DirEntry } from '../../adapters/files-bridge';
|
||||
|
||||
interface Props {
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
let { cwd }: Props = $props();
|
||||
|
||||
/** Directory where .puml files are stored */
|
||||
const ARCH_DIR = '.architecture';
|
||||
|
||||
let diagrams = $state<DirEntry[]>([]);
|
||||
let selectedFile = $state<string | null>(null);
|
||||
let pumlSource = $state('');
|
||||
let svgUrl = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let editing = $state(false);
|
||||
|
||||
// New diagram form
|
||||
let showNewForm = $state(false);
|
||||
let newName = $state('');
|
||||
|
||||
const DIAGRAM_TEMPLATES: Record<string, string> = {
|
||||
'Class Diagram': `@startuml
|
||||
title Class Diagram
|
||||
|
||||
class Service {
|
||||
+start()
|
||||
+stop()
|
||||
}
|
||||
|
||||
class Database {
|
||||
+query()
|
||||
+connect()
|
||||
}
|
||||
|
||||
Service --> Database : uses
|
||||
|
||||
@enduml`,
|
||||
'Sequence Diagram': `@startuml
|
||||
title Sequence Diagram
|
||||
|
||||
actor User
|
||||
participant "Frontend" as FE
|
||||
participant "Backend" as BE
|
||||
participant "Database" as DB
|
||||
|
||||
User -> FE: action
|
||||
FE -> BE: request
|
||||
BE -> DB: query
|
||||
DB --> BE: result
|
||||
BE --> FE: response
|
||||
FE --> User: display
|
||||
|
||||
@enduml`,
|
||||
'State Diagram': `@startuml
|
||||
title State Diagram
|
||||
|
||||
[*] --> Idle
|
||||
Idle --> Running : start
|
||||
Running --> Idle : stop
|
||||
Running --> Error : failure
|
||||
Error --> Idle : reset
|
||||
|
||||
@enduml`,
|
||||
'Component Diagram': `@startuml
|
||||
title Component Diagram
|
||||
|
||||
package "Frontend" {
|
||||
[UI Components]
|
||||
[State Store]
|
||||
}
|
||||
|
||||
package "Backend" {
|
||||
[API Server]
|
||||
[Database]
|
||||
}
|
||||
|
||||
[UI Components] --> [State Store]
|
||||
[State Store] --> [API Server]
|
||||
[API Server] --> [Database]
|
||||
|
||||
@enduml`,
|
||||
};
|
||||
|
||||
let archPath = $derived(`${cwd}/${ARCH_DIR}`);
|
||||
|
||||
async function loadDiagrams() {
|
||||
try {
|
||||
const entries = await listDirectoryChildren(archPath);
|
||||
diagrams = entries.filter(e => e.name.endsWith('.puml') || e.name.endsWith('.plantuml'));
|
||||
} catch {
|
||||
// Directory might not exist yet
|
||||
diagrams = [];
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadDiagrams();
|
||||
});
|
||||
|
||||
async function selectDiagram(filePath: string) {
|
||||
selectedFile = filePath;
|
||||
loading = true;
|
||||
error = null;
|
||||
editing = false;
|
||||
try {
|
||||
const content = await readFileContent(filePath);
|
||||
if (content.type === 'Text') {
|
||||
pumlSource = content.content;
|
||||
renderPlantUml(content.content);
|
||||
} else {
|
||||
error = 'Not a text file';
|
||||
}
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPlantUml(source: string) {
|
||||
// Encode PlantUML source for the server renderer
|
||||
const encoded = plantumlEncode(source);
|
||||
svgUrl = `https://www.plantuml.com/plantuml/svg/${encoded}`;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!selectedFile) return;
|
||||
try {
|
||||
await writeFileContent(selectedFile, pumlSource);
|
||||
renderPlantUml(pumlSource);
|
||||
editing = false;
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(template: string) {
|
||||
if (!newName.trim()) return;
|
||||
const fileName = newName.trim().replace(/\s+/g, '-').toLowerCase();
|
||||
const filePath = `${archPath}/${fileName}.puml`;
|
||||
try {
|
||||
await writeFileContent(filePath, template);
|
||||
showNewForm = false;
|
||||
newName = '';
|
||||
await loadDiagrams();
|
||||
await selectDiagram(filePath);
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
// PlantUML text encoder (deflate + base64 variant)
|
||||
// Uses the PlantUML encoding scheme: https://plantuml.com/text-encoding
|
||||
function plantumlEncode(text: string): string {
|
||||
const data = unescape(encodeURIComponent(text));
|
||||
const compressed = rawDeflate(data);
|
||||
return encode64(compressed);
|
||||
}
|
||||
|
||||
// Minimal raw deflate (store-only for simplicity — works with plantuml.com)
|
||||
function rawDeflate(data: string): string {
|
||||
// For PlantUML server compatibility, we use the ~h hex encoding as fallback
|
||||
// which is simpler and doesn't require deflate
|
||||
return data;
|
||||
}
|
||||
|
||||
// PlantUML base64 encoding (6-bit alphabet: 0-9A-Za-z-_)
|
||||
function encode64(data: string): string {
|
||||
// Use hex encoding prefix for simplicity (supported by PlantUML server)
|
||||
let hex = '~h';
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
hex += data.charCodeAt(i).toString(16).padStart(2, '0');
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="architecture-tab">
|
||||
<div class="arch-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-title">Diagrams</span>
|
||||
<button class="btn-new" onclick={() => showNewForm = !showNewForm}>
|
||||
{showNewForm ? '✕' : '+'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showNewForm}
|
||||
<div class="new-form">
|
||||
<input
|
||||
class="new-name-input"
|
||||
bind:value={newName}
|
||||
placeholder="Diagram name"
|
||||
/>
|
||||
<div class="template-list">
|
||||
{#each Object.entries(DIAGRAM_TEMPLATES) as [name, template]}
|
||||
<button
|
||||
class="template-btn"
|
||||
onclick={() => handleCreate(template)}
|
||||
disabled={!newName.trim()}
|
||||
>{name}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="diagram-list">
|
||||
{#each diagrams as file (file.path)}
|
||||
<button
|
||||
class="diagram-item"
|
||||
class:active={selectedFile === file.path}
|
||||
onclick={() => selectDiagram(file.path)}
|
||||
>
|
||||
<span class="diagram-icon">📐</span>
|
||||
<span class="diagram-name">{file.name.replace(/\.(puml|plantuml)$/, '')}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if diagrams.length === 0 && !showNewForm}
|
||||
<div class="empty-hint">
|
||||
No diagrams yet. The Architect agent creates .puml files in <code>{ARCH_DIR}/</code>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arch-content">
|
||||
{#if !selectedFile}
|
||||
<div class="empty-state">
|
||||
Select a diagram or create a new one
|
||||
</div>
|
||||
{:else if loading}
|
||||
<div class="empty-state">Loading...</div>
|
||||
{:else if error}
|
||||
<div class="empty-state error-text">{error}</div>
|
||||
{:else}
|
||||
<div class="content-header">
|
||||
<span class="file-name">{selectedFile?.split('/').pop()}</span>
|
||||
<button class="btn-toggle-edit" onclick={() => editing = !editing}>
|
||||
{editing ? 'Preview' : 'Edit'}
|
||||
</button>
|
||||
{#if editing}
|
||||
<button class="btn-save" onclick={handleSave}>Save</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if editing}
|
||||
<textarea
|
||||
class="puml-editor"
|
||||
bind:value={pumlSource}
|
||||
></textarea>
|
||||
{:else if svgUrl}
|
||||
<div class="diagram-preview">
|
||||
<img src={svgUrl} alt="PlantUML diagram" class="diagram-img" />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.architecture-tab {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.arch-sidebar {
|
||||
width: 10rem;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--ctp-surface0);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-subtext0);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.btn-new {
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-new:hover {
|
||||
background: var(--ctp-surface1);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.new-form {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.new-name-input {
|
||||
padding: 0.25rem 0.375rem;
|
||||
background: var(--ctp-base);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-text);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.template-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.template-btn {
|
||||
padding: 0.2rem 0.375rem;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.125rem;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.6rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.template-btn:hover:not(:disabled) {
|
||||
background: var(--ctp-surface1);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.template-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.diagram-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.diagram-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.3125rem 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.7rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.diagram-item:hover {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.diagram-item.active {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.diagram-icon { font-size: 0.8rem; }
|
||||
|
||||
.diagram-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.65rem;
|
||||
color: var(--ctp-overlay0);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.empty-hint code {
|
||||
background: var(--ctp-surface0);
|
||||
padding: 0.0625rem 0.25rem;
|
||||
border-radius: 0.125rem;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.arch-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.error-text { color: var(--ctp-red); }
|
||||
|
||||
.content-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-text);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-toggle-edit, .btn-save {
|
||||
padding: 0.2rem 0.5rem;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.65rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-toggle-edit:hover, .btn-save:hover {
|
||||
background: var(--ctp-surface1);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: var(--ctp-green);
|
||||
color: var(--ctp-base);
|
||||
border-color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.puml-editor {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
background: var(--ctp-mantle);
|
||||
border: none;
|
||||
color: var(--ctp-text);
|
||||
font-family: var(--term-font-family, monospace);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.puml-editor:focus { outline: none; }
|
||||
|
||||
.diagram-preview {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
background: var(--ctp-mantle);
|
||||
}
|
||||
|
||||
.diagram-img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -11,7 +11,10 @@
|
|||
import FilesTab from './FilesTab.svelte';
|
||||
import SshTab from './SshTab.svelte';
|
||||
import MemoriesTab from './MemoriesTab.svelte';
|
||||
import { getTerminalTabs } from '../../stores/workspace.svelte';
|
||||
import TaskBoardTab from './TaskBoardTab.svelte';
|
||||
import ArchitectureTab from './ArchitectureTab.svelte';
|
||||
import TestingTab from './TestingTab.svelte';
|
||||
import { getTerminalTabs, getActiveGroup } from '../../stores/workspace.svelte';
|
||||
import { getProjectHealth, setStallThreshold } from '../../stores/health.svelte';
|
||||
import { fsWatchProject, fsUnwatchProject, onFsWriteDetected, fsWatcherStatus } from '../../adapters/fs-watcher-bridge';
|
||||
import { recordExternalWrite } from '../../stores/conflicts.svelte';
|
||||
|
|
@ -31,9 +34,13 @@
|
|||
let mainSessionId = $state<string | null>(null);
|
||||
let terminalExpanded = $state(false);
|
||||
|
||||
type ProjectTab = 'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memories';
|
||||
type ProjectTab = 'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memories' | 'tasks' | 'architecture' | 'selenium' | 'tests';
|
||||
let activeTab = $state<ProjectTab>('model');
|
||||
|
||||
let activeGroup = $derived(getActiveGroup());
|
||||
let agentRole = $derived(project.agentRole);
|
||||
let isAgent = $derived(project.isAgent ?? false);
|
||||
|
||||
// PERSISTED-LAZY: track which tabs have been activated at least once
|
||||
let everActivated = $state<Record<string, boolean>>({});
|
||||
|
||||
|
|
@ -149,6 +156,16 @@
|
|||
class:active={activeTab === 'memories'}
|
||||
onclick={() => switchTab('memories')}
|
||||
>Memory</button>
|
||||
{#if isAgent && agentRole === 'manager'}
|
||||
<button class="ptab ptab-role" class:active={activeTab === 'tasks'} onclick={() => switchTab('tasks')}>Tasks</button>
|
||||
{/if}
|
||||
{#if isAgent && agentRole === 'architect'}
|
||||
<button class="ptab ptab-role" class:active={activeTab === 'architecture'} onclick={() => switchTab('architecture')}>Arch</button>
|
||||
{/if}
|
||||
{#if isAgent && agentRole === 'tester'}
|
||||
<button class="ptab ptab-role" class:active={activeTab === 'selenium'} onclick={() => switchTab('selenium')}>Selenium</button>
|
||||
<button class="ptab ptab-role" class:active={activeTab === 'tests'} onclick={() => switchTab('tests')}>Tests</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="project-content-area">
|
||||
|
|
@ -182,6 +199,26 @@
|
|||
<MemoriesTab />
|
||||
</div>
|
||||
{/if}
|
||||
{#if everActivated['tasks'] && activeGroup}
|
||||
<div class="content-pane" style:display={activeTab === 'tasks' ? 'flex' : 'none'}>
|
||||
<TaskBoardTab groupId={activeGroup.id} projectId={project.id} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if everActivated['architecture']}
|
||||
<div class="content-pane" style:display={activeTab === 'architecture' ? 'flex' : 'none'}>
|
||||
<ArchitectureTab cwd={project.cwd} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if everActivated['selenium']}
|
||||
<div class="content-pane" style:display={activeTab === 'selenium' ? 'flex' : 'none'}>
|
||||
<TestingTab cwd={project.cwd} mode="selenium" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if everActivated['tests']}
|
||||
<div class="content-pane" style:display={activeTab === 'tests' ? 'flex' : 'none'}>
|
||||
<TestingTab cwd={project.cwd} mode="tests" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="terminal-section" style:display={activeTab === 'model' ? 'flex' : 'none'}>
|
||||
|
|
@ -267,6 +304,14 @@
|
|||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.ptab-role {
|
||||
color: var(--ctp-mauve);
|
||||
}
|
||||
|
||||
.ptab-role:hover {
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.project-content-area {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
|
|
|||
572
v2/src/lib/components/Workspace/TaskBoardTab.svelte
Normal file
572
v2/src/lib/components/Workspace/TaskBoardTab.svelte
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { listTasks, updateTaskStatus, createTask, deleteTask, addTaskComment, type Task, type TaskComment, getTaskComments } from '../../adapters/bttask-bridge';
|
||||
|
||||
interface Props {
|
||||
groupId: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
let { groupId, projectId }: Props = $props();
|
||||
|
||||
const STATUSES = ['todo', 'progress', 'review', 'done', 'blocked'] as const;
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
todo: 'To Do',
|
||||
progress: 'In Progress',
|
||||
review: 'Review',
|
||||
done: 'Done',
|
||||
blocked: 'Blocked',
|
||||
};
|
||||
const STATUS_ICONS: Record<string, string> = {
|
||||
todo: '○',
|
||||
progress: '◐',
|
||||
review: '◑',
|
||||
done: '●',
|
||||
blocked: '✗',
|
||||
};
|
||||
const PRIORITY_LABELS: Record<string, string> = {
|
||||
critical: 'CRIT',
|
||||
high: 'HIGH',
|
||||
medium: 'MED',
|
||||
low: 'LOW',
|
||||
};
|
||||
|
||||
let tasks = $state<Task[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// New task form
|
||||
let showAddForm = $state(false);
|
||||
let newTitle = $state('');
|
||||
let newDesc = $state('');
|
||||
let newPriority = $state('medium');
|
||||
|
||||
// Expanded task detail
|
||||
let expandedTaskId = $state<string | null>(null);
|
||||
let taskComments = $state<TaskComment[]>([]);
|
||||
let newComment = $state('');
|
||||
|
||||
let tasksByStatus = $derived.by(() => {
|
||||
const map: Record<string, Task[]> = {};
|
||||
for (const s of STATUSES) map[s] = [];
|
||||
for (const t of tasks) {
|
||||
if (map[t.status]) map[t.status].push(t);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
let pendingCount = $derived(
|
||||
tasks.filter(t => t.status !== 'done').length
|
||||
);
|
||||
|
||||
async function loadTasks() {
|
||||
try {
|
||||
tasks = await listTasks(groupId);
|
||||
error = null;
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadTasks();
|
||||
pollTimer = setInterval(loadTasks, 5000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
});
|
||||
|
||||
async function handleStatusChange(taskId: string, newStatus: string) {
|
||||
try {
|
||||
await updateTaskStatus(taskId, newStatus);
|
||||
await loadTasks();
|
||||
} catch (e) {
|
||||
console.warn('Failed to update task status:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddTask() {
|
||||
if (!newTitle.trim()) return;
|
||||
try {
|
||||
await createTask(newTitle.trim(), newDesc.trim(), newPriority, groupId, 'admin');
|
||||
newTitle = '';
|
||||
newDesc = '';
|
||||
newPriority = 'medium';
|
||||
showAddForm = false;
|
||||
await loadTasks();
|
||||
} catch (e) {
|
||||
console.warn('Failed to create task:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(taskId: string) {
|
||||
try {
|
||||
await deleteTask(taskId);
|
||||
if (expandedTaskId === taskId) expandedTaskId = null;
|
||||
await loadTasks();
|
||||
} catch (e) {
|
||||
console.warn('Failed to delete task:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleExpand(taskId: string) {
|
||||
if (expandedTaskId === taskId) {
|
||||
expandedTaskId = null;
|
||||
return;
|
||||
}
|
||||
expandedTaskId = taskId;
|
||||
try {
|
||||
taskComments = await getTaskComments(taskId);
|
||||
} catch {
|
||||
taskComments = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddComment() {
|
||||
if (!expandedTaskId || !newComment.trim()) return;
|
||||
try {
|
||||
await addTaskComment(expandedTaskId, 'admin', newComment.trim());
|
||||
newComment = '';
|
||||
taskComments = await getTaskComments(expandedTaskId);
|
||||
} catch (e) {
|
||||
console.warn('Failed to add comment:', e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="task-board-tab">
|
||||
<div class="board-header">
|
||||
<span class="board-title">Task Board</span>
|
||||
<span class="pending-badge" class:all-done={pendingCount === 0}>
|
||||
{pendingCount === 0 ? 'All done' : `${pendingCount} pending`}
|
||||
</span>
|
||||
<button class="btn-add" onclick={() => showAddForm = !showAddForm}>
|
||||
{showAddForm ? '✕' : '+ Task'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showAddForm}
|
||||
<div class="add-task-form">
|
||||
<input
|
||||
class="task-title-input"
|
||||
bind:value={newTitle}
|
||||
placeholder="Task title"
|
||||
onkeydown={e => { if (e.key === 'Enter') handleAddTask(); }}
|
||||
/>
|
||||
<textarea
|
||||
class="task-desc-input"
|
||||
bind:value={newDesc}
|
||||
placeholder="Description (optional)"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<div class="form-row">
|
||||
<select class="priority-select" bind:value={newPriority}>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="critical">Critical</option>
|
||||
</select>
|
||||
<button class="btn-create" onclick={handleAddTask} disabled={!newTitle.trim()}>Create</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Loading tasks...</div>
|
||||
{:else if error}
|
||||
<div class="error">{error}</div>
|
||||
{:else}
|
||||
<div class="kanban">
|
||||
{#each STATUSES as status}
|
||||
<div class="kanban-column">
|
||||
<div class="column-header">
|
||||
<span class="column-icon">{STATUS_ICONS[status]}</span>
|
||||
<span class="column-title">{STATUS_LABELS[status]}</span>
|
||||
<span class="column-count">{tasksByStatus[status].length}</span>
|
||||
</div>
|
||||
<div class="column-cards">
|
||||
{#each tasksByStatus[status] as task (task.id)}
|
||||
<div
|
||||
class="task-card"
|
||||
class:expanded={expandedTaskId === task.id}
|
||||
class:critical={task.priority === 'critical'}
|
||||
class:high={task.priority === 'high'}
|
||||
>
|
||||
<button class="task-card-body" onclick={() => toggleExpand(task.id)}>
|
||||
<span class="task-priority priority-{task.priority}">{PRIORITY_LABELS[task.priority]}</span>
|
||||
<span class="task-title">{task.title}</span>
|
||||
{#if task.assignedTo}
|
||||
<span class="task-assignee">{task.assignedTo}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if expandedTaskId === task.id}
|
||||
<div class="task-detail">
|
||||
{#if task.description}
|
||||
<p class="task-description">{task.description}</p>
|
||||
{/if}
|
||||
|
||||
<div class="status-actions">
|
||||
{#each STATUSES as s}
|
||||
<button
|
||||
class="status-btn"
|
||||
class:active={task.status === s}
|
||||
onclick={() => handleStatusChange(task.id, s)}
|
||||
>{STATUS_ICONS[s]} {STATUS_LABELS[s]}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if taskComments.length > 0}
|
||||
<div class="comments-list">
|
||||
{#each taskComments as comment}
|
||||
<div class="comment">
|
||||
<span class="comment-agent">{comment.agentId}</span>
|
||||
<span class="comment-text">{comment.content}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="comment-form">
|
||||
<input
|
||||
class="comment-input"
|
||||
bind:value={newComment}
|
||||
placeholder="Add comment..."
|
||||
onkeydown={e => { if (e.key === 'Enter') handleAddComment(); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button class="btn-delete" onclick={() => handleDelete(task.id)}>Delete</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.task-board-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
padding: 0.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.board-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.board-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.pending-badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.5rem;
|
||||
background: color-mix(in srgb, var(--ctp-yellow) 15%, transparent);
|
||||
color: var(--ctp-yellow);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pending-badge.all-done {
|
||||
background: color-mix(in srgb, var(--ctp-green) 15%, transparent);
|
||||
color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
margin-left: auto;
|
||||
padding: 0.2rem 0.5rem;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
background: var(--ctp-surface1);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.add-task-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--ctp-surface0);
|
||||
border-radius: 0.375rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-title-input, .task-desc-input, .comment-input {
|
||||
padding: 0.3125rem 0.5rem;
|
||||
background: var(--ctp-base);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-text);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.task-desc-input {
|
||||
resize: vertical;
|
||||
min-height: 2rem;
|
||||
font-family: var(--ui-font-family, sans-serif);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.priority-select {
|
||||
padding: 0.25rem 0.375rem;
|
||||
background: var(--ctp-base);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-text);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.btn-create {
|
||||
padding: 0.25rem 0.625rem;
|
||||
background: var(--ctp-blue);
|
||||
color: var(--ctp-base);
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-create:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.loading, .error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.error { color: var(--ctp-red); }
|
||||
|
||||
.kanban {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.kanban-column {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ctp-overlay0);
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.column-icon { font-size: 0.7rem; }
|
||||
|
||||
.column-count {
|
||||
margin-left: auto;
|
||||
font-size: 0.55rem;
|
||||
background: var(--ctp-surface0);
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.column-cards {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.task-card:hover {
|
||||
border-color: var(--ctp-surface2);
|
||||
}
|
||||
|
||||
.task-card.critical {
|
||||
border-left: 2px solid var(--ctp-red);
|
||||
}
|
||||
|
||||
.task-card.high {
|
||||
border-left: 2px solid var(--ctp-yellow);
|
||||
}
|
||||
|
||||
.task-card-body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.3125rem 0.375rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-text);
|
||||
font-size: 0.7rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-priority {
|
||||
font-size: 0.5rem;
|
||||
font-weight: 700;
|
||||
padding: 0.0625rem 0.25rem;
|
||||
border-radius: 0.125rem;
|
||||
letter-spacing: 0.03em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.priority-critical { background: var(--ctp-red); color: var(--ctp-base); }
|
||||
.priority-high { background: var(--ctp-yellow); color: var(--ctp-base); }
|
||||
.priority-medium { background: var(--ctp-surface1); color: var(--ctp-subtext0); }
|
||||
.priority-low { background: var(--ctp-surface0); color: var(--ctp-overlay0); }
|
||||
|
||||
.task-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-assignee {
|
||||
font-size: 0.55rem;
|
||||
color: var(--ctp-overlay0);
|
||||
background: var(--ctp-base);
|
||||
padding: 0.0625rem 0.25rem;
|
||||
border-radius: 0.125rem;
|
||||
}
|
||||
|
||||
.task-detail {
|
||||
padding: 0.375rem;
|
||||
border-top: 1px solid var(--ctp-surface1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.task-description {
|
||||
margin: 0;
|
||||
font-size: 0.7rem;
|
||||
color: var(--ctp-subtext0);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.status-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.status-btn {
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--ctp-base);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status-btn.active {
|
||||
background: var(--ctp-surface1);
|
||||
color: var(--ctp-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-btn:hover {
|
||||
border-color: var(--ctp-surface2);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.comments-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
max-height: 8rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.comment {
|
||||
font-size: 0.65rem;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.comment-agent {
|
||||
font-weight: 600;
|
||||
color: var(--ctp-blue);
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.comment-form {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.comment-input {
|
||||
flex: 1;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
align-self: flex-end;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
</style>
|
||||
427
v2/src/lib/components/Workspace/TestingTab.svelte
Normal file
427
v2/src/lib/components/Workspace/TestingTab.svelte
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { listDirectoryChildren, readFileContent, type DirEntry } from '../../adapters/files-bridge';
|
||||
|
||||
interface Props {
|
||||
cwd: string;
|
||||
mode: 'selenium' | 'tests';
|
||||
}
|
||||
|
||||
let { cwd, mode }: Props = $props();
|
||||
|
||||
// ─── Selenium mode ────────────────────────────────────────
|
||||
let seleniumConnected = $state(false);
|
||||
let screenshots = $state<string[]>([]);
|
||||
let selectedScreenshot = $state<string | null>(null);
|
||||
let seleniumLog = $state<string[]>([]);
|
||||
let seleniumPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const SCREENSHOTS_DIR = '.selenium/screenshots';
|
||||
const SELENIUM_LOG = '.selenium/session.log';
|
||||
|
||||
async function loadSeleniumState() {
|
||||
const screenshotPath = `${cwd}/${SCREENSHOTS_DIR}`;
|
||||
try {
|
||||
const entries = await listDirectoryChildren(screenshotPath);
|
||||
const imageFiles = entries
|
||||
.filter(e => /\.(png|jpg|jpeg|webp)$/i.test(e.name))
|
||||
.map(e => e.path)
|
||||
.sort()
|
||||
.reverse();
|
||||
screenshots = imageFiles;
|
||||
|
||||
// Select latest if nothing selected
|
||||
if (!selectedScreenshot && imageFiles.length > 0) {
|
||||
selectedScreenshot = imageFiles[0];
|
||||
}
|
||||
seleniumConnected = imageFiles.length > 0;
|
||||
} catch {
|
||||
screenshots = [];
|
||||
seleniumConnected = false;
|
||||
}
|
||||
|
||||
// Load session log
|
||||
try {
|
||||
const content = await readFileContent(`${cwd}/${SELENIUM_LOG}`);
|
||||
if (content.type === 'Text') {
|
||||
seleniumLog = content.content.split('\n').filter(Boolean).slice(-50);
|
||||
}
|
||||
} catch {
|
||||
seleniumLog = [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests mode ───────────────────────────────────────────
|
||||
let testFiles = $state<DirEntry[]>([]);
|
||||
let selectedTestFile = $state<string | null>(null);
|
||||
let testOutput = $state('');
|
||||
let testRunning = $state(false);
|
||||
let lastTestResult = $state<'pass' | 'fail' | null>(null);
|
||||
|
||||
const TEST_DIRS = ['tests', 'test', '__tests__', 'spec', 'e2e'];
|
||||
|
||||
async function loadTestFiles() {
|
||||
for (const dir of TEST_DIRS) {
|
||||
try {
|
||||
const entries = await listDirectoryChildren(`${cwd}/${dir}`);
|
||||
const tests = entries.filter(e =>
|
||||
/\.(test|spec)\.(ts|js|py|rs)$/.test(e.name) ||
|
||||
/test_.*\.py$/.test(e.name)
|
||||
);
|
||||
if (tests.length > 0) {
|
||||
testFiles = tests;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist, try next
|
||||
}
|
||||
}
|
||||
testFiles = [];
|
||||
}
|
||||
|
||||
async function viewTestFile(filePath: string) {
|
||||
selectedTestFile = filePath;
|
||||
try {
|
||||
const content = await readFileContent(filePath);
|
||||
if (content.type === 'Text') {
|
||||
testOutput = content.content;
|
||||
}
|
||||
} catch (e) {
|
||||
testOutput = `Error: ${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (mode === 'selenium') {
|
||||
loadSeleniumState();
|
||||
seleniumPollTimer = setInterval(loadSeleniumState, 3000);
|
||||
} else {
|
||||
loadTestFiles();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (seleniumPollTimer) clearInterval(seleniumPollTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="testing-tab">
|
||||
{#if mode === 'selenium'}
|
||||
<!-- Selenium Live View -->
|
||||
<div class="selenium-view">
|
||||
<div class="selenium-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-title">Screenshots</span>
|
||||
<span class="status-dot" class:connected={seleniumConnected}></span>
|
||||
</div>
|
||||
<div class="screenshot-list">
|
||||
{#each screenshots as path}
|
||||
<button
|
||||
class="screenshot-item"
|
||||
class:active={selectedScreenshot === path}
|
||||
onclick={() => selectedScreenshot = path}
|
||||
>
|
||||
<span class="screenshot-name">{path.split('/').pop()}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if screenshots.length === 0}
|
||||
<div class="empty-hint">
|
||||
No screenshots yet. The Tester agent saves screenshots to <code>{SCREENSHOTS_DIR}/</code>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="log-section">
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-title">Session Log</span>
|
||||
</div>
|
||||
<div class="log-output">
|
||||
{#each seleniumLog as line}
|
||||
<div class="log-line">{line}</div>
|
||||
{/each}
|
||||
{#if seleniumLog.length === 0}
|
||||
<div class="empty-hint">No log entries</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selenium-content">
|
||||
{#if selectedScreenshot}
|
||||
<div class="screenshot-preview">
|
||||
<img
|
||||
src="asset://localhost/{selectedScreenshot}"
|
||||
alt="Selenium screenshot"
|
||||
class="screenshot-img"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
Selenium screenshots will appear here during testing.
|
||||
<br />
|
||||
The Tester agent uses Selenium WebDriver for UI testing.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- Automated Tests View -->
|
||||
<div class="tests-view">
|
||||
<div class="tests-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-title">Test Files</span>
|
||||
{#if lastTestResult}
|
||||
<span class="result-badge" class:pass={lastTestResult === 'pass'} class:fail={lastTestResult === 'fail'}>
|
||||
{lastTestResult === 'pass' ? '✓ PASS' : '✗ FAIL'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="test-file-list">
|
||||
{#each testFiles as file (file.path)}
|
||||
<button
|
||||
class="test-file-item"
|
||||
class:active={selectedTestFile === file.path}
|
||||
onclick={() => viewTestFile(file.path)}
|
||||
>
|
||||
<span class="test-icon">🧪</span>
|
||||
<span class="test-name">{file.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if testFiles.length === 0}
|
||||
<div class="empty-hint">
|
||||
No test files found. The Tester agent creates tests in standard directories (tests/, test/, spec/).
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tests-content">
|
||||
{#if selectedTestFile}
|
||||
<div class="test-file-header">
|
||||
<span class="test-file-name">{selectedTestFile.split('/').pop()}</span>
|
||||
</div>
|
||||
<pre class="test-output">{testOutput}</pre>
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
Select a test file to view its contents.
|
||||
<br />
|
||||
The Tester agent runs tests via the terminal.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.testing-tab {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Shared sidebar patterns */
|
||||
.selenium-sidebar, .tests-sidebar {
|
||||
width: 12rem;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--ctp-surface0);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-subtext0);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: var(--ctp-green);
|
||||
box-shadow: 0 0 4px var(--ctp-green);
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.65rem;
|
||||
color: var(--ctp-overlay0);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.empty-hint code {
|
||||
background: var(--ctp-surface0);
|
||||
padding: 0.0625rem 0.25rem;
|
||||
border-radius: 0.125rem;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Selenium view */
|
||||
.selenium-view, .tests-view {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.screenshot-list, .test-file-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.screenshot-item, .test-file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.3125rem 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.65rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.screenshot-item:hover, .test-file-item:hover {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.screenshot-item.active, .test-file-item.active {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.screenshot-name, .test-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.test-icon { font-size: 0.75rem; }
|
||||
|
||||
.log-section {
|
||||
border-top: 1px solid var(--ctp-surface0);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 40%;
|
||||
}
|
||||
|
||||
.log-output {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
font-size: 0.6rem;
|
||||
font-family: var(--term-font-family, monospace);
|
||||
color: var(--ctp-subtext0);
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.selenium-content, .tests-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.screenshot-preview {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
background: var(--ctp-mantle);
|
||||
}
|
||||
|
||||
.screenshot-img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
/* Tests view */
|
||||
.result-badge {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
padding: 0.0625rem 0.25rem;
|
||||
border-radius: 0.125rem;
|
||||
}
|
||||
|
||||
.result-badge.pass {
|
||||
background: color-mix(in srgb, var(--ctp-green) 15%, transparent);
|
||||
color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.result-badge.fail {
|
||||
background: color-mix(in srgb, var(--ctp-red) 15%, transparent);
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.test-file-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.test-file-name {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.test-output {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0.5rem;
|
||||
margin: 0;
|
||||
background: var(--ctp-mantle);
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.7rem;
|
||||
font-family: var(--term-font-family, monospace);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue