CRITICAL:
- Message persistence race: snapshot batchEnd before async save
- Double-start guard: startingProjects Set prevents concurrent launches
- Symlink path traversal: fs.realpathSync() in path-guard.ts
- Relay false success: connect() returns { ok, machineId, error }
HIGH:
- Session restore skips if active session exists
- Remote remove: new RPC, cleans backend map
- Task board poll token: stale responses discarded after drag-drop
- Health concurrent tools: toolsInFlight counter (was boolean)
- bttask transactions: delete wraps comments+task, addComment validates
- PTY buffer cleared on reconnect
- PTY large paste: chunked String.fromCharCode (8KB chunks)
- Sidecar max line: 10MB limit prevents unbounded memory
- btmsg authorization: group validation, channel membership checks
MEDIUM:
- Session retention: max 5 per project, purgeSession/untrackProject
- Relay IPv6: URL parser replaces string split
- PTY schema: fixed misleading base64 comment
522 lines
14 KiB
Svelte
522 lines
14 KiB
Svelte
<script lang="ts">
|
|
import { appRpc } from './rpc.ts';
|
|
|
|
// ── Types ────────────────────────────────────────────────────────────
|
|
|
|
interface Task {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
status: string;
|
|
priority: string;
|
|
assignedTo: string | null;
|
|
createdBy: string;
|
|
groupId: string;
|
|
version: number;
|
|
}
|
|
|
|
interface Props {
|
|
groupId: string;
|
|
/** Agent ID perspective for creating tasks (defaults to 'admin'). */
|
|
agentId?: string;
|
|
}
|
|
|
|
let { groupId, agentId = 'admin' }: Props = $props();
|
|
|
|
// ── State ────────────────────────────────────────────────────────────
|
|
|
|
const COLUMNS = ['todo', 'progress', 'review', 'done', 'blocked'] as const;
|
|
const COL_LABELS: Record<string, string> = {
|
|
todo: 'To Do', progress: 'In Progress', review: 'Review',
|
|
done: 'Done', blocked: 'Blocked',
|
|
};
|
|
const PRIORITY_COLORS: Record<string, string> = {
|
|
high: 'var(--ctp-red)', medium: 'var(--ctp-peach)',
|
|
low: 'var(--ctp-teal)',
|
|
};
|
|
|
|
let tasks = $state<Task[]>([]);
|
|
let showCreateForm = $state(false);
|
|
let newTitle = $state('');
|
|
let newDesc = $state('');
|
|
let newPriority = $state('medium');
|
|
let error = $state('');
|
|
|
|
// Drag state
|
|
let draggedTaskId = $state<string | null>(null);
|
|
let dragOverCol = $state<string | null>(null);
|
|
|
|
// Fix #7 (Codex audit): Poll token to discard stale responses
|
|
let pollToken = $state(0);
|
|
|
|
// ── Derived ──────────────────────────────────────────────────────────
|
|
|
|
let tasksByCol = $derived(
|
|
COLUMNS.reduce((acc, col) => {
|
|
acc[col] = tasks.filter(t => t.status === col);
|
|
return acc;
|
|
}, {} as Record<string, Task[]>)
|
|
);
|
|
|
|
// ── Data fetching ────────────────────────────────────────────────────
|
|
|
|
async function loadTasks() {
|
|
// Fix #7 (Codex audit): Capture token before async call, discard if stale
|
|
const tokenAtStart = ++pollToken;
|
|
try {
|
|
const res = await appRpc.request['bttask.listTasks']({ groupId });
|
|
if (tokenAtStart < pollToken) return; // Stale response — discard
|
|
tasks = res.tasks;
|
|
} catch (err) {
|
|
console.error('[TaskBoard] loadTasks:', err);
|
|
}
|
|
}
|
|
|
|
async function createTask() {
|
|
const title = newTitle.trim();
|
|
if (!title) { error = 'Title required'; return; }
|
|
error = '';
|
|
|
|
try {
|
|
const res = await appRpc.request['bttask.createTask']({
|
|
title,
|
|
description: newDesc.trim(),
|
|
priority: newPriority,
|
|
groupId,
|
|
createdBy: agentId,
|
|
});
|
|
if (res.ok) {
|
|
newTitle = '';
|
|
newDesc = '';
|
|
newPriority = 'medium';
|
|
showCreateForm = false;
|
|
await loadTasks();
|
|
} else {
|
|
error = res.error ?? 'Failed to create task';
|
|
}
|
|
} catch (err) {
|
|
console.error('[TaskBoard] createTask:', err);
|
|
error = 'Failed to create task';
|
|
}
|
|
}
|
|
|
|
async function moveTask(taskId: string, newStatus: string) {
|
|
const task = tasks.find(t => t.id === taskId);
|
|
if (!task || task.status === newStatus) return;
|
|
|
|
pollToken++; // Fix #7: Invalidate in-flight polls
|
|
try {
|
|
const res = await appRpc.request['bttask.updateTaskStatus']({
|
|
taskId,
|
|
status: newStatus,
|
|
expectedVersion: task.version,
|
|
});
|
|
if (res.ok) {
|
|
// Optimistic update
|
|
task.status = newStatus;
|
|
task.version = res.newVersion ?? task.version + 1;
|
|
tasks = [...tasks]; // trigger reactivity
|
|
} else {
|
|
error = res.error ?? 'Version conflict';
|
|
await loadTasks(); // reload on conflict
|
|
}
|
|
} catch (err) {
|
|
console.error('[TaskBoard] moveTask:', err);
|
|
await loadTasks();
|
|
}
|
|
}
|
|
|
|
async function deleteTask(taskId: string) {
|
|
try {
|
|
await appRpc.request['bttask.deleteTask']({ taskId });
|
|
tasks = tasks.filter(t => t.id !== taskId);
|
|
} catch (err) {
|
|
console.error('[TaskBoard] deleteTask:', err);
|
|
}
|
|
}
|
|
|
|
// ── Drag handlers ────────────────────────────────────────────────────
|
|
|
|
function onDragStart(e: DragEvent, taskId: string) {
|
|
draggedTaskId = taskId;
|
|
if (e.dataTransfer) {
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
e.dataTransfer.setData('text/plain', taskId);
|
|
}
|
|
}
|
|
|
|
function onDragOver(e: DragEvent, col: string) {
|
|
e.preventDefault();
|
|
dragOverCol = col;
|
|
}
|
|
|
|
function onDragLeave() {
|
|
dragOverCol = null;
|
|
}
|
|
|
|
function onDrop(e: DragEvent, col: string) {
|
|
e.preventDefault();
|
|
dragOverCol = null;
|
|
if (draggedTaskId) {
|
|
moveTask(draggedTaskId, col);
|
|
draggedTaskId = null;
|
|
}
|
|
}
|
|
|
|
function onDragEnd() {
|
|
draggedTaskId = null;
|
|
dragOverCol = null;
|
|
}
|
|
|
|
// ── Init + polling ───────────────────────────────────────────────────
|
|
|
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
$effect(() => {
|
|
loadTasks();
|
|
pollTimer = setInterval(loadTasks, 5000);
|
|
return () => { if (pollTimer) clearInterval(pollTimer); };
|
|
});
|
|
</script>
|
|
|
|
<div class="task-board">
|
|
<!-- Toolbar -->
|
|
<div class="tb-toolbar">
|
|
<span class="tb-title">Task Board</span>
|
|
<span class="tb-count">{tasks.length} tasks</span>
|
|
<button class="tb-add-btn" onclick={() => { showCreateForm = !showCreateForm; }}>
|
|
{showCreateForm ? 'Cancel' : '+ Task'}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Create form -->
|
|
{#if showCreateForm}
|
|
<div class="tb-create-form">
|
|
<input
|
|
class="tb-input"
|
|
type="text"
|
|
placeholder="Task title"
|
|
bind:value={newTitle}
|
|
onkeydown={(e) => { if (e.key === 'Enter') createTask(); }}
|
|
/>
|
|
<input
|
|
class="tb-input tb-desc"
|
|
type="text"
|
|
placeholder="Description (optional)"
|
|
bind:value={newDesc}
|
|
/>
|
|
<div class="tb-form-row">
|
|
<select class="tb-select" bind:value={newPriority}>
|
|
<option value="low">Low</option>
|
|
<option value="medium">Medium</option>
|
|
<option value="high">High</option>
|
|
</select>
|
|
<button class="tb-submit" onclick={createTask}>Create</button>
|
|
</div>
|
|
{#if error}
|
|
<span class="tb-error">{error}</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Kanban columns -->
|
|
<div class="tb-columns">
|
|
{#each COLUMNS as col}
|
|
<div
|
|
class="tb-column"
|
|
class:drag-over={dragOverCol === col}
|
|
ondragover={(e) => onDragOver(e, col)}
|
|
ondragleave={onDragLeave}
|
|
ondrop={(e) => onDrop(e, col)}
|
|
role="list"
|
|
aria-label="{COL_LABELS[col]} column"
|
|
>
|
|
<div class="tb-col-header">
|
|
<span class="tb-col-label">{COL_LABELS[col]}</span>
|
|
<span class="tb-col-count">{tasksByCol[col]?.length ?? 0}</span>
|
|
</div>
|
|
|
|
<div class="tb-col-body">
|
|
{#each tasksByCol[col] ?? [] as task (task.id)}
|
|
<div
|
|
class="tb-card"
|
|
class:dragging={draggedTaskId === task.id}
|
|
draggable="true"
|
|
ondragstart={(e) => onDragStart(e, task.id)}
|
|
ondragend={onDragEnd}
|
|
role="listitem"
|
|
>
|
|
<div class="card-header">
|
|
<span
|
|
class="priority-dot"
|
|
style:background={PRIORITY_COLORS[task.priority] ?? 'var(--ctp-overlay0)'}
|
|
title="Priority: {task.priority}"
|
|
></span>
|
|
<span class="card-title">{task.title}</span>
|
|
<button
|
|
class="card-delete"
|
|
onclick={() => deleteTask(task.id)}
|
|
title="Delete task"
|
|
aria-label="Delete task"
|
|
>×</button>
|
|
</div>
|
|
{#if task.description}
|
|
<div class="card-desc">{task.description}</div>
|
|
{/if}
|
|
{#if task.assignedTo}
|
|
<div class="card-assignee">
|
|
<span class="assignee-icon">@</span>
|
|
{task.assignedTo}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.task-board {
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.tb-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.375rem 0.625rem;
|
|
background: var(--ctp-mantle);
|
|
border-bottom: 1px solid var(--ctp-surface0);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.tb-title {
|
|
font-size: 0.8125rem;
|
|
font-weight: 600;
|
|
color: var(--ctp-text);
|
|
}
|
|
|
|
.tb-count {
|
|
font-size: 0.6875rem;
|
|
color: var(--ctp-overlay0);
|
|
margin-left: auto;
|
|
}
|
|
|
|
.tb-add-btn {
|
|
padding: 0.125rem 0.5rem;
|
|
background: color-mix(in srgb, var(--ctp-green) 15%, transparent);
|
|
border: 1px solid var(--ctp-green);
|
|
border-radius: 0.25rem;
|
|
color: var(--ctp-green);
|
|
font-family: var(--ui-font-family);
|
|
font-size: 0.6875rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: background 0.1s;
|
|
}
|
|
|
|
.tb-add-btn:hover {
|
|
background: color-mix(in srgb, var(--ctp-green) 30%, transparent);
|
|
}
|
|
|
|
.tb-create-form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
padding: 0.5rem 0.625rem;
|
|
background: var(--ctp-mantle);
|
|
border-bottom: 1px solid var(--ctp-surface0);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.tb-input {
|
|
height: 1.625rem;
|
|
background: var(--ctp-surface0);
|
|
border: 1px solid var(--ctp-surface1);
|
|
border-radius: 0.25rem;
|
|
color: var(--ctp-text);
|
|
font-family: var(--ui-font-family);
|
|
font-size: 0.75rem;
|
|
padding: 0 0.375rem;
|
|
outline: none;
|
|
}
|
|
|
|
.tb-input:focus { border-color: var(--ctp-mauve); }
|
|
|
|
.tb-form-row {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.tb-select {
|
|
height: 1.625rem;
|
|
background: var(--ctp-surface0);
|
|
border: 1px solid var(--ctp-surface1);
|
|
border-radius: 0.25rem;
|
|
color: var(--ctp-text);
|
|
font-family: var(--ui-font-family);
|
|
font-size: 0.6875rem;
|
|
padding: 0 0.25rem;
|
|
outline: none;
|
|
}
|
|
|
|
.tb-submit {
|
|
padding: 0.125rem 0.625rem;
|
|
height: 1.625rem;
|
|
background: color-mix(in srgb, var(--ctp-mauve) 20%, transparent);
|
|
border: 1px solid var(--ctp-mauve);
|
|
border-radius: 0.25rem;
|
|
color: var(--ctp-mauve);
|
|
font-family: var(--ui-font-family);
|
|
font-size: 0.6875rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
margin-left: auto;
|
|
}
|
|
|
|
.tb-submit:hover {
|
|
background: color-mix(in srgb, var(--ctp-mauve) 35%, transparent);
|
|
}
|
|
|
|
.tb-error {
|
|
font-size: 0.6875rem;
|
|
color: var(--ctp-red);
|
|
}
|
|
|
|
.tb-columns {
|
|
display: flex;
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow-x: auto;
|
|
gap: 1px;
|
|
background: var(--ctp-surface0);
|
|
}
|
|
|
|
.tb-column {
|
|
flex: 1;
|
|
min-width: 8rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--ctp-base);
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.tb-column.drag-over {
|
|
background: color-mix(in srgb, var(--ctp-blue) 8%, var(--ctp-base));
|
|
}
|
|
|
|
.tb-col-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.375rem 0.5rem;
|
|
border-bottom: 1px solid var(--ctp-surface0);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.tb-col-label {
|
|
font-size: 0.6875rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
color: var(--ctp-subtext0);
|
|
}
|
|
|
|
.tb-col-count {
|
|
font-size: 0.625rem;
|
|
color: var(--ctp-overlay0);
|
|
background: var(--ctp-surface0);
|
|
padding: 0 0.25rem;
|
|
border-radius: 0.25rem;
|
|
}
|
|
|
|
.tb-col-body {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 0.375rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.tb-card {
|
|
background: var(--ctp-mantle);
|
|
border: 1px solid var(--ctp-surface0);
|
|
border-radius: 0.375rem;
|
|
padding: 0.375rem 0.5rem;
|
|
cursor: grab;
|
|
transition: border-color 0.12s, opacity 0.12s;
|
|
}
|
|
|
|
.tb-card:hover { border-color: var(--ctp-surface1); }
|
|
.tb-card.dragging { opacity: 0.4; }
|
|
|
|
.card-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
}
|
|
|
|
.priority-dot {
|
|
width: 0.5rem;
|
|
height: 0.5rem;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.card-title {
|
|
flex: 1;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
color: var(--ctp-text);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.card-delete {
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--ctp-overlay0);
|
|
font-size: 0.875rem;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
line-height: 1;
|
|
flex-shrink: 0;
|
|
opacity: 0;
|
|
transition: opacity 0.1s, color 0.1s;
|
|
}
|
|
|
|
.tb-card:hover .card-delete { opacity: 1; }
|
|
.card-delete:hover { color: var(--ctp-red); }
|
|
|
|
.card-desc {
|
|
font-size: 0.6875rem;
|
|
color: var(--ctp-subtext0);
|
|
margin-top: 0.125rem;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.card-assignee {
|
|
font-size: 0.625rem;
|
|
color: var(--ctp-overlay1);
|
|
margin-top: 0.25rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.125rem;
|
|
}
|
|
|
|
.assignee-icon {
|
|
color: var(--ctp-blue);
|
|
font-weight: 700;
|
|
}
|
|
</style>
|