Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Draft reviews before publishing
Open did:key:z6Mki9XN...FvWF opened 10 months ago

Reviews are locally persisted but only published to the network by an explicit user action.

  • Delete line comments on draft reviews
17 files changed +562 -294 d35920b8 6ea09cf5
modified crates/radicle-tauri/src/commands/cob/patch.rs
@@ -1,6 +1,7 @@
use std::ops::ControlFlow;

-
use radicle::patch::TYPENAME;
+
use radicle::patch::cache::Patches as _;
+
use radicle::patch::{ReviewId, TYPENAME};
use radicle::storage::ReadStorage;
use radicle::{cob, git, identity};

@@ -121,6 +122,41 @@ pub fn edit_patch(
}

#[tauri::command]
+
pub fn create_patch_review(
+
    ctx: tauri::State<AppState>,
+
    args: models::patch::CreateReviewArgs,
+
) -> Result<ReviewId, Error> {
+
    let repo = ctx.profile.storage.repository(args.rid)?;
+
    let signer = ctx.profile.signer()?;
+
    let mut patches = ctx.profile.patches_mut(&repo)?;
+
    let patch_id = match patches.find_by_revision(&args.revision)? {
+
        Some(found) => found.id,
+
        None => return Err(cob::patch::Error::RevisionNotFound(args.revision).into()),
+
    };
+
    let mut patch = patches.get_mut(&patch_id)?;
+
    let review_id = patch.review(
+
        args.revision,
+
        args.verdict.map(Into::into),
+
        args.summary,
+
        args.labels,
+
        &signer,
+
    )?;
+

+
    for comment in args.comments {
+
        patch.review_comment(
+
            review_id,
+
            comment.body,
+
            comment.location.map(Into::into),
+
            None,
+
            vec![],
+
            &signer,
+
        )?;
+
    }
+

+
    Ok(review_id)
+
}
+

+
#[tauri::command]
pub fn activity_by_patch(
    ctx: tauri::State<AppState>,
    rid: identity::RepoId,
modified crates/radicle-tauri/src/lib.rs
@@ -37,6 +37,7 @@ pub fn run() {
            cob::patch::list_patches,
            cob::patch::patch_by_id,
            cob::patch::edit_patch,
+
            cob::patch::create_patch_review,
            cob::patch::rebuild_patch_cache,
            cob::patch::review_by_patch_and_revision_and_id,
            cob::patch::revisions_by_patch,
added crates/radicle-types/bindings/cob/patch/CreateReviewArgs.ts
@@ -0,0 +1,12 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
import type { CodeLocation } from "../thread/CodeLocation";
+
import type { Verdict } from "./Verdict";
+

+
export type CreateReviewArgs = {
+
  rid: string;
+
  revision: string;
+
  verdict: Verdict | null;
+
  summary: string | null;
+
  labels: Array<string>;
+
  comments: Array<{ body: string; location: CodeLocation | null }>;
+
};
added crates/radicle-types/bindings/cob/patch/ReviewInit.ts
@@ -0,0 +1,10 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
import type { Verdict } from "./Verdict";
+

+
export type ReviewInit = {
+
  "type": "ReviewInit";
+
  revision: string;
+
  summary?: string;
+
  verdict?: Verdict;
+
  labels?: Array<string>;
+
};
modified crates/radicle-types/src/domain/patch/models/patch.rs
@@ -4,6 +4,7 @@ use std::ops::Index;

use radicle::node::AliasStore;
use radicle::patch::Status;
+
use radicle::prelude::RepoId;
use radicle::profile::Aliases;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
@@ -671,6 +672,28 @@ impl FromRadicleAction<radicle::patch::Action> for Action {
    }
}

+
#[derive(Debug, TS, Deserialize)]
+
#[ts(export)]
+
#[ts(export_to = "cob/patch/")]
+
pub struct CreateReviewArgs {
+
    #[ts(as = "String")]
+
    pub rid: RepoId,
+
    #[ts(as = "String")]
+
    pub revision: patch::RevisionId,
+
    pub verdict: Option<Verdict>,
+
    pub summary: Option<String>,
+
    #[ts(as = "Vec<String>")]
+
    pub labels: Vec<cob::Label>,
+
    #[ts(inline)]
+
    pub comments: Vec<CreateReviewComment>,
+
}
+

+
#[derive(Debug, TS, Deserialize)]
+
pub struct CreateReviewComment {
+
    pub body: String,
+
    pub location: Option<cobs::thread::CodeLocation>,
+
}
+

#[derive(Debug, Default, TS, Serialize)]
#[ts(export)]
#[ts(export_to = "cob/patch/")]
modified src/components/Comment.svelte
@@ -35,7 +35,8 @@
    editComment?: (body: string, embeds: Embed[]) => Promise<void>;
    reactOnComment?: (authors: Author[], reaction: string) => Promise<void>;
    styleWidth?: string;
-
    allowAttachments?: boolean;
+
    // See `ExtendedTextarea`
+
    disableAttachments?: boolean | string;
  }

  /* eslint-disable prefer-const */
