Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 33 additions & 26 deletions web/src/routes/[username]/[workspace]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,36 @@
{/if}
{/if}

<!-- 2b. Needs Attention, first when it has anything (TASK-3588): the
daily question is "what needs me?", and it matters more as agents
produce work faster than people inspect it. Compact (capped, with
the same Show all toggle); absent when empty, which leaves the
rest of the layout exactly as before. -->
{#if dashboard.attention.length > 0}
<section class="section attention-first" data-testid="needs-attention">
<div class="section-header">
<span class="section-label">Needs Attention</span>
<span class="section-badge">{dashboard.attention.length}</span>
</div>
<div class="attention-list">
{#each visibleRows(attentionOrdered, ATTENTION_CAP, attentionExpanded) as alert (`${alert.type}:${alert.item_slug}`)}
<div class="attention-card">
<span class="attention-icon">{attentionIcon(alert.type)}</span>
<div class="attention-content">
<a href="/{username}/{wsSlug}/{alert.collection}/{alert.item_slug}" class="attention-title">{alert.item_title}</a>
<span class="attention-reason">{alert.reason}</span>
</div>
</div>
{/each}
</div>
{#if dashboard.attention.length > ATTENTION_CAP}
<button type="button" class="show-all-btn" aria-expanded={attentionExpanded} onclick={toggleAttention}>
{attentionExpanded ? 'Show fewer' : `Show all (${dashboard.attention.length})`}
</button>
{/if}
</section>
{/if}

<!-- 3. Active Work -->
{#if dashboard.active_items.length > 0}
<section class="section">
Expand Down Expand Up @@ -911,33 +941,10 @@
</div>
</section>

<!-- 6. Attention + Up Next (side by side) -->
{#if dashboard.attention.length > 0 || dashboard.suggested_next.length > 0}
<!-- 6. Up Next. Needs Attention, when it has anything, renders first
(TASK-3588); with it empty this row is what it always was. -->
{#if dashboard.suggested_next.length > 0}
<section class="section dual-section">
{#if dashboard.attention.length > 0}
<div class="dual-col">
<div class="section-header">
<span class="section-label">Needs Attention</span>
<span class="section-badge">{dashboard.attention.length}</span>
</div>
<div class="attention-list">
{#each visibleRows(attentionOrdered, ATTENTION_CAP, attentionExpanded) as alert (`${alert.type}:${alert.item_slug}`)}
<div class="attention-card">
<span class="attention-icon">{attentionIcon(alert.type)}</span>
<div class="attention-content">
<a href="/{username}/{wsSlug}/{alert.collection}/{alert.item_slug}" class="attention-title">{alert.item_title}</a>
<span class="attention-reason">{alert.reason}</span>
</div>
</div>
{/each}
</div>
{#if dashboard.attention.length > ATTENTION_CAP}
<button type="button" class="show-all-btn" aria-expanded={attentionExpanded} onclick={toggleAttention}>
{attentionExpanded ? 'Show fewer' : `Show all (${dashboard.attention.length})`}
</button>
{/if}
</div>
{/if}
{#if dashboard.suggested_next.length > 0}
<div class="dual-col">
<div class="section-header">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// TASK-3588: Needs Attention renders first when it has anything.
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { cleanup, render } from '@testing-library/svelte';
import { tick } from 'svelte';

const mocks = vi.hoisted(() => ({
itemEventCallbacks: [] as Array<(e: Record<string, unknown>) => void>,
unsubscribed: 0,
needsOnboarding: false,
attention: [] as unknown[],
suggested: [] as unknown[],
}));

// Reactive, so a leg can switch workspaces under the mounted page.
vi.mock('$app/state', async () => ({ page: (await import('../../../test/mocks/reactivePage.svelte')).page }));
vi.mock('$lib/stores/auth.svelte', () => ({
authStore: {
identityEpoch: 0,
userId: 'u1',
user: { id: 'u1', name: 'A', email: 'a@example.com' },
mcpPublicUrl: '',
identityFence: () => () => true,
onIdentityChange: () => () => {},
},
}));
vi.mock('$lib/api/client', () => ({
api: {
dashboard: { get: vi.fn(async () => board()) },
collections: { list: vi.fn(async () => []) },
workspaces: { claimCode: vi.fn(async () => ({ code: 'x', expires_at: '' })) },
},
PadApiError: class PadApiError extends Error {},
}));
vi.mock('$lib/services/sse.svelte', () => ({
sseService: {
onItemEvent: (cb: (e: Record<string, unknown>) => void) => {
mocks.itemEventCallbacks.push(cb);
return () => {
mocks.unsubscribed++;
mocks.itemEventCallbacks = mocks.itemEventCallbacks.filter((c) => c !== cb);
};
},
},
}));
vi.mock('$lib/stores/workspace.svelte', () => ({
workspaceStore: {
setCurrent: vi.fn(async () => {}),
get current() { return { id: 'w1', slug: 'ws', name: 'WS' }; },
get isOwner() { return true; },
get membershipKnown() { return true; },
get currentRole() { return 'owner'; },
get workspaces() { return []; },
loadAll: vi.fn(async () => {}),
},
}));
vi.mock('$lib/services/sync.svelte', () => ({
syncService: { onSync: () => () => {}, start: () => {}, stop: () => {} },
}));
vi.mock('$lib/stores/ui.svelte', () => ({
uiStore: {
get connectAfterNavigateSlug() { return null; },
consumeConnectAfterNavigate() {},
requestQuickAdd() {},
},
}));
vi.mock('$lib/stores/collections.svelte', () => ({ collectionStore: { loadCollections: vi.fn() } }));
vi.mock('$lib/stores/title.svelte', () => ({ titleStore: { setPageTitle() {} } }));
vi.mock('$lib/scroll/restore.svelte', () => ({
createScrollRestoration: () => ({ snapshot: { capture: () => null, restore: () => {} } }),
}));

import { api } from '$lib/api/client';
import { page } from '$app/state';
import DashboardPage from './+page.svelte';

function board() {
return {
summary: { total_items: 3, by_collection: {} },
active_items: [{ slug: 'busy', title: 'Busy task', collection_slug: 'tasks', collection_icon: '✓', status: 'in-progress', updated_at: '2026-10-11T00:00:00Z' }],
starred_items: [],
active_plans: [],
attention: mocks.attention,
recent_activity: [],
suggested_next: mocks.suggested,
has_agent_activity: false,
needs_onboarding: false,
degraded: false,
degraded_sections: [],
};
}

async function flush() {
for (let i = 0; i < 10; i++) {
await Promise.resolve();
await tick();
}
}

async function mount() {
page.params = { username: 'dave', workspace: 'ws' };
page.url = new URL('http://localhost/dave/ws');
const r = render(DashboardPage);
await flush();
return r;
}

/** The section labels in document order. */
function sectionOrder(container: HTMLElement): string[] {
return Array.from(container.querySelectorAll('.section-label')).map((e) => (e.textContent ?? '').trim());
}

beforeEach(() => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] });
mocks.attention = [];
mocks.suggested = [];
});

afterEach(() => {
cleanup();
vi.useRealTimers();
});

// TASK-3588: "what needs me?" is the daily question, so a populated Needs
// Attention renders first, above the inventory; empty, the layout is as it was.
describe('Needs Attention comes first (TASK-3588)', () => {
it('populated: it is the first section, above Active Work, with its reason and link', async () => {
mocks.attention = [{ type: 'stalled', item_slug: 'stuck', item_title: 'Stuck task', collection: 'tasks', reason: 'In progress for 9 days' }];
mocks.suggested = [{ item_slug: 'next', item_title: 'Next task', collection: 'tasks', reason: 'High priority' }];
const { container } = await mount();
const order = sectionOrder(container);
expect(order[0]).toBe('Needs Attention');
expect(order.indexOf('Needs Attention')).toBeLessThan(order.indexOf('Active Work'));
// Up Next stays where it was, below the inventory.
expect(order.indexOf('Up Next')).toBeGreaterThan(order.indexOf('Active Work'));
const section = container.querySelector('[data-testid="needs-attention"]')!;
expect(section.textContent).toContain('In progress for 9 days');
expect(section.querySelector('a')?.getAttribute('href')).toBe('/dave/ws/tasks/stuck');
// Rendered once, not also in the old place.
expect(order.filter((l) => l === 'Needs Attention')).toHaveLength(1);
});

it('empty: no attention section, and the order is unchanged', async () => {
mocks.suggested = [{ item_slug: 'next', item_title: 'Next task', collection: 'tasks', reason: 'High priority' }];
const { container } = await mount();
const order = sectionOrder(container);
expect(order).not.toContain('Needs Attention');
expect(container.querySelector('[data-testid="needs-attention"]')).toBeNull();
expect(order[0]).toBe('Active Work');
expect(order.indexOf('Up Next')).toBeGreaterThan(order.indexOf('Collections'));
});
});
Loading