diff --git a/.ai-team/agents/linus/history.md b/.ai-team/agents/linus/history.md index 27837ff..d37df3a 100644 --- a/.ai-team/agents/linus/history.md +++ b/.ai-team/agents/linus/history.md @@ -135,3 +135,18 @@ - v1.1 enables observability (skills usage, burndown metrics) - Key implementation: DecisionService.search() and HealthCheck diagnostic command - Roadmap session logged to .ai-team/log/2026-02-23-feature-roadmap.md + +### DecisionSearchService (#69) (2026-02-24) +- Created DecisionSearchService in src/services/DecisionSearchService.ts - pure service, no file I/O, no VS Code deps +- Methods: search(), filterByDate(), filterByAuthor(), filter() (combined criteria) +- Search ranking: title match (weight 10) > author match (weight 5) > content match (weight 3) +- Multi-word queries split on whitespace; each term scored independently and summed +- filterByDate() uses string comparison on YYYY-MM-DD keys (avoids timezone issues) +- filterByAuthor() is case-insensitive substring match +- filter() chains: search first (preserves ranking order) then date range then author +- Open-ended date ranges: omit startDate or endDate in DecisionSearchCriteria +- Decisions without date excluded from date filters; decisions without author excluded from author filters +- Exported DecisionSearchCriteria and ScoredDecision interfaces for consumer use +- 37 tests in decisionSearchService.test.ts covering all methods and edge cases +- Service is decoupled from DecisionService - operates on DecisionEntry[], not files +- UI integration (tree view search box, filter controls) is Rusty's follow-up diff --git a/.ai-team/agents/rusty/history.md b/.ai-team/agents/rusty/history.md index 0c2cbca..d52f17f 100644 --- a/.ai-team/agents/rusty/history.md +++ b/.ai-team/agents/rusty/history.md @@ -48,3 +48,14 @@ The SquadUI extension emerged from initial scaffolding through a rapid sequence ### Team Update: 2026-02-23 - Standup Report Enhancements & Fork-Aware Issues **Team update (2026-02-23):** Two decisions merged: (1) Standup report issue linkification (#N in AI summaries become clickable GitHub links) with escape-then-linkify pipeline to prevent injection; (2) Velocity chart legend repositioning below canvas for better viewport + accessibility. Fork-aware issue fetching auto-detects upstream repos via GitHub API, with manual override via team.md. decided by @copilot + Rusty +### Active Status Redesign (2026-02-24, Issue #73) +- **Rich contextual status:** Replaced binary `'working' | 'idle'` with `MemberStatus` enum: `'working-on-issue' | 'reviewing-pr' | 'waiting-review' | 'working' | 'idle'`. Added `isActiveStatus()` helper and `ActivityContext` interface with `description`, `shortLabel`, `issueNumber?`, `prNumber?`. +- **OrchestrationLogService.getMemberActivity():** New method parses log entries to derive per-member activity context. Detects issue work vs PR review vs waiting status from log content. Falls back to generic `'working'` when no specific context is available. Original `getMemberStates()` preserved for backward compat. +- **SquadDataProvider:** Uses `getMemberActivity()` to populate `activityContext` on each `SquadMember`. GitHub-aware status now sets `'working-on-issue'` (not generic `'working'`). +- **Tree view:** Working members get `sync~spin` icon with green color. Description shows `role • ⚙️ Issue #42` style. Tooltip shows full context description. +- **Dashboard:** Member cards show status badge below name. "Working" summary card restored. Uses `isActiveStatus()` for counting. +- **Status bar:** Shows `Squad: 3/5 working` when members are active, falls back to `Squad: N members` when none are working. +- **Work details webview:** Shows member's activity context shortLabel in assigned-to card. +- **Key files:** `src/models/index.ts` (MemberStatus, ActivityContext, isActiveStatus), `src/services/OrchestrationLogService.ts` (getMemberActivity), `src/views/SquadTreeProvider.ts` (rich status display), `src/views/dashboard/htmlTemplate.ts` (status badges). +- **Tests:** Updated 8 test files to accept rich status values. 1093 tests passing (up from 1056). + diff --git a/.ai-team/decisions/inbox/linus-decision-search-api.md b/.ai-team/decisions/inbox/linus-decision-search-api.md new file mode 100644 index 0000000..dac0af5 --- /dev/null +++ b/.ai-team/decisions/inbox/linus-decision-search-api.md @@ -0,0 +1,33 @@ +# Decision: DecisionSearchService API Design + +**Date:** 2026-02-24 +**Author:** Linus +**Issue:** #69 + +## Context + +Issue #69 requires search and filtering for decisions. The existing `DecisionService` handles parsing; we need a separate service for query operations. + +## Decision + +Created `DecisionSearchService` as a pure, stateless service operating on `DecisionEntry[]`: + +- **`search(decisions, query)`** — full-text search with relevance ranking (title 10× > author 5× > content 3×) +- **`filterByDate(decisions, startDate, endDate)`** — inclusive date range using YYYY-MM-DD string comparison +- **`filterByAuthor(decisions, author)`** — case-insensitive substring match +- **`filter(decisions, criteria)`** — chains all three: search first (preserves ranking), then date, then author + +Exported types: `DecisionSearchCriteria`, `ScoredDecision`. + +## Rationale + +- Separation from `DecisionService` keeps parsing and querying decoupled — each can evolve independently +- Pure functions on arrays = trivially testable, no file I/O or VS Code deps +- Search-first chaining preserves relevance ordering through subsequent filters +- String comparison on YYYY-MM-DD avoids timezone issues that plague Date object comparisons + +## Impact + +- Rusty can consume `DecisionSearchService` from tree view code to wire up the search UI +- The `filter()` method accepts a `DecisionSearchCriteria` object — Rusty should bind UI inputs to this interface +- No changes to `DecisionEntry` model or `DecisionService` were needed diff --git a/.ai-team/decisions/inbox/rusty-rich-status-redesign.md b/.ai-team/decisions/inbox/rusty-rich-status-redesign.md new file mode 100644 index 0000000..635b42d --- /dev/null +++ b/.ai-team/decisions/inbox/rusty-rich-status-redesign.md @@ -0,0 +1,28 @@ +# Rich Status Redesign + +**Author:** Rusty +**Date:** 2026-02-24 +**Issue:** #73 — Active Status Redesign + +## Decision + +Replaced binary `'working' | 'idle'` MemberStatus with rich contextual statuses: + +- `'working-on-issue'` — agent is working on a GitHub issue (shows `⚙️ Issue #N`) +- `'reviewing-pr'` — agent is reviewing a pull request (shows `🔍 PR #N`) +- `'waiting-review'` — agent is waiting for a review (shows `⏳ Awaiting review`) +- `'working'` — generic active state when no specific context available (shows `⚡ Working`) +- `'idle'` — no recent activity (shows `—`) + +Added `isActiveStatus()` helper — use this instead of `=== 'working'` to check if a member is active. + +Added `ActivityContext` interface: `{ description, shortLabel, issueNumber?, prNumber? }` on `SquadMember` and `TeamMemberOverview`. + +New `OrchestrationLogService.getMemberActivity()` method derives rich context from log entries. + +## Impact + +- **All code checking member status** should use `isActiveStatus(member.status)` instead of `member.status === 'working'`. +- **Tree view** now shows spinning icons for active members and contextual text in descriptions. +- **Dashboard** member cards show status badge. "Working" summary card restored. +- **Status bar** shows working/total count when members are active. diff --git a/src/models/index.ts b/src/models/index.ts index 1d35f0b..4b17c8d 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -5,10 +5,34 @@ /** * Status of a squad member in the current session. - * - 'working': Currently executing a task + * - 'working-on-issue': Actively working on a GitHub issue + * - 'reviewing-pr': Reviewing a pull request + * - 'waiting-review': Waiting for a review from another member + * - 'working': Currently executing a task (generic) * - 'idle': Available, no active task */ -export type MemberStatus = 'working' | 'idle'; +export type MemberStatus = 'working-on-issue' | 'reviewing-pr' | 'waiting-review' | 'working' | 'idle'; + +/** + * Returns true if the status represents an active (non-idle) state. + */ +export function isActiveStatus(status: MemberStatus): boolean { + return status !== 'idle'; +} + +/** + * Rich context about what a squad member is currently doing. + */ +export interface ActivityContext { + /** Human-readable summary of the current activity */ + description: string; + /** Short label for tree view (e.g., "⚙️ Issue #42") */ + shortLabel: string; + /** Related issue number, if any */ + issueNumber?: number; + /** Related PR number, if any */ + prNumber?: number; +} /** * Status of a task in the workflow. @@ -57,6 +81,9 @@ export interface SquadMember { /** Current working status */ status: MemberStatus; + /** Rich context about what the member is currently doing */ + activityContext?: ActivityContext; + /** The task currently being worked on, if any */ currentTask?: Task; } @@ -374,6 +401,10 @@ export interface TeamMemberOverview { role: string; /** Current status */ status: MemberStatus; + /** Short status label for display (e.g., "⚙️ Issue #42") */ + statusLabel?: string; + /** Rich activity context */ + activityContext?: { description: string; shortLabel: string }; /** Special icon type (scribe, ralph, copilot, or undefined for regular members) */ iconType?: 'scribe' | 'ralph' | 'copilot'; /** Number of open issues assigned */ diff --git a/src/services/DecisionSearchService.ts b/src/services/DecisionSearchService.ts new file mode 100644 index 0000000..8fe0fb0 --- /dev/null +++ b/src/services/DecisionSearchService.ts @@ -0,0 +1,165 @@ +/** + * Service for searching and filtering parsed decisions. + * + * Pure service layer — operates on DecisionEntry[] arrays, no file I/O. + * Designed to be consumed by tree providers and webview controllers. + * + * Ranking strategy: title match > body match > metadata match. + */ + +import { DecisionEntry } from '../models'; + +/** + * Criteria bundle for combined search + filter operations. + * All fields are optional — omitted fields are not applied. + */ +export interface DecisionSearchCriteria { + /** Free-text query matched against title, content, and author */ + query?: string; + /** Inclusive start date (YYYY-MM-DD) */ + startDate?: string; + /** Inclusive end date (YYYY-MM-DD) */ + endDate?: string; + /** Author name (case-insensitive substring match) */ + author?: string; +} + +/** + * A decision paired with its relevance score from a search operation. + */ +export interface ScoredDecision { + decision: DecisionEntry; + score: number; +} + +// Relevance weights — title matches are worth more than body/metadata +const TITLE_WEIGHT = 10; +const CONTENT_WEIGHT = 3; +const AUTHOR_WEIGHT = 5; + +export class DecisionSearchService { + + /** + * Full-text search across decisions with relevance ranking. + * Matches against title, content, and author fields. + * Results are sorted by score descending (most relevant first). + * + * Returns all decisions (unfiltered) when query is empty/whitespace. + */ + search(decisions: DecisionEntry[], query: string): DecisionEntry[] { + const trimmed = query.trim(); + if (!trimmed) { + return decisions; + } + + const scored = this.scoreDecisions(decisions, trimmed); + return scored + .filter(s => s.score > 0) + .sort((a, b) => b.score - a.score) + .map(s => s.decision); + } + + /** + * Filters decisions to those within an inclusive date range. + * Compares against the decision's `date` field (YYYY-MM-DD strings). + * Decisions without a date are excluded. + */ + filterByDate(decisions: DecisionEntry[], startDate: Date, endDate: Date): DecisionEntry[] { + const startKey = toDateKey(startDate); + const endKey = toDateKey(endDate); + + return decisions.filter(d => { + if (!d.date) { return false; } + return d.date >= startKey && d.date <= endKey; + }); + } + + /** + * Filters decisions by author (case-insensitive substring match). + * Returns all decisions when author is empty/whitespace. + * Decisions without an author field are excluded. + */ + filterByAuthor(decisions: DecisionEntry[], author: string): DecisionEntry[] { + const trimmed = author.trim().toLowerCase(); + if (!trimmed) { + return decisions; + } + + return decisions.filter(d => { + if (!d.author) { return false; } + return d.author.toLowerCase().includes(trimmed); + }); + } + + /** + * Combined filter that chains query search, date range, and author filter. + * Applies search first (to get ranking), then narrows with date/author. + */ + filter(decisions: DecisionEntry[], criteria: DecisionSearchCriteria): DecisionEntry[] { + let results = decisions; + + // Apply text search first (preserves relevance ordering) + if (criteria.query && criteria.query.trim()) { + results = this.search(results, criteria.query); + } + + // Apply date range filter + if (criteria.startDate || criteria.endDate) { + const start = criteria.startDate || '0000-00-00'; + const end = criteria.endDate || '9999-99-99'; + results = results.filter(d => { + if (!d.date) { return false; } + return d.date >= start && d.date <= end; + }); + } + + // Apply author filter + if (criteria.author && criteria.author.trim()) { + results = this.filterByAuthor(results, criteria.author); + } + + return results; + } + + // ─── Private Helpers ──────────────────────────────────────────────── + + /** + * Scores each decision against the query. + * Splits the query into individual terms for multi-word matching. + */ + private scoreDecisions(decisions: DecisionEntry[], query: string): ScoredDecision[] { + const terms = query.toLowerCase().split(/\s+/).filter(t => t.length > 0); + + return decisions.map(decision => { + let score = 0; + const titleLower = (decision.title || '').toLowerCase(); + const contentLower = (decision.content || '').toLowerCase(); + const authorLower = (decision.author || '').toLowerCase(); + + for (const term of terms) { + if (titleLower.includes(term)) { + score += TITLE_WEIGHT; + } + if (contentLower.includes(term)) { + score += CONTENT_WEIGHT; + } + if (authorLower.includes(term)) { + score += AUTHOR_WEIGHT; + } + } + + return { decision, score }; + }); + } +} + +/** + * Converts a Date to a YYYY-MM-DD string using local time. + * Avoids timezone-shifting issues that toISOString() causes. + */ +function toDateKey(d: Date): string { + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} diff --git a/src/services/OrchestrationLogService.ts b/src/services/OrchestrationLogService.ts index 0c0f085..b8c8499 100644 --- a/src/services/OrchestrationLogService.ts +++ b/src/services/OrchestrationLogService.ts @@ -6,7 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { OrchestrationLogEntry, Task, MemberStatus } from '../models'; +import { OrchestrationLogEntry, Task, MemberStatus, ActivityContext } from '../models'; import { normalizeEol } from '../utils/eol'; import { parseDateAsLocal, toLocalDateKey } from '../utils/dateUtils'; @@ -235,6 +235,108 @@ export class OrchestrationLogService { return states; } + /** + * Derives rich activity context for each member from log entries. + * Returns contextual status (e.g., 'working-on-issue') plus a human-readable + * description of what each member is doing. + * + * @param entries - Parsed log entries + * @returns Map of member name to their status and optional activity context + */ + getMemberActivity(entries: OrchestrationLogEntry[]): Map { + const activity = new Map(); + + if (entries.length === 0) { + return activity; + } + + // Collect all unique participants (case-insensitive dedup, preserve first casing) + const allParticipants = new Map(); + for (const entry of entries) { + for (const p of entry.participants) { + const key = p.toLowerCase(); + if (!allParticipants.has(key)) { + allParticipants.set(key, p); + } + } + } + + const sortedEntries = [...entries].sort((a, b) => b.date.localeCompare(a.date)); + const mostRecentEntry = sortedEntries[0]; + const mostRecentParticipants = new Set( + mostRecentEntry.participants.map(p => p.toLowerCase()) + ); + + for (const [key, name] of allParticipants) { + if (!mostRecentParticipants.has(key)) { + activity.set(name, { status: 'idle' }); + continue; + } + + // Find the most recent entry where this member participated + const memberEntry = sortedEntries.find(e => + e.participants.some(p => p.toLowerCase() === key) + ); + + if (!memberEntry) { + activity.set(name, { status: 'idle' }); + continue; + } + + // Look for per-agent work description + const agentWork = memberEntry.whatWasDone?.find(w => + w.agent.toLowerCase() === key + ); + + const description = agentWork?.description ?? memberEntry.summary ?? ''; + const issues = memberEntry.relatedIssues ?? []; + + // Detect PR review signals + const isReview = /\breview(?:ing|ed)?\b.*\bPR\b|\bPR\b.*\breview/i.test(description) + || /\breview(?:ing|ed)?\s+(?:pull\s+request|PR\s*#?\d+)/i.test(description); + const isWaiting = /\bwaiting\b|\bpending\s+review\b|\bawaiting\b/i.test(description); + + // Extract issue/PR numbers from description and relatedIssues + const prMatch = description.match(/\bPR\s*#?(\d+)/i); + const issueMatch = description.match(/#(\d+)/) + ?? (issues.length > 0 ? [`#${issues[0].replace('#', '')}`, issues[0].replace('#', '')] : null); + + let status: MemberStatus; + let shortLabel: string; + + if (isWaiting) { + status = 'waiting-review'; + shortLabel = '⏳ Awaiting review'; + } else if (isReview && prMatch) { + status = 'reviewing-pr'; + shortLabel = `🔍 PR #${prMatch[1]}`; + } else if (issues.length > 0 || issueMatch) { + status = 'working-on-issue'; + const issueNum = issueMatch?.[1] ?? issues[0]?.replace('#', ''); + shortLabel = `⚙️ Issue #${issueNum}`; + } else { + status = 'working'; + shortLabel = '⚡ Working'; + } + + const contextDescription = agentWork?.description + ?? memberEntry.summary + ?? memberEntry.topic.replace(/-/g, ' '); + + activity.set(name, { + status, + context: { + description: contextDescription, + shortLabel, + issueNumber: issueMatch ? parseInt(issueMatch[1], 10) : undefined, + prNumber: prMatch ? parseInt(prMatch[1], 10) : undefined, + }, + }); + } + + return activity; + } + /** * Extracts active tasks from log entries. * Tasks are derived from related issues and outcomes. diff --git a/src/services/SquadDataProvider.ts b/src/services/SquadDataProvider.ts index f5c25f9..c81f2af 100644 --- a/src/services/SquadDataProvider.ts +++ b/src/services/SquadDataProvider.ts @@ -11,7 +11,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { SquadMember, Task, WorkDetails, OrchestrationLogEntry, DecisionEntry, MemberIssueMap, GitHubIssue } from '../models'; +import { SquadMember, Task, WorkDetails, OrchestrationLogEntry, DecisionEntry, MemberIssueMap, GitHubIssue, isActiveStatus } from '../models'; import { OrchestrationLogService } from './OrchestrationLogService'; import { TeamMdService } from './TeamMdService'; import { DecisionService } from './DecisionService'; @@ -96,7 +96,7 @@ export class SquadDataProvider { } const entries = await this.getOrchestrationLogEntries(); - const memberStates = this.orchestrationService.getMemberStates(entries); + const memberActivity = this.orchestrationService.getMemberActivity(entries); const tasks = await this.getTasks(); // Try team.md as authoritative roster first @@ -118,18 +118,20 @@ export class SquadDataProvider { if (roster && roster.members.length > 0) { // Primary path: team.md defines the roster, logs overlay status members = roster.members.map(member => { - const logStatus = memberStates.get(member.name) ?? 'idle'; + const logActivity = memberActivity.get(member.name); + const logStatus = logActivity?.status ?? 'idle'; const currentTask = tasks.find(t => t.assignee === member.name && t.status === 'in_progress'); const memberTasks = tasks.filter(t => t.assignee === member.name); - // Override 'working' to 'idle' ONLY if the member has tasks but none are in-progress + // Override active to 'idle' ONLY if the member has tasks but none are in-progress // (all their work is completed — they shouldn't show as spinning). // If they have NO tasks at all, trust the log status (Copilot Chat scenario). const hasTasksButNoneActive = memberTasks.length > 0 && !currentTask; - const status = (logStatus === 'working' && hasTasksButNoneActive) ? 'idle' : logStatus; + const status = (isActiveStatus(logStatus) && hasTasksButNoneActive) ? 'idle' : logStatus; return { name: member.name, role: member.role, status, + activityContext: status !== 'idle' ? logActivity?.context : undefined, currentTask, }; }); @@ -139,16 +141,17 @@ export class SquadDataProvider { if (agentMembers.length > 0) { members = agentMembers.map(member => { - const logStatus = memberStates.get(member.name) ?? 'idle'; + const logActivity = memberActivity.get(member.name); + const logStatus = logActivity?.status ?? 'idle'; const currentTask = tasks.find(t => t.assignee === member.name && t.status === 'in_progress'); const memberTasks = tasks.filter(t => t.assignee === member.name); - // Override 'working' to 'idle' ONLY if the member has tasks but none are in-progress const hasTasksButNoneActive = memberTasks.length > 0 && !currentTask; - const status = (logStatus === 'working' && hasTasksButNoneActive) ? 'idle' : logStatus; + const status = (isActiveStatus(logStatus) && hasTasksButNoneActive) ? 'idle' : logStatus; return { name: member.name, role: member.role, status, + activityContext: status !== 'idle' ? logActivity?.context : undefined, currentTask, }; }); @@ -163,16 +166,17 @@ export class SquadDataProvider { members = []; for (const name of memberNames) { - const logStatus = memberStates.get(name) ?? 'idle'; + const logActivity = memberActivity.get(name); + const logStatus = logActivity?.status ?? 'idle'; const currentTask = tasks.find(t => t.assignee === name && t.status === 'in_progress'); const memberTasks = tasks.filter(t => t.assignee === name); - // Override 'working' to 'idle' ONLY if the member has tasks but none are in-progress const hasTasksButNoneActive = memberTasks.length > 0 && !currentTask; - const status = (logStatus === 'working' && hasTasksButNoneActive) ? 'idle' : logStatus; + const status = (isActiveStatus(logStatus) && hasTasksButNoneActive) ? 'idle' : logStatus; members.push({ name, role: 'Squad Member', status, + activityContext: status !== 'idle' ? logActivity?.context : undefined, currentTask, }); } @@ -183,12 +187,13 @@ export class SquadDataProvider { const activeMarkers = await this.detectActiveMarkers(); for (const member of members) { const slug = member.name.toLowerCase(); - if (activeMarkers.has(slug)) { + if (activeMarkers.has(slug) && member.status === 'idle') { member.status = 'working'; + member.activityContext = { description: 'Active work marker detected', shortLabel: '⚡ Working' }; } } - // GitHub-aware status: members with open squad:{member} issues show as working + // GitHub-aware status: members with open squad:{member} issues show as working-on-issue // This is the fallback for "cold start" and between-session scenarios if (this.openIssuesByMember) { for (const member of members) { @@ -196,10 +201,15 @@ export class SquadDataProvider { if (member.status === 'idle') { const memberIssues = this.openIssuesByMember.get(member.name.toLowerCase()); if (memberIssues && memberIssues.length > 0) { - member.status = 'working'; + const mostRecent = this.getMostRecentIssue(memberIssues); + member.status = 'working-on-issue'; + member.activityContext = { + description: mostRecent.title, + shortLabel: `⚙️ Issue #${mostRecent.number}`, + issueNumber: mostRecent.number, + }; // If no currentTask from logs, use most recent issue if (!member.currentTask) { - const mostRecent = this.getMostRecentIssue(memberIssues); member.currentTask = { id: `#${mostRecent.number}`, title: mostRecent.title, diff --git a/src/services/index.ts b/src/services/index.ts index 390d59d..584da6b 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -22,6 +22,11 @@ export { } from './GitHubIssuesService'; export { SkillCatalogService } from './SkillCatalogService'; export { DecisionService } from './DecisionService'; +export { + DecisionSearchService, + type DecisionSearchCriteria, + type ScoredDecision +} from './DecisionSearchService'; export { SquadVersionService, type UpgradeCheckResult } from './SquadVersionService'; export { StandupReportService, diff --git a/src/test/suite/acceptance.test.ts b/src/test/suite/acceptance.test.ts index d965718..6e29cab 100644 --- a/src/test/suite/acceptance.test.ts +++ b/src/test/suite/acceptance.test.ts @@ -14,6 +14,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { TeamTreeProvider, SquadTreeItem } from '../../views/SquadTreeProvider'; import { SquadDataProvider } from '../../services/SquadDataProvider'; +import { isActiveStatus } from '../../models'; const ACCEPTANCE_FIXTURES = path.resolve(__dirname, '../../../test-fixtures/acceptance-scenario'); @@ -56,7 +57,7 @@ suite('Acceptance: Orchestration Logs → Tree View', () => { const alice = members.find(m => m.name === 'Alice')!; const bob = members.find(m => m.name === 'Bob')!; - assert.strictEqual(carol.status, 'working', 'Carol (most recent log) should be working'); + assert.ok(isActiveStatus(carol.status), 'Carol (most recent log) should be working'); assert.strictEqual(alice.status, 'idle', 'Alice (older log) should be idle'); assert.strictEqual(bob.status, 'idle', 'Bob (older log) should be idle'); }); @@ -247,13 +248,15 @@ suite('Acceptance: Orchestration Logs → Tree View', () => { }); suite('member item rendering', () => { - test('all members have person icon (status not shown)', async () => { + test('all members have appropriate icon based on status', async () => { const members = await treeProvider.getChildren(); const carol = members.find(r => r.label === 'Carol'); assert.ok(carol); assert.ok(carol.iconPath instanceof vscode.ThemeIcon); - assert.strictEqual((carol.iconPath as vscode.ThemeIcon).id, 'person'); + // Working members get sync~spin, idle get person + const carolIcon = (carol.iconPath as vscode.ThemeIcon).id; + assert.ok(carolIcon === 'sync~spin' || carolIcon === 'person', `Carol icon should be sync~spin or person, got ${carolIcon}`); }); test('idle members (Alice, Bob) have person icon', async () => { diff --git a/src/test/suite/activeWorkMarkers.test.ts b/src/test/suite/activeWorkMarkers.test.ts index 5c0d8bf..c85d64d 100644 --- a/src/test/suite/activeWorkMarkers.test.ts +++ b/src/test/suite/activeWorkMarkers.test.ts @@ -11,6 +11,7 @@ import * as assert from 'assert'; import * as path from 'path'; import * as fs from 'fs'; import { SquadDataProvider } from '../../services/SquadDataProvider'; +import { isActiveStatus } from '../../models'; const TEST_FIXTURES_ROOT = path.resolve(__dirname, '../../../test-fixtures'); @@ -387,7 +388,7 @@ suite('SquadDataProvider — Active-Work Markers', () => { const danny = members.find(m => m.name === 'Danny'); assert.ok(danny, 'Danny should be in members'); - assert.strictEqual(danny!.status, 'working', + assert.ok(isActiveStatus(danny!.status), 'Danny should be working (from either log or marker — no conflict)'); }); }); diff --git a/src/test/suite/decisionSearchService.test.ts b/src/test/suite/decisionSearchService.test.ts new file mode 100644 index 0000000..137f64e --- /dev/null +++ b/src/test/suite/decisionSearchService.test.ts @@ -0,0 +1,336 @@ +/** + * Tests for DecisionSearchService — search, filter, and ranking of decisions. + * + * Coverage: + * - search(): full-text search with relevance ranking + * - filterByDate(): inclusive date range filtering + * - filterByAuthor(): case-insensitive author matching + * - filter(): combined criteria chaining + * - Edge cases: empty inputs, missing fields, malformed data + */ + +import * as assert from 'assert'; +import { DecisionSearchService, DecisionSearchCriteria } from '../../services/DecisionSearchService'; +import { DecisionEntry } from '../../models'; + +/** + * Helper to build a DecisionEntry with sensible defaults. + */ +function makeDecision(overrides: Partial & { title: string }): DecisionEntry { + return { + filePath: '/fake/decisions.md', + lineNumber: 0, + ...overrides, + }; +} + +suite('DecisionSearchService', () => { + let service: DecisionSearchService; + + const decisions: DecisionEntry[] = [ + makeDecision({ + title: 'Use TypeScript for Extension', + date: '2026-02-14', + author: 'Linus', + content: '## Use TypeScript for Extension\n**By:** Linus\nWe chose TypeScript for type safety.', + }), + makeDecision({ + title: 'CI Pipeline Configuration', + date: '2026-02-15', + author: 'Livingston', + content: '## CI Pipeline Configuration\n**By:** Livingston\nGitHub Actions for CI/CD pipeline.', + }), + makeDecision({ + title: 'Lazy Activation Strategy', + date: '2026-02-16', + author: 'Danny', + content: '## Lazy Activation Strategy\n**By:** Danny\nActivate on onView:squadMembers for performance.', + }), + makeDecision({ + title: 'TypeScript Strict Mode', + date: '2026-02-17', + author: 'Linus', + content: '## TypeScript Strict Mode\n**By:** Linus\nEnable strict mode in tsconfig for safety.', + }), + makeDecision({ + title: 'Dashboard Tab Layout', + date: '2026-02-18', + author: 'Rusty', + content: '## Dashboard Tab Layout\n**By:** Rusty\nUse tabbed layout for the dashboard webview.', + }), + ]; + + setup(() => { + service = new DecisionSearchService(); + }); + + // ─── search() ──────────────────────────────────────────────────────── + + suite('search()', () => { + test('returns all decisions for empty query', () => { + const results = service.search(decisions, ''); + assert.strictEqual(results.length, decisions.length); + }); + + test('returns all decisions for whitespace-only query', () => { + const results = service.search(decisions, ' '); + assert.strictEqual(results.length, decisions.length); + }); + + test('matches title text', () => { + const results = service.search(decisions, 'Pipeline'); + assert.ok(results.length > 0, 'Should find at least one result'); + assert.strictEqual(results[0].title, 'CI Pipeline Configuration'); + }); + + test('matches content text', () => { + const results = service.search(decisions, 'GitHub Actions'); + assert.ok(results.length > 0, 'Should find CI Pipeline decision'); + assert.ok(results.some(d => d.title === 'CI Pipeline Configuration')); + }); + + test('matches author name', () => { + const results = service.search(decisions, 'Rusty'); + assert.ok(results.length > 0, 'Should find Rusty\'s decisions'); + assert.strictEqual(results[0].title, 'Dashboard Tab Layout'); + }); + + test('is case-insensitive', () => { + const results = service.search(decisions, 'typescript'); + assert.ok(results.length >= 2, 'Should find both TypeScript decisions'); + }); + + test('title matches rank higher than content-only matches', () => { + // 'TypeScript' appears in title of two decisions; content-only in none extra + const results = service.search(decisions, 'TypeScript'); + // Both TypeScript decisions should be at the top + const topTitles = results.slice(0, 2).map(d => d.title); + assert.ok(topTitles.includes('Use TypeScript for Extension')); + assert.ok(topTitles.includes('TypeScript Strict Mode')); + }); + + test('multi-word query matches across fields', () => { + const results = service.search(decisions, 'Linus strict'); + assert.ok(results.length > 0); + // "TypeScript Strict Mode" by Linus should rank highest (matches in title + author) + assert.strictEqual(results[0].title, 'TypeScript Strict Mode'); + }); + + test('returns empty array for no matches', () => { + const results = service.search(decisions, 'xyzzyNonExistent'); + assert.strictEqual(results.length, 0); + }); + + test('handles empty decisions array', () => { + const results = service.search([], 'anything'); + assert.strictEqual(results.length, 0); + }); + + test('handles decisions with missing fields gracefully', () => { + const sparse: DecisionEntry[] = [ + makeDecision({ title: 'Minimal Decision' }), + makeDecision({ title: '', content: undefined, author: undefined }), + ]; + // Should not throw + const results = service.search(sparse, 'Minimal'); + assert.ok(results.length >= 1); + assert.strictEqual(results[0].title, 'Minimal Decision'); + }); + }); + + // ─── filterByDate() ────────────────────────────────────────────────── + + suite('filterByDate()', () => { + test('filters to decisions within date range (inclusive)', () => { + const start = new Date(2026, 1, 15); // Feb 15 + const end = new Date(2026, 1, 17); // Feb 17 + const results = service.filterByDate(decisions, start, end); + assert.strictEqual(results.length, 3); + assert.ok(results.every(d => d.date! >= '2026-02-15' && d.date! <= '2026-02-17')); + }); + + test('single-day range returns only that day', () => { + const day = new Date(2026, 1, 16); // Feb 16 + const results = service.filterByDate(decisions, day, day); + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].title, 'Lazy Activation Strategy'); + }); + + test('excludes decisions without a date', () => { + const withNoDate: DecisionEntry[] = [ + ...decisions, + makeDecision({ title: 'No Date Decision' }), + ]; + const start = new Date(2026, 0, 1); + const end = new Date(2026, 11, 31); + const results = service.filterByDate(withNoDate, start, end); + assert.ok(!results.some(d => d.title === 'No Date Decision')); + }); + + test('returns empty when no decisions fall in range', () => { + const start = new Date(2025, 0, 1); + const end = new Date(2025, 0, 31); + const results = service.filterByDate(decisions, start, end); + assert.strictEqual(results.length, 0); + }); + + test('handles empty decisions array', () => { + const start = new Date(2026, 0, 1); + const end = new Date(2026, 11, 31); + const results = service.filterByDate([], start, end); + assert.strictEqual(results.length, 0); + }); + }); + + // ─── filterByAuthor() ──────────────────────────────────────────────── + + suite('filterByAuthor()', () => { + test('filters by exact author name (case-insensitive)', () => { + const results = service.filterByAuthor(decisions, 'linus'); + assert.strictEqual(results.length, 2); + assert.ok(results.every(d => d.author?.toLowerCase() === 'linus')); + }); + + test('filters by partial author name', () => { + const results = service.filterByAuthor(decisions, 'Lin'); + assert.strictEqual(results.length, 2); + }); + + test('returns all decisions for empty author', () => { + const results = service.filterByAuthor(decisions, ''); + assert.strictEqual(results.length, decisions.length); + }); + + test('returns all decisions for whitespace-only author', () => { + const results = service.filterByAuthor(decisions, ' '); + assert.strictEqual(results.length, decisions.length); + }); + + test('excludes decisions without an author', () => { + const withNoAuthor: DecisionEntry[] = [ + ...decisions, + makeDecision({ title: 'Anonymous', date: '2026-02-20' }), + ]; + const results = service.filterByAuthor(withNoAuthor, 'Linus'); + assert.ok(!results.some(d => d.title === 'Anonymous')); + }); + + test('returns empty when no author matches', () => { + const results = service.filterByAuthor(decisions, 'NonExistentAuthor'); + assert.strictEqual(results.length, 0); + }); + + test('handles empty decisions array', () => { + const results = service.filterByAuthor([], 'Linus'); + assert.strictEqual(results.length, 0); + }); + }); + + // ─── filter() (combined) ───────────────────────────────────────────── + + suite('filter() — combined criteria', () => { + test('applies all criteria together', () => { + const criteria: DecisionSearchCriteria = { + query: 'TypeScript', + startDate: '2026-02-14', + endDate: '2026-02-16', + author: 'Linus', + }; + const results = service.filter(decisions, criteria); + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].title, 'Use TypeScript for Extension'); + }); + + test('returns all decisions with empty criteria', () => { + const results = service.filter(decisions, {}); + assert.strictEqual(results.length, decisions.length); + }); + + test('applies only query when other fields are absent', () => { + const results = service.filter(decisions, { query: 'Dashboard' }); + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].title, 'Dashboard Tab Layout'); + }); + + test('applies only date range when query/author are absent', () => { + const results = service.filter(decisions, { + startDate: '2026-02-17', + endDate: '2026-02-18', + }); + assert.strictEqual(results.length, 2); + }); + + test('applies only author when query/date are absent', () => { + const results = service.filter(decisions, { author: 'Danny' }); + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].title, 'Lazy Activation Strategy'); + }); + + test('open-ended start date (endDate only)', () => { + const results = service.filter(decisions, { endDate: '2026-02-15' }); + assert.strictEqual(results.length, 2); + }); + + test('open-ended end date (startDate only)', () => { + const results = service.filter(decisions, { startDate: '2026-02-17' }); + assert.strictEqual(results.length, 2); + }); + + test('combined criteria with no matches returns empty', () => { + const results = service.filter(decisions, { + query: 'TypeScript', + author: 'Rusty', // Rusty didn't write TypeScript decisions + }); + assert.strictEqual(results.length, 0); + }); + + test('preserves search ranking order after date/author filters', () => { + const criteria: DecisionSearchCriteria = { + query: 'TypeScript', + author: 'Linus', + }; + const results = service.filter(decisions, criteria); + assert.strictEqual(results.length, 2); + // Both should be Linus's TypeScript decisions, ranked by relevance + assert.ok(results[0].title.includes('TypeScript')); + assert.ok(results[1].title.includes('TypeScript')); + }); + }); + + // ─── Edge Cases ────────────────────────────────────────────────────── + + suite('edge cases', () => { + test('decisions with identical scores maintain stable order', () => { + const identical: DecisionEntry[] = [ + makeDecision({ title: 'Alpha Feature', content: 'same content', author: 'A', date: '2026-01-01' }), + makeDecision({ title: 'Beta Feature', content: 'same content', author: 'A', date: '2026-01-02' }), + ]; + const results = service.search(identical, 'same content'); + assert.strictEqual(results.length, 2); + }); + + test('special regex characters in query do not throw', () => { + // Shouldn't throw — query is used via includes(), not regex + const results = service.search(decisions, '.*+?^${}()|[]\\'); + assert.strictEqual(results.length, 0); + }); + + test('very long query string does not throw', () => { + const longQuery = 'word '.repeat(1000); + const results = service.search(decisions, longQuery); + assert.ok(Array.isArray(results)); + }); + + test('filterByDate with reversed range returns empty', () => { + const start = new Date(2026, 1, 18); + const end = new Date(2026, 1, 14); + const results = service.filterByDate(decisions, start, end); + assert.strictEqual(results.length, 0); + }); + + test('filter with only whitespace query behaves like no query', () => { + const results = service.filter(decisions, { query: ' ' }); + assert.strictEqual(results.length, decisions.length); + }); + }); +}); diff --git a/src/test/suite/e2e-validation.test.ts b/src/test/suite/e2e-validation.test.ts index e016148..325de98 100644 --- a/src/test/suite/e2e-validation.test.ts +++ b/src/test/suite/e2e-validation.test.ts @@ -17,7 +17,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { TeamTreeProvider, SquadTreeItem } from '../../views/SquadTreeProvider'; import { SquadDataProvider } from '../../services/SquadDataProvider'; -import { WorkDetails } from '../../models'; +import { WorkDetails, isActiveStatus } from '../../models'; const ACCEPTANCE_FIXTURES = path.resolve(__dirname, '../../../test-fixtures/acceptance-scenario'); @@ -238,11 +238,11 @@ suite('E2E MVP Validation (Issue #14)', () => { assert.deepStrictEqual(treeLabels, memberNames); }); - test('working member (Carol) gets person icon (status not shown)', async () => { + test('working member (Carol) gets sync~spin icon (status shown)', async () => { const members = await treeProvider.getChildren(); const carol = members.find(r => r.label === 'Carol')!; assert.ok(carol.iconPath instanceof vscode.ThemeIcon); - assert.strictEqual((carol.iconPath as vscode.ThemeIcon).id, 'person'); + assert.strictEqual((carol.iconPath as vscode.ThemeIcon).id, 'sync~spin'); }); test('idle members get person icon (not spinning)', async () => { @@ -273,7 +273,7 @@ suite('E2E MVP Validation (Issue #14)', () => { const alice = members.find(m => m.name === 'Alice')!; const bob = members.find(m => m.name === 'Bob')!; - assert.strictEqual(carol.status, 'working', 'Carol (most recent log) should be working'); + assert.ok(isActiveStatus(carol.status), 'Carol (most recent log) should be working'); assert.strictEqual(alice.status, 'idle', 'Alice (older log only) should be idle'); assert.strictEqual(bob.status, 'idle', 'Bob (older log only) should be idle'); }); diff --git a/src/test/suite/services.test.ts b/src/test/suite/services.test.ts index 39b590e..e0ec830 100644 --- a/src/test/suite/services.test.ts +++ b/src/test/suite/services.test.ts @@ -286,7 +286,7 @@ suite('SquadDataProvider', () => { for (const member of members) { assert.ok(member.name, 'Member should have name'); - assert.ok(['working', 'idle'].includes(member.status), 'Member should have valid status'); + assert.ok(['working', 'working-on-issue', 'reviewing-pr', 'waiting-review', 'idle'].includes(member.status), 'Member should have valid status'); } }); diff --git a/src/test/suite/sessionLogIsolation.test.ts b/src/test/suite/sessionLogIsolation.test.ts index e4eff3d..12c2c0b 100644 --- a/src/test/suite/sessionLogIsolation.test.ts +++ b/src/test/suite/sessionLogIsolation.test.ts @@ -15,6 +15,7 @@ import * as assert from 'assert'; import * as path from 'path'; import { SquadDataProvider } from '../../services/SquadDataProvider'; import { OrchestrationLogService } from '../../services/OrchestrationLogService'; +import { isActiveStatus } from '../../models'; const SENSEI_FIXTURES = path.resolve(__dirname, '../../../test-fixtures/sensei-scenario'); const SESSION_LOG_ISSUES_FIXTURES = path.resolve(__dirname, '../../../test-fixtures/session-log-issues'); @@ -145,7 +146,7 @@ suite('Session Log Isolation: Issue reference scenario', () => { // Rusty appears in orchestration-log/2026-02-13-member-working.md assert.ok(rusty, 'Rusty should be in roster from team.md'); - assert.strictEqual(rusty!.status, 'working', 'Rusty should be working (in orchestration-log)'); + assert.ok(isActiveStatus(rusty!.status), 'Rusty should be working (in orchestration-log)'); }); }); diff --git a/src/test/suite/squadDataProviderExtended.test.ts b/src/test/suite/squadDataProviderExtended.test.ts index 39ad08e..1830e3d 100644 --- a/src/test/suite/squadDataProviderExtended.test.ts +++ b/src/test/suite/squadDataProviderExtended.test.ts @@ -10,6 +10,7 @@ import * as assert from 'assert'; import * as path from 'path'; import * as fs from 'fs'; import { SquadDataProvider } from '../../services/SquadDataProvider'; +import { isActiveStatus } from '../../models'; const TEST_FIXTURES_ROOT = path.resolve(__dirname, '../../../test-fixtures'); @@ -219,8 +220,8 @@ suite('SquadDataProvider — Extended Coverage', () => { const bob = members.find(m => m.name === 'Bob'); assert.ok(bob, 'Should find Bob'); - // Bob has no tasks at all, so should stay "working" (Copilot Chat scenario) - assert.strictEqual(bob!.status, 'working', 'Should stay working when no tasks exist'); + // Bob has no tasks at all, so should stay active (Copilot Chat scenario) + assert.ok(isActiveStatus(bob!.status), 'Should stay working when no tasks exist'); }); test('member stays working when log says working and has in-progress tasks', async () => { @@ -254,8 +255,8 @@ suite('SquadDataProvider — Extended Coverage', () => { const carol = members.find(m => m.name === 'Carol'); assert.ok(carol, 'Should find Carol'); - // Carol has an in-progress task, should stay "working" - assert.strictEqual(carol!.status, 'working', 'Should stay working with in-progress tasks'); + // Carol has an in-progress task, should stay active + assert.ok(isActiveStatus(carol!.status), 'Should stay working with in-progress tasks'); }); test('member not in logs shows as idle', async () => { @@ -322,7 +323,7 @@ suite('SquadDataProvider — Extended Coverage', () => { members = await provider.getSquadMembers(); eve = members.find(m => m.name === 'Eve'); - assert.strictEqual(eve!.status, 'working', 'Eve becomes working with open issues'); + assert.strictEqual(eve!.status, 'working-on-issue', 'Eve becomes working-on-issue with open issues'); assert.ok(eve!.currentTask, 'Eve should have currentTask from issue'); assert.strictEqual(eve!.currentTask!.id, '#42'); assert.strictEqual(eve!.currentTask!.title, 'Fix the widget'); @@ -415,7 +416,7 @@ suite('SquadDataProvider — Extended Coverage', () => { const members = await provider.getSquadMembers(); const grace = members.find(m => m.name === 'Grace'); - assert.strictEqual(grace!.status, 'working'); + assert.strictEqual(grace!.status, 'working-on-issue'); assert.strictEqual(grace!.currentTask!.id, '#20', 'Should pick most recent issue (#20)'); assert.strictEqual(grace!.currentTask!.title, 'Recent issue'); }); @@ -466,7 +467,7 @@ suite('SquadDataProvider — Extended Coverage', () => { const members = await provider.getSquadMembers(); const henry = members.find(m => m.name === 'Henry'); - assert.strictEqual(henry!.status, 'working'); + assert.ok(isActiveStatus(henry!.status)); // Should keep the log-derived task, not the GitHub issue // Log-derived task IDs don't have # prefix assert.strictEqual(henry!.currentTask!.id, '99', 'Should keep log task, not GitHub issue'); @@ -503,7 +504,7 @@ suite('SquadDataProvider — Extended Coverage', () => { const members = await provider.getSquadMembers(); const isabella = members.find(m => m.name === 'Isabella'); - assert.strictEqual(isabella!.status, 'working', 'Should match case-insensitively'); + assert.strictEqual(isabella!.status, 'working-on-issue', 'Should match case-insensitively'); }); }); }); diff --git a/src/test/suite/squadStatusBar.test.ts b/src/test/suite/squadStatusBar.test.ts index 9c02702..b1fcbb1 100644 --- a/src/test/suite/squadStatusBar.test.ts +++ b/src/test/suite/squadStatusBar.test.ts @@ -68,11 +68,11 @@ suite('SquadStatusBar', () => { await statusBar.update(); const statusBarItem = (statusBar as any).statusBarItem; - assert.ok(statusBarItem.text.includes('1 member'), 'Should show 1 member'); - assert.ok(!statusBarItem.text.includes('1 members'), 'Should not show "1 members"'); + // Working member shows "1/1 working" + assert.ok(statusBarItem.text.includes('1/1 working'), 'Should show 1/1 working'); }); - test('does not show active/idle counts', async () => { + test('shows working count when members are active', async () => { mockProvider.setMembers([ { name: 'Alice', role: 'Dev', status: 'working' }, { name: 'Bob', role: 'Tester', status: 'working' }, @@ -83,12 +83,11 @@ suite('SquadStatusBar', () => { await statusBar.update(); const statusBarItem = (statusBar as any).statusBarItem; - assert.ok(!statusBarItem.text.includes('Active'), 'Should not show Active'); assert.ok(!statusBarItem.text.includes('🟢'), 'Should not show health icon'); assert.ok(!statusBarItem.text.includes('🟡'), 'Should not show health icon'); assert.ok(!statusBarItem.text.includes('🟠'), 'Should not show health icon'); assert.ok(!statusBarItem.text.includes('⚪'), 'Should not show health icon'); - assert.ok(statusBarItem.text.includes('4 members'), 'Should show total member count'); + assert.ok(statusBarItem.text.includes('2/4 working'), 'Should show 2/4 working'); }); }); @@ -149,7 +148,7 @@ suite('SquadStatusBar', () => { await statusBar.update(); const statusBarItem = (statusBar as any).statusBarItem; - assert.ok(statusBarItem.text.includes('3 members'), 'Should show 3 members'); + assert.ok(statusBarItem.text.includes('2/3 working'), 'Should show 2/3 working'); }); }); diff --git a/src/test/suite/treeProvider.test.ts b/src/test/suite/treeProvider.test.ts index 23ce944..f6ada2e 100644 --- a/src/test/suite/treeProvider.test.ts +++ b/src/test/suite/treeProvider.test.ts @@ -205,13 +205,13 @@ suite('TeamTreeProvider Test Suite', () => { }); suite('tree item icons', () => { - test('member has person icon regardless of status', async () => { + test('working member has sync~spin icon', async () => { const children = await provider.getChildren(); const danny = children.find((c) => c.label === 'Danny'); assert.ok(danny); assert.ok(danny.iconPath instanceof vscode.ThemeIcon); - assert.strictEqual((danny.iconPath as vscode.ThemeIcon).id, 'person'); + assert.strictEqual((danny.iconPath as vscode.ThemeIcon).id, 'sync~spin'); }); test('idle member has person icon', async () => { diff --git a/src/views/SquadStatusBar.ts b/src/views/SquadStatusBar.ts index 985a11a..20cb74d 100644 --- a/src/views/SquadStatusBar.ts +++ b/src/views/SquadStatusBar.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import { SquadDataProvider } from '../services/SquadDataProvider'; +import { isActiveStatus } from '../models'; export class SquadStatusBar { private statusBarItem: vscode.StatusBarItem; @@ -38,17 +39,25 @@ export class SquadStatusBar { } const totalCount = members.length; + const workingCount = members.filter(m => isActiveStatus(m.status)).length; - // Simple member count — no active/idle status - this.statusBarItem.text = `$(organization) Squad: ${totalCount} member${totalCount !== 1 ? 's' : ''}`; + // Show working/total member count with contextual status + if (workingCount > 0) { + this.statusBarItem.text = `$(organization) Squad: ${workingCount}/${totalCount} working`; + } else { + this.statusBarItem.text = `$(organization) Squad: ${totalCount} member${totalCount !== 1 ? 's' : ''}`; + } - // Build tooltip with member list + // Build tooltip with member list and status const tooltip = new vscode.MarkdownString(); tooltip.appendMarkdown(`**Squad**\n\n`); - tooltip.appendMarkdown(`Members: ${totalCount}\n\n`); + tooltip.appendMarkdown(`Members: ${totalCount} · Working: ${workingCount}\n\n`); for (const member of members) { - tooltip.appendMarkdown(`- ${member.name} — ${member.role}\n`); + const statusIndicator = member.activityContext + ? member.activityContext.shortLabel + : '—'; + tooltip.appendMarkdown(`- ${member.name} — ${member.role} · ${statusIndicator}\n`); } this.statusBarItem.tooltip = tooltip; diff --git a/src/views/SquadTreeProvider.ts b/src/views/SquadTreeProvider.ts index dec21b1..a20dd43 100644 --- a/src/views/SquadTreeProvider.ts +++ b/src/views/SquadTreeProvider.ts @@ -3,7 +3,7 @@ */ import * as vscode from 'vscode'; -import { SquadMember, Task, GitHubIssue, Skill, DecisionEntry, IGitHubIssuesService } from '../models'; +import { SquadMember, Task, GitHubIssue, Skill, DecisionEntry, IGitHubIssuesService, isActiveStatus } from '../models'; import { SquadDataProvider } from '../services/SquadDataProvider'; import { SkillCatalogService } from '../services/SkillCatalogService'; import { DecisionService } from '../services/DecisionService'; @@ -120,13 +120,23 @@ export class TeamTreeProvider implements vscode.TreeDataProvider : lowerName === 'ralph' ? 'eye' : isCopilot ? 'robot' : undefined; - item.iconPath = new vscode.ThemeIcon(specialIcon ?? 'person'); + + // Active members get a spinning icon, idle get person (unless special) + const memberActive = isActiveStatus(member.status); + if (!specialIcon) { + item.iconPath = memberActive + ? new vscode.ThemeIcon('sync~spin', new vscode.ThemeColor('charts.green')) + : new vscode.ThemeIcon('person'); + } else { + item.iconPath = new vscode.ThemeIcon(specialIcon); + } - // Build description with role and issue count + // Build description with role, status context, and issue count const issueCount = await this.getIssueCount(member.name); const issueText = issueCount > 0 ? ` • ${issueCount} issue${issueCount > 1 ? 's' : ''}` : ''; + const statusText = member.activityContext ? ` • ${member.activityContext.shortLabel}` : ''; - item.description = `${member.role}${issueText}`; + item.description = `${member.role}${statusText}${issueText}`; item.tooltip = this.getMemberTooltip(member); if (!isCopilot) { @@ -273,7 +283,13 @@ export class TeamTreeProvider implements vscode.TreeDataProvider private getMemberTooltip(member: SquadMember): vscode.MarkdownString { const md = new vscode.MarkdownString(); md.appendMarkdown(`**${stripMarkdownLinks(member.name)}**\n\n`); - md.appendMarkdown(`Role: ${member.role}`); + md.appendMarkdown(`Role: ${member.role}\n\n`); + if (member.activityContext) { + md.appendMarkdown(`Status: ${member.activityContext.shortLabel}\n\n`); + md.appendMarkdown(`${member.activityContext.description}`); + } else { + md.appendMarkdown(`Status: —`); + } return md; } diff --git a/src/views/WorkDetailsWebview.ts b/src/views/WorkDetailsWebview.ts index 2cab08b..be9b2c4 100644 --- a/src/views/WorkDetailsWebview.ts +++ b/src/views/WorkDetailsWebview.ts @@ -241,7 +241,7 @@ export class WorkDetailsWebview {
${this.getInitials(stripMarkdownLinks(member.name))}
${renderMarkdownLinks(this.escapeHtml(member.name))}
-
${this.escapeHtml(member.role)}
+
${this.escapeHtml(member.role)}
${member.activityContext ? `\n
${this.escapeHtml(member.activityContext.shortLabel)}
` : ''}
diff --git a/src/views/dashboard/DashboardDataBuilder.ts b/src/views/dashboard/DashboardDataBuilder.ts index f181710..56f2151 100644 --- a/src/views/dashboard/DashboardDataBuilder.ts +++ b/src/views/dashboard/DashboardDataBuilder.ts @@ -3,7 +3,7 @@ * Builds data for velocity charts, activity timelines, and decision browser. */ -import { OrchestrationLogEntry, Task, SquadMember, DashboardData, VelocityDataPoint, ActivityHeatmapPoint, ActivitySwimlane, TimelineTask, DecisionEntry, TeamMemberOverview, TeamSummary, MemberIssueMap, GitHubIssue, MilestoneBurndown, BurndownDataPoint } from '../../models'; +import { OrchestrationLogEntry, Task, SquadMember, DashboardData, VelocityDataPoint, ActivityHeatmapPoint, ActivitySwimlane, TimelineTask, DecisionEntry, TeamMemberOverview, TeamSummary, MemberIssueMap, GitHubIssue, MilestoneBurndown, BurndownDataPoint, isActiveStatus } from '../../models'; import { parseDateAsLocal, toLocalDateKey } from '../../utils/dateUtils'; export class DashboardDataBuilder { @@ -233,12 +233,16 @@ export class DashboardDataBuilder { totalOpenIssues += memberOpenIssues.length; totalClosedIssues += memberClosedIssues.length; totalActiveTasks += memberTasks.length; - if (member.status === 'working') { activeMembers++; } + if (isActiveStatus(member.status)) { activeMembers++; } return { name: member.name, role: member.role, status: member.status, + statusLabel: member.activityContext?.shortLabel, + activityContext: member.activityContext + ? { description: member.activityContext.description, shortLabel: member.activityContext.shortLabel } + : undefined, iconType, openIssueCount: memberOpenIssues.length, closedIssueCount: memberClosedIssues.length, diff --git a/src/views/dashboard/htmlTemplate.ts b/src/views/dashboard/htmlTemplate.ts index e537ec4..3d2d4ac 100644 --- a/src/views/dashboard/htmlTemplate.ts +++ b/src/views/dashboard/htmlTemplate.ts @@ -388,6 +388,20 @@ export function getDashboardHtml(data: DashboardData): string { font-size: 0.85em; color: var(--vscode-descriptionForeground); } + .member-card-status { + font-size: 0.8em; + margin-top: 4px; + padding: 2px 6px; + border-radius: 3px; + display: inline-block; + } + .member-card-status.active { + background-color: var(--vscode-inputValidation-infoBackground, #063b49); + color: var(--vscode-inputValidation-infoForeground, #3794ff); + } + .member-card-status.idle { + color: var(--vscode-descriptionForeground); + } .member-card-stats { display: flex; gap: 12px; @@ -584,6 +598,10 @@ export function getDashboardHtml(data: DashboardData): string { \${summary.totalMembers} Members +
+ \${summary.activeMembers} + Working +
\${summary.totalOpenIssues} Open Issues @@ -610,6 +628,10 @@ export function getDashboardHtml(data: DashboardData): string { : m.iconType === 'copilot' ? '🤖' : '👤'; const displayName = stripMarkdownLinks(m.name); + const hasActivity = m.activityContext && m.activityContext.shortLabel; + const statusHtml = hasActivity + ? \`
\${escapeHtml(m.activityContext.shortLabel)}
\` + : \`
\`; return \`
@@ -618,6 +640,7 @@ export function getDashboardHtml(data: DashboardData): string {
\${escapeHtml(displayName)}
\${escapeHtml(m.role)}
+ \${statusHtml}