Radish alpha
r
Radicle desktop app
Radicle
Git (anonymous pull)
Log in to clone via SSH
Combine comments and activity into one timeline
Brandon Oxendine committed 1 month ago
commit 6375f05d7f658530a6d952f50468e7f68656124d
parent 667cbf79e458133b27d59fca2252482b98d0eeeb
6 files changed +587 -84
modified src/components/Discussion.svelte
@@ -1,17 +1,23 @@
-
<script lang="ts">
+
<script lang="ts" module>
+
  export interface ActivityItem<T = unknown> {
+
    key: string;
+
    timestamp: number;
+
    data: T;
+
  }
+
</script>
+

+
<script lang="ts" generics="A">
  import type { Author } from "@bindings/cob/Author";
  import type { Embed } from "@bindings/cob/thread/Embed";
  import type { Thread } from "@bindings/cob/thread/Thread";
  import type { Config } from "@bindings/config/Config";
+
  import type { Snippet } from "svelte";

  import partial from "lodash/partial";
-
  import sum from "lodash/sum";

  import * as roles from "@app/lib/roles";

-
  import Button from "@app/components/Button.svelte";
  import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
-
  import Icon from "@app/components/Icon.svelte";
  import ThreadComponent from "@app/components/Thread.svelte";

  interface Props {
@@ -35,6 +41,8 @@
    ) => Promise<void>;
    repoDelegates: Author[];
    rid: string;
+
    activityItems?: ActivityItem<A>[];
+
    renderActivity?: Snippet<[A]>;
  }

  /* eslint-disable prefer-const */
@@ -47,6 +55,8 @@
    reactOnComment,
    repoDelegates,
    rid,
+
    activityItems,
+
    renderActivity,
  }: Props = $props();
  /* eslint-enable prefer-const */

@@ -54,7 +64,32 @@
  let focusReply: boolean = $state(false);
  let commentFormKey = $state(0);

-
  let hideDiscussion = $state(false);
+
  type TimelineEntry =
+
    | { kind: "thread"; key: string; timestamp: number; thread: Thread }
+
    | { kind: "activity"; key: string; timestamp: number; data: A };
+

