feat: a fork's band counts its drafts beside its drift, and every count opens what it names

A fork that had drifted stopped saying anything about its drafts: the banner
printed the draft count only while the fork was in sync, and the drift badges
replaced it the moment there was anything to deploy. A fork can be 86 ahead, 39
behind and holding 128 drafts; it now says so.

All four counts open the page that holds them — ahead the deploy direction,
behind `?dir=update`, conflicts the side they are resolved from before
deploying, drafts the drafts half. The button beside them still chooses a
direction on the reader's behalf; a badge means the one thing it names.

The colours and the icons are the compare page's, so the counts in the bar and
the rows behind them say the same thing the same way: ahead green, behind blue,
conflicts orange. Drafts share blue with behind and are told apart by a pencil
and by naming what they count, rather than by an arrow that would read as a
third direction. Both arrows are straight, here and on the compare page.

"Review changes" replaces "Review & Deploy Changes", and it renders in every
state. What the comparison has to say — still counting, or failed — is a mark
beside the button rather than a branch that replaces it: a fork whose count is
unfinished is exactly when a reader wants the page that counts properly.

The drafts banner outside a fork drops "This workspace has", so the count reads
the same wherever it appears.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-07 03:50:13 -07:00
co-authored by Claude Opus 5
parent 04c6a9fbdc
commit 87a975f025
3 changed files with 92 additions and 28 deletions
@@ -1,9 +1,9 @@
<script lang="ts">
import {
AlertTriangle,
ArrowDownRight,
ArrowDown,
ArrowRight,
ArrowUpRight,
ArrowUp,
Building,
CircleCheck,
CircleX,
@@ -1650,13 +1650,13 @@
{#if isConflict || existsInBothWorkspaces}
{#if diff.ahead > 0}
<Badge color="green" size="xs">
<ArrowUpRight class="w-3 h-3 inline" />
<ArrowUp class="w-3 h-3 inline" />
{diff.ahead} ahead
</Badge>
{/if}
{#if diff.behind > 0}
<Badge color="blue" size="xs">
<ArrowDownRight class="w-3 h-3 inline" />
<ArrowDown class="w-3 h-3 inline" />
{diff.behind} behind
</Badge>
{/if}
@@ -1,6 +1,7 @@
<script lang="ts">
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { Badge } from './common'
import { AlertTriangle, ArrowDown, ArrowUp, Pencil } from 'lucide-svelte'
import { workspaceStore, userWorkspaces, userStore, type UserExt } from '$lib/stores'
import type { WorkspaceComparison } from '$lib/gen'
import { fetchWorkspaceComparison } from '$lib/workspaceComparison'
@@ -137,6 +138,18 @@
// Opens the direction the button offers, so the label and the list agree: a fork
// with nothing to deploy lands on the update side, not on an empty deploy list.
/** The compare page opened on one direction, for a badge that counts exactly that. The button
* beside them still chooses on the reader's behalf; a badge means the one thing it names. */
function openCompareDir(dir: 'deploy_to' | 'update') {
if (!parentWorkspaceId || !$workspaceStore) return
goto(
'/forks/compare?workspace_id=' +
encodeURIComponent($workspaceStore) +
(dir === 'update' ? '&dir=update' : ''),
{ replaceState: true }
)
}
function openComparisonDrawer() {
if (parentWorkspaceId && $workspaceStore) {
const dir = hasAnswer && changesAhead === 0 ? '&dir=update' : ''
@@ -188,6 +201,9 @@
{#snippet forkAction()}
{@const total = comparison?.summary.total_diffs ?? 0}
{@const conflicts = comparison?.summary.conflicts ?? 0}
<!-- What the comparison has to say is a mark beside the button, never instead of it: a fork
still being counted, or one whose count failed, is exactly when a reader wants the page
that does the counting properly. The button below renders in every state. -->
{#if loading}
<span class="text-2xs text-tertiary">Checking for changes…</span>
{:else if error}
@@ -196,6 +212,71 @@
<Badge color="blue" small>
{draftCount} draft{draftCount !== 1 ? 's' : ''}
</Badge>
{:else if total > 0}
<!-- Which way the drift goes is the thing to know at a glance: ahead is what this fork has
to give its parent, behind is what it has yet to take. -->
<!-- The colours and the icons the compare page gives these three, so the counts in the bar
and the rows behind the button say the same thing the same way. -->
{#if changesAhead > 0}
<Badge
color="green"
small
clickable
onclick={() => openCompareDir('deploy_to')}
title="{changesAhead} ahead of {parentWorkspaceId} — review what this {currentNoun} has to give"
>
<ArrowUp class="w-3 h-3 inline" />
{changesAhead}
</Badge>
{/if}
{#if changesBehind > 0}
<Badge
color="blue"
small
clickable
onclick={() => openCompareDir('update')}
title="{changesBehind} behind {parentWorkspaceId} — review what this {currentNoun} has yet to take"
>
<ArrowDown class="w-3 h-3 inline" />
{changesBehind}
</Badge>
{/if}
{#if conflicts > 0}
<!-- A conflict is ahead and behind at once, so it has no direction of its own; it opens
the side the reader resolves it from before deploying. -->
<Badge
color="orange"
small
clickable
onclick={() => openCompareDir('deploy_to')}
title="{conflicts} conflicting item{conflicts !== 1 ? 's' : ''} — review them"
>
<AlertTriangle class="w-3 h-3 inline" />
{conflicts} conflict{conflicts !== 1 ? 's' : ''}
</Badge>
{/if}
<!-- Drafts are a different axis from the drift beside them: ahead and behind count deployed
items against the parent, this counts work in this fork that is not deployed anywhere
yet. It shares blue with "behind" and is told apart by the pencil and by naming what it
counts, rather than by an arrow that would read as a third direction. Clicking goes to
the drafts half of the compare page rather than the drawer the button opens. It shows at
every drift — a fork both behind and holding drafts said nothing about the drafts. -->
{#if draftCount > 0}
<Badge
color="blue"
small
clickable
onclick={openDraftCompare}
title="{draftCount} draft{draftCount !== 1
? 's'
: ''} in this {currentNoun}, not deployed anywhere yet — review and deploy"
>
<Pencil class="w-3 h-3 inline" />
{draftCount} draft{draftCount !== 1 ? 's' : ''}
</Badge>
{/if}
{/if}
{#if showDraftsOnly}
<Button
variant="subtle"
unifiedSize="sm"
@@ -204,32 +285,17 @@
>
Review & deploy drafts
</Button>
{:else if total > 0}
<!-- Which way the drift goes is the thing to know at a glance: ahead is what this fork has
to give its parent, behind is what it has yet to take. -->
{#if changesAhead > 0}
<Badge color="blue" small title="{changesAhead} ahead of {parentWorkspaceId}">
↑ {changesAhead}
</Badge>
{/if}
{#if changesBehind > 0}
<Badge color="gray" small title="{changesBehind} behind {parentWorkspaceId}">
↓ {changesBehind}
</Badge>
{/if}
{#if conflicts > 0}
<Badge color="orange" small title="{conflicts} conflicting item{conflicts !== 1 ? 's' : ''}">
{conflicts} conflict{conflicts !== 1 ? 's' : ''}
</Badge>
{/if}
{:else}
<Button
variant="subtle"
unifiedSize="sm"
onclick={openComparisonDrawer}
title={`${forkAheadBehindMessage(changesAhead, changesBehind)} ${parentWorkspaceId} over ${total} items`}
title={hasAnswer
? `${forkAheadBehindMessage(changesAhead, changesBehind)} ${parentWorkspaceId} over ${total} items`
: `Review this ${currentNoun} against ${parentWorkspaceId}`}
>
{#if !hasAnswer || changesAhead > 0}
Review & Deploy Changes
Review changes
{:else}
Review & Update {currentNoun}
{/if}
@@ -37,9 +37,8 @@
{/if}
{#snippet draftsAction()}
<!-- A count on its own leaves the reader to work out what it counts; the sentence says it.
On a phone the sentence goes and the badge becomes the button: the count is the part that
has to be seen, and the bar has room for one thing. -->
<!-- On a phone the badge becomes the button: the count is the part that has to be seen, and
the bar has room for one thing. -->
{#if phone}
<Button
variant="subtle"
@@ -50,7 +49,6 @@
<Badge color="blue" small>{draftCount}</Badge>
</Button>
{:else}
<span class="shrink-0 text-2xs text-tertiary">This workspace has</span>
<Badge color="blue" small>
{draftCount} draft{draftCount !== 1 ? 's' : ''}
</Badge>