feat: refonte UX du dashboard (coquille, tri/filtre/pagination, ⌘K, toasts)

Refonte front (packages/web) : coquille applicative partagée (sidebar desktop +
barre d'onglets mobile + PageHeader, langue/push/logout centralisés) pilotée par
route.meta.layout ; design system (BaseButton/BaseBadge/SegmentedControl/EmptyState/
SkeletonRow, anneau de focus, icônes @lucide/vue) ; tri/filtre/recherche/pagination
côté client via useListControls (+ synchro URL) sur sessions/worktrees/groupes ;
section « À traiter », palette de commandes ⌘K, toasts. Aucune modif serveur/protocole/DB.
This commit is contained in:
2026-06-18 11:47:36 +02:00
parent c1390bfe06
commit d6c110fc3b
41 changed files with 2416 additions and 448 deletions

View File

@@ -0,0 +1,161 @@
import type { GroupSummary, RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
import type { FilterDef, SortDef } from './useListControls';
// ---- état canonique d'une session (réutilise la logique de SessionStateBadge) ----
type CanonState = 'waiting' | 'busy' | 'idle' | null;
const canonState = (s: SessionSummary): CanonState => (s.activity ?? s.registryStatus ?? null);
/** Rang d'actionnabilité : waiting(0) < busy(1) < idle(2) < live(3) < resumable(4) < exited(5). */
export function sessionStateRank(s: SessionSummary): number {
if (s.live) {
const st = canonState(s);
return st === 'waiting' ? 0 : st === 'busy' ? 1 : st === 'idle' ? 2 : 3;
}
return s.resumable ? 4 : 5;
}
/** Une session correspond-elle à une valeur d'état de filtre ? */
function sessionMatchesState(s: SessionSummary, value: string): boolean {
switch (value) {
case 'live':
return s.live;
case 'waiting':
case 'busy':
case 'idle':
return s.live && canonState(s) === value;
case 'exited':
return !s.live && !s.resumable;
case 'resumable':
return !s.live && s.resumable;
default:
return false;
}
}
// ---- sessions ----
export function sessionSorts(): SortDef<SessionSummary>[] {
return [
{ key: 'state', labelKey: 'sort.session.state', compare: (a, b) => sessionStateRank(a) - sessionStateRank(b), defaultDir: 'asc' },
{ key: 'recent', labelKey: 'sort.session.recent', compare: (a, b) => a.createdAt.localeCompare(b.createdAt), defaultDir: 'desc' },
{ key: 'clients', labelKey: 'sort.session.clients', compare: (a, b) => a.clients - b.clients, defaultDir: 'desc' },
{ key: 'cwd', labelKey: 'sort.session.cwd', compare: (a, b) => a.cwd.localeCompare(b.cwd), defaultDir: 'asc' },
{ key: 'command', labelKey: 'sort.session.command', compare: (a, b) => a.command.localeCompare(b.command), defaultDir: 'asc' },
];
}
export function sessionFilters(repos: readonly RepoSummary[]): FilterDef<SessionSummary>[] {
const STATES = ['live', 'waiting', 'busy', 'idle', 'exited', 'resumable'];
// corrélation session→repo par préfixe de chemin (cwd sous repo.path).
const repoPaths = repos.map((r) => ({ id: r.id, path: r.path }));
return [
{
key: 'state',
labelKey: 'filter.state.label',
options: STATES.map((v) => ({ value: v, labelKey: `filter.state.${v}` })),
predicate: (s, active) => [...active].some((v) => sessionMatchesState(s, v)),
},
{
key: 'command',
labelKey: 'filter.command.label',
options: [
{ value: 'claude', label: 'claude' },
{ value: 'bash', label: 'bash' },
],
predicate: (s, active) => active.has(s.command),
},
{
key: 'source',
labelKey: 'filter.source.label',
options: [
{ value: 'managed', labelKey: 'filter.source.managed' },
{ value: 'discovered', labelKey: 'filter.source.discovered' },
],
predicate: (s, active) => active.has(s.source),
},
{
key: 'repo',
labelKey: 'filter.repo.label',
options: repos.map((r) => ({ value: r.id, label: r.label })),
predicate: (s, active) =>
repoPaths.some((r) => active.has(r.id) && (s.cwd === r.path || s.cwd.startsWith(r.path + '/'))),
},
];
}
// ---- worktrees ----
function worktreeActivityRank(w: WorktreeSummary): number {
if (w.sessions.length === 0) return 99;
return Math.min(...w.sessions.map(sessionStateRank));
}
export function worktreeSorts(): SortDef<WorktreeSummary>[] {
return [
{ key: 'main', labelKey: 'sort.worktree.main', compare: (a, b) => Number(b.isMain) - Number(a.isMain), defaultDir: 'asc' },
{ key: 'branch', labelKey: 'sort.worktree.branch', compare: (a, b) => (a.branch ?? '').localeCompare(b.branch ?? ''), defaultDir: 'asc' },
{ key: 'dirty', labelKey: 'sort.worktree.dirty', compare: (a, b) => a.git.dirtyCount - b.git.dirtyCount, defaultDir: 'desc' },
{ key: 'ahead', labelKey: 'sort.worktree.ahead', compare: (a, b) => a.git.ahead - b.git.ahead, defaultDir: 'desc' },
{ key: 'behind', labelKey: 'sort.worktree.behind', compare: (a, b) => a.git.behind - b.git.behind, defaultDir: 'desc' },
{ key: 'activity', labelKey: 'sort.worktree.activity', compare: (a, b) => worktreeActivityRank(a) - worktreeActivityRank(b), defaultDir: 'asc' },
];
}
export function worktreeFilters(): FilterDef<WorktreeSummary>[] {
return [
{
key: 'dirtiness',
labelKey: 'filter.worktree.dirtiness',
options: [
{ value: 'dirty', labelKey: 'filter.worktree.dirty' },
{ value: 'clean', labelKey: 'filter.worktree.clean' },
],
predicate: (w, active) =>
(active.has('dirty') && w.git.dirtyCount > 0) || (active.has('clean') && w.git.dirtyCount === 0),
},
{
key: 'session',
labelKey: 'filter.worktree.session',
options: [
{ value: 'with', labelKey: 'filter.worktree.with' },
{ value: 'without', labelKey: 'filter.worktree.without' },
],
predicate: (w, active) =>
(active.has('with') && w.sessions.length > 0) || (active.has('without') && w.sessions.length === 0),
},
{
key: 'flags',
labelKey: 'filter.worktree.flags',
options: [
{ value: 'locked', labelKey: 'filter.worktree.locked' },
{ value: 'prunable', labelKey: 'filter.worktree.prunable' },
],
predicate: (w, active) => (active.has('locked') && w.locked) || (active.has('prunable') && w.prunable),
},
];
}
// ---- repos ----
export interface RepoSortCtx {
worktreeCount: (repoId: string) => number;
activeCount: (repoId: string) => number;
}
export function repoSorts(ctx: RepoSortCtx): SortDef<RepoSummary>[] {
return [
{ key: 'label', labelKey: 'sort.repo.label', compare: (a, b) => a.label.localeCompare(b.label), defaultDir: 'asc' },
{ key: 'created', labelKey: 'sort.repo.created', compare: (a, b) => a.createdAt.localeCompare(b.createdAt), defaultDir: 'desc' },
{ key: 'worktrees', labelKey: 'sort.repo.worktrees', compare: (a, b) => ctx.worktreeCount(a.id) - ctx.worktreeCount(b.id), defaultDir: 'desc' },
{ key: 'activity', labelKey: 'sort.repo.activity', compare: (a, b) => ctx.activeCount(a.id) - ctx.activeCount(b.id), defaultDir: 'desc' },
];
}
// ---- groupes ----
export interface GroupSortCtx {
activeCount: (groupId: string) => number;
}
export function groupSorts(ctx: GroupSortCtx): SortDef<GroupSummary>[] {
return [
{ key: 'label', labelKey: 'sort.group.label', compare: (a, b) => a.label.localeCompare(b.label), defaultDir: 'asc' },
{ key: 'created', labelKey: 'sort.group.created', compare: (a, b) => a.createdAt.localeCompare(b.createdAt), defaultDir: 'desc' },
{ key: 'repos', labelKey: 'sort.group.repos', compare: (a, b) => a.repoIds.length - b.repoIds.length, defaultDir: 'desc' },
{ key: 'activeSessions', labelKey: 'sort.group.activeSessions', compare: (a, b) => ctx.activeCount(a.id) - ctx.activeCount(b.id), defaultDir: 'desc' },
];
}