@@ -56,7 +57,7 @@
    reactOnComment,
    styleWidth,
    emptyBodyTooltip,
-
    allowAttachments = true,
+
    disableAttachments: disableAttachments,
  }: Props = $props();
  /* eslint-enable prefer-const */

@@ -195,7 +196,7 @@
            {embeds}
            {disallowEmptyBody}
            {emptyBodyTooltip}
-
            {allowAttachments}
+
            {disableAttachments}
            borderVariant="ghost"
            submitInProgress={state === "submit"}
            submitCaption="Save"
modified src/components/CommentToggleInput.svelte
@@ -12,6 +12,8 @@
    onclose?: () => void;
    onexpand?: () => void;
    disallowEmptyBody?: boolean;
+
    // See `ExtendedTextarea`
+
    disableAttachments?: boolean | string;
  }

  /* eslint-disable prefer-const */
@@ -23,6 +25,7 @@
    onclose,
    onexpand,
    disallowEmptyBody = false,
+
    disableAttachments,
  }: Props = $props();
  /* eslint-enable prefer-const */

@@ -58,6 +61,7 @@
        state = "collapsed";
      }
    }}
+
    {disableAttachments}
    submit={async ({ comment, embeds }) => {
      try {
        state = "submit";
modified src/components/Diff.svelte
@@ -24,6 +24,8 @@
    ) => Promise<void>;
    // Defaults to `true`.
    canReply?: boolean;
+
    // See `ExtendedTextarea`.
+
    disableAttachments?: boolean | string;
    repoDelegates: Author[];
    rid: string;
    threads: Thread<CodeLocation>[];
@@ -504,6 +506,7 @@
                }}
                focus
                placeholder="Leave a comment"
+
                disableAttachments={codeComments.disableAttachments}
                submit={async (body, embeds) => {
                  if (selection?.codeLocation) {
                    try {
modified src/components/ExtendedTextarea.svelte
@@ -39,7 +39,11 @@
      embeds: Map<string, Embed>;
    }) => Promise<void>;
    close: () => void;
-
    allowAttachments?: boolean;
+
    // If true, adding attachments through drag-and-drop is disabled and there
+
    // is no "Attach" button. If a string is provided, the "Attach" button is
+
    // visible but disabled and uses the string as the title to indicate the
+
    // reason for disabling. Defaults to `false`
+
    disableAttachments?: boolean | string;
  }

  /* eslint-disable prefer-const */
@@ -63,10 +67,15 @@
    borderVariant = "float",
    submit,
    close,
-
    allowAttachments = true,
+
    disableAttachments: attachDisabled = false,
  }: Props = $props();
  /* eslint-enable prefer-const */

+
  const attachEnabled = $derived(attachDisabled === false);
+
  const attachDisabledReason = $derived(
+
    typeof attachDisabled === "string" ? attachDisabled : undefined,
+
  );
+

  let selectionStart = $state(body.length);
  let selectionEnd = $state(body.length);
  let draggingOver = $state(false);
