Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Improve code review usability
Rūdolfs Ošiņš committed 10 months ago
commit a55a138a7f2485f71d10ec4e4d76031fe92a0f3b
parent 18f5aa9
20 files changed +923 -639
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>