Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Improve code review UX
Open rudolfs opened 10 months ago

This patch moves the revision list from the tab bar drop-down to a sidebar on wide screens and above the current revision on narrow screens. This way all revisions are visible at all times.

We also move and nest the revision reviews into that list so that one can see whether there’s anything actionable at a glance.

Other tweaks:

  • added comment counts to revisions and reviews
  • displayed unresolved comment thread counts per review
  • swapped icons for accepted/rejected and neutral reviews to avoid clashing with resolved/unresolved comment icons
  • made label and assignee sections more compact with hover-triggered “+” buttons
  • added author and status sections to the sidebar
  • enabled patch state changes from the title bar
  • made “Review revision” the primary action and “Checkout patch” secondary
  • added “Draft” label to draft patches
  • capitalized all badges for consistent UI text
21 files changed +925 -641 18f5aa90 832344f8
modified src/components/AssigneeInput.svelte
@@ -94,9 +94,14 @@
</script>

<style>
-
  .header {
+
  .add-icon {
+
    display: none;
+
  }
+
  .title-button:hover .add-icon {
+
    display: flex;
+
  }
+
  .title-button {
    font-size: var(--font-size-small);
-
    margin-bottom: 0.5rem;
    color: var(--color-foreground-dim);
  }
  .body {
@@ -104,8 +109,9 @@
    align-items: center;
    flex-wrap: wrap;
    flex-direction: row;
-
    gap: 0.5rem;
+
    gap: 1rem;
    font-size: var(--font-size-small);
+
    margin-top: 1rem;
  }
  .validation-message {
    display: flex;
@@ -123,33 +129,75 @@
    border: none;
    display: flex;
    color: var(--color-foreground-default);
+
    padding: 0;
+
    align-items: center;
  }
</style>

-
<div style:width="100%">
-
  <div class="global-flex" style:align-items="flex-start">
-
    <div class="header">Assignees</div>
+
<div class="global-flex">
+
  <button
+
    disabled={!allowedToEdit}
+
    style:color={allowedToEdit
+
      ? "var(--color-foreground-dim)"
+
      : "var(--color-foreground-disabled)"}
+
    title={allowedToEdit
+
      ? undefined
+
      : "Only delegates are allowed to add assignees"}
+
    style:cursor={allowedToEdit ? "pointer" : "default"}
+
    class="title-button"
+
    onclick={() => {
+
      inputValue = "";
+
      showInput = !showInput;
+
    }}>
+
    {#if updatedAssignees.length === 0}
+
      Add assignees
+
    {:else}
+
      Assignees
+
    {/if}

-
    {#if allowedToEdit}
-
      <div class="global-flex" style:margin-left="auto">
-
        {#if showInput}
-
          <Icon
-
            onclick={addAssignee}
-
            name="checkmark"
-
            disabled={!valid || inputValue === ""} />
-
          <Icon
-
            onclick={() => {
-
              inputValue = "";
-
              showInput = false;
-
            }}
-
            name="cross" />
-
        {:else}
-
          <Icon name="add" onclick={() => (showInput = true)}></Icon>
-
        {/if}
+
    {#if !showInput && allowedToEdit}
+
      <span class="add-icon">
+
        <Icon name="add" />
+
      </span>
+
    {/if}
+
  </button>
+

+
  {#if allowedToEdit}
+
    <div class="global-flex edit-icons">
+
      {#if showInput}
+
        <Icon
+
          onclick={addAssignee}
+
          name="checkmark"
+
          disabled={!valid || inputValue === ""} />
+
        <Icon
+
          onclick={() => {
+
            inputValue = "";
+
            showInput = false;
+
          }}
+
          name="cross" />
+
      {/if}
+
    </div>
+
  {/if}
+
</div>
+

+
{#if showInput}
+
  <div style:margin-top="1rem">
+
    <TextInput
+
      autofocus
+
      {valid}
+
      disabled={submitInProgress}
+
      placeholder="Assignee DID, e.g. did:key:z6MkwPUeUS2…"
+
      bind:value={inputValue}
+
      onSubmit={addAssignee} />
+
    {#if !valid && validationMessage}
+
      <div class="validation-message">
+
        <Icon name="warning" />{validationMessage}
      </div>
    {/if}
  </div>
+
{/if}

+
{#if updatedAssignees.length > 0}
  <div class="body">
    {#if allowedToEdit}
      {#each updatedAssignees as assignee}
@@ -163,32 +211,10 @@
          {/if}
        </button>
      {/each}
-
      {#if updatedAssignees.length === 0 && !showInput}
-
        <div class="txt-missing">Not assigned to anyone.</div>
-
      {/if}
    {:else}
      {#each updatedAssignees as assignee}
        <NodeId {...authorForNodeId(assignee)} />
-
      {:else}
-
        <div class="txt-missing">Not assigned to anyone.</div>
      {/each}
    {/if}
  </div>
-

-
  {#if showInput}
-
    <div style:margin-top="0.5rem">
-
      <TextInput
-
        autofocus
-
        {valid}
-
        disabled={submitInProgress}
-
        placeholder="Add assignee"
-
        bind:value={inputValue}
-
        onSubmit={addAssignee} />
-
      {#if !valid && validationMessage}
-
        <div class="validation-message">
-
          <Icon name="warning" />{validationMessage}
-
        </div>
-
      {/if}
-
    </div>
-
  {/if}
-
</div>
+
{/if}
modified src/components/Changes.svelte
@@ -171,7 +171,7 @@
            <Id
              id={revision.base}
              variant={selectedCommit ? "none" : "commit"} />
-
            <div class="global-counter">base</div>
+
            <div class="global-counter">Base</div>
          </div>
          <div class="commits">
            {#each commits.reverse() as commit}
modified src/components/CheckoutPatchButton.svelte
@@ -2,9 +2,9 @@
  import { formatOid } from "@app/lib/utils";

  import Border from "@app/components/Border.svelte";
-
  import Button from "@app/components/Button.svelte";
  import Command from "@app/components/Command.svelte";
  import Icon from "@app/components/Icon.svelte";
+
  import NakedButton from "@app/components/NakedButton.svelte";
  import Popover from "@app/components/Popover.svelte";

  interface Props {
@@ -31,13 +31,15 @@
  popoverPositionTop="3rem"
  bind:expanded={popoverExpanded}>
  {#snippet toggle(onclick)}
-
    <Button
+
    <NakedButton
+
      title="Checkout patch"
      styleHeight="2.5rem"
-
      variant="secondary"
+
      variant="ghost"
      {onclick}
      active={popoverExpanded}>
-
      <Icon name="checkout" />Checkout patch
-
    </Button>
+
      <Icon name="checkout" />
+
      <span class="global-hide-on-medium-desktop-down">Checkout patch</span>
+
    </NakedButton>
  {/snippet}
  {#snippet popover()}
    <Border
modified src/components/Diff.svelte
@@ -420,7 +420,7 @@
                    onclick={() => toggleCommentExpand(thread.root.id)} />
                {:else}
                  <Icon
-
                    name="comment"
+
                    name="comment-cross"
                    onclick={() => toggleCommentExpand(thread.root.id)} />
                {/if}
              {/if}
@@ -439,7 +439,7 @@
                    {#if thread.root.resolved}
                      <div title="Unresolve comment thread">
                        <Icon
-
                          name="cross"
+
                          name="unresolve"
                          onclick={partial(
                            codeComments.changeCommentStatus,
                            thread.root.id,
modified src/components/FileDiff.svelte
@@ -96,13 +96,13 @@
    {/if}

    {#if file.status === "added"}
-
      <span class="global-counter added">added</span>
+
      <span class="global-counter added">Added</span>
    {:else if file.status === "deleted"}
-
      <span class="global-counter deleted">deleted</span>
+
      <span class="global-counter deleted">Deleted</span>
    {:else if file.status === "moved"}
-
      <span class="global-counter moved">moved</span>
+
      <span class="global-counter moved">Moved</span>
    {:else if file.status === "copied"}
-
      <span class="global-counter copied">copied</span>
+
      <span class="global-counter copied">Copied</span>
    {/if}
  {/snippet}

@@ -120,7 +120,7 @@
    {#if commentsOfThisFile && commentsOfThisFile.threads.length > 0}
      {#if unresolvedThreads > 0}
        <div class="global-flex">
-
          <Icon name="comment" />
+
          <Icon name="comment-cross" />
          {unresolvedThreads}
        </div>
      {/if}
modified src/components/Icon.svelte
@@ -71,12 +71,15 @@
      | "plus"
      | "reply"
      | "repo"
+
      | "review"
      | "revision"
      | "seedling"
      | "seedling-filled"
      | "settings"
+
      | "stop"
      | "sun"
      | "thumb-up"
+
      | "unresolve"
      | "user"
      | "warning";
  }
@@ -1163,6 +1166,35 @@
    <path d="M7 8H5V9L7 9L7 8Z" />
    <path d="M7 10H5L5 11H7L7 10Z" />
    <path d="M7 12H5L5 13H7L7 12Z" />
+
  {:else if name === "review"}
+
    <path d="M11 5L8 5V4L11 4V5Z" />
+
    <path d="M9 7L10 7V6L9 6V7Z" />
+
    <path d="M10 9H9L9 10H10V9Z" />
+
    <path d="M7 6V7H6L6 6L7 6Z" />
+
    <path d="M4 6L4 7H3L3 6H4Z" />
+
    <path d="M12 10V9L13 9V10L12 10Z" />
+
    <path d="M12 6L11 6V5L12 5V6Z" />
+
    <path d="M7 10L8 10L8 11H7L7 10Z" />
+
    <path d="M4 10H5L5 11L4 11L4 10Z" />
+
    <path d="M5 11L7 11L7 12L5 12L5 11Z" />
+
    <path d="M8 6L7 6V5L8 5V6Z" />
+
    <path d="M5 6H4L4 5L5 5V6Z" />
+
    <path d="M7 5L5 5L5 4L7 4V5Z" />
+
    <path d="M11 10H12V11L11 11V10Z" />
+
    <path d="M12 7V6L13 6V7L12 7Z" />
+
    <path d="M7 9L7 10H6L6 9H7Z" />
+
    <path d="M4 9L4 10H3L3 9H4Z" />
+
    <path d="M8 11H11L11 12H8L8 11Z" />
+
    <path d="M8 8L8 7H9L9 8H8Z" />
+
    <path d="M11 8L11 9L10 9L10 8L11 8Z" />
+
    <path d="M10 8V7L11 7V8L10 8Z" />
+
    <path d="M9 8L9 9H8V8H9Z" />
+
    <path d="M14 7V8H13V7L14 7Z" />
+
    <path d="M5 9V8H6V9L5 9Z" />
+
    <path d="M2 9L2 8H3L3 9L2 9Z" />
+
    <path d="M6 7L6 8H5L5 7H6Z" />
+
    <path d="M3 7L3 8H2L2 7H3Z" />
+
    <path d="M13 9V8H14V9H13Z" />
  {:else if name === "revision"}
    <path d="M6 13H8V14H6V13Z" />
    <path d="M8 13H9V14H8V13Z" />
@@ -1262,6 +1294,20 @@
    <path d="M4 4L5 4L5 7H4V4Z" />
    <path d="M11 9L12 9V12H11V9Z" />
    <path d="M7 4L8 4V7L7 7V4Z" />
+
  {:else if name === "stop"}
+
    <path d="M3.5 5L3.5 13H2.5L2.5 5H3.5Z" />
+
    <path d="M5.5 4L5.5 8H4.5L4.5 4H5.5Z" />
+
    <path d="M7.5 3L7.5 8H6.5L6.5 3L7.5 3Z" />
+
    <path d="M3.5 13L12.5 13V14L3.5 14L3.5 13Z" />
+
    <path d="M3.5 4H5.5V5H3.5L3.5 4Z" />
+
    <path d="M12.5 8L13.5 8L13.5 12H12.5L12.5 8Z" />
+
    <path d="M11.5 7H12.5V8H11.5V7Z" />
+
    <path d="M8.5 3H9.5V8H8.5V3Z" />
+
    <path d="M10.5 4H11.5L11.5 10H10.5L10.5 4Z" />
+
    <path d="M5.5 3L6.5 3L6.5 4H5.5L5.5 3Z" />
+
    <path d="M7.5 2H8.5V3L7.5 3V2Z" />
+
    <path d="M9.5 3L10.5 3V4L9.5 4V3Z" />
+
    <path d="M11.5 12L12.5 12V13H11.5V12Z" />
  {:else if name === "sun"}
    <path d="M8 2H9V3H8V2Z" />
    <path d="M14 8V9H13V8H14Z" />
@@ -1306,6 +1352,32 @@
    <path d="M7 6H8V7H7L7 6Z" />
    <path d="M7 10L8 10V11L7 11L7 10Z" />
    <path d="M7 12L8 12L8 13H7L7 12Z" />
+
  {:else if name === "unresolve"}
+
    <path d="M7 11.5V12.5H6V11.5H7Z" />
+
    <path d="M8 10.5V11.5L7 11.5L7 10.5H8Z" />
+
    <path d="M10 8.5V9.5H9V8.5H10Z" />
+
    <path d="M11 7.5V8.5L10 8.5L10 7.5H11Z" />
+
    <path d="M10 7.5L10 8.5H9L9 7.5L10 7.5Z" />
+
    <path d="M12 6.5V7.5H11V6.5H12Z" />
+
    <path d="M13 5.5V6.5L12 6.5V5.5L13 5.5Z" />
+
    <path d="M4 8.5V9.5H3L3 8.5H4Z" />
+
    <path d="M5 8.5L5 9.5H4V8.5H5Z" />
+
    <path d="M6 9.5L6 10.5H5V9.5H6Z" />
+
    <path d="M7 10.5L7 11.5H6L6 10.5H7Z" />
+
    <path d="M8 9.5V10.5H7V9.5H8Z" />
+
    <path d="M11 6.5V7.5H10V6.5H11Z" />
+
    <path d="M12 5.5V6.5H11V5.5H12Z" />
+
    <path d="M5 9.5V10.5H4L4 9.5H5Z" />
+
    <path d="M6 10.5L6 11.5H5L5 10.5H6Z" />
+
    <path d="M12 11.5H13V12.5H12V11.5Z" />
+
    <path d="M11 10.5H12L12 11.5H11V10.5Z" />
+
    <path d="M10 9.5L11 9.5V10.5H10V9.5Z" />
+
    <path d="M9 8.5H10V9.5H9V8.5Z" />
+
    <path d="M8 7.5L9 7.5L9 8.5H8V7.5Z" />
+
    <path d="M7 6.5H8V7.5H7V6.5Z" />
+
    <path d="M6 5.5H7V6.5H6V5.5Z" />
+
    <path d="M5 4.5H6V5.5L5 5.5V4.5Z" />
+
    <path d="M4 3.5L5 3.5V4.5L4 4.5V3.5Z" />
  {:else if name === "user"}
    <path d="M5 3H6V4H5V3Z" />
    <path d="M5 6L5 8H4V6H5Z" />
modified src/components/LabelInput.svelte
@@ -73,9 +73,14 @@
</script>

<style>
-
  .header {
+
  .add-icon {
+
    display: none;
+
  }
+
  .title-button:hover .add-icon {
+
    display: flex;
+
  }
+
  .title-button {
    font-size: var(--font-size-small);
-
    margin-bottom: 0.5rem;
    color: var(--color-foreground-dim);
  }
  .body {
@@ -85,6 +90,7 @@
    flex-direction: row;
    gap: 0.5rem;
    font-size: var(--font-size-small);
+
    margin-top: 1rem;
  }
  .validation-message {
    display: flex;
@@ -97,74 +103,101 @@
  button {
    border: 0;
    cursor: pointer;
-
    color: var(--color-foreground-default);
    gap: 0.5rem;
+
    background-color: transparent;
+
    border: none;
+
    display: flex;
+
    color: var(--color-foreground-default);
+
    padding: 0;
+
    align-items: center;
  }
</style>

-
<div style:width="100%">
-
  <div class="global-flex" style:align-items="flex-start">
-
    <div class="header">Labels</div>
+
<div class="global-flex">
+
  <button
+
    disabled={!allowedToEdit}
+
    style:color={allowedToEdit
+
      ? "var(--color-foreground-dim)"
+
      : "var(--color-foreground-disabled)"}
+
    title={allowedToEdit
+
      ? undefined
+
      : "Only delegates are allowed to add labels"}
+
    style:cursor={allowedToEdit ? "pointer" : "default"}
+
    class="title-button"
+
    onclick={() => {
+
      inputValue = "";
+
      showInput = !showInput;
+
    }}>
+
    {#if updatedLabels.length === 0}
+
      Add labels
+
    {:else}
+
      Labels
+
    {/if}

-
    {#if allowedToEdit}
-
      <div class="global-flex" style:margin-left="auto">
-
        {#if showInput}
-
          <Icon
-
            onclick={addLabel}
-
            disabled={!valid || inputValue === ""}
-
            name="checkmark" />
-
          <Icon
-
            onclick={() => {
-
              inputValue = "";
-
              showInput = false;
-
            }}
-
            name="cross" />
-
        {:else}
-
          <Icon name="add" onclick={() => (showInput = true)}></Icon>
-
        {/if}
+
    {#if !showInput && allowedToEdit}
+
      <span class="add-icon">
+
        <Icon name="add"></Icon>
+
      </span>
+
    {/if}
+
  </button>
+

+
  {#if allowedToEdit}
+
    <div class="global-flex edit-icons">
+
      {#if showInput}
+
        <Icon
+
          onclick={addLabel}
+
          name="checkmark"
+
          disabled={!valid || inputValue === ""} />
+
        <Icon
+
          onclick={() => {
+
            inputValue = "";
+
            showInput = false;
+
          }}
+
          name="cross" />
+
      {/if}
+
    </div>
+
  {/if}
+
</div>
+

+
{#if showInput}
+
  <div style:margin-top="1rem">
+
    <TextInput
+
      autofocus
+
      {valid}
+
      disabled={submitInProgress}
+
      placeholder="Add label"
+
      bind:value={inputValue}
+
      onSubmit={addLabel} />
+
    {#if !valid && validationMessage}
+
      <div class="validation-message">
+
        <Icon name="warning" />{validationMessage}
      </div>
    {/if}
  </div>
+
{/if}

+
{#if updatedLabels.length > 0}
  <div class="body">
    {#if allowedToEdit}
      {#each updatedLabels as label}
        <button
          class="global-counter txt-small"
+
          style:background-color="var(--color-fill-counter)"
+
          style:padding="0 0.5rem"
          style:max-width="10rem"
          onclick={() => (removeToggles[label] = !removeToggles[label])}>
          <div class="txt-overflow" title={label}>{label}</div>
          {#if removeToggles[label]}
-
            <Icon name="cross" onclick={() => removeLabel(label)} />
+
            <span style:margin-right="0.5rem">
+
              <Icon name="cross" onclick={() => removeLabel(label)} />
+
            </span>
          {/if}
        </button>
      {/each}
-
      {#if updatedLabels.length === 0 && !showInput}
-
        <div class="txt-missing">No labels.</div>
-
      {/if}
    {:else}
      {#each updatedLabels as label}
        <Label {label} />
-
      {:else}
-
        <div class="txt-missing">No labels.</div>
      {/each}
    {/if}
  </div>
-

-
  {#if showInput}
-
    <div style:margin-top="0.5rem">
-
      <TextInput
-
        autofocus
-
        {valid}
-
        disabled={submitInProgress}
-
        placeholder="Add label"
-
        bind:value={inputValue}
-
        onSubmit={addLabel} />
-
      {#if !valid && validationMessage}
-
        <div class="validation-message">
-
          <Icon name="warning" />{validationMessage}
-
        </div>
-
      {/if}
-
    </div>
-
  {/if}
-
</div>
+
{/if}
added src/components/PatchMetadata.svelte
@@ -0,0 +1,144 @@
+
<script lang="ts">
+
  import type { Author } from "@bindings/cob/Author";
+
  import type { Config } from "@bindings/config/Config";
+
  import type { Patch } from "@bindings/cob/patch/Patch";
+
  import type { RepoInfo } from "@bindings/repo/RepoInfo";
+

+
  import * as roles from "@app/lib/roles";
+
  import { announce } from "@app/components/AnnounceSwitch.svelte";
+
  import { invoke } from "@app/lib/invoke";
+
  import { nodeRunning } from "@app/lib/events";
+

+
  import AssigneeInput from "@app/components/AssigneeInput.svelte";
+
  import LabelInput from "@app/components/LabelInput.svelte";
+
  import NodeId from "./NodeId.svelte";
+
  import { authorForNodeId } from "@app/lib/utils";
+
  import PatchStateButton from "./PatchStateButton.svelte";
+

+
  interface Props {
+
    config: Config;
+
    horizontal?: boolean;
+
    loadPatch: () => Promise<void>;
+
    patch: Patch;
+
    repo: RepoInfo;
+
    saveState: (newState: Patch["state"]) => Promise<void>;
+
  }
+

+
  const {
+
    config,
+
    horizontal = false,
+
    loadPatch,
+
    patch,
+
    repo,
+
    saveState,
+
  }: Props = $props();
+

+
  let labelSaveInProgress: boolean = $state(false);
+
  let assigneesSaveInProgress: boolean = $state(false);
+

+
  async function saveLabels(labels: string[]) {
+
    try {
+
      labelSaveInProgress = true;
+
      await invoke("edit_patch", {
+
        rid: repo.rid,
+
        cobId: patch.id,
+
        action: {
+
          type: "label",
+
          labels,
+
        },
+
        opts: { announce: $nodeRunning && $announce },
+
      });
+
    } catch (error) {
+
      console.error("Editing labels failed", error);
+
    } finally {
+
      labelSaveInProgress = false;
+
      await loadPatch();
+
    }
+
  }
+

+
  async function saveAssignees(assignees: Author[]) {
+
    try {
+
      assigneesSaveInProgress = true;
+
      await invoke("edit_patch", {
+
        rid: repo.rid,
+
        cobId: patch.id,
+
        action: {
+
          type: "assign",
+
          assignees,
+
        },
+
        opts: { announce: $nodeRunning && $announce },
+
      });
+
    } catch (error) {
+
      console.error("Editing assignees failed", error);
+
    } finally {
+
      assigneesSaveInProgress = false;
+
      await loadPatch();
+
    }
+
  }
+
</script>
+

+
<style>
+
  .metadata-section {
+
    padding: 0.5rem;
+
    font-size: var(--font-size-small);
+
    display: flex;
+
    flex-direction: column;
+
    align-items: flex;
+
    height: 100%;
+
  }
+
  .metadata-section-title {
+
    margin-bottom: 0.5rem;
+
    color: var(--color-foreground-dim);
+
  }
+
</style>
+

+
<div
+
  class="global-flex"
+
  style:flex-direction={horizontal ? "row" : "column"}
+
  style:align-items="flex-start">
+
  <div
+
    class="metadata-section"
+
    style={horizontal ? "flex: 1;" : "width: 100%;"}>
+
    <div class="metadata-section-title">Author</div>
+
    <NodeId {...authorForNodeId(patch.author)} />
+
  </div>
+

+
  <div
+
    class="metadata-section"
+
    style={horizontal ? "flex: 1;" : "width: 100%;"}>
+
    <div class="metadata-section-title">Status</div>
+
    <PatchStateButton
+
      selectedState={patch.state}
+
      onSelect={newState => {
+
        void saveState(newState);
+
      }} />
+
  </div>
+

+
  <div
+
    class="metadata-section"
+
    style={horizontal ? "flex: 1;" : "width: 100%;"}>
+
    <LabelInput
+
      allowedToEdit={!!roles.isDelegateOrAuthor(
+
        config.publicKey,
+
        repo.delegates.map(delegate => delegate.did),
+
        patch.author.did,
+
      )}
+
      labels={patch.labels}
+
      submitInProgress={labelSaveInProgress}
+
      save={saveLabels} />
+
  </div>
+

+
  <div
+
    class="metadata-section"
+
    style={horizontal ? "flex: 1;" : "width: 100%;"}>
+
    <AssigneeInput
+
      allowedToEdit={!!roles.isDelegateOrAuthor(
+
        config.publicKey,
+
        repo.delegates.map(delegate => delegate.did),
+
        patch.author.did,
+
      )}
+
      assignees={patch.assignees}
+
      submitInProgress={assigneesSaveInProgress}
+
      save={saveAssignees} />
+
  </div>
+
</div>
modified src/components/PatchStateButton.svelte
@@ -33,6 +33,7 @@
    align-items: center;
    justify-content: center;
    font-size: var(--font-size-small);
+
    background-color: none;
  }
  button:disabled {
    cursor: inherit;
@@ -50,7 +51,12 @@
  popoverPositionLeft="0"
  bind:expanded={popoverExpanded}>
  {#snippet toggle(onclick)}
-
    <button disabled={selectedState.status === "merged"} {onclick}>
+
    <button
+
      disabled={selectedState.status === "merged"}
+
      {onclick}
+
      title={selectedState.status === "merged"
+
        ? "The state of merged patches can not be changed"
+
        : "Click to change patch state"}>
      <span
        class="global-counter badge"
        style:color={patchStatusColor[selectedState.status]}
added src/components/PatchStateButtonCompact.svelte
@@ -0,0 +1,120 @@
+
<script lang="ts">
+
  import type { ComponentProps } from "svelte";
+
  import type { State } from "@bindings/cob/patch/State";
+

+
  import capitalize from "lodash/capitalize";
+

+
  import { closeFocused } from "@app/components/Popover.svelte";
+
  import { patchStatusBackgroundColor, patchStatusColor } from "@app/lib/utils";
+

+
  import Border from "@app/components/Border.svelte";
+
  import DropdownList from "@app/components/DropdownList.svelte";
+
  import DropdownListItem from "@app/components/DropdownListItem.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+

+
  interface Props {
+
    selectedState: State;
+
    onSelect: (newState: State) => void;
+
  }
+

+
  const { selectedState, onSelect }: Props = $props();
+
  let focus = $state(false);
+

+
  let popoverExpanded: boolean = $state(false);
+

+
  function icon(): ComponentProps<typeof Icon>["name"] {
+
    if (selectedState.status === "merged") {
+
      return "patch-merged";
+
    } else if (focus) {
+
      return "chevron-down";
+
    } else {
+
      if (selectedState.status === "open") {
+
        return "patch";
+
      } else {
+
        return `patch-${selectedState.status}`;
+
      }
+
    }
+
  }
+
</script>
+

+
<style>
+
  button {
+
    cursor: pointer;
+
    border: 0;
+
    background: none;
+
    margin: 0;
+
    padding: 0;
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
    font-size: var(--font-size-small);
+
  }
+
  button:disabled {
+
    cursor: inherit;
+
  }
+

+
  .badge {
+
    height: 2.5rem;
+
    width: 2.5rem;
+
    gap: 0.375rem;
+
    padding-right: 0.625rem;
+
  }
+
</style>
+

+
<Popover
+
  popoverPadding="0"
+
  popoverPositionTop="3rem"
+
  popoverPositionLeft="0"
+
  bind:expanded={popoverExpanded}>
+
  {#snippet toggle(onclick)}
+
    <button
+
      onmouseenter={() => (focus = true)}
+
      onfocus={() => (focus = true)}
+
      onblur={() => (focus = false)}
+
      onmouseleave={() => (focus = false)}
+
      disabled={selectedState.status === "merged"}
+
      {onclick}
+
      title={selectedState.status === "merged"
+
        ? "The state of merged patches can not be changed"
+
        : "Click to change patch state"}>
+
      <span
+
        class="global-counter badge"
+
        style:color={patchStatusColor[selectedState.status]}
+
        style:background-color={patchStatusBackgroundColor[
+
          selectedState.status
+
        ]}>
+
        <Icon name={icon()} />
+
      </span>
+
    </button>
+
  {/snippet}
+
  {#snippet popover()}
+
    <Border variant="ghost">
+
      <DropdownList
+
        items={[
+
          { status: "open" },
+
          { status: "draft" },
+
          { status: "archived" },
+
        ] as State[]}>
+
        {#snippet item(state)}
+
          <DropdownListItem
+
            selected={selectedState.status === state.status}
+
            onclick={() => {
+
              onSelect(state);
+
              closeFocused();
+
            }}>
+
            <span
+
              class="global-flex"
+
              style:color={patchStatusColor[state.status]}>
+
              <Icon
+
                name={state.status === "open"
+
                  ? "patch"
+
                  : `patch-${state.status}`} />
+
              {capitalize(state.status)}
+
            </span>
+
          </DropdownListItem>
+
        {/snippet}
+
      </DropdownList>
+
    </Border>
+
  {/snippet}
+
</Popover>
modified src/components/PatchTimeline.svelte
@@ -251,7 +251,7 @@
      <div class="timeline-item">
        {#if op.verdict === "accept"}
          <div class="icon" style:color="var(--color-foreground-success)">
-
            <Icon name="comment-checkmark" />
+
            <Icon name="thumb-up" />
          </div>
          <div class="wrapper">
            <NodeId {...authorForNodeId(op.author)} />
@@ -262,7 +262,7 @@
          </div>
        {:else if op.verdict === "reject"}
          <div class="icon" style:color="var(--color-foreground-red)">
-
            <Icon name="comment-cross" />
+
            <Icon name="stop" />
          </div>
          <div class="wrapper">
            <NodeId {...authorForNodeId(op.author)} />
modified src/components/Review.svelte
@@ -345,11 +345,15 @@
        <NodeId
          {...authorForNodeId(review.author)}
          styleFontSize="var(--font-size-medium)"
-
          styleFontWeight="var(--font-weight-medium)" />'s
+
          styleFontWeight="var(--font-weight-medium)" />'s review
        {#if "draft" in review}
-
          draft
+
          <span
+
            class="global-counter"
+
            style:margin-left="0.5rem"
+
            title="This review is not yet visible to your peers">
+
            Draft
+
          </span>
        {/if}
-
        review
      </span>
      {#if "draft" in review}
        <div style:margin-inline-start="auto">
deleted src/components/ReviewTeaser.svelte
@@ -1,158 +0,0 @@
-
<script lang="ts">
-
  import type { DraftReview } from "@app/lib/draftReviewStorage";
-
  import type { PatchStatus } from "@app/views/repo/router";
-
  import type { Review } from "@bindings/cob/patch/Review";
-

-
  import {
-
    absoluteTimestamp,
-
    authorForNodeId,
-
    formatTimestamp,
-
    verdictIcon,
-
  } from "@app/lib/utils";
-
  import { push } from "@app/lib/router";
-

-
  import Icon from "@app/components/Icon.svelte";
-
  import Id from "@app/components/Id.svelte";
-
  import Label from "@app/components/Label.svelte";
-
  import Markdown from "@app/components/Markdown.svelte";
-
  import NodeId from "@app/components/NodeId.svelte";
-

-
  interface Props {
-
    patchId: string;
-
    review: Review | DraftReview;
-
    rid: string;
-
    status: PatchStatus | undefined;
-
    first?: boolean;
-
    last?: boolean;
-
  }
-

-
  const { patchId, review, rid, status, first, last }: Props = $props();
-
  const style = $derived(
-
    (first && last && "--local-clip-path: var(--2px-corner-fill)") ||
-
      (first && "--local-clip-path: var(--2px-top-corner-fill)") ||
-
      (last && "--local-clip-path: var(--2px-bottom-corner-fill)") ||
-
      "",
-
  );
-
</script>
-

-
<style>
-
  .review {
-
    display: flex;
-
    align-items: flex-start;
-
    gap: 0.75rem;
-
    z-index: 1;
-
    position: relative;
-
  }
-
  /* We put the background and clip-path in a separate element to prevent
-
     popovers being clipped in the main element. */
-
  .review::after {
-
    position: absolute;
-
    z-index: -1;
-
    content: " ";
-
    background-color: var(--color-fill-float);
-
    clip-path: var(--local-clip-path);
-
    width: 100%;
-
    height: 100%;
-
    top: 0;
-
  }
-
  .review:hover::after {
-
    background-color: var(--color-fill-float-hover);
-
  }
-
  .review-content {
-
    padding: 10px 0.75rem 0.5rem 0;
-
    width: 100%;
-
    font-size: var(--font-size-small);
-
    display: flex;
-
    flex-direction: column;
-
    gap: 0.5rem;
-
  }
-
  .timestamp {
-
    color: var(--color-foreground-dim);
-
  }
-
  .review-header {
-
    display: flex;
-
    justify-content: space-between;
-
    align-items: center;
-
    width: 100%;
-
  }
-
  .status {
-
    padding: 0;
-
    margin: 0.5rem 0 0 0.5rem;
-
  }
-

-
  .accepted {
-
    background-color: var(--color-fill-diff-green-light);
-
    color: var(--color-foreground-success);
-
  }
-

-
  .rejected {
-
    background-color: var(--color-fill-diff-red-light);
-
    color: var(--color-foreground-red);
-
  }
-

-
  .no-verdict {
-
    background-color: var(--color-fill-ghost);
-
    color: var(--color-foreground-dim);
-
  }
-
</style>
-

-
<!-- svelte-ignore a11y_click_events_have_key_events -->
-
<div
-
  tabindex="0"
-
  role="button"
-
  class="review"
-
  {style}
-
  style:cursor="pointer"
-
  onclick={() => {
-
    void push({
-
      resource: "repo.patch",
-
      rid,
-
      patch: patchId,
-
      status,
-
      reviewId: review.id,
-
    });
-
  }}>
-
  <div
-
    class:accepted={review.verdict === "accept"}
-
    class:rejected={review.verdict === "reject"}
-
    class:no-verdict={review.verdict === undefined}
-
    class="global-counter status">
-
    <Icon name={verdictIcon(review.verdict)} />
-
  </div>
-
  <div class="review-content">
-
    <div class="review-header">
-
      <div class="global-flex">
-
        <NodeId {...authorForNodeId(review.author)} />
-
        {#if "draft" in review}
-
          <span>draft review</span>
-
        {:else}
-
          <span>published review</span>
-
          <Id id={review.id} variant="oid" />
-
          <div class="timestamp" title={absoluteTimestamp(review.timestamp)}>
-
            {formatTimestamp(review.timestamp)}
-
          </div>
-
        {/if}
-
      </div>
-

-
      <div class="global-flex" style:gap="1rem">
-
        {#if review.labels.length > 0}
-
          <div class="global-flex" style:margin-left="auto">
-
            {#each review.labels as label}
-
              <Label {label} />
-
            {/each}
-
          </div>
-
        {/if}
-
        {#if review.comments.length > 0}
-
          <div class="global-flex" style:gap="0.25rem" style:margin-left="auto">
-
            <Icon name="comment" />{review.comments.length}
-
          </div>
-
        {/if}
-
      </div>
-
    </div>
-
    {#if review.summary?.trim()}
-
      <div>
-
        <Markdown {rid} breaks content={review.summary} />
-
      </div>
-
    {/if}
-
  </div>
-
</div>
deleted src/components/Reviews.svelte
@@ -1,112 +0,0 @@
-
<script lang="ts">
-
  import type { Config } from "@bindings/config/Config";
-
  import type { DraftReview } from "@app/lib/draftReviewStorage";
-
  import type { PatchStatus } from "@app/views/repo/router";
-
  import type { Review } from "@bindings/cob/patch/Review";
-
  import type { Revision } from "@bindings/cob/patch/Revision";
-

-
  import Icon from "@app/components/Icon.svelte";
-
  import NakedButton from "@app/components/NakedButton.svelte";
-
  import ReviewTeaser from "@app/components/ReviewTeaser.svelte";
-
  import { didFromPublicKey } from "@app/lib/utils";
-
  import { draftReviewStorage } from "@app/lib/draftReviewStorage";
-
  import { push } from "@app/lib/router";
-

-
  interface Props {
-
    config: Config;
-
    patchId: string;
-
    revision: Revision;
-
    rid: string;
-
    status: PatchStatus | undefined;
-
  }
-

-
  const { config, patchId, revision, rid, status }: Props = $props();
-

-
  let hideReviews = $derived.by(() => {
-
    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
-
    patchId;
-

-
    return reviews.length === 0;
-
  });
-

-
  const reviews: Array<Review | DraftReview> = $derived(
-
    [
-
      draftReviewStorage.getForRevision(revision.id, {
-
        did: didFromPublicKey(config.publicKey),
-
        alias: config.alias,
-
      }),
-
      ...(revision.reviews ?? []),
-
    ].filter((review): review is Review | DraftReview => Boolean(review)),
-
  );
-

-
  const hasOwnReview = $derived(
-
    reviews.some(
-
      value => value.author.did === didFromPublicKey(config.publicKey),
-
    ),
-
  );
-
</script>
-

-
<style>
-
  .review-list {
-
    margin-top: 1rem;
-
    display: flex;
-
    flex-direction: column;
-
    gap: 2px;
-
  }
-
</style>
-

-
<div style:margin={hideReviews ? "1.5rem 0" : "1.5rem 0 2.5rem 0"}>
-
  <div class="global-flex">
-
    <div class="global-flex">
-
      <NakedButton
-
        stylePadding="0 4px"
-
        disabled={reviews.length === 0}
-
        variant="ghost"
-
        onclick={() => (hideReviews = !hideReviews)}>
-
        <Icon name={hideReviews ? "chevron-right" : "chevron-down"} />
-
      </NakedButton>
-
      <div
-
        class="txt-semibold global-flex txt-regular"
-
        style:color={reviews.length === 0
-
          ? "var(--color-foreground-disabled)"
-
          : undefined}>
-
        Reviews <span style:font-weight="var(--font-weight-regular)">
-
          {reviews.length}
-
        </span>
-
      </div>
-
    </div>
-

-
    <div class="global-flex" style:margin-left="auto">
-
      <NakedButton
-
        variant="ghost"
-
        disabled={hasOwnReview}
-
        onclick={() => {
-
          const id = draftReviewStorage.create(rid, revision.id);
-

-
          void push({
-
            resource: "repo.patch",
-
            rid,
-
            patch: patchId,
-
            reviewId: id,
-
            status,
-
          });
-
        }}
-
        title={hasOwnReview ? "You already published a review" : undefined}>
-
        <Icon name="add" />
-
        <span class="txt-small">Review</span>
-
      </NakedButton>
-
    </div>
-
  </div>
-

-
  <div style:display={hideReviews ? "none" : "flex"} class="review-list">
-
    {#each reviews as review, idx}
-
      <ReviewTeaser
-
        {rid}
-
        {review}
-
        {patchId}
-
        {status}
-
        first={idx === 0}
-
        last={idx === reviews.length - 1} />
-
    {/each}
-
  </div>
-
</div>
modified src/components/Revision.svelte
@@ -2,7 +2,6 @@
  import type { Author } from "@bindings/cob/Author";
  import type { Config } from "@bindings/config/Config";
  import type { Embed } from "@bindings/cob/thread/Embed";
-
  import type { PatchStatus } from "@app/views/repo/router";
  import type { Revision } from "@bindings/cob/patch/Revision";
  import type { Thread } from "@bindings/cob/thread/Thread";

@@ -17,7 +16,6 @@
  import Changes from "@app/components/Changes.svelte";
  import CommentComponent from "@app/components/Comment.svelte";
  import Discussion from "@app/components/Discussion.svelte";
-
  import Reviews from "@app/components/Reviews.svelte";

  interface Props {
    rid: string;
@@ -25,19 +23,11 @@
    patchId: string;
    revision: Revision;
    config: Config;
-
    status: PatchStatus | undefined;
    loadPatch: () => Promise<void>;
  }

-
  const {
-
    rid,
-
    repoDelegates,
-
    patchId,
-
    revision,
-
    config,
-
    status,
-
    loadPatch,
-
  }: Props = $props();
+
  const { rid, repoDelegates, patchId, revision, config, loadPatch }: Props =
+
    $props();

  const commentThreads = $derived(
    ((revision.discussion &&
@@ -219,8 +209,6 @@
  </CommentComponent>
</div>

-
<Reviews {config} {patchId} {revision} {rid} {status} />
-

<Discussion
  cobId={patchId}
  {commentThreads}
modified src/components/RevisionBadges.svelte
@@ -11,19 +11,20 @@
  /* eslint-enable prefer-const */
</script>

-
{#if revision.id === revisions.slice(-1)[0].id}
-
  <span
-
    class="global-counter"
-
    style:height="1.375rem"
-
    style:color="var(--color-foreground-contrast)">
-
    Latest
-
  </span>
-
{/if}
-
{#if revision.id === revisions[0].id}
-
  <span
-
    class="global-counter"
-
    style:height="1.375rem"
-
    style:color="var(--color-foreground-contrast)">
-
    Initial
-
  </span>
+
{#if revisions.length > 1}
+
  {#if revision.id === revisions[0].id}
+
    <span
+
      class="global-counter"
+
      style:height="1.375rem"
+
      style:color="var(--color-foreground-contrast)">
+
      Initial
+
    </span>
+
  {:else if revision.id === revisions.slice(-1)[0].id}
+
    <span
+
      class="global-counter"
+
      style:height="1.375rem"
+
      style:color="var(--color-foreground-contrast)">
+
      Latest
+
    </span>
+
  {/if}
{/if}
added src/components/RevisionReviews.svelte
@@ -0,0 +1,124 @@
+
<script lang="ts">
+
  import type { Config } from "@bindings/config/Config";
+
  import type { DraftReview } from "@app/lib/draftReviewStorage";
+
  import type { Patch } from "@bindings/cob/patch/Patch";
+
  import type { PatchStatus } from "@app/views/repo/router";
+
  import type { Review } from "@bindings/cob/patch/Review";
+
  import type { Revision } from "@bindings/cob/patch/Revision";
+

+
  import DropdownListItem from "./DropdownListItem.svelte";
+
  import Icon from "./Icon.svelte";
+
  import NodeId from "./NodeId.svelte";
+

+
  import {
+
    authorForNodeId,
+
    didFromPublicKey,
+
    verdictIcon,
+
  } from "@app/lib/utils";
+

+
  import { draftReviewStorage } from "@app/lib/draftReviewStorage";
+
  import { push } from "@app/lib/router";
+

+
  interface Props {
+
    config: Config;
+
    patch: Patch;
+
    revision: Revision;
+
    rid: string;
+
    status: PatchStatus | undefined;
+
  }
+

+
  const { config, patch, revision, rid, status }: Props = $props();
+

+
  const reviews: Array<Review | DraftReview> = $derived(
+
    [
+
      draftReviewStorage.getForRevision(revision.id, {
+
        did: didFromPublicKey(config.publicKey),
+
        alias: config.alias,
+
      }),
+
      ...(revision.reviews ?? []),
+
    ].filter((review): review is Review | DraftReview => Boolean(review)),
+
  );
+

+
  function unresolvedCommentsCount(review: Review | DraftReview) {
+
    return review.comments.filter(t => {
+
      return t.resolved === false && t.location !== null && t.replyTo === null;
+
    }).length;
+
  }
+
</script>
+

+
<style>
+
  .status {
+
    padding: 0;
+
  }
+
  .accepted {
+
    color: var(--color-foreground-success);
+
  }
+

+
  .rejected {
+
    color: var(--color-foreground-red);
+
  }
+

+
  .no-verdict {
+
    color: var(--color-foreground-dim);
+
  }
+
</style>
+

+
{#each reviews as review}
+
  <div style:margin="0.5rem 0">
+
    <DropdownListItem
+
      selected={false}
+
      onclick={() => {
+
        void push({
+
          resource: "repo.patch",
+
          rid,
+
          patch: patch.id,
+
          status,
+
          reviewId: review.id,
+
        });
+
      }}>
+
      <div
+
        class="global-flex"
+
        style:width="100%"
+
        style:gap="0.25rem"
+
        style:padding-left="1.5rem">
+
        <div
+
          style:margin-right="0.25rem"
+
          class:accepted={review.verdict === "accept"}
+
          class:rejected={review.verdict === "reject"}
+
          class:no-verdict={review.verdict === undefined}
+
          class="status">
+
          <Icon name={verdictIcon(review.verdict)} />
+
        </div>
+
        <span class="global-flex" style:gap="0">
+
          <NodeId
+
            {...authorForNodeId(review.author)}
+
            styleFontWeight="var(--font-weight-regular)" />'s
+
        </span>
+
        review
+
        {#if "draft" in review}
+
          <span
+
            class="global-counter"
+
            title="This review is not yet visible to your peers">
+
            Draft
+
          </span>
+
        {/if}
+
        <div class="global-flex" style:margin-left="auto">
+
          {#if review.comments.length > 0}
+
            {@const unresolved = unresolvedCommentsCount(review)}
+
            {#if unresolved > 0}
+
              <div class="global-flex" style:gap="0.25rem">
+
                <Icon name="comment-cross" />
+
                {unresolved}
+
              </div>
+
            {/if}
+
            {#if unresolved === 0 || review.comments.length > unresolved}
+
              <div class="global-flex" style:gap="0.25rem">
+
                <Icon name="comment" />{review.comments.length}
+
              </div>
+
            {/if}
+
          {/if}
+
        </div>
+
      </div>
+
    </DropdownListItem>
+
  </div>
+
{/each}
added src/components/Revisions.svelte
@@ -0,0 +1,113 @@
+
<script lang="ts">
+
  import type { Config } from "@bindings/config/Config";
+
  import type { Patch } from "@bindings/cob/patch/Patch";
+
  import type { PatchStatus } from "@app/views/repo/router";
+
  import type { Revision } from "@bindings/cob/patch/Revision";
+

+
  import orderBy from "lodash/orderBy";
+
  import uniqBy from "lodash/uniqBy";
+

+
  import { authorForNodeId } from "@app/lib/utils";
+

+
  import DropdownListItem from "./DropdownListItem.svelte";
+
  import Icon from "./Icon.svelte";
+
  import NodeId from "./NodeId.svelte";
+
  import RevisionReviews from "./RevisionReviews.svelte";
+
  import RevisionBadges from "./RevisionBadges.svelte";
+

+
  interface Props {
+
    config: Config;
+
    patch: Patch;
+
    revisions: Revision[];
+
    rid: string;
+
    selectRevision: (revision: Revision) => void;
+
    selectedRevision: Revision;
+
    status: PatchStatus | undefined;
+
  }
+

+
  const {
+
    config,
+
    patch,
+
    revisions,
+
    rid,
+
    selectRevision,
+
    selectedRevision,
+
    status,
+
  }: Props = $props();
+

+
  const revisionAuthors = $derived(
+
    orderBy(
+
      uniqBy(
+
        revisions.map(r => r.author),
+
        "did",
+
      ),
+
      [o => o.did === patch.author.did],
+
      ["desc"],
+
    ),
+
  );
+
</script>
+

+
<style>
+
  .author-revisions:not(:last-of-type) {
+
    margin-bottom: 1.5rem;
+
  }
+
</style>
+

+
{#each revisionAuthors as author}
+
  <div class="author-revisions">
+
    <div style:padding-bottom="0.5rem">
+
      <span class="global-flex txt-small" style:gap="0">
+
        <NodeId
+
          {...authorForNodeId(author)}
+
          styleFontWeight="var(--font-weight-regular)" />'s revisions
+
      </span>
+
    </div>
+
    {#each orderBy( revisions.filter(r => {
+
        return r.author.did === author.did;
+
      }), "timestamp", ["asc"], ) as revision}
+
      <div style:margin="0.5rem 0">
+
        <DropdownListItem
+
          selected={revision.id === selectedRevision.id}
+
          onclick={() => {
+
            selectRevision(revision);
+
          }}>
+
          <div class="global-flex txt-overflow" style:width="100%">
+
            {#if patch.state.status === "merged" && patch.state.revision === revision.id}
+
              <div style:color="var(--color-fill-primary)">
+
                <Icon name="patch-merged" />
+
              </div>
+
            {:else}
+
              <Icon name="revision" />
+
            {/if}
+
            <span class="global-oid">
+
              {revision.id.substring(0, 4)}
+
            </span>
+
            <RevisionBadges {revision} {revisions} />
+
            <span class="txt-overflow">
+
              {#if revision.description[0].body.trim()}
+
                {revision.description[0].body}
+
              {:else}
+
                <span
+
                  class="txt-missing"
+
                  style:font-weight="var(--font-weight-regular)">
+
                  No description.
+
                </span>
+
              {/if}
+
            </span>
+
            <div class="global-flex" style:margin-left="auto">
+
              {#if revision.discussion && revision.discussion.length > 0}
+
                <div
+
                  class="global-flex"
+
                  style:font-weight="var(--font-weight-regular)"
+
                  style:gap="0.25rem">
+
                  <Icon name="comment" />{revision.discussion.length}
+
                </div>
+
              {/if}
+
            </div>
+
          </div>
+
        </DropdownListItem>
+
      </div>
+
      <RevisionReviews {config} {rid} {status} {revision} {patch} />
+
    {/each}
+
  </div>
+
{/each}
modified src/lib/utils.ts
@@ -241,11 +241,11 @@ export function gravatarURL(email: string): string {

export function verdictIcon(verdict: Review["verdict"]) {
  if (verdict === "accept") {
-
    return "comment-checkmark";
+
    return "thumb-up";
  } else if (verdict === "reject") {
-
    return "comment-cross";
+
    return "stop";
  } else {
-
    return "comment";
+
    return "review";
  }
}

modified src/views/repo/Patch.svelte
@@ -1,6 +1,5 @@
<script lang="ts">
  import type { Action } from "@bindings/cob/patch/Action";
-
  import type { Author } from "@bindings/cob/Author";
  import type { Config } from "@bindings/config/Config";
  import type { DraftReview } from "@app/lib/draftReviewStorage";
  import type { Operation } from "@bindings/cob/Operation";
@@ -13,51 +12,48 @@

  import fuzzysort from "fuzzysort";

-
  import * as roles from "@app/lib/roles";
  import * as router from "@app/lib/router";
  import { DEFAULT_TAKE } from "./router";
  import { announce } from "@app/components/AnnounceSwitch.svelte";
-
  import { draftReviewStorage } from "@app/lib/draftReviewStorage";
  import {
+
    didFromPublicKey,
    explorerUrl,
    formatOid,
-
    patchStatusBackgroundColor,
-
    patchStatusColor,
    verdictIcon,
  } from "@app/lib/utils";
+
  import { draftReviewStorage } from "@app/lib/draftReviewStorage";
  import { invoke } from "@app/lib/invoke";
  import { modifierKey } from "@app/lib/utils";
  import { nodeRunning } from "@app/lib/events";
+
  import { push } from "@app/lib/router";

-
  import AssigneeInput from "@app/components/AssigneeInput.svelte";
  import Border from "@app/components/Border.svelte";
+
  import Button from "@app/components/Button.svelte";
  import CheckoutPatchButton from "@app/components/CheckoutPatchButton.svelte";
+
  import DropdownListItem from "@app/components/DropdownListItem.svelte";
  import EditableTitle from "@app/components/EditableTitle.svelte";
  import Icon from "@app/components/Icon.svelte";
  import InlineTitle from "@app/components/InlineTitle.svelte";
-
  import LabelInput from "@app/components/LabelInput.svelte";
  import Link from "@app/components/Link.svelte";
+
  import MoreBreadcrumbsButton from "@app/components/MoreBreadcrumbsButton.svelte";
  import NakedButton from "@app/components/NakedButton.svelte";
  import NewPatchButton from "@app/components/NewPatchButton.svelte";
  import NodeBreadcrumb from "@app/components/NodeBreadcrumb.svelte";
-
  import PatchStateButton from "@app/components/PatchStateButton.svelte";
+
  import PatchMetadata from "@app/components/PatchMetadata.svelte";
+
  import PatchStateButtonCompact from "@app/components/PatchStateButtonCompact.svelte";
  import PatchStateFilterButton from "@app/components/PatchStateFilterButton.svelte";
  import PatchTeaser from "@app/components/PatchTeaser.svelte";
  import PatchTimeline from "@app/components/PatchTimeline.svelte";
  import ReviewComponent from "@app/components/Review.svelte";
-
  import RevisionBadges from "@app/components/RevisionBadges.svelte";
  import RevisionComponent from "@app/components/Revision.svelte";
-
  import RevisionSelector from "@app/components/RevisionSelector.svelte";
+
  import Revisions from "@app/components/Revisions.svelte";
  import Sidebar from "@app/components/Sidebar.svelte";
-
  import Tab from "@app/components/Tab.svelte";
  import TextInput from "@app/components/TextInput.svelte";

+
  import BreadcrumbCopyButton from "./BreadcrumbCopyButton.svelte";
  import Layout from "./Layout.svelte";
  import PatchesBreadcrumb from "./PatchesBreadcrumb.svelte";
  import RepoBreadcrumb from "./RepoBreadcrumb.svelte";
-
  import BreadcrumbCopyButton from "./BreadcrumbCopyButton.svelte";
-
  import MoreBreadcrumbsButton from "@app/components/MoreBreadcrumbsButton.svelte";
-
  import DropdownListItem from "@app/components/DropdownListItem.svelte";

  interface Props {
    repo: RepoInfo;
@@ -89,10 +85,9 @@
  let more: boolean = $state(false);
  let patchTeasers: Patch[] = $state([]);

+
  let hideTimeline = $state(true);
  let patches = $state(initialPatches);
  let status = $state(initialStatus);
-
  let labelSaveInProgress: boolean = $state(false);
-
  let assigneesSaveInProgress: boolean = $state(false);
  let tab: "patch" | "revisions" | "timeline" = $state(
    revisions.length > 1 ? "revisions" : "patch",
  );
@@ -114,79 +109,39 @@

  const project = $derived(repo.payloads["xyz.radicle.project"]!);

-
  async function updateTitle(newTitle: string) {
-
    try {
-
      await invoke("edit_patch", {
-
        rid: repo.rid,
-
        cobId: patch.id,
-
        action: {
-
          id: patch.id,
-
          type: "edit",
-
          title: newTitle,
-
          target: "delegates",
-
        },
-
        opts: { announce: $nodeRunning && $announce },
-
      });
-
    } catch (error) {
-
      console.error("Editing title failed: ", error);
-
    } finally {
-
      await loadPatch();
-
    }
-
  }
-

-
  async function saveLabels(labels: string[]) {
-
    try {
-
      labelSaveInProgress = true;
-
      await invoke("edit_patch", {
-
        rid: repo.rid,
-
        cobId: patch.id,
-
        action: {
-
          type: "label",
-
          labels,
-
        },
-
        opts: { announce: $nodeRunning && $announce },
-
      });
-
    } catch (error) {
-
      console.error("Editing labels failed", error);
-
    } finally {
-
      labelSaveInProgress = false;
-
      await loadPatch();
-
    }
-
  }
-

-
  async function saveAssignees(assignees: Author[]) {
+
  async function saveState(newState: Patch["state"]) {
    try {
-
      assigneesSaveInProgress = true;
      await invoke("edit_patch", {
        rid: repo.rid,
        cobId: patch.id,
        action: {
-
          type: "assign",
-
          assignees,
+
          type: "lifecycle",
+
          state: newState,
        },
        opts: { announce: $nodeRunning && $announce },
      });
    } catch (error) {
-
      console.error("Editing assignees failed", error);
+
      console.error("Changing state failed", error);
    } finally {
-
      assigneesSaveInProgress = false;
      await loadPatch();
    }
  }

-
  async function saveState(newState: Patch["state"]) {
+
  async function updateTitle(newTitle: string) {
    try {
      await invoke("edit_patch", {
        rid: repo.rid,
        cobId: patch.id,
        action: {
-
          type: "lifecycle",
-
          state: newState,
+
          id: patch.id,
+
          type: "edit",
+
          title: newTitle,
+
          target: "delegates",
        },
        opts: { announce: $nodeRunning && $announce },
      });
    } catch (error) {
-
      console.error("Changing state failed", error);
+
      console.error("Editing title failed: ", error);
    } finally {
      await loadPatch();
    }
@@ -320,37 +275,30 @@
      }
    }
  }
+
  const reviewsOfSelectedRevision: Array<Review | DraftReview> = $derived(
+
    [
+
      draftReviewStorage.getForRevision(selectedRevision.id, {
+
        did: didFromPublicKey(config.publicKey),
+
        alias: config.alias,
+
      }),
+
      ...(selectedRevision.reviews ?? []),
+
    ].filter((review): review is Review | DraftReview => Boolean(review)),
+
  );
+
  const hasOwnReview = $derived(
+
    reviewsOfSelectedRevision.some(
+
      value => value.author.did === didFromPublicKey(config.publicKey),
+
    ),
+
  );
</script>

<style>
-
  .status {
-
    padding: 0;
-
    height: 2.5rem;
-
    width: 2.5rem;
-
  }
  .content {
    padding: 1rem 1rem 1rem 0;
  }
-

-
  .metadata-divider {
-
    width: 2px;
-
    background-color: var(--color-fill-ghost);
-
    height: calc(100% + 4px);
-
    top: 0;
-
    position: relative;
-
  }
-
  .metadata-section {
-
    padding: 0.5rem;
-
    font-size: var(--font-size-small);
-
    display: flex;
-
    flex-direction: column;
-
    align-items: flex-start;
-
    height: 100%;
-
    z-index: 20;
-
  }
-
  .metadata-section-title {
-
    margin-bottom: 0.5rem;
-
    color: var(--color-foreground-dim);
+
  .container {
+
    display: grid;
+
    grid-template-columns: 1fr min-content;
+
    grid-template-areas: "main-content right-sidebar";
  }
  .list {
    display: flex;
@@ -593,162 +541,135 @@
  {:else}
    <div class="content">
      <div class="global-flex" style:margin-bottom="1rem" style:gap="0.75rem">
-
        <div
-
          class="global-counter status"
-
          style:color={patchStatusColor[patch.state.status]}
-
          style:background-color={patchStatusBackgroundColor[
-
            patch.state.status
-
          ]}>
-
          <Icon
-
            name={patch.state.status === "open"
-
              ? "patch"
-
              : `patch-${patch.state.status}`} />
-
        </div>
+
        <PatchStateButtonCompact
+
          selectedState={patch.state}
+
          onSelect={newState => {
+
            void saveState(newState);
+
          }} />
        <EditableTitle
          {updateTitle}
          allowedToEdit={true}
          title={patch.title}
          cobId={patch.id} />
-
        <div style:margin-left="auto" style:z-index="40">
+
        <div
+
          class="global-flex"
+
          style:margin-left="auto"
+
          style:z-index="40"
+
          style:gap="1rem">
          <CheckoutPatchButton
            {tab}
            selectedRevisionId={selectedRevision.id}
            patchId={patch.id} />
+
          <Button
+
            variant="secondary"
+
            styleHeight="2.5rem"
+
            disabled={hasOwnReview}
+
            onclick={() => {
+
              const id = draftReviewStorage.create(
+
                repo.rid,
+
                selectedRevision.id,
+
              );
+
              void push({
+
                resource: "repo.patch",
+
                rid: repo.rid,
+
                patch: patch.id,
+
                reviewId: id,
+
                status,
+
              });
+
            }}
+
            title={hasOwnReview
+
              ? "You already created a review for this revision"
+
              : "Review revision"}>
+
            <Icon name="review" />
+
            <span class="txt-small global-hide-on-medium-desktop-down">
+
              Review revision
+
            </span>
+
          </Button>
        </div>
      </div>
-
      <Border variant="ghost" styleGap="0">
-
        <div class="metadata-section" style:min-width="8rem">
-
          <div class="metadata-section-title">Status</div>
-
          <PatchStateButton
-
            selectedState={patch.state}
-
            onSelect={newState => {
-
              void saveState(newState);
-
              if (status !== undefined && newState.status !== status) {
-
                status = undefined;
-
                void loadPatches(status);
-
              }
-
            }} />
-
        </div>
-

-
        <div class="metadata-divider"></div>
-

-
        <div class="metadata-section" style:flex="1">
-
          <LabelInput
-
            allowedToEdit={!!roles.isDelegateOrAuthor(
-
              config.publicKey,
-
              repo.delegates.map(delegate => delegate.did),
-
              patch.author.did,
-
            )}
-
            labels={patch.labels}
-
            submitInProgress={labelSaveInProgress}
-
            save={saveLabels} />
-
        </div>
-

-
        <div class="metadata-divider"></div>
-

-
        <div class="metadata-section" style:flex="1">
-
          <AssigneeInput
-
            allowedToEdit={!!roles.isDelegateOrAuthor(
-
              config.publicKey,
-
              repo.delegates.map(delegate => delegate.did),
-
              patch.author.did,
-
            )}
-
            assignees={patch.assignees}
-
            submitInProgress={assigneesSaveInProgress}
-
            save={saveAssignees} />
+
      <div class="global-hide-on-desktop-up" style:margin-top="1rem">
+
        <PatchMetadata
+
          {config}
+
          {loadPatch}
+
          {patch}
+
          {repo}
+
          {saveState}
+
          horizontal />
+
      </div>
+
      <div
+
        class="global-hide-on-desktop-up"
+
        style:padding="0.5rem"
+
        style:margin-bottom="2rem">
+
        <div
+
          class="txt-small"
+
          style:margin-bottom="1rem"
+
          style:color="var(--color-foreground-dim)">
+
          Revisions
        </div>
-
      </Border>
-

-
      <div class="global-flex" style:gap="0.5rem" style:margin-top="1rem">
-
        <Border stylePosition="relative" variant="ghost" flatBottom>
-
          <div
-
            class="global-flex"
-
            style:z-index="10"
-
            style:gap="1rem"
-
            style:padding="0 1rem"
-
            style:width="100%">
-
            <span class="txt-small" style:color="var(--color-foreground-dim)">
-
              Revisions
-
            </span>
-
            <Tab
-
              active={tab === "patch"}
-
              onclick={() => {
-
                tab = "patch";
-
              }}>
-
              {formatOid(patch.id)}
-
              <span
-
                class="global-counter"
-
                style:height="1.5rem"
-
                style:color="var(--color-foreground-contrast)">
-
                Initial
-
              </span>
-
            </Tab>
-
            {#if revisions.length > 1}
-
              <Tab
-
                active={tab === "revisions"}
-
                onclick={() => {
-
                  tab = "revisions";
-
                }}>
-
                {formatOid(selectedRevision.id)}
-
                <div class="global-flex" style:gap="0.25rem">
-
                  <RevisionBadges revision={selectedRevision} {revisions} />
-
                  <RevisionSelector
-
                    {patch}
-
                    {revisions}
-
                    {selectedRevision}
-
                    selectRevision={rev => {
-
                      selectedRevision = rev;
-
                      tab = "revisions";
-
                    }} />
-
                </div>
-
              </Tab>
-
            {/if}
-

-
            <div style:margin-left="auto">
-
              <Tab
-
                active={tab === "timeline"}
-
                onclick={() => {
-
                  tab = "timeline";
-
                }}>
-
                <Icon name="clock" />
-
                Timeline
-
              </Tab>
-
            </div>
-
          </div>
-
        </Border>
+
        <Revisions
+
          {config}
+
          rid={repo.rid}
+
          selectRevision={rev => {
+
            selectedRevision = rev;
+
            tab = "revisions";
+
          }}
+
          {patch}
+
          {revisions}
+
          {selectedRevision}
+
          {status} />
      </div>
-

-
      <Border
-
        variant="ghost"
-
        flatTop
-
        styleWidth="100%"
-
        stylePadding="1rem"
-
        styleMinWidth="0"
-
        styleDisplay="block"
-
        styleFlexDirection="column"
-
        styleAlignItems="flex-start">
-
        {#if tab === "patch"}
-
          <RevisionComponent
-
            rid={repo.rid}
-
            repoDelegates={repo.delegates}
-
            patchId={patch.id}
-
            {loadPatch}
-
            {status}
-
            revision={revisions[0]}
-
            {config} />
-
        {:else if tab === "timeline"}
-
          <PatchTimeline {activity} patchId={patch.id} />
-
        {:else}
+
      <div class="container">
+
        <div style:grid-area="main-content" style:min-width="0">
          <RevisionComponent
            rid={repo.rid}
            repoDelegates={repo.delegates}
            patchId={patch.id}
            {loadPatch}
-
            {status}
            revision={selectedRevision}
            {config} />
-
        {/if}
-
      </Border>
+
          <div class="global-flex" style:margin-top="1.5rem">
+
            <NakedButton
+
              variant="ghost"
+
              onclick={() => (hideTimeline = !hideTimeline)}
+
              stylePadding="0 4px">
+
              <Icon name={hideTimeline ? "chevron-right" : "chevron-down"} />
+
            </NakedButton>
+
            <div class="txt-semibold global-flex txt-regular">Timeline</div>
+
          </div>
+
          <div
+
            style:display={hideTimeline ? "none" : "revert"}
+
            style:margin-top="1rem">
+
            <PatchTimeline {activity} patchId={patch.id} />
+
          </div>
+
        </div>
+

+
        <div
+
          class="global-hide-on-medium-desktop-down"
+
          style:grid-area="right-sidebar"
+
          style:margin-left="1rem"
+
          style:width="22rem">
+
          <PatchMetadata {config} {loadPatch} {patch} {repo} {saveState} />
+
          <div style:margin-top="0.5rem" style:padding="0.5rem">
+
            <div
+
              class="txt-small"
+
              style:margin-bottom="1rem"
+
              style:color="var(--color-foreground-dim)">
+
              Revisions
+
            </div>
+
            <Revisions
+
              {config}
+
              rid={repo.rid}
+
              selectRevision={rev => {
+
                selectedRevision = rev;
+
                tab = "revisions";
+
              }}
+
              {patch}
+
              {revisions}
+
              {selectedRevision}
+
              {status} />
+
          </div>
+
        </div>
+
      </div>
    </div>
  {/if}
</Layout>
modified tests/e2e/theme.spec.ts
@@ -9,7 +9,7 @@ test("default theme", async ({ page }) => {
test("theme persistence", async ({ page }) => {
  await page.goto("/repos");
  await expect(page.getByRole("button", { name: "markdown" })).toBeVisible();
-
  await page.getByRole("button", { name: "Settings" }).click();
+
  await page.getByRole("button", { name: "Settings", exact: true }).click();

  await page
    .getByRole("button", { name: "icon-sun Light", exact: true })
@@ -24,7 +24,7 @@ test("theme persistence", async ({ page }) => {
test("change theme", async ({ page }) => {
  await page.goto("/repos");
  await expect(page.getByRole("button", { name: "markdown" })).toBeVisible();
-
  await page.getByRole("button", { name: "Settings" }).click();
+
  await page.getByRole("button", { name: "Settings", exact: true }).click();

  await page
    .getByRole("button", { name: "icon-sun Light", exact: true })