ui: compact workspace overview

This commit is contained in:
Keisuke Hirata 2026-06-27 03:25:28 +09:00
parent 816c79290f
commit 7d90cb644d
No known key found for this signature in database
4 changed files with 206 additions and 176 deletions

View File

@ -52,6 +52,8 @@
--radius-soft: 8px;
--radius-panel: 12px;
--interactive-hover: color-mix(in oklch, var(--bg-subtle) 88%, white 12%);
--interactive-selected: color-mix(in oklch, var(--bg-subtle) 88%, var(--accent) 12%);
--font-sans:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
@ -143,7 +145,8 @@
}
.shell {
display: grid;
display: flex;
flex-direction: column;
gap: var(--space-6);
min-width: 0;
min-height: 0;
@ -296,18 +299,32 @@
display: grid;
gap: 3px;
min-width: 0;
padding: var(--space-2) 0 var(--space-2) var(--space-3);
border-left: 2px solid transparent;
margin-inline: calc(-1 * var(--space-2));
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-soft);
color: inherit;
text-align: left;
text-decoration: none;
transition:
background-color 140ms ease,
color 140ms ease;
}
.nav-item.active,
.objective-link.active,
.nav-item:hover,
.objective-link:hover {
border-left-color: var(--accent);
a.nav-item:hover,
a.nav-item:focus-visible,
a.objective-link:hover,
a.objective-link:focus-visible {
background: var(--interactive-hover);
}
a.nav-item.active,
a.objective-link.active {
background: var(--interactive-selected);
}
a.nav-item.active .item-title,
a.objective-link.active .item-title {
color: var(--accent);
}
.item-title,
@ -322,6 +339,26 @@
font-weight: 650;
}
.worker-nav-item {
gap: 2px;
}
.worker-title-row {
display: grid;
grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
align-items: baseline;
gap: var(--space-2);
min-width: 0;
}
.worker-task-title {
overflow: hidden;
color: var(--text-muted);
font-size: 0.82rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-meta,
.section-note,
.section-state,
@ -338,22 +375,107 @@
.card {
min-width: 0;
padding: var(--space-5) 0 0;
border-top: 1px solid var(--line);
}
.card:first-child {
padding-top: 0;
border-top: 0;
padding: 0;
}
.runtime-card {
padding: var(--space-4) 0 0;
border-top: 1px solid var(--line);
padding: 0;
}
.selected-card.selected {
border-top-color: var(--accent);
.objective-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: var(--space-4);
}
.objective-row {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
align-items: start;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-panel);
background: var(--bg-raised);
color: inherit;
text-decoration: none;
transition: background-color 140ms ease;
}
.objective-row:hover,
.objective-row:focus-visible {
background: var(--interactive-hover);
}
.objective-row.selected {
background: var(--interactive-selected);
}
.objective-row.selected .objective-title {
color: var(--accent);
}
.objective-main {
display: grid;
gap: var(--space-1);
min-width: 0;
}
.objective-title-row {
display: flex;
align-items: baseline;
gap: var(--space-2);
min-width: 0;
}
.objective-title {
min-width: 0;
overflow: hidden;
color: var(--text-strong);
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.state-pill {
flex: 0 0 auto;
color: var(--success);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.objective-summary {
margin: 0;
color: var(--text-muted);
font-size: 0.88rem;
line-height: 1.4;
}
.objective-meta {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-2);
color: var(--text-faint);
font-size: 0.78rem;
line-height: 1.35;
text-align: right;
white-space: nowrap;
}
@media (max-width: 900px) {
.objective-row {
grid-template-columns: 1fr;
gap: var(--space-2);
}
.objective-meta {
justify-content: flex-start;
text-align: left;
white-space: normal;
}
}
.runtime-heading,
@ -384,20 +506,37 @@
dl {
display: grid;
gap: var(--space-1);
margin: 0;
}
dl > div {
display: grid;
grid-template-columns: minmax(6.5rem, 10rem) minmax(0, 1fr);
align-items: baseline;
gap: var(--space-3);
min-width: 0;
}
dt {
color: var(--text-faint);
font-size: 0.78rem;
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
dd {
min-width: 0;
margin: 0;
}
dd small {
display: inline;
margin-top: 0;
margin-left: var(--space-2);
}
.table-wrap {
overflow-x: auto;
}

View File

@ -8,7 +8,6 @@
ObjectiveDetail,
ObjectiveListResponse,
RepositoryDetailResponse,
RepositoryLogResponse,
RepositorySummary,
RepositoryTicketsResponse,
Worker,
@ -33,7 +32,6 @@
{ label: 'Tickets', path: '/api/tickets' },
{ label: 'Objectives', path: '/api/objectives' },
{ label: 'Repositories', path: '/api/repositories' },
{ label: 'Repository log', path: '/api/repositories/local/log' },
{ label: 'Repository tickets', path: '/api/repositories/local/tickets' },
{ label: 'Runs', path: '/api/runs' },
{ label: 'Hosts', path: '/api/hosts' },
@ -44,7 +42,6 @@
let hosts = $state<ListResponse<Host> | null>(null);
let workers = $state<ListResponse<Worker> | null>(null);
let repository = $state<RepositorySummary | null>(null);
let repositoryLog = $state<RepositoryLogResponse | null>(null);
let repositoryTickets = $state<RepositoryTicketsResponse | null>(null);
let objectives = $state<ObjectiveListResponse | null>(null);
let objectiveDetail = $state<ObjectiveDetail | null>(null);
@ -53,7 +50,6 @@
let hostsError = $state<string | null>(null);
let workersError = $state<string | null>(null);
let repositoryError = $state<string | null>(null);
let repositoryLogError = $state<string | null>(null);
let repositoryTicketsError = $state<string | null>(null);
let objectivesError = $state<string | null>(null);
let objectiveDetailError = $state<string | null>(null);
@ -111,16 +107,6 @@
}
}
async function loadRepositoryLog() {
repositoryLogError = null;
try {
repositoryLog = await getJson<RepositoryLogResponse>('/api/repositories/local/log?limit=10');
} catch (error) {
repositoryLogError = error instanceof Error ? error.message : String(error);
repositoryLog = null;
}
}
async function loadRepositoryTickets() {
repositoryTicketsError = null;
try {
@ -196,16 +182,11 @@
return value ?? 'not recorded';
}
function shortHash(hash: string | null | undefined): string {
return hash ? hash.slice(0, 12) : 'unknown';
}
$effect(() => {
void loadWorkspace();
void loadHosts();
void loadWorkers();
void loadRepository();
void loadRepositoryLog();
void loadRepositoryTickets();
void loadObjectives();
});
@ -237,8 +218,7 @@
<main class="shell">
{#if route.page === 'repository'}
<section class="grid runtime">
<div class="card">
<section class="card">
<h2>Repository summary</h2>
{#if repository}
<dl>
@ -268,86 +248,6 @@
{:else}
<p>Waiting for <code>/api/repositories/local</code></p>
{/if}
</div>
<div class="card">
<h2>Git summary</h2>
{#if repository}
{#if repository.git.status === 'available'}
<dl>
<div>
<dt>Root</dt>
<dd><code>{repository.git.root ?? 'unknown'}</code></dd>
</div>
<div>
<dt>Branch</dt>
<dd>{repository.git.branch ?? 'unknown'}</dd>
</div>
<div>
<dt>HEAD</dt>
<dd><code>{shortHash(repository.git.head)}</code></dd>
</div>
<div>
<dt>Dirty</dt>
<dd>{repository.git.dirty === null || repository.git.dirty === undefined ? 'unknown' : repository.git.dirty ? 'yes' : 'no'} <small>{repository.git.dirty_scope}</small></dd>
</div>
<div>
<dt>Remote</dt>
<dd>
{#if repository.git.remote}
<code>{repository.git.remote.name}</code> · {repository.git.remote.url}
{#if repository.git.remote.redacted}<small>credentials redacted</small>{/if}
{:else}
not configured
{/if}
</dd>
</div>
</dl>
{:else}
<p>Git metadata is unavailable for this local Repository.</p>
{/if}
{:else if repositoryError}
<p class="error">{repositoryError}</p>
{:else}
<p>Waiting for Git summary…</p>
{/if}
</div>
</section>
<section class="card">
<h2>Recent Git log</h2>
{#if repositoryLog}
{#if repositoryLog.items.length === 0}
<p>No recent commits are available from the bounded Git log API.</p>
{:else}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Commit</th>
<th>Subject</th>
<th>Author</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
{#each repositoryLog.items as commit (commit.hash)}
<tr>
<td><code>{shortHash(commit.hash)}</code></td>
<td>{commit.subject}</td>
<td>{commit.author_name} <small>{commit.author_email}</small></td>
<td>{commit.timestamp}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{:else if repositoryLogError}
<p class="error">{repositoryLogError}</p>
{:else}
<p>Waiting for <code>/api/repositories/local/log</code></p>
{/if}
</section>
<section class="card">
@ -364,7 +264,7 @@
{/if}
</section>
{@const repositoryDiagnostics = diagnosticsFor(repository?.git.diagnostics, repositoryLog?.diagnostics, repositoryTickets?.diagnostics)}
{@const repositoryDiagnostics = diagnosticsFor(repository?.git.diagnostics, repositoryTickets?.diagnostics)}
{#if repositoryDiagnostics.length > 0}
<section class="card diagnostics">
<h2>Repository diagnostics</h2>
@ -389,30 +289,22 @@
{#if objectives.items.length === 0}
<p>No Objective records are present.</p>
{:else}
<div class="stack">
<div class="objective-list">
{#each objectives.items as objective (objective.id)}
<article class="runtime-card selected-card" class:selected={route.page === 'objective' && route.objectiveId === objective.id}>
<div class="runtime-heading">
<strong>{objective.title}</strong>
<span>{objective.state}</span>
<a class="objective-row" class:selected={route.page === 'objective' && route.objectiveId === objective.id} href={`/objectives/${objective.id}`}>
<div class="objective-main">
<div class="objective-title-row">
<strong class="objective-title">{objective.title}</strong>
<span class="state-pill">{objective.state}</span>
</div>
<p>{objective.summary || 'No summary text is available.'}</p>
<dl>
<div>
<dt>ID</dt>
<dd><code>{objective.id}</code></dd>
<p class="objective-summary">{objective.summary || 'No summary text is available.'}</p>
</div>
<div>
<dt>Updated</dt>
<dd>{formatDate(objective.updated_at)}</dd>
<div class="objective-meta" aria-label="Objective metadata">
<span>Updated {formatDate(objective.updated_at)}</span>
<span>{objective.linked_tickets?.length ? `${objective.linked_tickets.length} linked ticket(s)` : 'No linked tickets'}</span>
<code>{objective.id}</code>
</div>
<div>
<dt>Linked tickets</dt>
<dd>{objective.linked_tickets?.length ? objective.linked_tickets.join(', ') : 'none'}</dd>
</div>
</dl>
<p><a href={`/objectives/${objective.id}`}>View detail</a></p>
</article>
</a>
{/each}
</div>
{/if}

View File

@ -23,8 +23,4 @@
</a>
</li>
</ul>
<p class="section-note">
Repository authority remains the current workspace root and canonical project records.
</p>
</section>

View File

@ -64,10 +64,13 @@
{:else}
<ul class="nav-list" aria-label="Workers">
{#each workers as worker (worker.worker_id)}
<li class="nav-item">
<li class="nav-item worker-nav-item">
<span class="worker-title-row">
<span class="item-title">{worker.label}</span>
<span class="worker-task-title">-</span>
</span>
<span class="item-meta">
{worker.state} · {worker.status}{worker.role ? ` · ${worker.role}` : ''}
{worker.role ? `${worker.role} · ` : ''}{worker.state} · {worker.status}
</span>
</li>
{/each}