Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Improve patch timeline and add description
Sebastian Martinez committed 3 years ago
commit d8d049518b2d7f5761e3dd4d384d4d20646d2281
parent bb51de6a9a9ded9acecee0ba3ee37635a62bc6ac
23 files changed +682 -376
modified httpd-client/index.ts
@@ -1,5 +1,11 @@
import type { BaseUrl } from "./lib/fetcher.js";
-
import type { Blob, Project, Remote, Tree } from "./lib/project.js";
+
import type {
+
  Blob,
+
  Project,
+
  Remote,
+
  Tree,
+
  DiffResponse,
+
} from "./lib/project.js";
import type { Comment } from "./lib/project/comment.js";
import type {
  Commit,
@@ -9,7 +15,13 @@ import type {
  HunkLine,
} from "./lib/project/commit.js";
import type { Issue, IssueState } from "./lib/project/issue.js";
-
import type { Merge, Patch, PatchState, Review } from "./lib/project/patch.js";
+
import type {
+
  Merge,
+
  Patch,
+
  PatchState,
+
  Review,
+
  Revision,
+
} from "./lib/project/patch.js";
import type { RequestOptions, Method } from "./lib/fetcher.js";
import type { ZodSchema } from "zod";

@@ -27,6 +39,7 @@ export type {
  CommitHeader,
  Diff,
  DiffAddedDeletedModifiedChangeset,
+
  DiffResponse,
  HunkLine,
  Issue,
  IssueState,
@@ -36,6 +49,7 @@ export type {
  Project,
  Remote,
  Review,
+
  Revision,
  Tree,
};

modified httpd-client/lib/project.ts
@@ -153,7 +153,7 @@ const remoteSchema = strictObject({

const remotesSchema = array(remoteSchema) satisfies ZodSchema<Remote[]>;

-
interface DiffResponse {
+
export interface DiffResponse {
  commits: CommitHeader[];
  diff: Diff;
}
modified httpd-client/lib/project/patch.ts
@@ -85,7 +85,7 @@ const reviewSchema = strictObject({
  timestamp: number(),
}) satisfies ZodSchema<Review>;

-
interface Revision {
+
export interface Revision {
  id: string;
  description: string;
  base: string;
modified scripts/run-httpd-with-fixtures
@@ -1,7 +1,7 @@
#!/bin/bash
set -e

-
REV=414477a31676d5e75efa7f3e8dc6624bcf2b2e52
+
REV=b29321dbf7999ec7ec9b7ac9192071e512ada407

REPO_ROOT=$(git rev-parse --show-toplevel)
FIXTURE=$REPO_ROOT/tests/fixtures/seeds/palm.tar.bz2
modified src/components/Authorship.svelte
@@ -15,7 +15,7 @@
  .authorship {
    display: inline-flex;
    align-items: center;
-
    color: var(--color-foreground-6);
+
    color: inherit;
    padding: 0.125rem 0;
    gap: 0.25rem;
  }
@@ -29,7 +29,7 @@
  }
</style>

-
<span class="authorship txt-tiny" title={relativeTimestamp(timestamp)}>
+
<span class="authorship txt-tiny">
  {#if !noAvatar}
    <Avatar inline nodeId={authorId} />
  {/if}
@@ -39,14 +39,16 @@
  <span class="id layout-mobile">
    {formatNodeId(authorId).replace("did:key:", "")}
  </span>
-
  <span class="body">
-
    {#if !caption}
-
      <slot />
-
    {:else}
+
  {#if !caption}
+
    <slot />
+
  {:else}
+
    <span class="body">
      {caption}
-
    {/if}
-
  </span>
+
    </span>
+
  {/if}
  {#if timestamp}
-
    {formatTimestamp(timestamp)}
+
    <span title={relativeTimestamp(timestamp)}>
+
      {formatTimestamp(timestamp)}
+
    </span>
  {/if}
</span>
modified src/components/Comment.svelte
@@ -20,13 +20,12 @@

<style>
  .comment {
-
    margin-bottom: 1rem;
    display: flex;
  }
  .card {
    flex: 1;
-
    border: 1px solid var(--color-foreground-4);
    border-radius: var(--border-radius);
+
    background-color: var(--color-foreground-1);
  }
  .card-header {
    display: flex;
@@ -37,7 +36,7 @@
  }
  .card-body {
    font-size: var(--font-size-small);
-
    padding: 0rem 1rem 1rem 1rem;
+
    padding: 0 1rem 1rem 1rem;
  }
  .actions {
    display: flex;
@@ -52,12 +51,7 @@
<div class="comment" {id}>
  <div class="card">
    <div class="card-header">
-
      <div class="layout-desktop">
-
        <Authorship {caption} {authorId} {timestamp} />
-
      </div>
-
      <div class="layout-mobile">
-
        <Authorship {authorId} {timestamp} />
-
      </div>
+
      <Authorship {caption} {authorId} {timestamp} />
      <div class="actions">
        {#if showReplyIcon}
          <Button
modified src/components/Dropdown.svelte
@@ -57,7 +57,7 @@
      on:click={() => onSelect(item)}
      title={item.title}>
      <slot name="item" {item}>
-
        {item.value}
+
        {item.title}
        {#if item.badge}
          <Badge variant="primary">{item.badge}</Badge>
        {/if}
modified src/components/ErrorMessage.svelte
@@ -8,11 +8,14 @@
<style>
  .error {
    padding: 1rem;
+
    font-size: var(--font-size-regular);
+
    font-family: var(--font-family-sans-serif);
    color: var(--color-negative);
    border-radius: var(--border-radius);
    background-color: var(--color-negative-3);
    display: flex;
    align-items: center;
+
    width: 100%;
  }
  .stack-trace {
    display: flex;
modified src/components/InlineMarkdown.svelte
@@ -6,7 +6,7 @@
  import { twemoji } from "@app/lib/utils";

  export let content: string;
-
  export let fontSize: "small" | "medium" = "small";
+
  export let fontSize: "tiny" | "small" | "medium" = "small";

  marked.use({
    renderer,
@@ -34,6 +34,7 @@
  class="markdown"
  use:twemoji
  class:txt-medium={fontSize === "medium"}
-
  class:txt-small={fontSize === "small"}>
+
  class:txt-small={fontSize === "small"}
+
  class:txt-tiny={fontSize === "tiny"}>
  {@html render(content)}
</span>
modified src/components/Markdown.svelte
@@ -280,6 +280,10 @@
  .markdown :global(.list-content) {
    margin: 1rem 0;
  }
+
  /* Allows the parent to specify its own bottom margin */
+
  .markdown :global(:last-child) {
+
    margin-bottom: 0;
+
  }
  .markdown :global(li > ul) {
    margin-bottom: 0rem;
  }
modified src/components/TextInput.svelte
@@ -14,8 +14,6 @@
  export let disabled: boolean = false;
  export let loading: boolean = false;
  export let valid: boolean = false;
-
  // Changes the background color to the background of the page
-
  export let transparent: boolean = false;
  export let validationMessage: string | undefined = undefined;

  const dispatch = createEventDispatcher<{
@@ -74,9 +72,6 @@
    color: var(--color-secondary);
    cursor: not-allowed;
  }
-
  .transparent {
-
    background: var(--color-background) !important;
-
  }
  .regular {
    border: 1px solid var(--color-secondary);
    padding: 1rem 1.5rem;
@@ -148,7 +143,6 @@
    </div>

    <input
-
      class:transparent
      class:regular={variant === "regular"}
      class:form={variant === "form"}
      style:padding-left={leftContainerWidth
modified src/components/Thread.svelte
@@ -1,4 +1,4 @@
-
<script lang="ts">
+
<script lang="ts" strictEvents>
  import type { Comment } from "@httpd-client";

  import Button from "@app/components/Button.svelte";
@@ -10,7 +10,6 @@

  export let thread: { root: Comment; replies: Comment[] };
  export let rawPath: string;
-
  export let isDescription = false;
  export let showReplyTextarea = false;

  let replyText = "";
@@ -53,6 +52,7 @@
<style>
  .reply {
    margin-left: 3rem;
+
    margin-top: 1rem;
  }
  .actions {
    display: flex;
@@ -62,14 +62,16 @@
  }
</style>

-
<CommentComponent
-
  {rawPath}
-
  id={root.id}
-
  authorId={root.author.id}
-
  timestamp={root.timestamp}
-
  body={root.body}
-
  showReplyIcon={Boolean($sessionStore) && !isDescription}
-
  on:toggleReply={toggleReply} />
+
<div style:margin-top="1rem">
+
  <CommentComponent
+
    {rawPath}
+
    id={root.id}
+
    authorId={root.author.id}
+
    timestamp={root.timestamp}
+
    body={root.body}
+
    showReplyIcon={Boolean($sessionStore)}
+
    on:toggleReply={toggleReply} />
+
</div>
{#each replies as reply}
  <div class="reply">
    <CommentComponent
modified src/views/projects/Cob/AssigneeInput.svelte
@@ -11,7 +11,7 @@
  const dispatch = createEventDispatcher<{ save: string[] }>();

  export let action: "create" | "edit" | "view";
-
  export let edit: boolean = false;
+
  export let editInProgress: boolean = false;
  export let assignees: string[] = [];

  let updatedAssignees: string[] = assignees;
@@ -45,9 +45,6 @@
</script>

<style>
-
  .metadata-section {
-
    margin-bottom: 4rem;
-
  }
  .header {
    display: flex;
    gap: 1rem;
@@ -75,28 +72,28 @@
  }
</style>

-
<div class="metadata-section">
+
<div>
  <div class="header">
    <span>Assignees</span>
    {#if action === "edit"}
-
      {#if !edit}
+
      {#if editInProgress}
        <Button
          size="tiny"
          variant="text"
          on:click={() => {
-
            edit = !edit;
+
            dispatch("save", updatedAssignees);
+
            editInProgress = !editInProgress;
          }}>
-
          edit
+
          save
        </Button>
      {:else}
        <Button
          size="tiny"
          variant="text"
          on:click={() => {
-
            dispatch("save", updatedAssignees);
-
            edit = !edit;
+
            editInProgress = !editInProgress;
          }}>
-
          save
+
          edit
        </Button>
      {/if}
    {/if}
@@ -105,7 +102,7 @@
    {#each updatedAssignees as assignee, key (assignee)}
      <Chip
        on:remove={removeAssignee}
-
        removeable={edit || action === "create"}
+
        removeable={editInProgress || action === "create"}
        {key}>
        <div class="chip-content">
          <Avatar inline nodeId={assignee} />
@@ -116,7 +113,7 @@
      <div class="empty">No assignees</div>
    {/each}
  </div>
-
  {#if edit || action === "create"}
+
  {#if editInProgress || action === "create"}
    <div style:margin-bottom="1rem">
      <TextInput
        bind:value={inputValue}
modified src/views/projects/Cob/CobHeader.svelte
@@ -1,8 +1,7 @@
<script lang="ts" strictEvents>
  import { createEventDispatcher } from "svelte";

-
  import { formatObjectId } from "@app/lib/utils";
-

+
  import * as utils from "@app/lib/utils";
  import Button from "@app/components/Button.svelte";
  import Clipboard from "@app/components/Clipboard.svelte";
  import InlineMarkdown from "@app/components/InlineMarkdown.svelte";
@@ -19,76 +18,58 @@

<style>
  header {
-
    background: var(--color-foreground-1);
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.3rem;
    border-radius: var(--border-radius);
-
    margin-bottom: 1rem;
+
    border: 1px solid var(--color-foreground-3);
    padding: 1rem;
  }
-
  .summary {
+
  .title {
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
    white-space: nowrap;
+
  }
+
  .subtitle {
    display: flex;
    flex-direction: row;
-
    gap: 1rem;
-
    justify-content: space-between;
    align-items: center;
-
    margin-bottom: 0.5rem;
-
    padding-right: 0.5rem;
+
    flex-wrap: wrap;
+
    gap: 0.5rem;
+
    font-size: var(--font-size-tiny);
+
    font-family: var(--font-family-monospace);
+
    color: var(--color-foreground-6);
  }
-
  .summary-title {
+
  .summary {
    display: flex;
-
    flex-direction: row;
-
    align-items: baseline;
+
    align-items: center;
+
    justify-content: space-between;
    gap: 0.5rem;
-
    width: 90%;
  }
  .id {
    display: flex;
-
    flex-direction: row;
    align-items: center;
-
    font-size: var(--font-size-tiny);
-
    font-family: var(--font-family-monospace);
-
    color: var(--color-foreground-6);
  }
-
  .title {
-
    overflow: hidden;
-
    text-overflow: ellipsis;
-
    white-space: nowrap;
-
  }
-
  .summary-state {
-
    display: flex;
-
    flex-direction: row;
-
    gap: 1rem;
-
    border-radius: var(--border-radius);
+
  .description {
+
    font-size: var(--font-size-small);
+
    margin-top: 1rem;
  }
</style>

<header>
-
  <div class="summary">
-
    <div class="summary-title txt-medium">
-
      {#if editable}
-
        <TextInput transparent variant="form" bind:value={title} />
-
      {:else}
-
        {#if title}
-
          <div class="title">
-
            <InlineMarkdown fontSize="medium" content={title} />
-
          </div>
-
        {:else}
-
          <span class="txt-missing">No title</span>
-
        {/if}
-
        <slot name="revision" />
-
        {#if id}
-
          <div class="id">
-
            <div class="layout-desktop">{id}</div>
-
            <div class="layout-mobile">
-
              {formatObjectId(id)}
-
            </div>
-
            <Clipboard small text={id} />
-
          </div>
-
        {/if}
-
      {/if}
-
    </div>
+
  <div class="summary txt-medium">
+
    {#if editable}
+
      <TextInput variant="form" placeholder="Title" bind:value={title} />
+
    {:else if title}
+
      <div class="title">
+
        <InlineMarkdown fontSize="medium" content={title} />
+
      </div>
+
    {:else}
+
      <span class="txt-missing">No title</span>
+
    {/if}
    {#if action === "edit"}
      <Button
-
        variant="text"
+
        variant="foreground"
        size="small"
        on:click={() => {
          editable = !editable;
@@ -102,7 +83,17 @@
      </Button>
    {/if}
  </div>
-
  <div class="summary-state">
+
  <div class="subtitle">
    <slot name="state" />
+
    {#if id}
+
      <div class="id">
+
        {utils.formatObjectId(id)}
+
        <Clipboard text={id} small />
+
      </div>
+
    {/if}
+
    <slot name="author" />
+
  </div>
+
  <div class="description">
+
    <slot name="description" />
  </div>
</header>
added src/views/projects/Cob/Revision.svelte
@@ -0,0 +1,263 @@
+
<script lang="ts">
+
  import type { BaseUrl, DiffResponse } from "@httpd-client";
+
  import type { Timeline } from "@app/views/projects/Patch.svelte";
+

+
  import * as utils from "@app/lib/utils";
+
  import { onMount } from "svelte";
+
  import { HttpdClient } from "@httpd-client";
+

+
  import Authorship from "@app/components/Authorship.svelte";
+
  import Avatar from "@app/components/Avatar.svelte";
+
  import Clipboard from "@app/components/Clipboard.svelte";
+
  import CommentComponent from "@app/components/Comment.svelte";
+
  import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
+
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import InlineMarkdown from "@app/components/InlineMarkdown.svelte";
+
  import ProjectLink from "@app/components/ProjectLink.svelte";
+
  import ThreadComponent from "@app/components/Thread.svelte";
+

+
  export let authorId: string;
+
  export let baseUrl: BaseUrl;
+
  export let expanded: boolean = true;
+
  export let patchId: string;
+
  export let projectHead: string;
+
  export let projectId: string;
+
  export let revisionBase: string;
+
  export let revisionId: string;
+
  export let revisionOid: string;
+
  export let revisionTimestamp: number;
+
  export let timelines: Timeline[];
+

+
  const api = new HttpdClient(baseUrl);
+

+
  function formatVerdict(revision: string, verdict?: string | null) {
+
    switch (verdict) {
+
      case "accept":
+
        return `accepted revision ${utils.formatObjectId(revision)}`;
+
      case "reject":
+
        return `rejected revision ${utils.formatObjectId(revision)}`;
+
      default:
+
        return `left a comment on revision ${utils.formatObjectId(revision)}`;
+
    }
+
  }
+

+
  let response: DiffResponse | undefined = undefined;
+
  let error: any | undefined = undefined;
+

+
  onMount(async () => {
+
    try {
+
      response = await api.project.getDiff(
+
        projectId,
+
        revisionBase,
+
        revisionOid,
+
      );
+
    } catch (err: any) {
+
      error = err;
+
    }
+
  });
+
</script>
+

+
<style>
+
  .action {
+
    background-color: var(--color-foreground-1);
+
    border-radius: var(--border-radius);
+
    padding: 0.5rem 1rem;
+
  }
+
  .action-content {
+
    display: flex;
+
    align-items: center;
+
    justify-content: space-between;
+
  }
+
  .merge {
+
    color: var(--color-primary);
+
    background-color: var(--color-primary-3);
+
  }
+
  .positive-review {
+
    color: var(--color-positive);
+
    background-color: var(--color-positive-3);
+
  }
+
  .revision {
+
    border: 1px solid var(--color-foreground-3);
+
    border-radius: var(--border-radius-small);
+
    margin-bottom: 1rem;
+
  }
+
  .revision-header {
+
    height: 3rem;
+
    display: flex;
+
    justify-content: space-between;
+
    align-items: center;
+
    background: none;
+
    padding: 1rem;
+
    padding-right: 1.5rem;
+
  }
+
  .revision-name {
+
    display: flex;
+
    user-select: none;
+
  }
+
  .revision-data {
+
    gap: 0.5rem;
+
    display: flex;
+
  }
+
  .revision-body {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1rem;
+
    padding: 0 1.5rem;
+
    margin-bottom: 1rem;
+
    border-radius: var(--border-radius-small);
+
  }
+
  .expand-button {
+
    margin-right: 0.5rem;
+
    user-select: none;
+
    cursor: pointer;
+
  }
+
  .commit-event {
+
    color: var(--color-foreground-6);
+
    padding: 0.5rem 0.5rem 0.5rem 1rem;
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
    justify-content: space-between;
+
  }
+
  .commit-event span {
+
    display: flex;
+
    gap: 0.5rem;
+
    align-items: center;
+
  }
+
</style>
+

+
<div class="revision">
+
  <div class="revision-header">
+
    <div class="revision-name">
+
      <div class="expand-button">
+
        <Icon
+
          name={expanded ? "chevron-down" : "chevron-right"}
+
          on:click={() => (expanded = !expanded)} />
+
      </div>
+
      <span>
+
        Revision {utils.formatObjectId(revisionId)}
+
      </span>
+
      <Clipboard text={revisionId} small />
+
    </div>
+
    <div class="txt-small" />
+
    <div class="revision-data">
+
      {#if response?.diff.stats}
+
        {@const { insertions, deletions } = response.diff.stats}
+
        <DiffStatBadge {insertions} {deletions} />
+
      {/if}
+
      <span class="layout-desktop txt-small">
+
        {utils.formatTimestamp(revisionTimestamp)}
+
      </span>
+
    </div>
+
  </div>
+
  {#if expanded}
+
    <div class="revision-body">
+
      {#each timelines as element}
+
        {#if element.type === "thread"}
+
          <ThreadComponent
+
            rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
+
            thread={element.inner}
+
            on:reply />
+
        {:else if element.type === "revision"}
+
          {@const caption =
+
            patchId === element.inner.id
+
              ? "opened this patch"
+
              : `updated to ${utils.formatObjectId(element.inner.id)}`}
+
          {#if element.inner.description}
+
            <CommentComponent
+
              {caption}
+
              {authorId}
+
              timestamp={element.timestamp}
+
              rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
+
              body={element.inner.description} />
+
          {:else}
+
            <div class="action txt-tiny">
+
              <Authorship {authorId} timestamp={element.timestamp}>
+
                {caption}
+
              </Authorship>
+
            </div>
+
          {/if}
+
          {#if response?.commits}
+
            <div class="action txt-tiny">
+
              {#each response.commits as commit}
+
                <div class="commit-event">
+
                  <span>
+
                    <Avatar inline nodeId={authorId} />
+
                    <ProjectLink
+
                      projectParams={{
+
                        view: { resource: "commits" },
+
                        revision: commit.id,
+
                        search: undefined,
+
                      }}>
+
                      <InlineMarkdown
+
                        content={commit.summary}
+
                        fontSize="tiny" />
+
                    </ProjectLink>
+
                  </span>
+
                  <span>
+
                    {utils.formatCommit(commit.id)}
+
                  </span>
+
                </div>
+
              {/each}
+
            </div>
+
          {/if}
+
          {#if error}
+
            <div class="txt-monospace">
+
              <ErrorMessage
+
                message="Failed to load diff for this revision."
+
                stackTrace={error.stack.toString()} />
+
            </div>
+
          {/if}
+
        {:else if element.type === "merge"}
+
          <div class="action merge layout-desktop txt-tiny">
+
            <div class="action-content">
+
              <Authorship
+
                authorId={element.inner.node}
+
                timestamp={element.timestamp}>
+
                merged
+
                {utils.formatCommit(element.inner.commit)}
+
              </Authorship>
+
            </div>
+
          </div>
+
          <div class="action merge layout-mobile txt-tiny">
+
            <div class="action-content">
+
              <Authorship authorId={element.inner.node}>
+
                merged
+
                {utils.formatCommit(element.inner.commit)}
+
              </Authorship>
+
            </div>
+
          </div>
+
        {:else if element.type === "review"}
+
          {@const [revisionId, author, review] = element.inner}
+
          <div
+
            class="action layout-desktop txt-tiny"
+
            class:positive-review={element.inner[2].verdict === "accept"}>
+
            <div class="action-content">
+
              <Authorship authorId={author} timestamp={element.timestamp}>
+
                {formatVerdict(revisionId, review.verdict)}
+
              </Authorship>
+
            </div>
+
          </div>
+
          <div
+
            class="layout-mobile txt-tiny"
+
            class:positive-review={element.inner[2].verdict === "accept"}>
+
            <div class="action-content">
+
              <Authorship authorId={author}>
+
                {formatVerdict(revisionId, review.verdict)}
+
              </Authorship>
+
            </div>
+
          </div>
+
          {#if review.comment}
+
            <CommentComponent
+
              caption="left a comment"
+
              authorId={author}
+
              timestamp={review.timestamp}
+
              rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
+
              body={review.comment} />
+
          {/if}
+
        {/if}
+
      {/each}
+
    </div>
+
  {/if}
+
</div>
modified src/views/projects/Cob/TagInput.svelte
@@ -8,7 +8,7 @@
  const dispatch = createEventDispatcher<{ save: string[] }>();

  export let action: "create" | "edit" | "view" = "view";
-
  export let edit: boolean = false;
+
  export let editInProgress: boolean = false;
  export let tags: string[] = [];

  let updatedTags: string[] = tags;
@@ -42,9 +42,6 @@
</script>

<style>
-
  .metadata-section {
-
    margin-bottom: 4rem;
-
  }
  .metadata-section-header {
    display: flex;
    gap: 1rem;
@@ -70,28 +67,28 @@
  }
</style>

-
<div class="metadata-section">
+
<div>
  <div class="metadata-section-header">
    <span>Tags</span>
    {#if action === "edit"}
-
      {#if !edit}
+
      {#if editInProgress}
        <Button
          size="tiny"
          variant="text"
          on:click={() => {
-
            edit = !edit;
+
            dispatch("save", updatedTags);
+
            editInProgress = !editInProgress;
          }}>
-
          edit
+
          save
        </Button>
      {:else}
        <Button
          size="tiny"
          variant="text"
          on:click={() => {
-
            dispatch("save", updatedTags);
-
            edit = !edit;
+
            editInProgress = !editInProgress;
          }}>
-
          save
+
          edit
        </Button>
      {/if}
    {/if}
@@ -100,7 +97,7 @@
    {#each updatedTags as tag, key (tag)}
      <Chip
        on:remove={removeTag}
-
        removeable={edit || action === "create"}
+
        removeable={editInProgress || action === "create"}
        {key}>
        <div class="tag">{tag}</div>
      </Chip>
@@ -108,7 +105,7 @@
      <div class="metadata-section-empty">No tags</div>
    {/each}
  </div>
-
  {#if edit || action === "create"}
+
  {#if editInProgress || action === "create"}
    <div style:margin-bottom="1rem">
      <TextInput
        bind:value={inputValue}
modified src/views/projects/Commit/CommitTeaser.svelte
@@ -47,7 +47,6 @@
  }
  .browse {
    display: flex;
-
    z-index: 10;
    width: 100%;
    height: 100%;
  }
modified src/views/projects/Issue.svelte
@@ -14,6 +14,7 @@
  import Button from "@app/components/Button.svelte";
  import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
  import CobStateButton from "@app/views/projects/Cob/CobStateButton.svelte";
+
  import Markdown from "@app/components/Markdown.svelte";
  import TagInput from "./Cob/TagInput.svelte";
  import Textarea from "@app/components/Textarea.svelte";
  import Thread from "@app/components/Thread.svelte";
@@ -147,6 +148,7 @@

  $: selectedItem = issue.state.status === "closed" ? items[0] : items[1];
  $: threads = issue.discussion
+
    .slice(1) // Skip the first comment, which is the issue description
    .filter(comment => !comment.replyTo)
    .map(thread => {
      return {
@@ -168,6 +170,9 @@
    margin-bottom: 4.5rem;
  }
  .metadata {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 2rem;
    border-radius: var(--border-radius);
    font-size: var(--font-size-small);
    padding-left: 1rem;
@@ -181,6 +186,15 @@
    margin: 0 0 2.5rem 0;
    gap: 1rem;
  }
+
  .author {
+
    display: flex;
+
    align-items: center;
+
    flex-wrap: nowrap;
+
    gap: 0.5rem;
+
  }
+
  .comments {
+
    margin: 1rem 0;
+
  }

  @media (max-width: 960px) {
    .issue {
@@ -212,47 +226,50 @@
            {issue.state.reason}
          </Badge>
        {/if}
-
        <Authorship
-
          timestamp={issue.discussion[0].timestamp}
-
          authorId={issue.author.id}
-
          caption="opened this issue" />
      </svelte:fragment>
+
      <div slot="description">
+
        <Markdown
+
          content={issue.discussion[0].body}
+
          rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)} />
+
      </div>
+
      <div class="author" slot="author">
+
        opened by <Authorship authorId={issue.author.id} />
+
        {utils.formatTimestamp(issue.discussion[0].timestamp)}
+
      </div>
    </CobHeader>
-
    <div>
-
      {#each threads as thread, index (thread.root.id)}
-
        <Thread
-
          {thread}
-
          {rawPath}
-
          isDescription={index === 0}
-
          on:reply={createReply} />
+
    <div class="comments">
+
      {#each threads as thread (thread.root.id)}
+
        <Thread {thread} {rawPath} on:reply={createReply} />
      {/each}
-
      {#if $sessionStore}
-
        <Textarea
-
          resizable
-
          on:submit={() => {
-
            createComment(commentBody);
-
            commentBody = "";
-
          }}
-
          bind:value={commentBody}
-
          placeholder="Leave your comment" />
-
        <div class="actions txt-small">
-
          <CobStateButton
-
            {items}
-
            {selectedItem}
-
            state={issue.state}
-
            on:saveStatus={saveStatus} />
-
          <Button
-
            variant="secondary"
-
            size="small"
-
            disabled={!commentBody}
-
            on:click={() => {
+
      <div style:margin-top="1rem">
+
        {#if $sessionStore}
+
          <Textarea
+
            resizable
+
            on:submit={() => {
              createComment(commentBody);
              commentBody = "";
-
            }}>
-
            Comment
-
          </Button>
-
        </div>
-
      {/if}
+
            }}
+
            bind:value={commentBody}
+
            placeholder="Leave your comment" />
+
          <div class="actions txt-small">
+
            <CobStateButton
+
              {items}
+
              {selectedItem}
+
              state={issue.state}
+
              on:saveStatus={saveStatus} />
+
            <Button
+
              variant="secondary"
+
              size="small"
+
              disabled={!commentBody}
+
              on:click={() => {
+
                createComment(commentBody);
+
                commentBody = "";
+
              }}>
+
              Comment
+
            </Button>
+
          </div>
+
        {/if}
+
      </div>
    </div>
  </div>
  <div class="metadata">
modified src/views/projects/Issue/New.svelte
@@ -15,8 +15,9 @@
  import Badge from "@app/components/Badge.svelte";
  import Button from "@app/components/Button.svelte";
  import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
-
  import Comment from "@app/components/Comment.svelte";
+
  import Markdown from "@app/components/Markdown.svelte";
  import TagInput from "@app/views/projects/Cob/TagInput.svelte";
+
  import Textarea from "@app/components/Textarea.svelte";

  export let session: StoredSession;
  export let projectId: string;
@@ -32,7 +33,7 @@
      : "view";

  let issueTitle = "";
-
  let issueText = "";
+
  let issueText: string | undefined = undefined;
  let assignees: string[] = [];
  let tags: string[] = [];

@@ -44,7 +45,7 @@
        projectId,
        {
          title: issueTitle,
-
          description: issueText,
+
          description: issueText ?? "",
          assignees: utils.stripDidPrefix(assignees),
          tags: tags,
        },
@@ -82,6 +83,9 @@
    margin-top: 1rem;
  }
  .metadata {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 4rem;
    border-radius: var(--border-radius);
    font-size: var(--font-size-small);
    padding-left: 1rem;
@@ -91,6 +95,11 @@
    flex: 2;
    padding-right: 1rem;
  }
+
  .author {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
  }
  @media (max-width: 960px) {
    main {
      padding-left: 2rem;
@@ -100,6 +109,14 @@
    .form {
      grid-template-columns: minmax(0, 1fr);
    }
+
    .editor {
+
      padding-right: 0;
+
    }
+
    .metadata {
+
      margin-left: 0;
+
      padding-left: 0;
+
      gap: 2rem;
+
    }
  }
</style>

@@ -108,22 +125,31 @@
    <div class="editor">
      <CobHeader {action} bind:title={issueTitle}>
        <svelte:fragment slot="state">
-
          <Badge variant="positive">open</Badge>
-
          <Authorship
-
            timestamp={Date.now()}
-
            authorId={session.publicKey}
-
            caption="opened this issue" />
+
          {#if action === "view"}
+
            <Badge variant="positive">open</Badge>
+
          {/if}
        </svelte:fragment>
+
        <svelte:fragment slot="description">
+
          {#if action === "create"}
+
            <Textarea
+
              resizable
+
              bind:value={issueText}
+
              on:submit={createIssue}
+
              placeholder="Write a description" />
+
          {:else if !issueText}
+
            <p class="txt-missing">No description</p>
+
          {:else}
+
            <Markdown
+
              content={issueText}
+
              rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)} />
+
          {/if}
+
        </svelte:fragment>
+
        <div class="author" slot="author">
+
          {#if action === "view"}
+
            opened by <Authorship authorId={session.publicKey} /> now
+
          {/if}
+
        </div>
      </CobHeader>
-
      <div class="comments">
-
        <Comment
-
          bind:body={issueText}
-
          on:submit={createIssue}
-
          authorId={session.publicKey}
-
          timestamp={Date.now()}
-
          {action}
-
          rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)} />
-
      </div>
      <div class="actions">
        <Button
          size="small"
modified src/views/projects/Patch.svelte
@@ -1,6 +1,5 @@
-
<script lang="ts">
-
  import type { BaseUrl, Comment, Merge, Patch, Review } from "@httpd-client";
-
  import type { Variant } from "@app/components/Badge.svelte";
+
<script lang="ts" context="module">
+
  import type { Comment, Review, Revision, Merge } from "@httpd-client";

  interface Thread {
    root: Comment;
@@ -8,11 +7,17 @@
  }

  interface TimelineReview {
-
    inner: [string, Review];
+
    inner: [string, string, Review];
    type: "review";
    timestamp: number;
  }

+
  interface TimelineRevision {
+
    inner: Revision;
+
    type: "revision";
+
    timestamp: number;
+
  }
+

  interface TimelineMerge {
    inner: Merge;
    type: "merge";
@@ -25,32 +30,44 @@
    timestamp: number;
  }

-
  import capitalize from "lodash/capitalize";
+
  export type Timeline =
+
    | TimelineMerge
+
    | TimelineReview
+
    | TimelineRevision
+
    | TimelineThread;
+
</script>
+

+
<script lang="ts">
+
  import type { BaseUrl, Patch } from "@httpd-client";
+
  import type { Variant } from "@app/components/Badge.svelte";

-
  import * as router from "@app/lib/router";
  import * as utils from "@app/lib/utils";
+
  import * as router from "@app/lib/router";
+
  import { capitalize } from "lodash";
  import { HttpdClient } from "@httpd-client";
  import { sessionStore } from "@app/lib/session";

  import Authorship from "@app/components/Authorship.svelte";
  import Badge from "@app/components/Badge.svelte";
-
  import Changeset from "./SourceBrowser/Changeset.svelte";
+
  import Changeset from "@app/views/projects/SourceBrowser/Changeset.svelte";
  import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
-
  import CommentComponent from "@app/components/Comment.svelte";
-
  import CommitTeaser from "./Commit/CommitTeaser.svelte";
+
  import CommitTeaser from "@app/views/projects/Commit/CommitTeaser.svelte";
  import Dropdown from "@app/components/Dropdown.svelte";
+
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
  import Floating from "@app/components/Floating.svelte";
+
  import Markdown from "@app/components/Markdown.svelte";
+
  import Placeholder from "@app/components/Placeholder.svelte";
  import ProjectLink from "@app/components/ProjectLink.svelte";
+
  import RevisionComponent from "@app/views/projects/Cob/Revision.svelte";
  import SquareButton from "@app/components/SquareButton.svelte";
-
  import TagInput from "./Cob/TagInput.svelte";
-
  import ThreadComponent from "@app/components/Thread.svelte";
+
  import TagInput from "@app/views/projects/Cob/TagInput.svelte";

  export let projectId: string;
  export let baseUrl: BaseUrl;
  export let patch: Patch;
  export let projectHead: string;
-
  export let revision: string | undefined = undefined;
-
  export let currentTab: "activity" | "commits";
+
  export let revision: string;
+
  export let currentTab: "activity" | "commits" | "files";

  const api = new HttpdClient(baseUrl);

@@ -75,6 +92,19 @@
      patch = await api.project.getPatchById(projectId, patch.id);
    }
  }
+
  function badgeColor(status: string): Variant {
+
    if (status === "draft") {
+
      return "foreground";
+
    } else if (status === "open") {
+
      return "positive";
+
    } else if (status === "archived") {
+
      return "caution";
+
    } else if (status === "merged") {
+
      return "primary";
+
    } else {
+
      return "foreground";
+
    }
+
  }

  async function saveTags({ detail: tags }: CustomEvent<string[]>) {
    if ($sessionStore) {
@@ -92,80 +122,63 @@
    }
  }

-
  function formatVerdict(verdict?: string | null) {
-
    switch (verdict) {
-
      case "accept":
-
        return "accepted this revision";
-
      case "reject":
-
        return "rejected this revision";
-
      default:
-
        return "left a comment";
-
    }
-
  }
-

  const action: "create" | "edit" | "view" =
    $sessionStore && utils.isLocal(baseUrl.hostname) ? "edit" : "view";
-

-
  // Reactive due to eventual changes in patch.revisions
-
  $: enumeratedRevisions = patch.revisions.map((r, i) => [r, i] as const);
-
  $: currentRevisionTuple =
-
    enumeratedRevisions.find(([rev]) => rev.id === revision) ||
-
    enumeratedRevisions[enumeratedRevisions.length - 1];
-
  $: [currentRevision, currentRevisionIndex] = currentRevisionTuple;
-
  $: options = ["activity", "commits", "files"].map(o => ({
+
  const options = ["activity", "commits", "files"].map(o => ({
    value: o,
    title: capitalize(o),
    disabled: false,
  }));
-
  $: reviews = currentRevision.reviews.map<TimelineReview>(
-
    ([author, review]) => ({
-
      timestamp: review.timestamp,
-
      type: "review",
-
      inner: [author, review],
-
    }),
-
  );
-
  $: merges = currentRevision.merges.map<TimelineMerge>(inner => ({
-
    timestamp: inner.timestamp,
-
    type: "merge",
-
    inner,
-
  }));
-
  $: threads = currentRevision.discussions
-
    .filter(comment => !comment.replyTo)
-
    .map<TimelineThread>(
-
      thread => ({
-
        timestamp: thread.timestamp,
-
        type: "thread",
-
        inner: {
-
          root: thread,
-
          replies: currentRevision.discussions
-
            .filter(comment => comment.replyTo === thread.id)
-
            .sort((a, b) => a.timestamp - b.timestamp),
-
        },
-
      }),
-
      [],
-
    );
-
  $: timeline = [...reviews, ...merges, ...threads].sort(
-
    (a, b) => a.timestamp - b.timestamp,
-
  );
-
  $: diffPromise = api.project.getDiff(
-
    projectId,
-
    currentRevision.base,
-
    currentRevision.oid,
-
  );

-
  function badgeColor(status: string): Variant {
-
    if (status === "draft") {
-
      return "foreground";
-
    } else if (status === "open") {
-
      return "positive";
-
    } else if (status === "archived") {
-
      return "caution";
-
    } else if (status === "merged") {
-
      return "primary";
-
    } else {
-
      return "foreground";
-
    }
-
  }
+
  const currentRevision =
+
    patch.revisions.find(r => r.id === revision) || patch.revisions[0];
+
  $: timelineTuple = patch.revisions.map<
+
    [
+
      {
+
        revisionId: string;
+
        revisionTimestamp: number;
+
        revisionBase: string;
+
        revisionOid: string;
+
      },
+
      Timeline[],
+
    ]
+
  >(rev => [
+
    {
+
      revisionId: rev.id,
+
      revisionTimestamp: rev.timestamp,
+
      revisionBase: rev.base,
+
      revisionOid: rev.oid,
+
    },
+
    [
+
      ...rev.reviews.map<TimelineReview>(([author, review]) => ({
+
        timestamp: review.timestamp,
+
        type: "review",
+
        inner: [rev.id, author, review],
+
      })),
+
      ...rev.merges.map<TimelineMerge>(inner => ({
+
        timestamp: inner.timestamp,
+
        type: "merge",
+
        inner,
+
      })),
+
      ...rev.discussions
+
        .filter(comment => !comment.replyTo)
+
        .map<TimelineThread>(thread => ({
+
          timestamp: thread.timestamp,
+
          type: "thread",
+
          inner: {
+
            root: thread,
+
            replies: rev.discussions
+
              .filter(comment => comment.replyTo === thread.id)
+
              .sort((a, b) => a.timestamp - b.timestamp),
+
          },
+
        })),
+
      {
+
        type: "revision",
+
        timestamp: rev.timestamp,
+
        inner: rev,
+
      } as TimelineRevision,
+
    ].sort((a, b) => a.timestamp - b.timestamp),
+
  ]);
</script>

<style>
@@ -176,20 +189,32 @@
    margin-bottom: 4.5rem;
  }
  .metadata {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 2rem;
    border-radius: var(--border-radius);
    font-size: var(--font-size-small);
    padding-left: 1rem;
    margin-left: 1rem;
  }
-
  .action {
-
    margin: 1rem;
-
    color: var(--color-foreground-5);
-
  }
  .commit-list {
    border-radius: var(--border-radius);
    overflow: hidden;
    margin-top: 1rem;
  }
+
  .tab-line {
+
    display: flex;
+
    flex-direction: row;
+
    justify-content: space-between;
+
    align-items: center;
+
    margin: 1rem 0;
+
  }
+
  .author {
+
    display: flex;
+
    align-items: center;
+
    flex-wrap: nowrap;
+
    gap: 0.5rem;
+
  }

  @media (max-width: 1092px) {
    .patch {
@@ -210,25 +235,78 @@
<div class="patch">
  <div>
    <CobHeader id={patch.id} title={patch.title}>
-
      <span slot="revision" class="txt-monospace txt-tiny">
+
      <svelte:fragment slot="state">
+
        <Badge variant={badgeColor(patch.state.status)}>
+
          {patch.state.status}
+
        </Badge>
+
      </svelte:fragment>
+
      <svelte:fragment slot="description">
+
        {#if patch.description}
+
          <Markdown
+
            content={patch.description}
+
            rawPath={utils.getRawBasePath(
+
              projectId,
+
              baseUrl,
+
              currentRevision.oid,
+
            )} />
+
        {:else}
+
          <span class="txt-missing">No description available</span>
+
        {/if}
+
      </svelte:fragment>
+
      <div class="author" slot="author">
+
        opened by <Authorship authorId={patch.author.id} />
+
        {utils.formatTimestamp(patch.revisions[0].timestamp)}
+
      </div>
+
    </CobHeader>
+

+
    <div class="tab-line">
+
      <div style="display: flex; gap: 0.5rem;">
+
        {#each options as option}
+
          {#if !option.disabled}
+
            <ProjectLink
+
              projectParams={{
+
                search: `tab=${option.value}`,
+
              }}>
+
              <SquareButton
+
                size="small"
+
                clickable={option.disabled}
+
                active={option.value === currentTab}
+
                disabled={option.disabled}>
+
                {option.title}
+
              </SquareButton>
+
            </ProjectLink>
+
          {:else}
+
            <SquareButton
+
              size="small"
+
              clickable={option.disabled}
+
              active={option.value === currentTab}
+
              disabled={option.disabled}>
+
              {option.title}
+
            </SquareButton>
+
          {/if}
+
        {/each}
+
      </div>
+

+
      {#if currentTab !== "activity"}
        <Floating disabled={patch.revisions.length === 1}>
          <svelte:fragment slot="toggle">
            <SquareButton
+
              size="small"
              clickable={patch.revisions.length > 1}
              disabled={patch.revisions.length === 1}>
-
              Revision {currentRevisionIndex}
+
              Revision {utils.formatObjectId(currentRevision.id)}
            </SquareButton>
          </svelte:fragment>
          <svelte:fragment slot="modal">
            <Dropdown
-
              items={enumeratedRevisions.map(([r, i]) => {
+
              items={patch.revisions.map(r => {
                return {
-
                  title: `Revision ${i} (${utils.formatObjectId(r.id)})`,
+
                  title: `Revision ${utils.formatObjectId(r.id)}`,
                  value: r.id,
                  badge: null,
                };
              })}
-
              selected={currentRevision.toString()}
+
              selected={currentRevision.id}
              on:select={({ detail: item }) => {
                router.updateProjectRoute({
                  view: {
@@ -243,121 +321,43 @@
            </Dropdown>
          </svelte:fragment>
        </Floating>
-
      </span>
-
      <svelte:fragment slot="state">
-
        <Badge variant={badgeColor(patch.state.status)}>
-
          {patch.state.status}
-
        </Badge>
-
        <div class="layout-desktop">
-
          <Authorship
-
            timestamp={patch.revisions[0].timestamp}
-
            authorId={patch.author.id}
-
            caption="opened this patch" />
-
        </div>
-
        <div class="layout-mobile">
-
          <Authorship authorId={patch.author.id} />
-
        </div>
-
      </svelte:fragment>
-
    </CobHeader>
-
    <div style="display: flex; gap: 0.5rem;">
-
      {#each options as option}
-
        {#if !option.disabled}
-
          <ProjectLink
-
            projectParams={{
-
              search: `tab=${option.value}`,
-
            }}>
-
            <SquareButton
-
              size="small"
-
              clickable={option.disabled}
-
              active={option.value === currentTab}
-
              disabled={option.disabled}>
-
              {option.title}
-
            </SquareButton>
-
          </ProjectLink>
-
        {:else}
-
          <SquareButton
-
            size="small"
-
            clickable={option.disabled}
-
            active={option.value === currentTab}
-
            disabled={option.disabled}>
-
            {option.title}
-
          </SquareButton>
-
        {/if}
-
      {/each}
+
      {/if}
    </div>
    {#if currentTab === "activity"}
-
      <div style:margin-top="1rem">
-
        <div class="txt-tiny">
-
          <CommentComponent
-
            caption="created this revision"
-
            authorId={patch.author.id}
-
            timestamp={currentRevision.timestamp}
-
            rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
-
            body={currentRevisionIndex === 0
-
              ? patch.description
-
              : currentRevision.description} />
-
        </div>
-
        {#each timeline as element}
-
          {#if element.type === "thread"}
-
            <!-- TODO: Implement reply creation and comment editing -->
-
            <ThreadComponent
-
              rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
-
              isDescription={false}
-
              thread={element.inner}
-
              on:reply={createReply}
-
              on:select={({ detail: index }) => (currentRevision = index)} />
-
          {:else if element.type === "merge"}
-
            <div class="action layout-desktop txt-tiny">
-
              <Authorship
-
                authorId={element.inner.node}
-
                timestamp={element.timestamp}>
-
                merged
-
                {utils.formatCommit(element.inner.commit)}
-
              </Authorship>
-
            </div>
-
            <div class="action layout-mobile txt-tiny">
-
              <Authorship authorId={element.inner.node}>
-
                merged
-
                {utils.formatCommit(element.inner.commit)}
-
              </Authorship>
-
            </div>
-
          {:else if element.type === "review"}
-
            <!-- TODO: Implement inline code comments -->
-
            {@const [author, review] = element.inner}
-
            <div class="action layout-desktop txt-tiny">
-
              <Authorship authorId={author} timestamp={element.timestamp}>
-
                {formatVerdict(review.verdict)}
-
              </Authorship>
-
            </div>
-
            <div class="action layout-mobile txt-tiny">
-
              <Authorship authorId={author}>
-
                {formatVerdict(review.verdict)}
-
              </Authorship>
-
            </div>
-
            {#if review.comment}
-
              <CommentComponent
-
                caption="left a comment"
-
                authorId={author}
-
                timestamp={review.timestamp}
-
                rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
-
                body={review.comment} />
-
            {/if}
-
          {/if}
-
        {/each}
-
      </div>
+
      {#each timelineTuple as [revision, timelines], index}
+
        <RevisionComponent
+
          {baseUrl}
+
          {projectId}
+
          {timelines}
+
          {projectHead}
+
          {...revision}
+
          on:reply={createReply}
+
          patchId={patch.id}
+
          authorId={patch.author.id}
+
          expanded={index === patch.revisions.length - 1} />
+
      {:else}
+
        <Placeholder emoji="🍂">
+
          <div slot="title">No activity</div>
+
          <div slot="body">No activity on this patch yet</div>
+
        </Placeholder>
+
      {/each}
    {:else if currentTab === "commits"}
-
      {#await diffPromise then diff}
+
      {#await api.project.getDiff(projectId, currentRevision.base, currentRevision.oid) then diff}
        <div class="commit-list">
          {#each diff.commits as commit}
            <CommitTeaser {commit} />
          {/each}
        </div>
+
      {:catch e}
+
        <ErrorMessage message="Not able to load commits." stackTrace={e} />
      {/await}
    {:else if currentTab === "files"}
-
      {#await diffPromise then diff}
+
      {#await api.project.getDiff(projectId, currentRevision.base, currentRevision.oid) then diff}
        <div style:margin-top="1rem">
          <Changeset revision={currentRevision.oid} diff={diff.diff} />
        </div>
+
      {:catch e}
+
        <ErrorMessage message="Not able to load files diff." stackTrace={e} />
      {/await}
    {/if}
  </div>
modified src/views/projects/View.svelte
@@ -34,7 +34,7 @@
  $: searchParams = new URLSearchParams(activeRoute.params.search || "");
  $: issueFilter = (searchParams.get("state") as IssueStatus) || "open";
  $: patchTabFilter =
-
    (searchParams.get("tab") as "activity" | "commits") || "activity";
+
    (searchParams.get("tab") as "activity" | "commits" | "files") || "activity";
  $: patchFilter = (searchParams.get("state") as PatchStatus) || "open";
  $: baseUrl = utils.extractBaseUrl(activeRoute.params.hostnamePort);
  $: api = new HttpdClient(baseUrl);
@@ -254,13 +254,15 @@
        {#await api.project.getPatchById(project.id, activeRoute.params.view.params.patch)}
          <Loading center />
        {:then patch}
+
          {@const latestRevision = patch.revisions[patch.revisions.length - 1]}
          <Patch
+
            {patch}
            {baseUrl}
            projectId={project.id}
            projectHead={project.head}
-
            revision={activeRoute.params.view.params.revision}
-
            currentTab={patchTabFilter}
-
            {patch} />
+
            revision={activeRoute.params.view.params.revision ??
+
              latestRevision.id}
+
            currentTab={patchTabFilter} />
        {:catch e}
          <div class="message">
            <ErrorMessage message="Couldn't load patch." stackTrace={e} />
modified tests/e2e/project.spec.ts
@@ -267,9 +267,9 @@ test("peer and branch switching", async ({ page }) => {
    await page.getByTitle("Change peer").click();
    await page.locator(`text=${aliceRemote}`).click();
    await expect(page.getByTitle("Change peer")).toHaveText(
-
      ` did:key:${aliceRemote.substring(8).substring(0, 6)}…${aliceRemote.slice(
-
        -6,
-
      )} `,
+
      `  did:key:${aliceRemote
+
        .substring(8)
+
        .substring(0, 6)}…${aliceRemote.slice(-6)} delegate`,
    );
    await expect(
      page.locator(
modified tests/e2e/project/commits.spec.ts
@@ -15,9 +15,9 @@ test("peer and branch switching", async ({ page }) => {
    await page.getByTitle("Change peer").click();
    await page.locator(`text=${aliceRemote}`).click();
    await expect(page.getByTitle("Change peer")).toHaveText(
-
      ` did:key:${aliceRemote.substring(8).substring(0, 6)}…${aliceRemote.slice(
-
        -6,
-
      )} `,
+
      `  did:key:${aliceRemote
+
        .substring(8)
+
        .substring(0, 6)}…${aliceRemote.slice(-6)} delegate`,
    );

    await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();