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-soft: 8px;
--radius-panel: 12px; --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: --font-sans:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif; "Segoe UI", sans-serif;
@ -143,7 +145,8 @@
} }
.shell { .shell {
display: grid; display: flex;
flex-direction: column;
gap: var(--space-6); gap: var(--space-6);
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
@ -296,18 +299,32 @@
display: grid; display: grid;
gap: 3px; gap: 3px;
min-width: 0; min-width: 0;
padding: var(--space-2) 0 var(--space-2) var(--space-3); margin-inline: calc(-1 * var(--space-2));
border-left: 2px solid transparent; padding: var(--space-2) var(--space-3);
border-radius: var(--radius-soft);
color: inherit; color: inherit;
text-align: left; text-align: left;
text-decoration: none; text-decoration: none;
transition:
background-color 140ms ease,
color 140ms ease;
} }
.nav-item.active, a.nav-item:hover,
.objective-link.active, a.nav-item:focus-visible,
.nav-item:hover, a.objective-link:hover,
.objective-link:hover { a.objective-link:focus-visible {
border-left-color: var(--accent); 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, .item-title,
@ -322,6 +339,26 @@
font-weight: 650; 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, .item-meta,
.section-note, .section-note,
.section-state, .section-state,
@ -338,22 +375,107 @@
.card { .card {
min-width: 0; min-width: 0;
padding: var(--space-5) 0 0; padding: 0;
border-top: 1px solid var(--line);
}
.card:first-child {
padding-top: 0;
border-top: 0;
} }
.runtime-card { .runtime-card {
padding: var(--space-4) 0 0; padding: 0;
border-top: 1px solid var(--line);
} }
.selected-card.selected { .objective-list {
border-top-color: var(--accent); 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, .runtime-heading,
@ -384,20 +506,37 @@
dl { dl {
display: grid; 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); gap: var(--space-3);
min-width: 0;
} }
dt { dt {
color: var(--text-faint); color: var(--text-faint);
font-size: 0.78rem; font-size: 0.72rem;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
white-space: nowrap;
} }
dd { dd {
min-width: 0;
margin: 0; margin: 0;
} }
dd small {
display: inline;
margin-top: 0;
margin-left: var(--space-2);
}
.table-wrap { .table-wrap {
overflow-x: auto; overflow-x: auto;
} }

View File

@ -8,7 +8,6 @@
ObjectiveDetail, ObjectiveDetail,
ObjectiveListResponse, ObjectiveListResponse,
RepositoryDetailResponse, RepositoryDetailResponse,
RepositoryLogResponse,
RepositorySummary, RepositorySummary,
RepositoryTicketsResponse, RepositoryTicketsResponse,
Worker, Worker,
@ -33,7 +32,6 @@
{ label: 'Tickets', path: '/api/tickets' }, { label: 'Tickets', path: '/api/tickets' },
{ label: 'Objectives', path: '/api/objectives' }, { label: 'Objectives', path: '/api/objectives' },
{ label: 'Repositories', path: '/api/repositories' }, { label: 'Repositories', path: '/api/repositories' },
{ label: 'Repository log', path: '/api/repositories/local/log' },
{ label: 'Repository tickets', path: '/api/repositories/local/tickets' }, { label: 'Repository tickets', path: '/api/repositories/local/tickets' },
{ label: 'Runs', path: '/api/runs' }, { label: 'Runs', path: '/api/runs' },
{ label: 'Hosts', path: '/api/hosts' }, { label: 'Hosts', path: '/api/hosts' },
@ -44,7 +42,6 @@
let hosts = $state<ListResponse<Host> | null>(null); let hosts = $state<ListResponse<Host> | null>(null);
let workers = $state<ListResponse<Worker> | null>(null); let workers = $state<ListResponse<Worker> | null>(null);
let repository = $state<RepositorySummary | null>(null); let repository = $state<RepositorySummary | null>(null);
let repositoryLog = $state<RepositoryLogResponse | null>(null);
let repositoryTickets = $state<RepositoryTicketsResponse | null>(null); let repositoryTickets = $state<RepositoryTicketsResponse | null>(null);
let objectives = $state<ObjectiveListResponse | null>(null); let objectives = $state<ObjectiveListResponse | null>(null);
let objectiveDetail = $state<ObjectiveDetail | null>(null); let objectiveDetail = $state<ObjectiveDetail | null>(null);
@ -53,7 +50,6 @@
let hostsError = $state<string | null>(null); let hostsError = $state<string | null>(null);
let workersError = $state<string | null>(null); let workersError = $state<string | null>(null);
let repositoryError = $state<string | null>(null); let repositoryError = $state<string | null>(null);
let repositoryLogError = $state<string | null>(null);
let repositoryTicketsError = $state<string | null>(null); let repositoryTicketsError = $state<string | null>(null);
let objectivesError = $state<string | null>(null); let objectivesError = $state<string | null>(null);
let objectiveDetailError = $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() { async function loadRepositoryTickets() {
repositoryTicketsError = null; repositoryTicketsError = null;
try { try {
@ -196,16 +182,11 @@
return value ?? 'not recorded'; return value ?? 'not recorded';
} }
function shortHash(hash: string | null | undefined): string {
return hash ? hash.slice(0, 12) : 'unknown';
}
$effect(() => { $effect(() => {
void loadWorkspace(); void loadWorkspace();
void loadHosts(); void loadHosts();
void loadWorkers(); void loadWorkers();
void loadRepository(); void loadRepository();
void loadRepositoryLog();
void loadRepositoryTickets(); void loadRepositoryTickets();
void loadObjectives(); void loadObjectives();
}); });
@ -237,116 +218,35 @@
<main class="shell"> <main class="shell">
{#if route.page === 'repository'} {#if route.page === 'repository'}
<section class="grid runtime">
<div class="card">
<h2>Repository summary</h2>
{#if repository}
<dl>
<div>
<dt>ID</dt>
<dd><code>{repository.id}</code></dd>
</div>
<div>
<dt>Kind</dt>
<dd>{repository.kind}</dd>
</div>
<div>
<dt>Workspace root</dt>
<dd><code>{repository.workspace_root}</code></dd>
</div>
<div>
<dt>Record authority</dt>
<dd>{repository.record_authority}</dd>
</div>
<div>
<dt>Git</dt>
<dd>{repository.git.status}</dd>
</div>
</dl>
{:else if repositoryError}
<p class="error">{repositoryError}</p>
{: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"> <section class="card">
<h2>Recent Git log</h2> <h2>Repository summary</h2>
{#if repositoryLog} {#if repository}
{#if repositoryLog.items.length === 0} <dl>
<p>No recent commits are available from the bounded Git log API.</p> <div>
{:else} <dt>ID</dt>
<div class="table-wrap"> <dd><code>{repository.id}</code></dd>
<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> </div>
{/if} <div>
{:else if repositoryLogError} <dt>Kind</dt>
<p class="error">{repositoryLogError}</p> <dd>{repository.kind}</dd>
</div>
<div>
<dt>Workspace root</dt>
<dd><code>{repository.workspace_root}</code></dd>
</div>
<div>
<dt>Record authority</dt>
<dd>{repository.record_authority}</dd>
</div>
<div>
<dt>Git</dt>
<dd>{repository.git.status}</dd>
</div>
</dl>
{:else if repositoryError}
<p class="error">{repositoryError}</p>
{:else} {:else}
<p>Waiting for <code>/api/repositories/local/log</code></p> <p>Waiting for <code>/api/repositories/local</code></p>
{/if} {/if}
</section> </section>
@ -364,7 +264,7 @@
{/if} {/if}
</section> </section>
{@const repositoryDiagnostics = diagnosticsFor(repository?.git.diagnostics, repositoryLog?.diagnostics, repositoryTickets?.diagnostics)} {@const repositoryDiagnostics = diagnosticsFor(repository?.git.diagnostics, repositoryTickets?.diagnostics)}
{#if repositoryDiagnostics.length > 0} {#if repositoryDiagnostics.length > 0}
<section class="card diagnostics"> <section class="card diagnostics">
<h2>Repository diagnostics</h2> <h2>Repository diagnostics</h2>
@ -389,30 +289,22 @@
{#if objectives.items.length === 0} {#if objectives.items.length === 0}
<p>No Objective records are present.</p> <p>No Objective records are present.</p>
{:else} {:else}
<div class="stack"> <div class="objective-list">
{#each objectives.items as objective (objective.id)} {#each objectives.items as objective (objective.id)}
<article class="runtime-card selected-card" class:selected={route.page === 'objective' && route.objectiveId === objective.id}> <a class="objective-row" class:selected={route.page === 'objective' && route.objectiveId === objective.id} href={`/objectives/${objective.id}`}>
<div class="runtime-heading"> <div class="objective-main">
<strong>{objective.title}</strong> <div class="objective-title-row">
<span>{objective.state}</span> <strong class="objective-title">{objective.title}</strong>
<span class="state-pill">{objective.state}</span>
</div>
<p class="objective-summary">{objective.summary || 'No summary text is available.'}</p>
</div> </div>
<p>{objective.summary || 'No summary text is available.'}</p> <div class="objective-meta" aria-label="Objective metadata">
<dl> <span>Updated {formatDate(objective.updated_at)}</span>
<div> <span>{objective.linked_tickets?.length ? `${objective.linked_tickets.length} linked ticket(s)` : 'No linked tickets'}</span>
<dt>ID</dt> <code>{objective.id}</code>
<dd><code>{objective.id}</code></dd> </div>
</div> </a>
<div>
<dt>Updated</dt>
<dd>{formatDate(objective.updated_at)}</dd>
</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>
{/each} {/each}
</div> </div>
{/if} {/if}

View File

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

View File

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