# Agent Instructions: Background Tasks Sidebar Implementation

**Spec:** `docs/phase2/08-background-tasks-sidebar.md`
**Goal:** Remove orchestrator + implement hybrid sidebar for background tasks
**Estimated Time:** 2-3 days

## Part 1: Remove Orchestrator (Day 1)

### 1.1 Delete Files

```bash
# Delete these files entirely
rm src/orchestrator/router.ts
rm src/orchestrator/router.test.ts
```

**Keep `src/orchestrator/janitor.ts`** - it's used by the cron system for nightly session resets, not related to the intent classifier.

### 1.2 Modify dispatch-from-config.ts

**File:** `src/auto-reply/reply/dispatch-from-config.ts`

**Remove:**

1. Import line:

```typescript
// DELETE
import { IntentRouter } from "../../orchestrator/router.js";
```

2. Orchestrator classification block (~lines 270-310):

```typescript
// DELETE THIS ENTIRE BLOCK:
console.error(
  `[ORCHESTRATOR] cfg keys: ${Object.keys(cfg)...
console.error(
  `[ORCHESTRATOR] cfg.orchestrator?.enabled = ...
console.error(`[ORCHESTRATOR] checking orchestrator enabled: ...
if (cfg.orchestrator?.enabled) {
  const intentRouter = new IntentRouter();
  const bodyText = ...
  if (bodyText.trim()) {
    const complexity = await intentRouter.route(bodyText, cfg);
    ...
    if (complexity === "long-running") {
      isLongRunning = true;
      orchestratorModelOverride = cfg.orchestrator.longRunningModel;
    } else if (complexity === "conversational") {
      orchestratorModelOverride = cfg.orchestrator.conversationalModel;
    }
  }
}
```

3. Remove `isLongRunning` check and background handoff (~lines 320-360):

```typescript
// DELETE THIS ENTIRE BLOCK:
if (isLongRunning) {
  const ackPayload: ReplyPayload = {
    text: "⏳ **Task Backgrounded**\nI've started analyzing this task...",
  };
  // ... entire block ...
  return { queuedFinal: true, counts: dispatcher.getQueuedCounts() };
}
```

4. Remove unused variable declarations at top of function:

```typescript
// DELETE:
let isLongRunning = false;
let orchestratorModelOverride: string | undefined;
```

**Keep:** The `orchestratorModelOverride` param in `getReplyFromConfig` call - it's used by the sessions controller for subagent spawning.

### 1.3 Remove Config Types

**File:** `src/config/types.sophiaclaw.ts`

Find the `SophiaClawConfig` type and remove:

```typescript
// DELETE THIS PROPERTY:
orchestrator?: {
  enabled?: boolean;
  classifierModel?: string;
  conversationalModel?: string;
  longRunningModel?: string;
};
```

### 1.4 Remove Zod Schema

**File:** `src/config/zod-schema.ts`

Find and remove:

```typescript
// DELETE:
orchestrator: z.object({
  enabled: z.boolean().optional(),
  classifierModel: z.string().optional(),
  conversationalModel: z.string().optional(),
  longRunningModel: z.string().optional(),
}).optional(),
```

### 1.5 Remove Schema Help

**File:** `src/config/schema.help.ts`

Find and remove orchestrator-related help text.

### 1.6 Run Tests

```bash
# Ensure tests pass after removal
pnpm test src/auto-reply/reply/dispatch-from-config.test.ts
pnpm test src/config/
```

---

## Part 2: Sidebar Hybrid Mode (Days 2-3)

### 2.1 Add State to App Component

**File:** `ui/src/ui/app.ts`

**Add imports:**

```typescript
// Near top with other imports
import type { SessionsListResult } from "./types.ts";
```

**Add state properties (near other @state declarations):**

```typescript
@state() sidebarTab: 'tool' | 'tasks' = 'tool';
@state() taskSessions: SessionsListResult | null = null;
@state() taskPollInterval: number | null = null;
```

**Add methods (near handleOpenSidebar/handleCloseSidebar):**

```typescript
// Switch sidebar tab
setSidebarTab(tab: 'tool' | 'tasks') {
  this.sidebarTab = tab;
}

// Poll for subagent sessions
async pollTaskSessions() {
  try {
    // Call the gateway to get subagent sessions
    const result = await this.client?.listSessions({
      kinds: ['subagent'],
      activeMinutes: 60,
      limit: 50
    });
    this.taskSessions = result ?? null;
  } catch (err) {
    console.error('Failed to poll task sessions:', err);
  }
}

// Start polling when component connects
startTaskPolling() {
  if (this.taskPollInterval) return;
  this.pollTaskSessions(); // Initial poll
  this.taskPollInterval = window.setInterval(() => {
    this.pollTaskSessions();
  }, 5000); // Poll every 5 seconds
}

// Stop polling
stopTaskPolling() {
  if (this.taskPollInterval) {
    clearInterval(this.taskPollInterval);
    this.taskPollInterval = null;
  }
}
```

**Lifecycle hooks:**

```typescript
// In connectedCallback() or when tab === 'chat'
this.startTaskPolling();

// In disconnectedCallback()
this.stopTaskPolling();
```

**Auto-open sidebar on subagent spawn:**
Modify existing subagent detection or add a method:

```typescript
// Called when a new subagent is detected
onSubagentSpawned(label?: string) {
  this.sidebarTab = 'tasks';
  this.sidebarOpen = true;
  // Optional: flash attention or animation
}
```

### 2.2 Create Task Panel Component

**Create:** `ui/src/ui/views/task-panel.ts`

```typescript
import { html, nothing } from "lit";
import type { GatewaySessionRow, SessionsListResult } from "../types.ts";

export interface TaskPanelProps {
  sessions: SessionsListResult | null;
  loading: boolean;
  onViewSession: (key: string) => void;
  onKillSession: (key: string) => void;
  onDismiss: (key: string) => void;
}

export function renderTaskPanel(props: TaskPanelProps) {
  const rows = props.sessions?.sessions ?? [];

  if (rows.length === 0 && !props.loading) {
    return html`
      <div class="task-panel-empty">
        <div class="empty-icon">📋</div>
        <p>No background tasks running</p>
        <p class="hint">Use <code>sessions_spawn</code> or <code>/spawn</code> to start a task</p>
      </div>
    `;
  }

  return html`
    <div class="task-panel">
      ${props.loading ? html`<div class="loading">Loading...</div>` : nothing}
      <div class="task-list">${rows.map((row) => renderTaskItem(row, props))}</div>
    </div>
  `;
}

function renderTaskItem(row: GatewaySessionRow, props: TaskPanelProps) {
  const status = resolveStatus(row);
  const elapsed = row.lastMessageAt ? formatElapsed(row.lastMessageAt) : "just now";

  return html`
    <div class="task-item ${status}">
      <div class="task-header">
        <span class="task-status-icon">${statusIcon(status)}</span>
        <span class="task-label">${row.label || "Unnamed Task"}</span>
        <button
          class="btn btn--sm btn--icon"
          @click=${() => props.onDismiss(row.key)}
          title="Dismiss"
        >
          ×
        </button>
      </div>
      <div class="task-meta">
        <span class="task-status">${statusText(status)}</span>
        <span class="task-elapsed">${elapsed}</span>
      </div>
      <div class="task-actions">
        <button class="btn btn--sm" @click=${() => props.onViewSession(row.key)}>View</button>
        ${status === "running"
          ? html`
              <button class="btn btn--sm btn--danger" @click=${() => props.onKillSession(row.key)}>
                Kill
              </button>
            `
          : nothing}
      </div>
    </div>
  `;
}

function resolveStatus(row: GatewaySessionRow): "running" | "complete" | "failed" | "pending" {
  // Infer from session state
  if (row.endedAt) return "complete";
  if (row.error) return "failed";
  if (row.lastMessageAt) return "running";
  return "pending";
}

function statusIcon(status: string): string {
  const icons: Record<string, string> = {
    running: "⏳",
    complete: "✅",
    failed: "❌",
    pending: "🕐",
  };
  return icons[status] || "○";
}

function statusText(status: string): string {
  const texts: Record<string, string> = {
    running: "Running",
    complete: "Complete",
    failed: "Failed",
    pending: "Pending",
  };
  return texts[status] || status;
}

function formatElapsed(timestamp: number): string {
  const elapsed = Date.now() - timestamp;
  const minutes = Math.floor(elapsed / 60000);
  const hours = Math.floor(minutes / 60);

  if (hours > 0) return `${hours}h ${minutes % 60}m`;
  if (minutes > 0) return `${minutes}m`;
  return "just now";
}
```

### 2.3 Modify Chat View

**File:** `ui/src/ui/views/chat.ts`

**Add to imports:**

```typescript
import { renderTaskPanel } from "./task-panel.ts";
import type { SessionsListResult } from "../types.ts";
```

**Add to ChatViewProps interface:**

```typescript
interface ChatViewProps {
  // ... existing props ...

  // Sidebar tab state
  sidebarTab: "tool" | "tasks";
  onSidebarTabChange: (tab: "tool" | "tasks") => void;

  // Task data
  taskSessions: SessionsListResult | null;
  taskLoading: boolean;
  onViewTask: (key: string) => void;
  onKillTask: (key: string) => void;
  onDismissTask: (key: string) => void;
}
```

**Modify sidebar rendering:**
Find the sidebar rendering code and modify to show tab switcher:

```typescript
// In render function, where sidebar is rendered:
<div class="sidebar ${props.sidebarOpen ? 'open' : ''}">
  <div class="sidebar-header">
    <div class="tab-switcher">
      <button
        class="tab ${props.sidebarTab === 'tool' ? 'active' : ''}"
        @click=${() => props.onSidebarTabChange('tool')}
      >
        Tool Output
      </button>
      <button
        class="tab ${props.sidebarTab === 'tasks' ? 'active' : ''}"
        @click=${() => props.onSidebarTabChange('tasks')}
      >
        Tasks
        ${hasActiveTasks(props.taskSessions) ? html`<span class="badge">●</span>` : nothing}
      </button>
    </div>
    <button class="close-btn" @click=${props.onCloseSidebar}>×</button>
  </div>

  <div class="sidebar-content">
    ${props.sidebarTab === 'tool'
      ? renderToolOutput(props)
      : renderTaskPanel({
          sessions: props.taskSessions,
          loading: props.taskLoading,
          onViewSession: props.onViewTask,
          onKillSession: props.onKillTask,
          onDismiss: props.onDismissTask,
        })}
  </div>
</div>
```

### 2.4 Update App Render

**File:** `ui/src/ui/app-render.ts`

**Add to renderChat props:**

```typescript
renderChat({
  // ... existing props ...

  // Sidebar tabs
  sidebarTab: state.sidebarTab,
  onSidebarTabChange: (tab) => state.setSidebarTab(tab),

  // Tasks
  taskSessions: state.taskSessions,
  taskLoading: false, // Add loading state if needed
  onViewTask: (key) => {
    // Open session in new tab or switch to it
    state.sessionKey = key;
    state.setTab("chat");
  },
  onKillTask: async (key) => {
    // Use subagents tool to kill
    await state.callTool("subagents", { action: "kill", target: key });
  },
  onDismissTask: (key) => {
    // Just remove from UI (don't delete session)
    // Could track dismissed keys in state
  },
});
```

### 2.5 Add Styles

**File:** `ui/src/ui/styles.css` (or appropriate stylesheet)

```css
/* Task Panel Styles */
.task-panel {
  padding: 16px;
}

.task-panel-empty {
  text-align: center;
  padding: 40px 20px;
  color: var(--text-muted);
}

.task-panel-empty .empty-icon {
  font-size: 48px;
  margin-bottom: 16px;
}

.task-panel-empty .hint {
  font-size: 12px;
  margin-top: 8px;
}

.task-list {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.task-item {
  background: var(--surface-2);
  border-radius: 8px;
  padding: 12px;
  border-left: 3px solid var(--border);
}

.task-item.running {
  border-left-color: var(--color-primary);
}

.task-item.complete {
  border-left-color: var(--color-success);
}

.task-item.failed {
  border-left-color: var(--color-danger);
}

.task-header {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 8px;
}

.task-status-icon {
  font-size: 16px;
}

.task-label {
  flex: 1;
  font-weight: 500;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.task-meta {
  display: flex;
  gap: 12px;
  font-size: 12px;
  color: var(--text-muted);
  margin-bottom: 12px;
}

.task-actions {
  display: flex;
  gap: 8px;
}

/* Tab Switcher */
.tab-switcher {
  display: flex;
  gap: 4px;
  flex: 1;
}

.tab-switcher .tab {
  padding: 8px 12px;
  background: transparent;
  border: none;
  color: var(--text-muted);
  cursor: pointer;
  border-radius: 4px;
  font-size: 13px;
  position: relative;
}

.tab-switcher .tab.active {
  background: var(--surface-2);
  color: var(--text);
}

.tab-switcher .badge {
  color: var(--color-primary);
  margin-left: 4px;
}
```

### 2.6 Wire Up Auto-Open

**File:** `ui/src/ui/app.ts`

Detect when a new subagent is spawned and auto-open sidebar:

```typescript
// In the tool result handler or message handler
onToolResult(result: unknown) {
  // Check if this is a subagent spawn result
  if (isSubagentSpawnResult(result)) {
    this.sidebarTab = 'tasks';
    this.handleOpenSidebar(''); // Open with empty content, tasks will render
  }
}

// Or detect via session change
updated(changedProperties: Map<string, unknown>) {
  if (changedProperties.has('taskSessions')) {
    const oldSessions = changedProperties.get('taskSessions') as SessionsListResult | null;
    const newCount = this.taskSessions?.sessions?.length ?? 0;
    const oldCount = oldSessions?.sessions?.length ?? 0;

    // If new subagent appeared, switch to tasks tab
    if (newCount > oldCount) {
      this.sidebarTab = 'tasks';
      this.sidebarOpen = true;
    }
  }
}
```

---

## Testing

### Unit Tests

**Create:** `ui/src/ui/views/task-panel.test.ts`

Test cases:

- Empty state renders correctly
- Running task shows correct status
- Complete task shows view button only
- Kill button calls handler
- Dismiss removes from list

### Integration Tests

1. Spawn subagent → sidebar opens to tasks tab
2. Kill subagent → status updates to failed
3. Switch tabs → content changes
4. Resize sidebar → task list scrolls properly

### Manual Testing

```bash
# 1. Build UI
pnpm build

# 2. Start gateway
sophiaclaw gateway start

# 3. Open desktop app or browser
# 4. Spawn a test subagent:
#    /spawn analyze the codebase structure

# 5. Verify:
#    - Sidebar opens automatically
#    - Shows "Tasks" tab with running indicator
#    - Can switch to "Tool Output" tab
#    - Can kill the task
#    - Can view task session
```

---

## Checklist

### Part 1: Removal

- [ ] `src/orchestrator/router.ts` deleted
- [ ] `src/orchestrator/router.test.ts` deleted
- [ ] `src/orchestrator/janitor.ts` kept (verify still used)
- [ ] `dispatch-from-config.ts` cleaned of orchestrator code
- [ ] Config types updated
- [ ] Zod schema updated
- [ ] Tests pass

### Part 2: UI Implementation

- [ ] `sidebarTab` state added
- [ ] `taskSessions` state added
- [ ] Polling logic implemented
- [ ] `task-panel.ts` component created
- [ ] Tab switcher added to sidebar
- [ ] Auto-open on subagent spawn works
- [ ] View/Kill/Dismiss actions work
- [ ] Styles added
- [ ] Tests written
- [ ] Manual testing complete

---

## Notes

- **Janitor.ts**: Keep this file! It's used for nightly session cleanup/distillation, not for the orchestrator intent classification. It's in the same folder but serves a different purpose.

- **Subagent Detection**: The polling approach is simple but consider using WebSocket events if the gateway supports pushing session updates.

- **Performance**: Polling every 5s is fine for typical use. Reduce to 10s if needed.

- **Mobile**: Ensure tab switcher works on narrow screens (maybe collapse to icons).

- **Accessibility**: Add ARIA labels for tab switcher and task actions.