@@ -87,7 +96,7 @@

  onMount(async () => {
    if (window.__TAURI_INTERNALS__) {
-
      if (allowAttachments) {
+
      if (attachEnabled) {
        dragEnterUnlistenFn = await listen("tauri://drag-enter", () => {
          draggingOver = true;
        });
@@ -149,7 +158,7 @@
  }

  async function handlePaste(e: ClipboardEvent) {
-
    if (!allowAttachments) {
+
    if (!attachEnabled) {
      return;
    }

@@ -284,8 +293,8 @@
    {#if !preview}
      <div
        class="txt-overflow txt-small txt-missing"
-
        title={`${allowAttachments ? "Drag and drop files to add them. " : ""}Markdown is supported. Press ${utils.modifierKey()}↵ to submit.`}>
-
        {#if allowAttachments}Drag and drop files to add them.{/if}
+
        title={`${attachEnabled ? "Drag and drop files to add them. " : ""}Markdown is supported. Press ${utils.modifierKey()}↵ to submit.`}>
+
        {#if attachEnabled}Drag and drop files to add them.{/if}
        <Icon
          name="markdown"
          styleDisplay="inline"
@@ -294,8 +303,12 @@
      </div>
    {/if}
    <div class="buttons">
-
      {#if allowAttachments}
-
        <OutlineButton variant="ghost" onclick={selectFiles} disabled={preview}>
+
      {#if attachEnabled || attachDisabledReason}
+
        <OutlineButton
+
          variant="ghost"
+
          onclick={selectFiles}
+
          disabled={preview || attachDisabledReason !== undefined}
+
          title={attachDisabledReason}>
          <Icon name="attachment" />
          Attach
        </OutlineButton>
modified src/components/Review.svelte
@@ -29,6 +29,12 @@
  import NodeId from "@app/components/NodeId.svelte";
  import VerdictBadge from "@app/components/VerdictBadge.svelte";
  import VerdictButton from "@app/components/VerdictButton.svelte";
+
  import {
+
    type DraftReview,
+
    draftReviewStorage,
+
  } from "@app/lib/draftReviewStorage";
+
  import Button from "./Button.svelte";
+
  import { push } from "@app/lib/router";

  interface Props {
    config: Config;
@@ -36,7 +42,7 @@
    patchId: string;
    loadReview: () => Promise<void>;
    repo: RepoInfo;
-
    review: Review;
+
    review: Review | DraftReview;
    revision: Revision;
  }

@@ -62,6 +68,9 @@
    ),
  );

+
  let publishingInProgress = $state(false);
+
  const canPublish = $derived(review.verdict || review.summary);
+

  const commentThreads = $derived(
    ((review.comments &&
      review.comments
@@ -123,18 +132,26 @@

    try {
      labelSaveInProgress = true;
-
      await invoke("edit_patch", {
-
        rid: repo.rid,
-
        cobId: patchId,
-
        action: {
-
          type: "review.edit",
-
          review: reviewId,
-
          summary,
+
      if ("draft" in review) {
+
        draftReviewStorage.update(review.id, {
          verdict,
+
          summary: summary ?? "",
          labels,
-
        },
-
        opts: { announce: $nodeRunning && $announce },
-
      });
+
        });
+
      } else {
+
        await invoke("edit_patch", {
+
          rid: repo.rid,
+
          cobId: patchId,
+
          action: {
+
            type: "review.edit",
+
            review: reviewId,
+
            summary,
+
            verdict,
+
            labels,
+
          },
+
          opts: { announce: $nodeRunning && $announce },
+
        });
+
      }
    } catch (error) {
      console.error("Editing review failed: ", error);
    } finally {
@@ -150,19 +167,27 @@
    location?: CodeLocation,
  ) {
    try {
-
      await invoke("edit_patch", {
-
        rid: repo.rid,
-
        cobId: patchId,
-
        action: {
-
          type: "review.comment",
-
          review: review.id,
+
      if ("draft" in review) {
+
        draftReviewStorage.addComment(review.id, {
          body,
          embeds,
-
          replyTo,
-
          location,
-
        },
-
        opts: { announce: $nodeRunning && $announce },
-
      });
+
          location: location!,
+
        });
+
      } else {
+
        await invoke("edit_patch", {
+
          rid: repo.rid,
+
          cobId: patchId,
+
          action: {
+
            type: "review.comment",
+
            review: review.id,
+
            body,
+
            embeds,
+
            replyTo,
+
            location,
+
          },
+
          opts: { announce: $nodeRunning && $announce },
+
        });
+
      }
    } catch (error) {
      console.error("Creating comment failed", error);
    } finally {
@@ -172,18 +197,25 @@

  async function editComment(commentId: string, body: string, embeds: Embed[]) {
    try {
-
      await invoke("edit_patch", {
-
        rid: repo.rid,
-
        cobId: patchId,
-
        action: {
-
          type: "review.comment.edit",
-
          comment: commentId,
+
      if ("draft" in review) {
+
        draftReviewStorage.updateComment(review.id, commentId, {
          body,
-
          review: review.id,
          embeds,
-
        },
-
        opts: { announce: $nodeRunning && $announce },
-
      });
+
        });
+
      } else {
+
        await invoke("edit_patch", {
+
          rid: repo.rid,
+
          cobId: patchId,
+
          action: {
+
            type: "review.comment.edit",
+
            comment: commentId,
+
            body,
+
            review: review.id,
+
            embeds,
+
          },
+
          opts: { announce: $nodeRunning && $announce },
+
        });
+
      }
    } catch (error) {
      console.error("Editing comment failed: ", error);
    } finally {
@@ -197,6 +229,9 @@
    authors: Author[],
    reaction: string,
  ) {
+
    if ("draft" in review) {
+
      throw new Error("Cannot react on comment for draft review");
+
    }
    try {
      await invoke("edit_patch", {
        rid: repo.rid,
@@ -220,6 +255,9 @@
  }

  async function changeCommentStatus(commentId: string, resolved: boolean) {
+
    if ("draft" in review) {
+
      throw new Error("Cannot change comment status for draft review");
+
    }
    try {
      await invoke("edit_patch", {
        rid: repo.rid,
@@ -309,8 +347,40 @@
        <NodeId
          {...authorForNodeId(review.author)}
          styleFontSize="var(--font-size-medium)"
-
          styleFontWeight="var(--font-weight-medium)" />'s review
+
          styleFontWeight="var(--font-weight-medium)" />'s
+
        {#if "draft" in review}
+
          draft
+
        {/if}
+
        review
      </span>
+
      {#if "draft" in review}
+
        <div style:margin-inline-start="auto">
+
          <Button
+
            styleHeight="2.5rem"
+
            variant="secondary"
+
            title={canPublish
+
              ? undefined
+
              : "Add a summary or select a verdict to publish the review"}
+
            disabled={!canPublish || publishingInProgress}
+
            onclick={async () => {
+
              publishingInProgress = true;
+
              try {
+
                await draftReviewStorage.publish(review.id);
+
                await push({
+
                  resource: "repo.patch",
+
                  rid: repo.rid,
+
                  patch: patchId,
+
                  reviewId: undefined,
+
                  status: undefined,
+
                });
+
              } finally {
+
                publishingInProgress = false;
+
              }
+
            }}>
+
            <Icon name="checkout" />Publish
+
          </Button>
+
        </div>
+
      {/if}
    </div>

    <Border variant="ghost" styleGap="0">
@@ -365,15 +435,15 @@

    <div class="review-body">
      <CommentComponent
-
        allowAttachments={false}
+
        disableAttachments
        rid={repo.rid}
        disallowEmptyBody={review.verdict === undefined}
        emptyBodyTooltip="Summary is mandatory when verdict is None"
        styleWidth="100%"
-
        caption="published review"
-
        id={review.id}
+
        caption={"draft" in review ? "draft review" : "published review"}
+
        id={"draft" in review ? undefined : review.id}
        author={review.author}
-
        timestamp={review.timestamp}
+
        timestamp={"draft" in review ? undefined : review.timestamp}
        editComment={(publicKeyFromDid(review.author.did) ===
          config.publicKey ||
          undefined) &&
@@ -388,24 +458,29 @@
    </div>
  </div>

-
  <Discussion
-
    cobId={patchId}
-
    repoDelegates={repo.delegates}
-
    rid={repo.rid}
-
    {commentThreads}
-
    {config}
-
    {createComment}
-
    {editComment}
-
    {reactOnComment} />
+
  {#if !("draft" in review)}
+
    <Discussion
+
      cobId={patchId}
+
      repoDelegates={repo.delegates}
+
      rid={repo.rid}
+
      {commentThreads}
+
      {config}
+
      {createComment}
+
      {editComment}
+
      {reactOnComment} />
+
  {/if}

  <Changes
    codeComments={{
-
      changeCommentStatus,
+
      changeCommentStatus: "draft" in review ? undefined : changeCommentStatus,
      config,
      createComment,
      editComment,
-
      reactOnComment,
+
      reactOnComment: "draft" in review ? undefined : reactOnComment,
      repoDelegates: repo.delegates,
+
      canReply: false,
+
      disableAttachments:
+
        "draft" in review ? "Publish your review to attach files" : false,
      rid: repo.rid,
      threads: codeCommentThreads,
    }}
deleted src/components/ReviewButton.svelte
@@ -1,142 +0,0 @@
-
<script lang="ts">
-
  import type { Config } from "@bindings/config/Config";
-
  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 type { Verdict } from "@bindings/cob/patch/Verdict";
-

-
  import { closeFocused } from "./Popover.svelte";
-
  import { didFromPublicKey } from "@app/lib/utils";
-
  import { push } from "@app/lib/router";
-

-
  import Border from "@app/components/Border.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import NakedButton from "@app/components/NakedButton.svelte";
-
  import OutlineButton from "@app/components/OutlineButton.svelte";
-
  import Popover from "@app/components/Popover.svelte";
-

-
  interface Props {
-
    rid: string;
-
    patchId: string;
-
    revision: Revision;
-
    config: Config;
-
    status: PatchStatus | undefined;
-
    loadPatch: () => Promise<void>;
-
    createReview: (verdict?: Verdict) => Promise<Review | undefined>;
-
  }
-

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

-
  const hasOwnReview = $derived(
-
    Boolean(
-
      revision.reviews &&
-
        revision.reviews.some(
-
          value => value.author.did === didFromPublicKey(config.publicKey),
-
        ),
-
    ),
-
  );
-

-
  let popoverExpanded: boolean = $state(false);
-
</script>
-

-
<Popover
-
  popoverPositionRight="0"
-
  popoverPositionTop="2.5rem"
-
  bind:expanded={popoverExpanded}>
-
  {#snippet toggle(onclick)}
-
    <NakedButton
-
      variant="ghost"
-
      disabled={hasOwnReview}
-
      active={popoverExpanded}
-
      {onclick}
-
      title={hasOwnReview ? "You already published a review" : undefined}>
-
      <Icon name="add" />
-
      <span class="txt-small">Review</span>
-
    </NakedButton>
-
  {/snippet}
-

-
  {#snippet popover()}
-
    <Border
-
      variant="ghost"
-
      stylePadding="1rem"
-
      styleDisplay="flex"
-
      styleFlexDirection="column">
-
      <div class="global-flex">
-
        <OutlineButton
-
          variant="ghost"
-
          disabled={hasOwnReview}
-
          title={hasOwnReview ? "You already published a review" : undefined}
-
          onclick={async () => {
-
            const newReview = await createReview();
-
            if (newReview) {
-
              await push({
-
                resource: "repo.patch",
-
                rid,
-
                patch: patchId,
-
                status,
-
                reviewId: newReview.id,
-
              });
-
            }
-
            closeFocused();
-
          }}>
-
          <span class="global-flex" style:color="var(--color-foreground-dim)">
-
            <Icon name="comment" />
-
            <span class="txt-small">Write Review</span>
-
          </span>
-
        </OutlineButton>
-
        <OutlineButton
-
          variant="ghost"
-
          disabled={hasOwnReview}
-
          title={hasOwnReview ? "You already published a review" : undefined}
-
          onclick={async () => {
-
            await createReview("reject");
-
            await loadPatch();
-
            closeFocused();
-
          }}>
-
          <span class="global-flex" style:color="var(--color-foreground-red)">
-
            <Icon name="comment-cross" />
-
            <span class="txt-small">Reject</span>
-
          </span>
-
        </OutlineButton>
-
        <OutlineButton
-
          variant="ghost"
-
          disabled={hasOwnReview}
-
          title={hasOwnReview ? "You already published a review" : undefined}
-
          onclick={async () => {
-
            await createReview("accept");
-
            await loadPatch();
-
            closeFocused();
-
          }}>
-
          <span
-
            class="global-flex"
-
            style:color="var(--color-foreground-success)">
-
            <Icon name="comment-checkmark" />
-
            <span class="txt-small">Accept</span>
-
            <span></span>
-
          </span>
-
        </OutlineButton>
-
      </div>
-

-
      <div
-
        class="txt-small txt-missing global-flex"
-
        style:margin-top="0.5rem"
-
        style:align-items="flex-start">
-
        <div style:padding-top="3px"><Icon name="info" /></div>
-
        <div>
-
          Clicking the buttons will create a blank review, add comments, a
-
          summary, and your verdict after. Depending on your sync settings your
-
          review might be published to the network right away. We are actively
-
          working on draft reviews, stay tuned.
-
        </div>
-
      </div>
-
    </Border>
-
  {/snippet}
-
</Popover>
modified src/components/ReviewTeaser.svelte
@@ -15,10 +15,11 @@
  import Label from "@app/components/Label.svelte";
  import Markdown from "@app/components/Markdown.svelte";
  import NodeId from "@app/components/NodeId.svelte";
+
  import type { DraftReview } from "@app/lib/draftReviewStorage";

  interface Props {
    patchId: string;
-
    review: Review;
+
    review: Review | DraftReview;
    rid: string;
    status: PatchStatus | undefined;
    first?: boolean;
@@ -122,11 +123,15 @@
    <div class="review-header">
      <div class="global-flex">
        <NodeId {...authorForNodeId(review.author)} />
-
        <span>published review</span>
-
        <Id id={review.id} variant="oid" />
-
        <div class="timestamp" title={absoluteTimestamp(review.timestamp)}>
-
          {formatTimestamp(review.timestamp)}
-
        </div>
+
        {#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">
modified src/components/Reviews.svelte
@@ -1,61 +1,51 @@
<script lang="ts">
  import type { Config } from "@bindings/config/Config";
-
  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 type { Verdict } from "@bindings/cob/patch/Verdict";
-

-
  import { announce } from "@app/components/AnnounceSwitch.svelte";
-
  import { invoke } from "@app/lib/invoke";
-
  import { nodeRunning } from "@app/lib/events";

+
  import { type PatchStatus } from "@app/views/repo/router";
  import Icon from "@app/components/Icon.svelte";
  import NakedButton from "@app/components/NakedButton.svelte";
-
  import ReviewButton from "@app/components/ReviewButton.svelte";
  import ReviewTeaser from "@app/components/ReviewTeaser.svelte";
+
  import { push } from "@app/lib/router";
+
  import { didFromPublicKey } from "@app/lib/utils";
+
  import {
+
    draftReviewStorage,
+
    type DraftReview,
+
  } from "@app/lib/draftReviewStorage";

  interface Props {
    config: Config;
-
    loadPatch: () => Promise<void>;
    patchId: string;
    revision: Revision;
    rid: string;
    status: PatchStatus | undefined;
  }

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

-
  let hideReviews = $state(
-
    revision.reviews === undefined || revision.reviews.length === 0,
-
  );
+
  const { config, patchId, revision, rid, status }: Props = $props();

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

-
    hideReviews =
-
      revision.reviews === undefined || revision.reviews.length === 0;
+
    return reviews.length === 0;
  });

-
  async function createReview(verdict?: Verdict): Promise<Review | undefined> {
-
    try {
-
      return await invoke("edit_patch", {
-
        rid: rid,
-
        cobId: patchId,
-
        action: {
-
          type: "review",
-
          revision: revision.id,
-
          verdict,
-
          // We need to pass an empty string to create a review without a verdict.
-
          summary: "",
-
          labels: [],
-
        },
-
        opts: { announce: $nodeRunning && $announce },
-
      });
-
    } catch (error) {
-
      console.error("Creating a review failed: ", error);
-
    }
-
  }
+
  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>
@@ -72,47 +62,53 @@
    <div class="global-flex">
      <NakedButton
        stylePadding="0 4px"
-
        disabled={revision.reviews === undefined ||
-
          revision.reviews.length === 0}
+
        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={revision.reviews === undefined ||
-
        revision.reviews.length === 0
+
        style:color={reviews.length === 0
          ? "var(--color-foreground-disabled)"
          : undefined}>
        Reviews <span style:font-weight="var(--font-weight-regular)">
-
          {revision.reviews?.length ?? 0}
+
          {reviews.length}
        </span>
      </div>
    </div>

    <div class="global-flex" style:margin-left="auto">
-
      <ReviewButton
+
      <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}
-
        {revision}
-
        {config}
        {status}
-
        {loadPatch}
-
        {createReview} />
-
    </div>
+
        first={idx === 0}
+
        last={idx === reviews.length - 1} />
+
    {/each}
  </div>
-

-
  {#if revision.reviews && revision.reviews.length}
-
    <div style:display={hideReviews ? "none" : "flex"} class="review-list">
-
      {#each revision.reviews as review, idx}
-
        <ReviewTeaser
-
          {rid}
-
          {review}
-
          {patchId}
-
          {status}
-
          first={idx === 0}
-
          last={idx === revision.reviews.length - 1} />
-
      {/each}
-
    </div>
-
  {/if}
</div>
modified src/components/Revision.svelte
@@ -219,7 +219,7 @@
  </CommentComponent>
</div>

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

<Discussion
  cobId={patchId}
added src/lib/draftReviewStorage.ts
@@ -0,0 +1,204 @@
+
import { z } from "zod";
+

+
import type { Verdict } from "@bindings/cob/patch/Verdict";
+
import type { CodeLocation } from "@bindings/cob/thread/CodeLocation";
+
import type { Comment } from "@bindings/cob/thread/Comment";
+
import type { Author } from "@bindings/cob/Author";
+
import type { CodeRange } from "@bindings/cob/thread/CodeRange";
+
import type { Embed } from "@bindings/cob/thread/Embed";
+
import type { CreateReviewArgs } from "@bindings/cob/patch/CreateReviewArgs";
+
import { type Patch } from "@bindings/cob/patch/Patch";
+
import useLocalStorage from "@app/lib/useLocalStorage.svelte";
+

+
import { invoke } from "./invoke";
+

+
// This is different from the stored draft review to align it with a
+
// published `Review`.
+
export interface DraftReview {
+
  id: string;
+
  draft: true;
+
  rid: string;
+
  author: Author;
+
  revisionId: string;
+
  verdict?: Verdict;
+
  summary?: string;
+
  labels: string[];
+
  comments: Array<Comment<CodeLocation>>;
+
}
+

+
const codeRangeSchema: z.Schema<CodeRange> = z.union([
+
  z.object({
+
    type: z.literal("lines"),
+
    range: z.object({ start: z.number(), end: z.number() }),
+
  }),
+
  z.object({
+
    type: z.literal("chars"),
+
    line: z.number(),
+
    range: z.object({ start: z.number(), end: z.number() }),
+
  }),
+
]);
+

+
const draftReviewStoredSchema = z.object({
+
  id: z.string(),
+
  rid: z.string(),
+
  revision: z.string(),
+
  verdict: z.union([z.literal("accept"), z.literal("reject")]).optional(),
+
  summary: z.string().default(""),
+
  labels: z.array(z.string()),
+
  comments: z.array(
+
    z.object({
+
      id: z.string(),
+
      body: z.string(),
+
      location: z
+
        .object({
+
          commit: z.string(),
+
          path: z.string(),
+
          old: codeRangeSchema.nullable(),
+
          new: codeRangeSchema.nullable(),
+
        })
+
        .optional(),
+
    }),
+
  ),
+
});
+

+
const storage = useLocalStorage(
+
  "repo.patches.draftReviews",
+
  z.record(z.string(), draftReviewStoredSchema),
+
  {},
+
);
+

+
export const draftReviewStorage = {
+
  get(id: string, author: Author): DraftReview | undefined {
+
    const draftReviewStored = storage.value[id];
+
    if (!draftReviewStored) {
+
      return undefined;
+
    }
+

+
    return draftReviewFromStored(draftReviewStored, author);
+
  },
+

+
  getForRevision(revisionId: string, author: Author): DraftReview | undefined {
+
    const draftReviewStored = Object.values(storage.value).find(
+
      draftReview => draftReview.revision === revisionId,
+
    );
+

+
    if (draftReviewStored) {
+
      return draftReviewFromStored(draftReviewStored, author);
+
    }
+
  },
+

+
  create(rid: string, revisionId: string): string {
+
    const draftReviews = storage.value;
+

+
    const id = crypto.randomUUID();
+
    draftReviews[id] = {
+
      id,
+
      rid,
+
      revision: revisionId,
+
      summary: "",
+
      labels: [],
+
      comments: [],
+
    };
+

+
    storage.value = draftReviews;
+
    return id;
+
  },
+

+
  update(
+
    id: string,
+
    props: { summary: string; verdict: Verdict | undefined; labels: string[] },
+
  ) {
+
    const draftPatches = storage.value;
+
    draftPatches[id].summary = props.summary;
+
    draftPatches[id].verdict = props.verdict;
+
    draftPatches[id].labels = props.labels;
+
    storage.value = draftPatches;
+
  },
+

+
  addComment(
+
    id: string,
+
    comment: {
+
      body: string;
+
      embeds: Embed[];
+
      location: CodeLocation;
+
    },
+
  ): string {
+
    const draftPatches = storage.value;
+
    const commentId = crypto.randomUUID();
+
    draftPatches[id].comments.push({
+
      id: crypto.randomUUID(),
+
      body: comment.body,
+
      location: comment.location,
+
    });
+
    storage.value = draftPatches;
+
    return commentId;
+
  },
+

+
  updateComment(
+
    id: string,
+
    commentId: string,
+
    comment: {
+
      body: string;
+
      embeds: Embed[];
+
    },
+
  ) {
+
    const draftPatches = storage.value;
+
    const storedComment = draftPatches[id].comments.find(
+
      comment => comment.id === commentId,
+
    );
+
    storedComment!.body = comment.body;
+
    storage.value = draftPatches;
+
  },
+

+
  async publish(id: string) {
+
    const draftReviewStored = storage.value[id];
+
    delete storage.value[id];
+
    // We need to explicitly persist the storage
+
    // eslint-disable-next-line no-self-assign
+
    storage.value = storage.value;
+
    await invoke<Patch>("create_patch_review", {
+
      args: {
+
        rid: draftReviewStored.rid,
+
        revision: draftReviewStored.revision,
+
        verdict: draftReviewStored.verdict ?? null,
+
        summary: draftReviewStored.summary,
+
        labels: draftReviewStored.labels,
+
        comments: draftReviewStored.comments.map(storedComment => ({
+
          body: storedComment.body,
+
          location: storedComment.location ?? null,
+
        })),
+
      } satisfies CreateReviewArgs,
+
    });
+
  },
+
};
+

+
function draftReviewFromStored(
+
  draftReviewStored: z.infer<typeof draftReviewStoredSchema>,
+
  author: Author,
+
): DraftReview {
+
  return {
+
    id: draftReviewStored.id,
+
    draft: true,
+
    rid: draftReviewStored.rid,
+
    summary: draftReviewStored.summary,
+
    author,
+
    revisionId: draftReviewStored.revision,
+
    verdict: draftReviewStored.verdict,
+
    labels: draftReviewStored.labels,
+
    comments: draftReviewStored.comments.map(rawComment => ({
+
      id: rawComment.id,
+
      author,
+
      edits: [
+
        {
+
          author,
+
          timestamp: 0,
+
          body: rawComment.body,
+
        },
+
      ],
+
      reactions: [],
+
      replyTo: null,
+
      location: rawComment.location ?? null,
+
      resolved: false,
+
    })),
+
  };
+
}
modified src/views/repo/Patch.svelte
@@ -56,6 +56,10 @@
  import BreadcrumbCopyButton from "./BreadcrumbCopyButton.svelte";
  import MoreBreadcrumbsButton from "@app/components/MoreBreadcrumbsButton.svelte";
  import DropdownListItem from "@app/components/DropdownListItem.svelte";
+
  import {
+
    draftReviewStorage,
+
    type DraftReview,
+
  } from "@app/lib/draftReviewStorage";

  interface Props {
    repo: RepoInfo;
@@ -65,7 +69,7 @@
    config: Config;
    activity: Operation<Action>[];
    status: PatchStatus | undefined;
-
    review: Review | undefined;
+
    review: Review | DraftReview | undefined;
    notificationCount: number;
  }

@@ -231,17 +235,21 @@
    ]);
  }

-
  async function loadReview(reviewId: string | undefined = review?.id) {
-
    if (!reviewId) {
+
  async function loadReview() {
+
    if (!review) {
      return;
    }

-
    review = await invoke<Review>("review_by_patch_and_revision_and_id", {
-
      rid: repo.rid,
-
      id: patch.id,
-
      revisionId: findReviewRevision(reviewId).id,
-
      reviewId,
-
    });
+
    if ("draft" in review) {
+
      review = draftReviewStorage.get(review.id, review.author);
+
    } else {
+
      review = await invoke<Review>("review_by_patch_and_revision_and_id", {
+
        rid: repo.rid,
+
        id: patch.id,
+
        revisionId: findReviewRevision(review).id,
+
        reviewId: review.id,
+
      });
+
    }
  }

  async function loadPatches(filter: PatchStatus | undefined) {
@@ -257,14 +265,18 @@
    }
  }

-
  function findReviewRevision(reviewId: string): Revision {
+
  function findReviewRevision(review: Review | DraftReview): Revision {
    // Every review is guaranteed to have a revision according to the protocol
    // model, so using type assertions here is safe.
-
    return revisions.find(revision => {
-
      return revision.reviews!.find(rev => {
-
        return rev.id === reviewId;
-
      });
-
    }) as Revision;
+
    if ("draft" in review) {
+
      return revisions.find(
+
        revision => revision.id === review.revisionId,
+
      ) as Revision;
+
    } else {
+
      return revisions.find(revision =>
+
        revision.reviews?.find(rev => rev.id === review.id),
+
      ) as Revision;
+
    }
  }

  let showFilters: boolean = $state(false);
@@ -422,10 +434,12 @@
      </span>
      <Icon name="chevron-right" />
      {review.author.alias}'s review
-
      <BreadcrumbCopyButton
-
        url={explorerUrl(`${repo.rid}/patches/${patch.id}`)}
-
        icon={verdictIcon(review.verdict)}
-
        id={review.id} />
+
      {#if !("draft" in review)}
+
        <BreadcrumbCopyButton
+
          url={explorerUrl(`${repo.rid}/patches/${patch.id}`)}
+
          icon={verdictIcon(review.verdict)}
+
          id={review.id} />
+
      {/if}
    {:else}
      <span class="txt-overflow" style:max-width="8rem">
        <InlineTitle content={breadcrumbTitle()} fontSize="small" />
@@ -574,7 +588,7 @@
      {repo}
      {loadReview}
      {review}
-
      revision={findReviewRevision(review.id)}
+
      revision={findReviewRevision(review)}
      onNavigateBack={() => {
        review = undefined;
      }} />
modified src/views/repo/router.ts
@@ -12,7 +12,11 @@ import type { Revision } from "@bindings/cob/patch/Revision";
import type { Thread } from "@bindings/cob/thread/Thread";

import { invoke } from "@app/lib/invoke";
-
import { unreachable } from "@app/lib/utils";
+
import { didFromPublicKey, unreachable } from "@app/lib/utils";
+
import {
+
  draftReviewStorage,
+
  type DraftReview,
+
} from "@app/lib/draftReviewStorage";

export type IssueStatus = "all" | Issue["state"]["status"];

@@ -106,7 +110,7 @@ export interface LoadedRepoPatchRoute {
    patch: Patch;
    patches: PaginatedQuery<Patch[]>;
    status: PatchStatus | undefined;
-
    review: Review | undefined;
+
    review: Review | DraftReview | undefined;
    revisions: Revision[];
    activity: Operation<PatchAction>[];
    notificationCount: number;
@@ -174,9 +178,18 @@ export async function loadPatch(
      }),
    ]);

-
  const review = revisions
-
    .flatMap(r => r.reviews || [])
-
    .find(review => review.id === route.reviewId);
+
  const draftReview =
+
    route.reviewId !== undefined &&
+
    draftReviewStorage.get(route.reviewId, {
+
      did: didFromPublicKey(config.publicKey),
+
      alias: config.alias,
+
    });
+

+
  const review =
+
    draftReview ||
+
    revisions
+
      .flatMap(r => r.reviews || [])
+
      .find(review => review.id === route.reviewId);

  return {
    resource: "repo.patch",