+
  const timeline: TimelineEntry[] = $derived(
+
    [
+
      ...commentThreads.map(
+
        thread =>
+
          ({
+
            kind: "thread",
+
            key: thread.root.id,
+
            timestamp: thread.root.edits[0].timestamp,
+
            thread,
+
          }) satisfies TimelineEntry,
+
      ),
+
      ...(activityItems ?? []).map(
+
        item =>
+
          ({
+
            kind: "activity",
+
            key: item.key,
+
            timestamp: item.timestamp,
+
            data: item.data,
+
          }) satisfies TimelineEntry,
+
      ),
+
    ].sort((a, b) => a.timestamp - b.timestamp),
+
  );

  $effect(() => {
    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
@@ -62,7 +97,6 @@

    if (cobId !== previousCobId) {
      previousCobId = cobId;
-
      hideDiscussion = false;
      focusReply = false;
      commentFormKey += 1;
    }
@@ -76,48 +110,34 @@
    margin-left: 1.25rem;
    background-color: var(--color-border-subtle);
  }
+
  .activity {
+
    padding: 0.25rem 0;
+
  }
</style>

-
<div style:margin={hideDiscussion ? "1.5rem 0" : "1.5rem 0 2.5rem 0"}>
-
  <div class="global-flex">
-
    <div class="global-flex">
-
      <Button
-
        variant="naked"
-
        disabled={commentThreads.length === 0}
-
        onclick={() => (hideDiscussion = !hideDiscussion)}>
-
        <Icon name={hideDiscussion ? "chevron-right" : "chevron-down"} />
-
      </Button>
-
      <div
-
        class="txt-body-m-regular global-flex"
-
        style:color={commentThreads.length === 0
-
          ? "var(--color-text-disabled)"
-
          : undefined}>
-
        Discussion <span>
-
          {sum(
-
            commentThreads.map(t => {
-
              return t.replies.length + 1;
-
            }),
+
<div style:margin="1.5rem 0 2.5rem 0">
+
  <div>
+
    {#each timeline as entry (entry.kind + ":" + entry.key)}
+
      {#if entry.kind === "thread"}
+
        <ThreadComponent
+
          thread={entry.thread}
+
          {rid}
+
          currentUserNid={config.publicKey}
+
          canEditComment={partial(
+
            roles.isDelegateOrAuthor,
+
            config.publicKey,
+
            repoDelegates.map(delegate => delegate.did),
          )}
-
        </span>
-
      </div>
-
    </div>
-
  </div>
-
  <div
-
    style:display={hideDiscussion ? "none" : "revert"}
-
    style:margin-top="1rem">
-
    {#each commentThreads as thread}
-
      <ThreadComponent
-
        {thread}
-
        {rid}
-
        canEditComment={partial(
-
          roles.isDelegateOrAuthor,
-
          config.publicKey,
-
          repoDelegates.map(delegate => delegate.did),
-
        )}
-
        {editComment}
-
        createReply={createComment}
-
        {reactOnComment} />
-
      <div class="connector"></div>
+
          {editComment}
+
          createReply={createComment}
+
          {reactOnComment} />
+
        <div class="connector"></div>
+
      {:else if renderActivity}
+
        <div class="activity">
+
          {@render renderActivity(entry.data)}
+
        </div>
+
        <div class="connector"></div>
+
      {/if}
    {/each}

    <div id={`reply-${cobId}`}>
added src/components/IssueActivityItem.svelte
@@ -0,0 +1,164 @@
+
<script lang="ts" module>
+
  import type { Author } from "@bindings/cob/Author";
+
  import type { Action } from "@bindings/cob/issue/Action";
+

+
  export type FlattenedIssueOperation = Action & {
+
    id: string;
+
    author: Author;
+
    timestamp: number;
+
    previous?: Action;
+
  };
+
</script>
+

+
<script lang="ts">
+
  import {
+
    absoluteTimestamp,
+
    authorForNodeId,
+
    formatTimestamp,
+
    issueStatusColor,
+
    pluralize,
+
  } from "@app/lib/utils";
+

+
  import Icon from "@app/components/Icon.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
+

+
  interface Props {
+
    op: FlattenedIssueOperation;
+
  }
+

+
  const { op }: Props = $props();
+

+
  function itemDiff<A>(previousState: A[], newState: A[]) {
+
    const removed = previousState.filter(x => !newState.includes(x));
+
    const added = newState.filter(x => !previousState.includes(x));
+
    return { removed, added };
+
  }
+
</script>
+

+
<style>
+
  .timeline-item {
+
    display: flex;
+
    align-items: flex-start;
+
    gap: 0.5rem;
+
    word-break: break-word;
+
  }
+
  .wrapper {
+
    display: flex;
+
    flex-wrap: wrap;
+
    gap: 0.25rem;
+
  }
+
  .icon {
+
    padding-top: 3px;
+
  }
+
</style>
+

+
{#if op.type === "lifecycle"}
+
  <div class="timeline-item txt-body-m-regular">
+
    <div class="icon" style:color={issueStatusColor[op.state.status]}>
+
      {#if op.state.status === "open"}
+
        <Icon name="issue" />
+
      {:else}
+
        <Icon name="issue-closed" />
+
      {/if}
+
    </div>
+
    <div class="wrapper">
+
      <NodeId {...authorForNodeId(op.author)} />
+
      {#if op.state.status === "closed"}
+
        closed issue as {op.state.reason}
+
      {:else if op.state.status === "open"}
+
        reopened issue
+
      {/if}
+
      <div title={absoluteTimestamp(op.timestamp)}>
+
        {formatTimestamp(op.timestamp)}
+
      </div>
+
    </div>
+
  </div>
+
{:else if op.type === "label"}
+
  <div class="timeline-item txt-body-m-regular">
+
    <div class="icon">
+
      <Icon name="label" />
+
    </div>
+
    <div class="wrapper">
+
      <NodeId {...authorForNodeId(op.author)} />
+
      {#if op.previous && op.previous.type === op.type}
+
        {@const changed = itemDiff(op.previous?.labels ?? [], op.labels)}
+
        {#if changed.added.length || changed.removed.length}
+
          {#if changed.added.length}
+
            added {pluralize("label", changed.removed.length)}
+
            {#each changed.added as label}
+
              <b>{label}</b>
+
            {/each}
+
          {/if}
+
          {#if changed.removed.length}
+
            removed {pluralize("label", changed.removed.length)}
+
            {#each changed.removed as label}
+
              <b>{label}</b>
+
            {/each}
+
          {/if}
+
        {/if}
+
      {:else}
+
        added {pluralize("label", op.labels.length)}
+
        {#each op.labels as label}
+
          <b>{label}</b>
+
        {/each}
+
      {/if}
+
      <div title={absoluteTimestamp(op.timestamp)}>
+
        {formatTimestamp(op.timestamp)}
+
      </div>
+
    </div>
+
  </div>
+
{:else if op.type === "assign"}
+
  <div class="timeline-item txt-body-m-regular">
+
    <div class="icon">
+
      <Icon name="avatar-incognito" />
+
    </div>
+
    <div class="wrapper">
+
      <NodeId {...authorForNodeId(op.author)} />
+
      {#if op.previous && op.previous.type === op.type}
+
        {@const changed = itemDiff<Author>(
+
          op.previous?.assignees ?? [],
+
          op.assignees,
+
        )}
+
        {#if changed.added.length || changed.removed.length}
+
          {#if changed.added.length}
+
            assigned
+
            {#each changed.added as assignee}
+
              <NodeId {...authorForNodeId(assignee)} />
+
            {/each}
+
          {/if}
+
          {#if changed.removed.length}
+
            unassigned
+
            {#each changed.removed as assignee}
+
              <NodeId {...authorForNodeId(assignee)} />
+
            {/each}
+
          {/if}
+
        {/if}
+
      {:else}
+
        assigned
+
        {#each op.assignees as assignee}
+
          <NodeId {...authorForNodeId(assignee)} />
+
        {/each}
+
      {/if}
+
      <div title={absoluteTimestamp(op.timestamp)}>
+
        {formatTimestamp(op.timestamp)}
+
      </div>
+
    </div>
+
  </div>
+
{:else if op.type === "edit"}
+
  {#if op.previous && op.previous.type === op.type}
+
    <div class="timeline-item txt-body-m-regular">
+
      <div class="icon">
+
        <Icon name="edit" />
+
      </div>
+
      <div class="wrapper">
+
        <NodeId {...authorForNodeId(op.author)} />
+
        changed title
+
        <s>{op.previous.title}</s>
+
        -> {op.title}
+
        <div title={absoluteTimestamp(op.timestamp)}>
+
          {formatTimestamp(op.timestamp)}
+
        </div>
+
      </div>
+
    </div>
+
  {/if}
+
{/if}
added src/components/PatchActivityItem.svelte
@@ -0,0 +1,249 @@
+
<script lang="ts" module>
+
  import type { Author } from "@bindings/cob/Author";
+
  import type { Action } from "@bindings/cob/patch/Action";
+

+
  export type FlattenedPatchOperation = Action & {
+
    id: string;
+
    author: Author;
+
    timestamp: number;
+
    previous?: Action;
+
  };
+
</script>
+

+
<script lang="ts">
+
  import {
+
    absoluteTimestamp,
+
    authorForNodeId,
+
    formatTimestamp,
+
    patchStatusColor,
+
    pluralize,
+
  } from "@app/lib/utils";
+

+
  import Icon from "@app/components/Icon.svelte";
+
  import Id from "@app/components/Id.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
+

+
  interface Props {
+
    op: FlattenedPatchOperation;
+
    patchId: string;
+
  }
+

+
  const { op, patchId }: Props = $props();
+

+
  function itemDiff<A>(previousState: A[], newState: A[]) {
+
    const removed = previousState.filter(x => !newState.includes(x));
+
    const added = newState.filter(x => !previousState.includes(x));
+
    return { removed, added };
+
  }
+
</script>
+

+
<style>
+
  .timeline-item {
+
    display: flex;
+
    align-items: flex-start;
+
    gap: 0.5rem;
+
    word-break: break-word;
+
  }
+
  .wrapper {
+
    display: flex;
+
    flex-wrap: wrap;
+
    gap: 0.25rem;
+
  }
+
  .icon {
+
    padding-top: 0.1875rem;
+
  }
+
</style>
+

+
{#if op.type === "revision"}
+
  {#if op.id === patchId}
+
    <div class="timeline-item txt-body-m-regular">
+
      <div class="icon" style:color="var(--color-feedback-success-text)">
+
        <Icon name="patch" />
+
      </div>
+
      <div class="wrapper">
+
        <NodeId {...authorForNodeId(op.author)} />
+
        <div>
+
          opened patch <Id id={op.id} clipboard={op.id} />
+
        </div>
+
        <div title={absoluteTimestamp(op.timestamp)}>
+
          {formatTimestamp(op.timestamp)}
+
        </div>
+
      </div>
+
    </div>
+
  {:else}
+
    <div class="timeline-item txt-body-m-regular">
+
      <div class="icon">
+
        <Icon name="revision" />
+
      </div>
+
      <div class="wrapper">
+
        <NodeId {...authorForNodeId(op.author)} />
+
        <div>
+
          created revision <Id id={op.id} clipboard={op.id} />
+
        </div>
+
        <div title={absoluteTimestamp(op.timestamp)}>
+
          {formatTimestamp(op.timestamp)}
+
        </div>
+
      </div>
+
    </div>
+
  {/if}
+
{:else if op.type === "lifecycle"}
+
  <div class="timeline-item txt-body-m-regular">
+
    <div class="icon" style:color={patchStatusColor[op.state.status]}>
+
      <Icon
+
        name={op.state.status === "open"
+
          ? "patch"
+
          : `patch-${op.state.status}`} />
+
    </div>
+
    <div class="wrapper">
+
      <NodeId {...authorForNodeId(op.author)} />
+
      {#if op.state.status === "draft"}
+
        converted patch to draft
+
      {:else if op.state.status === "archived"}
+
        archived patch
+
      {:else if op.state.status === "open"}
+
        reopened patch
+
      {/if}
+
      <div title={absoluteTimestamp(op.timestamp)}>
+
        {formatTimestamp(op.timestamp)}
+
      </div>
+
    </div>
+
  </div>
+
{:else if op.type === "label"}
+
  <div class="timeline-item txt-body-m-regular">
+
    <div class="icon">
+
      <Icon name="label" />
+
    </div>
+
    <div class="wrapper">
+
      <NodeId {...authorForNodeId(op.author)} />
+
      {#if op.previous && op.previous.type === op.type}
+
        {@const changed = itemDiff(op.previous?.labels ?? [], op.labels)}
+
        {#if changed.added.length || changed.removed.length}
+
          {#if changed.added.length}
+
            added {pluralize("label", changed.added.length)}
+
            {#each changed.added as label}
+
              <b>{label}</b>
+
            {/each}
+
          {/if}
+
          {#if changed.removed.length}
+
            removed {pluralize("label", changed.removed.length)}
+
            {#each changed.removed as label}
+
              <b>{label}</b>
+
            {/each}
+
          {/if}
+
        {/if}
+
      {:else}
+
        added {pluralize("label", op.labels.length)}
+
        {#each op.labels as label}
+
          <b>{label}</b>
+
        {/each}
+
      {/if}
+
      <div title={absoluteTimestamp(op.timestamp)}>
+
        {formatTimestamp(op.timestamp)}
+
      </div>
+
    </div>
+
  </div>
+
{:else if op.type === "assign"}
+
  <div class="timeline-item txt-body-m-regular">
+
    <div class="icon">
+
      <Icon name="avatar-incognito" />
+
    </div>
+
    <div class="wrapper">
+
      <NodeId {...authorForNodeId(op.author)} />
+
      {#if op.previous && op.previous.type === op.type}
+
        {@const changed = itemDiff<Author>(
+
          op.previous?.assignees ?? [],
+
          op.assignees,
+
        )}
+
        {#if changed.added.length}
+
          assigned
+
          {#each changed.added as assignee}
+
            <NodeId {...authorForNodeId(assignee)} />
+
          {/each}
+
        {/if}
+
        {#if changed.removed.length}
+
          unassigned
+
          {#each changed.removed as assignee}
+
            <NodeId {...authorForNodeId(assignee)} />
+
          {/each}
+
        {/if}
+
      {:else}
+
        assigned
+
        {#each op.assignees as assignee}
+
          <NodeId {...authorForNodeId(assignee)} />
+
        {/each}
+
      {/if}
+
      <div title={absoluteTimestamp(op.timestamp)}>
+
        {formatTimestamp(op.timestamp)}
+
      </div>
+
    </div>
+
  </div>
+
{:else if op.type === "merge"}
+
  <div class="timeline-item txt-body-m-regular">
+
    <div class="icon" style:color="var(--color-brand-bg)">
+
      <Icon name="patch-merged" />
+
    </div>
+
    <div class="wrapper">
+
      <NodeId {...authorForNodeId(op.author)} />
+
      <div>
+
        merged patch at revision <Id id={op.revision} clipboard={op.revision} />
+
      </div>
+
      <div title={absoluteTimestamp(op.timestamp)}>
+
        {formatTimestamp(op.timestamp)}
+
      </div>
+
    </div>
+
  </div>
+
{:else if op.type === "edit"}
+
  {#if op.previous && op.previous.type === op.type}
+
    <div class="timeline-item txt-body-m-regular">
+
      <div class="icon">
+
        <Icon name="edit" />
+
      </div>
+
      <div class="wrapper">
+
        <NodeId {...authorForNodeId(op.author)} />
+
        changed title
+
        <s>{op.previous.title}</s>
+
        -> {op.title}
+
        <div title={absoluteTimestamp(op.timestamp)}>
+
          {formatTimestamp(op.timestamp)}
+
        </div>
+
      </div>
+
    </div>
+
  {/if}
+
{:else if op.type === "review"}
+
  <div class="timeline-item txt-body-m-regular">
+
    {#if op.verdict === "accept"}
+
      <div class="icon" style:color="var(--color-feedback-success-text)">
+
        <Icon name="thumbs-up" />
+
      </div>
+
      <div class="wrapper">
+
        <NodeId {...authorForNodeId(op.author)} />
+
        accepted revision <Id id={op.revision} clipboard={op.revision} />
+
        <div title={absoluteTimestamp(op.timestamp)}>
+
          {formatTimestamp(op.timestamp)}
+
        </div>
+
      </div>
+
    {:else if op.verdict === "reject"}
+
      <div class="icon" style:color="var(--color-feedback-error-text)">
+
        <Icon name="stop" />
+
      </div>
+
      <div class="wrapper">
+
        <NodeId {...authorForNodeId(op.author)} />
+
        rejected revision <Id id={op.revision} clipboard={op.revision} />
+
        <div title={absoluteTimestamp(op.timestamp)}>
+
          {formatTimestamp(op.timestamp)}
+
        </div>
+
      </div>
+
    {:else if op.verdict === undefined}
+
      <div class="icon">
+
        <Icon name="comment" />
+
      </div>
+
      <div class="wrapper">
+
        <NodeId {...authorForNodeId(op.author)} />
+
        reviewed revision <Id id={op.revision} clipboard={op.revision} />
+
        <div title={absoluteTimestamp(op.timestamp)}>
+
          {formatTimestamp(op.timestamp)}
+
        </div>
+
      </div>
+
    {/if}
+
  </div>
+
{/if}
modified src/components/Revision.svelte
@@ -1,5 +1,7 @@
<script lang="ts">
  import type { Author } from "@bindings/cob/Author";
+
  import type { Operation } from "@bindings/cob/Operation";
+
  import type { Action } from "@bindings/cob/patch/Action";
  import type { Revision } from "@bindings/cob/patch/Revision";
  import type { Embed } from "@bindings/cob/thread/Embed";
  import type { Thread } from "@bindings/cob/thread/Thread";
@@ -13,7 +15,12 @@
  import { announce } from "@app/components/AnnounceSwitch.svelte";
  import Changes from "@app/components/Changes.svelte";
  import CommentComponent from "@app/components/Comment.svelte";
-
  import Discussion from "@app/components/Discussion.svelte";
+
  import Discussion, {
+
    type ActivityItem,
+
  } from "@app/components/Discussion.svelte";
+
  import PatchActivityItem, {
+
    type FlattenedPatchOperation,
+
  } from "@app/components/PatchActivityItem.svelte";

  interface Props {
    rid: string;
@@ -23,6 +30,7 @@
    config: Config;
    loadPatch: () => Promise<void>;
    view?: "description" | "activity" | "changes";
+
    activity?: Operation<Action>[];
  }

  const {
@@ -33,8 +41,57 @@
    config,
    loadPatch,
    view = "activity",
+
    activity = [],
  }: Props = $props();

+
  const skippedActivityTypes = new Set<Action["type"]>([
+
    "revision.comment",
+
    "revision.comment.edit",
+
    "revision.comment.redact",
+
    "revision.comment.react",
+
    "revision.react",
+
    "revision.edit",
+
    "revision.redact",
+
    "review.comment",
+
    "review.comment.edit",
+
    "review.comment.redact",
+
    "review.comment.react",
+
    "review.comment.resolve",
+
    "review.comment.unresolve",
+
    "review.edit",
+
    "review.redact",
+
    "review.react",
+
  ]);
+

+
  const activityItems: ActivityItem<FlattenedPatchOperation>[] = $derived.by(
+
    () => {
+
      const tracker: Partial<Record<Action["type"], Action>> = {};
+
      const items: ActivityItem<FlattenedPatchOperation>[] = [];
+
      activity.forEach(operation => {
+
        operation.actions.forEach((action, actionIndex) => {
+
          if (skippedActivityTypes.has(action.type)) {
+
            tracker[action.type] = action;
+
            return;
+
          }
+
          const previous = tracker[action.type];
+
          const op: FlattenedPatchOperation = {
+
            ...action,
+
            id: operation.id,
+
            author: operation.author,
+
            timestamp: operation.timestamp,
+
            previous,
+
          };
+
          tracker[action.type] = action;
+
          items.push({
+
            key: `${operation.id}:${actionIndex}`,
+
            timestamp: operation.timestamp,
+
            data: op,
+
          });
+
        });
+
      });
+
      return items;
+
    },
+
  );
  const commentThreads = $derived(
    ((revision.discussion &&
      revision.discussion
@@ -194,6 +251,10 @@
    </CommentComponent>
  </div>
{:else if view === "activity"}
+
  {#snippet renderActivity(op: FlattenedPatchOperation)}
+
    <PatchActivityItem {op} {patchId} />
+
  {/snippet}
+

  <Discussion
    cobId={patchId}
    {commentThreads}
@@ -202,7 +263,9 @@
    {editComment}
    {reactOnComment}
    {repoDelegates}
-
    {rid} />
+
    {rid}
+
    {activityItems}
+
    {renderActivity} />
{:else}
  <Changes {rid} {patchId} {revision} />
{/if}
modified src/views/repo/Issue.svelte
@@ -27,13 +27,17 @@
  import AssigneeInput from "@app/components/AssigneeInput.svelte";
  import Button from "@app/components/Button.svelte";
  import CommentComponent from "@app/components/Comment.svelte";
-
  import Discussion from "@app/components/Discussion.svelte";
+
  import Discussion, {
+
    type ActivityItem,
+
  } from "@app/components/Discussion.svelte";
  import EditableTitle from "@app/components/EditableTitle.svelte";
  import ExternalLink from "@app/components/ExternalLink.svelte";
  import Icon from "@app/components/Icon.svelte";
  import Id from "@app/components/Id.svelte";
+
  import IssueActivityItem, {
+
    type FlattenedIssueOperation,
+
  } from "@app/components/IssueActivityItem.svelte";
  import IssueStateButton from "@app/components/IssueStateButton.svelte";
-
  import IssueTimeline from "@app/components/IssueTimeline.svelte";
  import LabelInput from "@app/components/LabelInput.svelte";
  import ScrollArea from "@app/components/ScrollArea.svelte";
  import CreateIssueModal from "@app/modals/CreateIssue.svelte";
@@ -66,19 +70,36 @@
  const status = initialStatus;
  let labelSaveInProgress: boolean = $state(false);
  let assigneesSaveInProgress: boolean = $state(false);
-
  let hideTimeline = $state(true);

-
  $effect(() => {
-
    // The component doesn't get destroyed when we switch between different
-
    // issues in the second column and because of that the top-level state
-
    // gets retained when the issue changes. This reactive statement makes
-
    // sure we always reset the state to defaults.
-

-
    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
-
    issue.id;
-

-
    hideTimeline = true;
-
  });
+
  const activityItems: ActivityItem<FlattenedIssueOperation>[] = $derived.by(
+
    () => {
+
      const tracker: Partial<Record<Action["type"], Action>> = {};
+
      const items: ActivityItem<FlattenedIssueOperation>[] = [];
+
      activity.forEach(operation => {
+
        operation.actions.forEach((action, actionIndex) => {
+
          if (action.type === "comment") {
+
            tracker[action.type] = action;
+
            return;
+
          }
+
          const previous = tracker[action.type];
+
          const op: FlattenedIssueOperation = {
+
            ...action,
+
            id: operation.id,
+
            author: operation.author,
+
            timestamp: operation.timestamp,
+
            previous,
+
          };
+
          tracker[action.type] = action;
+
          items.push({
+
            key: `${operation.id}:${actionIndex}`,
+
            timestamp: operation.timestamp,
+
            data: op,
+
          });
+
        });
+
      });
+
      return items;
+
    },
+
  );

  async function saveLabels(labels: string[]) {
    try {
@@ -423,6 +444,10 @@
            </CommentComponent>
          </div>

+
          {#snippet renderActivity(op: FlattenedIssueOperation)}
+
            <IssueActivityItem {op} />
+
          {/snippet}
+

          <Discussion
            cobId={issue.id}
            commentThreads={threads}
@@ -431,21 +456,9 @@
            {editComment}
            {reactOnComment}
            repoDelegates={repo.delegates}
-
            rid={repo.rid} />
-

-
          <div class="global-flex" style:margin-top="1rem">
-
            <Button
-
              variant="naked"
-
              onclick={() => (hideTimeline = !hideTimeline)}>
-
              <Icon name={hideTimeline ? "chevron-right" : "chevron-down"} />
-
            </Button>
-
            <div class="txt-body-m-regular global-flex">Timeline</div>
-
          </div>
-
          <div
-
            style:display={hideTimeline ? "none" : "revert"}
-
            style:margin-top="1rem">
-
            <IssueTimeline {activity} />
-
          </div>
+
            rid={repo.rid}
+
            {activityItems}
+
            {renderActivity} />
        </div>

        <div class="sidebar">
modified src/views/repo/Patch.svelte
@@ -29,7 +29,6 @@
  import Id from "@app/components/Id.svelte";
  import NewPatchButton from "@app/components/NewPatchButton.svelte";
  import PatchMetadata from "@app/components/PatchMetadata.svelte";
-
  import PatchTimeline from "@app/components/PatchTimeline.svelte";
  import ReviewComponent from "@app/components/Review.svelte";
  import RevisionComponent from "@app/components/Revision.svelte";
  import ScrollArea from "@app/components/ScrollArea.svelte";
@@ -386,13 +385,8 @@
              {loadPatch}
              revision={selectedRevision}
              {config}
-
              view={patchView} />
-

-
            {#if patchView === "activity"}
-
              <div style:margin-top="1.5rem">
-
                <PatchTimeline {activity} patchId={patch.id} />
-
              </div>
-
            {/if}
+
              view={patchView}
+
              {activity} />
          </div>

          <div class="sidebar">