@agimon-ai/doompi-git 0.0.1-alpha.40 → 0.0.1-alpha.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { apiResponse, createApiClient, defineActivityGroup, defineApiRoutes, defineChannelFile, defineFill, defineGlobalStore, defineRepositorySettingsPanel, defineSessionStore, defineToolRenderer, defineWebLifecycle, defineWebPlugin, sessionApiAddress } from "@agimon-ai/doompi-core/web";
1
+ import { apiResponse, createApiClient, defineActivityGroup, defineApiRoutes, defineChannelFile, defineFill, defineGlobalStore, defineLeaderBinding, defineRepositorySettingsPanel, defineSessionStore, defineToolRenderer, defineWebLifecycle, defineWebPlugin, sessionApiAddress } from "@agimon-ai/doompi-core/web";
2
2
  import { AlertIcon, BranchIcon, Button, ChevronDownIcon, ChevronRightIcon, CloseIcon, Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DiffView, Dot, DropdownMenuItem, EmptyState, Input, Label, MessageIcon, MessageItem, MessageItemBody, MessageItemHeader, OptionRow, RadioGroup, RadioGroupCard, ReviewCommentDraft, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, ShieldIcon, Spinner, StatusBadge, buildReviewPrompt, reviewCommentAnchor, toolTone } from "@agimon-ai/doompi-web-components";
3
3
  import { createElement, useCallback, useEffect, useRef, useState } from "react";
4
4
  import { sealedTransport } from "@agimon-ai/doompi-web-security/browser";
@@ -777,68 +777,89 @@ var git_web_default$1 = defineRepositorySettingsPanel({
777
777
  component: GitAuthPanel
778
778
  });
779
779
  //#endregion
780
- //#region src/types/gitReview.ts
780
+ //#region src/types/webWorktrees.ts
781
781
  /**
782
- * The cockpit's view of what this session changed against its base: the counts
783
- * the '# diff' group shows, what the review tab lists and draws, and the manual
784
- * sync that sits beside them.
782
+ * The cockpit's view of this session's worktrees.
785
783
  *
786
784
  * DESIGN PATTERNS:
787
- * - Declarations only, shared by the hub and the page.
788
- * - Its own channel, apart from worktrees. Worktrees are repository
789
- * management; the diff is this session's code change.
790
- * - A diff row carries one line number whose file follows from its marker: the
791
- * new file's for context and additions, the old file's for removals. It is
792
- * the same shape the shared DiffView draws.
785
+ * - Declarations only. The channel frame type is a constant so the hub and the
786
+ * page cannot drift apart on a string.
787
+ * - A view is the subset of a record the dock needs, not the record. The
788
+ * registry may hold paths and provenance the page has no business rendering.
793
789
  *
794
790
  * AVOID:
795
- * - Behaviour or node builtins. Browser code imports this file.
791
+ * - Adding behaviour here. This file is imported by browser code, so it must
792
+ * stay free of node builtins.
796
793
  */
797
- /** The session channel carrying this checkout's changes and the sync around them. */
798
- const GIT_CHANGES_TYPE = "git_changes";
794
+ /** The data channel carrying one session's worktrees to the page. */
795
+ const GIT_WORKTREES_TYPE = "git_worktrees";
799
796
  //#endregion
800
- //#region src/extensions/workspaces/sessions/(frontend)/_lib/gitChangesStore.ts
797
+ //#region src/extensions/workspaces/sessions/(frontend)/_lib/worktreesActivityStore.ts
801
798
  /**
802
- * This session's code change against its base, and the manual sync beside it.
799
+ * The dock's view of this session's worktrees, and the commands it sends.
803
800
  *
804
801
  * DESIGN PATTERNS:
805
- * - Its own session store and hub channel, apart from worktrees. Worktrees are
806
- * repository management; this is the session's diff.
807
- * - The hub is authoritative: it computes the counts, runs pull, push and
808
- * rebase one at a time, and reports pending, errors and a paused rebase here.
802
+ * - Session-scoped store fed by one hub channel. The hub is authoritative; the
803
+ * page never reads a registry and never guesses.
804
+ * - Actions are channel frames, not prompts. The hub half owns the tool's
805
+ * operations, so the panel names the work and the hub performs it; a
806
+ * worktree made from the dock is the same object the tool makes, without
807
+ * spending a turn of the conversation to ask for it.
809
808
  * - The sender lives in a store rather than a module variable, because a web
810
809
  * plugin keeps no mutable module state and the page may reconnect.
811
810
  *
812
811
  * AVOID:
813
- * - `sendSessionFrame` for a sync command. That frame is bound for the agent
814
- * and never reaches a channel's `receive`.
812
+ * - Calling the hub package API from here. A dock slot gets no sealed request
813
+ * transport, and raw fetch breaks the remote cockpit.
814
+ * - `sendSessionFrame` for a command. It envelopes the frame as a session
815
+ * command bound for the agent, which never reaches a channel's `receive`.
815
816
  */
816
- const gitChanges = defineSessionStore({
817
- changes: void 0,
817
+ const worktreeActivity = defineSessionStore({
818
+ worktrees: [],
818
819
  pending: void 0,
819
820
  error: void 0,
820
821
  errorTarget: void 0
821
822
  });
822
- /** The page's hub socket sender; undefined before start and after dispose, when a command is dropped. */
823
- const changesSender = defineGlobalStore(void 0);
824
- /** Binds the page's hub socket for sync commands. The plugin host calls this once per page. */
825
- function startGitChangesRuntime(runtime) {
823
+ /** Captures the clicked card, so a focus change cannot retarget an open dialog. */
824
+ const worktreeCreateDialog = defineGlobalStore(null);
825
+ /**
826
+ * The page's hub socket sender, held for as long as the plugin runs.
827
+ *
828
+ * Undefined before `startWorktreeRuntime` and after it disposes; a command
829
+ * sent in that window is dropped rather than queued, because a queue would
830
+ * replay a worktree create against a socket that has since changed sessions.
831
+ */
832
+ const worktreeSender = defineGlobalStore(void 0);
833
+ /** Binds the page's hub socket. The plugin host calls this once per page. */
834
+ function startWorktreeRuntime(runtime) {
826
835
  const send = (frame) => {
827
836
  runtime.sendHubFrame(frame);
828
837
  };
829
- changesSender.update(() => send);
838
+ worktreeSender.update(() => send);
830
839
  return () => {
831
- changesSender.update((current) => current === send ? void 0 : current);
840
+ worktreeSender.update((current) => current === send ? void 0 : current);
841
+ worktreeCreateDialog.update(() => null);
832
842
  };
833
843
  }
834
- /** Whether a sync command is running for this session. */
835
- const gitChangesActivitySource = {
844
+ /**
845
+ * Whether this session has worktree work in flight.
846
+ *
847
+ * A standing worktree is state, not work: it sits there until someone closes
848
+ * it, so counting it would leave the session reading as busy forever, with a
849
+ * "background work is still running" notice under a conversation where
850
+ * nothing is running. Only a create or close actually in progress counts.
851
+ *
852
+ * The group stays visible either way. Declaring an active source at all is
853
+ * what keeps it in the dock; this answers how it is drawn, not whether it is
854
+ * there.
855
+ */
856
+ const worktreeActivitySource = {
836
857
  subscribe(listener) {
837
- const subscription = gitChanges.store.subscribe(listener);
858
+ const subscription = worktreeActivity.store.subscribe(listener);
838
859
  return () => subscription.unsubscribe();
839
860
  },
840
861
  isActive(sessionId) {
841
- return gitChanges.select(gitChanges.store.state, sessionId).pending !== void 0;
862
+ return worktreeActivity.select(worktreeActivity.store.state, sessionId).pending !== void 0;
842
863
  }
843
864
  };
844
865
  function isRecord$2(value) {
@@ -847,54 +868,21 @@ function isRecord$2(value) {
847
868
  function optionalText$1(value) {
848
869
  return typeof value === "string" && value !== "" ? value : void 0;
849
870
  }
850
- function count(value) {
851
- return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : void 0;
852
- }
853
- /** The page never trusts a frame's shape: a malformed field drops, the rest still renders. */
854
- const MAX_CONFLICTS = 100;
855
- function optionalChanges(value) {
856
- if (!isRecord$2(value)) return void 0;
857
- const added = count(value.added);
858
- const removed = count(value.removed);
859
- const files = count(value.files);
860
- if (added === void 0 || removed === void 0 || files === void 0) return void 0;
861
- const branch = optionalText$1(value.branch);
862
- const base = optionalText$1(value.base);
863
- const upstream = isRecord$2(value.upstream) ? value.upstream : void 0;
864
- const ahead = count(upstream?.ahead);
865
- const behind = count(upstream?.behind);
866
- const upstreamRef = optionalText$1(upstream?.ref);
867
- const conflicts = isRecord$2(value.rebase) && Array.isArray(value.rebase.conflicts) ? value.rebase.conflicts.filter((file) => typeof file === "string").slice(0, MAX_CONFLICTS) : void 0;
868
- return {
869
- added,
870
- removed,
871
- files,
872
- ...branch === void 0 ? {} : { branch },
873
- ...base === void 0 ? {} : { base },
874
- ...value.truncated === true ? { truncated: true } : {},
875
- ...upstreamRef === void 0 || ahead === void 0 || behind === void 0 ? {} : { upstream: {
876
- ref: upstreamRef,
877
- ahead,
878
- behind
879
- } },
880
- ...conflicts === void 0 ? {} : { rebase: { conflicts } }
881
- };
882
- }
883
871
  function optionalErrorTarget$1(value) {
884
872
  if (!isRecord$2(value)) return void 0;
885
- if (value.action === "pull" || value.action === "rebase" || value.action === "abort-rebase") return { action: value.action };
886
- if (value.action === "push") return value.forceRequired === true ? {
887
- action: "push",
888
- forceRequired: true
889
- } : { action: "push" };
873
+ if (value.action === "create") return { action: "create" };
874
+ if (value.action === "close" && typeof value.id === "string" && value.id !== "") return {
875
+ action: "close",
876
+ id: value.id
877
+ };
890
878
  }
891
- /** The session data channel: 'git_changes' payloads into the store. */
892
- const gitChangesChannel = gitChanges.channel({
893
- channel: GIT_CHANGES_TYPE,
879
+ /** The plugin's session data channel: 'git_worktrees' payloads into the store. */
880
+ const worktreesChannel = worktreeActivity.channel({
881
+ channel: GIT_WORKTREES_TYPE,
894
882
  parse(input) {
895
- if (!isRecord$2(input)) return null;
883
+ if (!isRecord$2(input) || !Array.isArray(input.worktrees)) return null;
896
884
  return {
897
- changes: optionalChanges(input.changes),
885
+ worktrees: input.worktrees.filter(isRecord$2),
898
886
  pending: optionalText$1(input.pending),
899
887
  error: optionalText$1(input.error),
900
888
  errorTarget: optionalErrorTarget$1(input.errorTarget)
@@ -904,1424 +892,1488 @@ const gitChangesChannel = gitChanges.channel({
904
892
  return next;
905
893
  }
906
894
  });
907
- /**
908
- * Asks the hub to pull, push, rebase or abort a paused rebase. `force` is only
909
- * ever sent after the reader confirmed a lease force for a rejected push.
910
- */
911
- function requestGitSync(sessionId, action, force = false) {
912
- const payload = action === "push" && force ? {
913
- action: "push",
914
- force: true
915
- } : { action };
916
- changesSender.store.state?.({
917
- type: GIT_CHANGES_TYPE,
895
+ /** Sends one command to the session's worktrees channel. */
896
+ function command(sessionId, payload) {
897
+ worktreeSender.store.state?.({
898
+ type: GIT_WORKTREES_TYPE,
918
899
  sessionId,
919
900
  payload
920
901
  });
921
902
  }
922
- //#endregion
923
- //#region src/extensions/workspaces/sessions/(frontend)/_lib/gitReviewApi.ts
924
- /**
925
- * The review tab's half of the session API: the changed-file list and one
926
- * file's hunks.
927
- *
928
- * The generated client owns the URL and the sealed transport, so nothing here
929
- * spells a route or reaches for `fetch`. What travels is file contents, which a
930
- * tunnel's relay must never see in the clear.
931
- */
932
- const UNREACHABLE = "The session is unreachable.";
933
- function isRecord$1(value) {
934
- return typeof value === "object" && value !== null && !Array.isArray(value);
935
- }
936
- /** The message a reader is shown: the route's own words, or why there were none. */
937
- function messageOf(result) {
938
- if (result.status === 0) return UNREACHABLE;
939
- return result.error === "" ? `The session answered ${String(result.status)}.` : result.error;
940
- }
941
- async function fetchReviewSummary(sessionId, signal) {
942
- const result = await api.session(sessionId).review(signal === void 0 ? {} : { signal });
943
- if (!result.ok) return {
944
- ok: false,
945
- error: messageOf(result)
946
- };
947
- if (!isRecord$1(result.data) || !Array.isArray(result.data.files)) return {
948
- ok: false,
949
- error: "The session answered with no file list."
950
- };
951
- return {
952
- ok: true,
953
- summary: result.data
954
- };
955
- }
956
- async function fetchReviewFile(sessionId, path, signal) {
957
- const result = await api.session(sessionId).reviewFile({
958
- query: { path },
959
- ...signal === void 0 ? {} : { signal }
903
+ /** Asks the hub for a worktree. The hub answers on the same channel. */
904
+ function requestWorktreeCreate(sessionId, branch, baseRef, reservationId) {
905
+ const trimmedBase = baseRef.trim();
906
+ command(sessionId, {
907
+ action: "create",
908
+ branch: branch.trim(),
909
+ ...trimmedBase === "" ? {} : { baseRef: trimmedBase },
910
+ ...reservationId === void 0 ? {} : { reservationId }
960
911
  });
961
- if (!result.ok) return {
962
- ok: false,
963
- error: messageOf(result)
964
- };
965
- if (!isRecord$1(result.data) || !Array.isArray(result.data.hunks)) return {
966
- ok: false,
967
- error: "The session answered with no diff."
968
- };
969
- return {
970
- ok: true,
971
- diff: result.data
972
- };
973
912
  }
974
- //#endregion
975
- //#region src/extensions/workspaces/sessions/(frontend)/_lib/gitReviewStore.ts
976
- const gitReview = defineSessionStore({ comments: [] });
977
- function addReviewComment(sessionId, comment) {
978
- const id = globalThis.crypto.randomUUID();
979
- gitReview.update(sessionId, (current) => ({ comments: [...current.comments, {
980
- ...comment,
913
+ /** Asks the hub to close one worktree. Refusal on a dirty tree stays the hub's call. */
914
+ function requestWorktreeClose(sessionId, id) {
915
+ command(sessionId, {
916
+ action: "close",
981
917
  id
982
- }] }));
983
- }
984
- function removeReviewComment(sessionId, id) {
985
- gitReview.update(sessionId, (current) => ({ comments: current.comments.filter((comment) => comment.id !== id) }));
986
- }
987
- function clearReviewComments(sessionId) {
988
- gitReview.update(sessionId, () => ({ comments: [] }));
918
+ });
989
919
  }
990
920
  //#endregion
991
- //#region src/extensions/workspaces/sessions/(frontend)/_lib/gitReviewView.ts
921
+ //#region src/extensions/workspaces/sessions/(frontend)/_components/WorktreesPanel.tsx
992
922
  /**
993
- * Pure wording for what the review tab sends the agent, and the one send.
923
+ * The '# git' group's tab: this session's worktrees, and the form that makes one.
994
924
  *
995
- * Both messages go as `enqueue_automatic`: the host stores it and starts a turn
996
- * at once when the agent is idle, or after the current one. A `prompt` frame
997
- * instead fails while a turn runs, and a review lost to that is the worst kind
998
- * of lost work.
925
+ * DESIGN PATTERNS:
926
+ * - Lives in the activity dock, opened by the group's name. A worktree is work
927
+ * in progress belonging to the session that started it, so it sits beside
928
+ * agents, runners and workflows rather than in a settings screen.
929
+ * - Header, form, then cards, the same rhythm the runners and workflows panels
930
+ * use. A worktree row is a card because it carries three facts and two
931
+ * actions, which a single line cannot hold without becoming a puzzle.
932
+ * - Local form state is never reset by a data refresh. Direct lifecycle events
933
+ * update the list without discarding a half-typed branch name.
934
+ * - Acts through the plugin's own hub channel, so a worktree made here and one
935
+ * made by the tool take exactly the same path.
936
+ * - Creating waits on the cockpit starting a session, so the hub reports each
937
+ * phase on that channel and the header shows it turning. A button that looks
938
+ * idle for two minutes reads as broken.
939
+ *
940
+ * AVOID:
941
+ * - Deriving form state from refreshed store data instead of the user's input.
942
+ * - Colour classes outside the token set. `doom-error`, `doom-line` and
943
+ * `doom-bright` are not tokens: they compile to nothing and the row silently
944
+ * loses its border.
999
945
  */
1000
- /** The opening line of a review, naming what was reviewed. */
1001
- function reviewHeading(count, branch, base) {
1002
- return `I reviewed ${branch === void 0 ? "this checkout" : `branch ${branch}`} (${base === void 0 ? "its uncommitted changes" : `its changes against ${base}`}) and left ${count === 1 ? "one review comment" : `${String(count)} review comments`}. ${count === 1 ? "Please address it." : "Please address them all."}`;
1003
- }
1004
- /** What "ask agent to resolve" sends: the files, and the steps that finish a paused rebase. */
1005
- function conflictPrompt(branch, conflicts) {
1006
- const files = conflicts.length === 0 ? "- (git reports no unmerged paths yet)" : conflicts.map((file) => `- ${file}`).join("\n");
1007
- return [
1008
- `A rebase of ${branch ?? "this checkout"} stopped on conflicts in:`,
1009
- "",
1010
- files,
1011
- "",
1012
- "Please resolve them:",
1013
- "1. Fix the conflict markers in each file, keeping the intent of both sides.",
1014
- "2. `git add` each resolved file.",
1015
- "3. Run `GIT_EDITOR=true git rebase --continue`, and repeat if it stops again.",
1016
- "",
1017
- "Do not push. I will push from the cockpit once the rebase is done."
1018
- ].join("\n");
1019
- }
1020
- /**
1021
- * Hands one message to the session's queue. Returns the reason it failed, or
1022
- * undefined once the host took it. The sender throws when the session is not
1023
- * connected, which is the only failure a caller can still act on.
1024
- */
1025
- function deliverToAgent(send, sessionId, message) {
1026
- try {
1027
- send(sessionId, {
1028
- type: "enqueue_automatic",
1029
- message
1030
- });
1031
- return;
1032
- } catch (error) {
1033
- return error instanceof Error ? error.message : "The session did not take the message.";
1034
- }
1035
- }
1036
- //#endregion
1037
- //#region src/extensions/workspaces/sessions/(frontend)/_lib/gitReviewTree.ts
1038
- function buildTree(files) {
1039
- const root = {
1040
- path: "",
1041
- dirs: /* @__PURE__ */ new Map(),
1042
- files: []
946
+ const WORKTREES_TAB_ID = "git-worktrees";
947
+ function worktreesTab() {
948
+ return {
949
+ id: WORKTREES_TAB_ID,
950
+ label: "worktrees",
951
+ panel: WorktreesPanel
1043
952
  };
1044
- for (const file of files) {
1045
- const parts = file.path.split("/");
1046
- parts.pop();
1047
- let dir = root;
1048
- for (const part of parts) {
1049
- let next = dir.dirs.get(part);
1050
- if (next === void 0) {
1051
- next = {
1052
- path: dir.path === "" ? part : `${dir.path}/${part}`,
1053
- dirs: /* @__PURE__ */ new Map(),
1054
- files: []
1055
- };
1056
- dir.dirs.set(part, next);
1057
- }
1058
- dir = next;
1059
- }
1060
- dir.files.push(file);
1061
- }
1062
- return root;
1063
953
  }
1064
- /** Folders first, then files, each alphabetical; single-child folder chains render as one row. */
1065
- function flattenTree(files, isOpen) {
1066
- const rows = [];
1067
- const walk = (dir, depth) => {
1068
- for (const [segment, child] of [...dir.dirs].sort(([left], [right]) => left.localeCompare(right))) {
1069
- let name = segment;
1070
- let node = child;
1071
- while (node.files.length === 0 && node.dirs.size === 1) {
1072
- const [[nextName, next]] = [...node.dirs];
1073
- name = `${name}/${nextName}`;
1074
- node = next;
1075
- }
1076
- const open = isOpen(node.path);
1077
- rows.push({
1078
- kind: "dir",
1079
- path: node.path,
1080
- name,
1081
- depth,
1082
- open
1083
- });
1084
- if (open) walk(node, depth + 1);
954
+ function WorktreesPanel({ sessionId, sessionReservationId }) {
955
+ const session = useStore(worktreeActivity.store, (state) => worktreeActivity.select(state, sessionId));
956
+ const [branch, setBranch] = useState("");
957
+ const [baseRef, setBaseRef] = useState("");
958
+ const busy = session.pending !== void 0;
959
+ const canCreate = sessionId !== null && branch.trim() !== "" && !busy;
960
+ const createError = session.errorTarget?.action === "create" ? session.error : void 0;
961
+ const closeErrorId = session.errorTarget?.action === "close" ? session.errorTarget.id : void 0;
962
+ const panelError = session.error !== void 0 && (session.errorTarget === void 0 || closeErrorId !== void 0 && !session.worktrees.some((worktree) => worktree.id === closeErrorId));
963
+ const create = () => {
964
+ if (sessionId === null || !canCreate) return;
965
+ requestWorktreeCreate(sessionId, branch, baseRef, sessionReservationId);
966
+ if (sessionReservationId === void 0) {
967
+ setBranch("");
968
+ setBaseRef("");
1085
969
  }
1086
- for (const file of [...dir.files].sort((left, right) => left.path.localeCompare(right.path))) rows.push({
1087
- kind: "file",
1088
- file,
1089
- depth
1090
- });
1091
970
  };
1092
- walk(buildTree(files), 0);
1093
- return rows;
971
+ return /* @__PURE__ */ jsxs("div", {
972
+ "data-testid": "git-worktrees-panel",
973
+ className: "flex min-h-0 flex-1 flex-col overflow-auto",
974
+ children: [
975
+ /* @__PURE__ */ jsxs("div", {
976
+ className: "flex min-h-11 shrink-0 items-center gap-2.5 border-b border-doom-border-soft px-3 sm:px-[26px]",
977
+ children: [
978
+ /* @__PURE__ */ jsx("span", {
979
+ className: "text-sm font-bold text-doom-hi",
980
+ children: "worktrees"
981
+ }),
982
+ /* @__PURE__ */ jsx("span", {
983
+ "data-testid": "git-worktrees-count",
984
+ className: "text-xs text-doom-faint",
985
+ children: session.worktrees.length === 0 ? "none yet" : `${String(session.worktrees.length)} open`
986
+ }),
987
+ /* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }),
988
+ session.pending === void 0 ? null : /* @__PURE__ */ jsxs("span", {
989
+ "data-testid": "git-worktree-pending",
990
+ className: "flex min-w-0 items-center gap-1.5",
991
+ children: [/* @__PURE__ */ jsx(Spinner, {
992
+ className: "h-3 w-3 shrink-0 text-doom-faint",
993
+ label: session.pending
994
+ }), /* @__PURE__ */ jsx("span", {
995
+ className: "min-w-0 truncate text-xs text-doom-dim",
996
+ children: session.pending
997
+ })]
998
+ })
999
+ ]
1000
+ }),
1001
+ /* @__PURE__ */ jsxs("div", {
1002
+ className: "flex shrink-0 flex-col gap-1.5 border-b border-doom-border-soft px-3 py-2.5 sm:px-[26px]",
1003
+ children: [/* @__PURE__ */ jsxs("div", {
1004
+ className: "flex items-center gap-2",
1005
+ children: [
1006
+ /* @__PURE__ */ jsx(Input, {
1007
+ "data-testid": "git-worktree-branch",
1008
+ "aria-label": "Branch",
1009
+ size: "sm",
1010
+ className: "min-w-0 flex-1",
1011
+ placeholder: "wt/fix-auth",
1012
+ value: branch,
1013
+ onChange: (event) => setBranch(event.target.value),
1014
+ onKeyDown: (event) => {
1015
+ if (event.key === "Enter") create();
1016
+ }
1017
+ }),
1018
+ /* @__PURE__ */ jsx(Input, {
1019
+ "data-testid": "git-worktree-base",
1020
+ "aria-label": "Base ref",
1021
+ size: "sm",
1022
+ placeholder: "base (current)",
1023
+ value: baseRef,
1024
+ onChange: (event) => setBaseRef(event.target.value),
1025
+ className: "w-32 shrink-0"
1026
+ }),
1027
+ /* @__PURE__ */ jsx(Button, {
1028
+ "data-testid": "git-worktree-create",
1029
+ size: "sm",
1030
+ disabled: !canCreate,
1031
+ onClick: create,
1032
+ children: "Create"
1033
+ })
1034
+ ]
1035
+ }), createError === void 0 ? /* @__PURE__ */ jsx("p", {
1036
+ className: "text-2xs text-doom-faint",
1037
+ children: "Creates the worktree outside the repository and starts a session nested under this one."
1038
+ }) : /* @__PURE__ */ jsx("p", {
1039
+ role: "alert",
1040
+ "data-testid": "git-worktree-error",
1041
+ className: "text-2xs text-doom-red",
1042
+ children: createError
1043
+ })]
1044
+ }),
1045
+ panelError ? /* @__PURE__ */ jsx("p", {
1046
+ role: "alert",
1047
+ "data-testid": "git-worktree-error",
1048
+ className: "px-3 pt-2 text-2xs text-doom-red sm:px-[26px]",
1049
+ children: session.error
1050
+ }) : null,
1051
+ session.worktrees.length === 0 ? /* @__PURE__ */ jsx(EmptyState, {
1052
+ "data-testid": "git-worktrees-empty",
1053
+ className: "px-4 py-8",
1054
+ title: "no worktrees yet",
1055
+ description: "a worktree is its own checkout on its own branch, with its own session nested under this one."
1056
+ }) : /* @__PURE__ */ jsx("ul", {
1057
+ "data-testid": "git-worktrees-list",
1058
+ className: "flex flex-col gap-2.5 px-3 py-3 sm:px-[26px]",
1059
+ children: session.worktrees.map((worktree) => /* @__PURE__ */ jsx(WorktreeCard, {
1060
+ worktree,
1061
+ sessionId,
1062
+ busy,
1063
+ error: closeErrorId === worktree.id ? session.error : void 0
1064
+ }, worktree.id))
1065
+ })
1066
+ ]
1067
+ });
1068
+ }
1069
+ /** One worktree: what it is, where it is, and the one thing you can do to it. */
1070
+ function WorktreeCard({ worktree, sessionId, busy, error }) {
1071
+ return /* @__PURE__ */ jsxs("li", {
1072
+ "data-testid": `git-worktree-${worktree.id}`,
1073
+ className: "flex min-w-0 flex-col gap-1.5 rounded-md border border-doom-border-soft bg-doom-panel p-2.5",
1074
+ children: [
1075
+ /* @__PURE__ */ jsxs("div", {
1076
+ className: "flex min-w-0 items-center gap-1.5",
1077
+ children: [
1078
+ /* @__PURE__ */ jsx(Dot, { tone: worktree.orphaned ? "red" : worktree.sessionId === null ? "muted" : "blue" }),
1079
+ /* @__PURE__ */ jsx("span", {
1080
+ className: "min-w-0 flex-1 truncate text-sm font-bold text-doom-hi",
1081
+ children: worktree.branch
1082
+ }),
1083
+ worktree.orphaned ? /* @__PURE__ */ jsx(StatusBadge, {
1084
+ tone: "error",
1085
+ size: "xs",
1086
+ children: "orphaned"
1087
+ }) : null,
1088
+ worktree.unowned ? /* @__PURE__ */ jsx(StatusBadge, {
1089
+ tone: "info",
1090
+ size: "xs",
1091
+ children: "unowned"
1092
+ }) : null
1093
+ ]
1094
+ }),
1095
+ /* @__PURE__ */ jsx("span", {
1096
+ className: "min-w-0 truncate text-2xs text-doom-faint",
1097
+ children: worktree.path
1098
+ }),
1099
+ /* @__PURE__ */ jsxs("div", {
1100
+ className: "flex items-center gap-1.5 pt-0.5",
1101
+ children: [/* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }), sessionId === null ? null : /* @__PURE__ */ jsx(Button, {
1102
+ variant: "danger-outline",
1103
+ size: "xs",
1104
+ disabled: busy,
1105
+ "data-testid": `git-worktree-close-${worktree.id}`,
1106
+ className: "px-2 text-2xs font-bold",
1107
+ onClick: () => requestWorktreeClose(sessionId, worktree.id),
1108
+ children: "close"
1109
+ })]
1110
+ }),
1111
+ error === void 0 ? null : /* @__PURE__ */ jsx("p", {
1112
+ role: "alert",
1113
+ "data-testid": "git-worktree-error",
1114
+ className: "text-2xs text-doom-red",
1115
+ children: error
1116
+ })
1117
+ ]
1118
+ });
1094
1119
  }
1095
1120
  //#endregion
1096
- //#region src/extensions/workspaces/sessions/(frontend)/_components/GitReviewBrowser.tsx
1121
+ //#region src/types/gitReview.ts
1097
1122
  /**
1098
- * The review tab's file list: every changed file as a folder tree (the VS Code
1099
- * explorer shape), with its status, its line counts and its comments.
1123
+ * The cockpit's view of what this session changed against its base: the counts
1124
+ * the '# diff' group shows, what the review tab lists and draws, and the manual
1125
+ * sync that sits beside them.
1100
1126
  *
1101
1127
  * DESIGN PATTERNS:
1102
- * - Picking a row scrolls the diff pane to that file; the list never opens a
1103
- * tab of its own. One tab holds the whole review.
1104
- * - Rows show only the file name, so a long path never truncates away the part
1105
- * that tells files apart. Folders say the rest; the full path is the tooltip.
1106
- * - Folder chains with a single child collapse into one row (`src/web/lib`),
1107
- * like VS Code's compact folders, so deep trees stay shallow.
1108
- * - Folders fold on click; a search shows every match with its folders open.
1109
- * - Each row is a button, reachable with Tab; arrow keys move and pick in one
1110
- * step across the visible files, so the diff follows the cursor.
1128
+ * - Declarations only, shared by the hub and the page.
1129
+ * - Its own channel, apart from worktrees. Worktrees are repository
1130
+ * management; the diff is this session's code change.
1131
+ * - A diff row carries one line number whose file follows from its marker: the
1132
+ * new file's for context and additions, the old file's for removals. It is
1133
+ * the same shape the shared DiffView draws.
1111
1134
  *
1112
1135
  * AVOID:
1113
- * - Colour classes outside the token set.
1136
+ * - Behaviour or node builtins. Browser code imports this file.
1114
1137
  */
1115
- const STATUS_MARK = {
1116
- added: {
1117
- letter: "A",
1118
- tone: "text-doom-green",
1119
- label: "added"
1120
- },
1121
- modified: {
1122
- letter: "M",
1123
- tone: "text-doom-yellow",
1124
- label: "modified"
1125
- },
1126
- deleted: {
1127
- letter: "D",
1128
- tone: "text-doom-red",
1129
- label: "deleted"
1130
- },
1131
- untracked: {
1132
- letter: "U",
1133
- tone: "text-doom-blue",
1134
- label: "untracked"
1135
- },
1136
- conflicted: {
1137
- letter: "C",
1138
- tone: "text-doom-red",
1139
- label: "conflicted"
1140
- }
1141
- };
1142
- const baseName = (path) => path.slice(path.lastIndexOf("/") + 1);
1143
- /** Left padding per depth level; tight so deep files keep room for their names. */
1144
- const indent = (depth) => ({ paddingLeft: `${String(.75 + depth * .5)}rem` });
1145
- function GitReviewBrowser({ files, activePath, commentCounts, onPick }) {
1146
- const [query, setQuery] = useState("");
1147
- const [collapsed, setCollapsed] = useState(/* @__PURE__ */ new Set());
1148
- const needle = query.trim().toLocaleLowerCase();
1149
- const shown = needle === "" ? files : files.filter((file) => file.path.toLocaleLowerCase().includes(needle));
1150
- const rows = flattenTree(shown, (dirPath) => needle !== "" || !collapsed.has(dirPath));
1151
- const ordered = rows.flatMap((row) => row.kind === "file" ? [row.file] : []);
1152
- const listRef = useRef(null);
1153
- useEffect(() => {
1154
- if (activePath === void 0) return;
1155
- (listRef.current?.querySelector(`[data-path="${CSS.escape(activePath)}"]`))?.scrollIntoView({ block: "nearest" });
1156
- }, [activePath]);
1157
- const toggle = (dirPath) => {
1158
- setCollapsed((prev) => {
1159
- const next = new Set(prev);
1160
- if (!next.delete(dirPath)) next.add(dirPath);
1161
- return next;
1162
- });
1163
- };
1164
- const allDirs = () => new Set(flattenTree(files, () => true).flatMap((row) => row.kind === "dir" ? [row.path] : []));
1165
- /** Arrow keys pick the neighbouring row and move focus with it, so the diff follows the cursor. */
1166
- const onKeyDown = (event) => {
1167
- if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
1168
- if (ordered.length === 0) return;
1169
- event.preventDefault();
1170
- const current = ordered.findIndex((file) => file.path === activePath);
1171
- const step = event.key === "ArrowDown" ? 1 : -1;
1172
- const next = current === -1 ? 0 : (current + step + ordered.length) % ordered.length;
1173
- const file = ordered[next];
1174
- if (file === void 0) return;
1175
- onPick(file.path);
1176
- event.currentTarget.closest("[data-git-review-list]")?.querySelectorAll("[data-git-review-row]")[next]?.focus();
1177
- };
1178
- return /* @__PURE__ */ jsxs("nav", {
1179
- "data-testid": "git-review-browser",
1180
- "aria-label": "changed files",
1181
- className: "flex min-h-0 w-64 shrink-0 flex-col border-r border-doom-border-soft",
1182
- children: [/* @__PURE__ */ jsxs("div", {
1183
- className: "flex shrink-0 flex-col gap-1 px-3 pt-2.5 pb-1.5",
1184
- children: [/* @__PURE__ */ jsx(Input, {
1185
- "data-testid": "git-review-search",
1186
- "aria-label": "search changed file paths",
1187
- size: "sm",
1188
- value: query,
1189
- placeholder: "search paths…",
1190
- onChange: (event) => setQuery(event.target.value)
1191
- }), /* @__PURE__ */ jsxs("div", {
1192
- className: "flex items-center justify-between gap-2",
1193
- children: [/* @__PURE__ */ jsx("span", {
1194
- className: "text-2xs text-doom-faint",
1195
- children: needle === "" ? `${String(files.length)} changed` : `${String(shown.length)} of ${String(files.length)} match`
1196
- }), needle === "" ? /* @__PURE__ */ jsx(Button, {
1197
- variant: "link",
1198
- size: "xs",
1199
- "data-testid": "git-review-fold-all",
1200
- className: "h-auto px-0",
1201
- onClick: () => setCollapsed(collapsed.size === 0 ? allDirs() : /* @__PURE__ */ new Set()),
1202
- children: collapsed.size === 0 ? "collapse all" : "expand all"
1203
- }) : null]
1204
- })]
1205
- }), /* @__PURE__ */ jsxs("div", {
1206
- ref: listRef,
1207
- "data-git-review-list": true,
1208
- className: "min-h-0 flex-1 overflow-y-auto pb-2",
1209
- children: [rows.map((row) => {
1210
- if (row.kind === "dir") {
1211
- const Chevron = row.open ? ChevronDownIcon : ChevronRightIcon;
1212
- return /* @__PURE__ */ jsxs(Button, {
1213
- variant: "ghost",
1214
- size: "card",
1215
- "aria-expanded": row.open,
1216
- "data-testid": `git-review-browser-dir-${row.path}`,
1217
- title: row.path,
1218
- onClick: () => toggle(row.path),
1219
- style: indent(row.depth),
1220
- className: "min-w-0 flex-row items-center gap-1 rounded-none border-l-2 border-transparent py-1 pr-3 hover:bg-doom-panel/60",
1221
- children: [/* @__PURE__ */ jsx(Chevron, {
1222
- "aria-hidden": true,
1223
- className: "h-3 w-3 shrink-0 text-doom-faint"
1224
- }), /* @__PURE__ */ jsx("span", {
1225
- className: "min-w-0 flex-1 truncate text-xs text-doom-dim",
1226
- children: row.name
1227
- })]
1228
- }, `dir:${row.path}`);
1229
- }
1230
- const { file } = row;
1231
- const mark = STATUS_MARK[file.status];
1232
- const comments = commentCounts[file.path] ?? 0;
1233
- const active = file.path === activePath;
1234
- return /* @__PURE__ */ jsxs(Button, {
1235
- variant: "ghost",
1236
- size: "card",
1237
- "data-git-review-row": true,
1238
- "data-path": file.path,
1239
- "aria-current": active ? "true" : void 0,
1240
- "data-testid": `git-review-browser-file-${file.path}`,
1241
- title: `${file.path} (${mark.label})`,
1242
- onClick: () => onPick(file.path),
1243
- onKeyDown,
1244
- style: indent(row.depth),
1245
- className: `min-w-0 flex-row items-center gap-1.5 rounded-none border-l-2 py-1 pr-3 ${active ? "border-doom-blue bg-doom-panel" : "border-transparent hover:bg-doom-panel/60"}`,
1246
- children: [
1247
- /* @__PURE__ */ jsx("span", {
1248
- "aria-label": mark.label,
1249
- className: `w-3 shrink-0 text-center text-2xs font-bold ${mark.tone}`,
1250
- children: mark.letter
1251
- }),
1252
- /* @__PURE__ */ jsx("span", {
1253
- className: `min-w-0 flex-1 truncate text-xs text-doom-hi ${active ? "font-bold" : ""} ${file.status === "deleted" ? "line-through opacity-70" : ""}`,
1254
- children: baseName(file.path)
1255
- }),
1256
- comments === 0 ? null : /* @__PURE__ */ jsxs("span", {
1257
- className: "flex shrink-0 items-center gap-0.5 text-2xs text-doom-blue",
1258
- title: `${String(comments)} comments`,
1259
- children: [/* @__PURE__ */ jsx(MessageIcon, {
1260
- "aria-hidden": true,
1261
- className: "h-2.5 w-2.5"
1262
- }), comments]
1263
- }),
1264
- file.binary ? /* @__PURE__ */ jsx("span", {
1265
- className: "shrink-0 text-2xs text-doom-faint",
1266
- children: "binary"
1267
- }) : /* @__PURE__ */ jsxs("span", {
1268
- className: "shrink-0 text-2xs",
1269
- children: [
1270
- /* @__PURE__ */ jsxs("span", {
1271
- className: "text-doom-green",
1272
- children: ["+", file.added]
1273
- }),
1274
- " ",
1275
- /* @__PURE__ */ jsxs("span", {
1276
- className: "text-doom-red",
1277
- children: ["-", file.removed]
1278
- })
1279
- ]
1280
- })
1281
- ]
1282
- }, file.path);
1283
- }), ordered.length === 0 ? /* @__PURE__ */ jsx("p", {
1284
- className: "px-3 py-2 text-2xs text-doom-faint",
1285
- children: "no path matches"
1286
- }) : null]
1287
- })]
1288
- });
1289
- }
1138
+ /** The session channel carrying this checkout's changes and the sync around them. */
1139
+ const GIT_CHANGES_TYPE = "git_changes";
1290
1140
  //#endregion
1291
- //#region src/extensions/workspaces/sessions/(frontend)/_components/GitSyncBar.tsx
1141
+ //#region src/extensions/workspaces/sessions/(frontend)/_lib/gitChangesStore.ts
1292
1142
  /**
1293
- * The review tab's header: what is being reviewed, and the manual sync that
1294
- * goes with it.
1143
+ * This session's code change against its base, and the manual sync beside it.
1295
1144
  *
1296
1145
  * DESIGN PATTERNS:
1297
- * - Everything here is a report or a request. The hub runs the git, one
1298
- * command per session, and answers on the channel: pending while it runs,
1299
- * an error target when it fails, a paused rebase when it stops on conflicts.
1300
- * - A push the remote rejects is not an error to shrug at: the strip asks
1301
- * before forcing, and the force it sends is a lease, which git refuses if
1302
- * the remote holds commits this branch never fetched and integrated.
1303
- * - A paused rebase blocks the sync buttons. The two ways out are named on the
1304
- * banner, and abort says what it throws away.
1146
+ * - Its own session store and hub channel, apart from worktrees. Worktrees are
1147
+ * repository management; this is the session's diff.
1148
+ * - The hub is authoritative: it computes the counts, runs pull, push and
1149
+ * rebase one at a time, and reports pending, errors and a paused rebase here.
1150
+ * - The sender lives in a store rather than a module variable, because a web
1151
+ * plugin keeps no mutable module state and the page may reconnect.
1305
1152
  *
1306
1153
  * AVOID:
1307
- * - Colour classes outside the token set.
1154
+ * - `sendSessionFrame` for a sync command. That frame is bound for the agent
1155
+ * and never reaches a channel's `receive`.
1308
1156
  */
1309
- function GitSyncBar({ changes, mergeBase, pending, error, errorTarget, onSync, onForcePush, onAskAgent, onDismissError }) {
1310
- const busy = pending !== void 0;
1311
- const paused = changes?.rebase !== void 0;
1312
- const forceRequired = errorTarget?.action === "push" && "forceRequired" in errorTarget && errorTarget.forceRequired === true;
1313
- const syncError = error !== void 0 && !forceRequired ? error : void 0;
1314
- const upstream = changes?.upstream;
1315
- return /* @__PURE__ */ jsxs("div", {
1316
- "data-testid": "git-sync-bar",
1317
- className: "flex shrink-0 flex-col gap-2 border-b border-doom-border-soft px-3 py-2.5 sm:px-[26px]",
1318
- children: [
1319
- /* @__PURE__ */ jsxs("div", {
1320
- className: "flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1.5",
1321
- children: [
1322
- /* @__PURE__ */ jsxs("span", {
1323
- className: "flex min-w-0 items-center gap-1.5",
1324
- children: [
1325
- /* @__PURE__ */ jsx(BranchIcon, {
1326
- "aria-hidden": true,
1327
- className: "h-3.5 w-3.5 shrink-0 text-doom-faint"
1328
- }),
1329
- /* @__PURE__ */ jsx("span", {
1330
- "data-testid": "git-review-branch",
1331
- className: "truncate text-sm font-bold text-doom-hi",
1332
- children: changes?.branch ?? "detached HEAD"
1333
- }),
1334
- /* @__PURE__ */ jsx("span", {
1335
- className: "shrink-0 text-xs text-doom-faint",
1336
- children: changes?.base === void 0 ? "uncommitted changes" : `vs ${changes.base}`
1337
- }),
1338
- mergeBase === void 0 ? null : /* @__PURE__ */ jsxs("span", {
1339
- className: "shrink-0 text-2xs text-doom-faint",
1340
- children: ["from ", mergeBase]
1341
- })
1342
- ]
1343
- }),
1344
- changes === void 0 ? null : /* @__PURE__ */ jsxs("span", {
1345
- "data-testid": "git-review-totals",
1346
- className: "flex shrink-0 items-center gap-1.5 text-xs",
1347
- children: [
1348
- /* @__PURE__ */ jsxs("span", {
1349
- className: "font-bold text-doom-green",
1350
- children: ["+", changes.added]
1351
- }),
1352
- /* @__PURE__ */ jsxs("span", {
1353
- className: "font-bold text-doom-red",
1354
- children: ["-", changes.removed]
1355
- }),
1356
- /* @__PURE__ */ jsxs("span", {
1357
- className: "text-doom-faint",
1358
- children: [
1359
- changes.files,
1360
- changes.truncated ? "+" : "",
1361
- " ",
1362
- changes.files === 1 ? "file" : "files"
1363
- ]
1364
- })
1365
- ]
1366
- }),
1367
- /* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }),
1368
- upstream === void 0 ? /* @__PURE__ */ jsx("span", {
1369
- className: "shrink-0 text-2xs text-doom-faint",
1370
- children: "no upstream yet"
1371
- }) : /* @__PURE__ */ jsxs("span", {
1372
- className: "shrink-0 text-2xs text-doom-faint",
1373
- title: upstream.ref,
1374
- children: [
1375
- "↑",
1376
- upstream.ahead,
1377
- " ↓",
1378
- upstream.behind,
1379
- " ",
1380
- upstream.ref
1381
- ]
1382
- }),
1383
- /* @__PURE__ */ jsxs("span", {
1384
- className: "flex shrink-0 items-center gap-1.5",
1385
- children: [
1386
- /* @__PURE__ */ jsx(Button, {
1387
- size: "xs",
1388
- "data-testid": "git-sync-pull",
1389
- disabled: busy || paused || upstream === void 0,
1390
- title: upstream === void 0 ? "push once to set an upstream" : `fetch, then rebase onto ${upstream.ref}`,
1391
- onClick: () => onSync("pull"),
1392
- children: "pull"
1393
- }),
1394
- /* @__PURE__ */ jsx(Button, {
1395
- size: "xs",
1396
- "data-testid": "git-sync-push",
1397
- disabled: busy || paused || changes?.branch === void 0,
1398
- title: upstream === void 0 ? "push and set the upstream" : `push to ${upstream.ref}`,
1399
- onClick: () => onSync("push"),
1400
- children: "push"
1401
- }),
1402
- /* @__PURE__ */ jsx(Button, {
1403
- size: "xs",
1404
- "data-testid": "git-sync-rebase",
1405
- disabled: busy || paused || changes?.base === void 0,
1406
- title: changes?.base === void 0 ? "no base branch to rebase onto" : `fetch, then rebase onto ${changes.base}`,
1407
- onClick: () => onSync("rebase"),
1408
- children: "rebase"
1409
- })
1410
- ]
1411
- })
1412
- ]
1413
- }),
1414
- pending === void 0 ? null : /* @__PURE__ */ jsxs("span", {
1415
- "data-testid": "git-sync-pending",
1416
- className: "flex min-w-0 items-center gap-1.5",
1417
- children: [/* @__PURE__ */ jsx(Spinner, {
1418
- className: "h-3 w-3 shrink-0 text-doom-faint",
1419
- label: pending
1420
- }), /* @__PURE__ */ jsx("span", {
1421
- className: "min-w-0 truncate text-xs text-doom-dim",
1422
- children: pending
1423
- })]
1424
- }),
1425
- syncError === void 0 ? null : /* @__PURE__ */ jsxs("div", {
1426
- role: "alert",
1427
- "data-testid": "git-sync-error",
1428
- className: "flex min-w-0 items-start gap-1.5 text-2xs text-doom-red",
1429
- children: [
1430
- /* @__PURE__ */ jsx(AlertIcon, {
1431
- "aria-hidden": true,
1432
- className: "mt-px h-3 w-3 shrink-0"
1433
- }),
1434
- /* @__PURE__ */ jsx("span", {
1435
- className: "min-w-0 flex-1",
1436
- children: syncError
1437
- }),
1438
- /* @__PURE__ */ jsx(Button, {
1439
- variant: "ghost",
1440
- size: "xs",
1441
- className: "h-auto min-h-0 px-1 py-0 text-2xs",
1442
- onClick: onDismissError,
1443
- children: "dismiss"
1444
- })
1445
- ]
1446
- }),
1447
- forceRequired ? /* @__PURE__ */ jsxs("div", {
1448
- "data-testid": "git-push-force-confirm",
1449
- className: "flex min-w-0 flex-wrap items-center gap-2 rounded-md border border-doom-edge-yellow bg-doom-tint-yellow px-2.5 py-2",
1450
- children: [
1451
- /* @__PURE__ */ jsx("span", {
1452
- className: "min-w-0 flex-1 text-xs text-doom-hi",
1453
- children: "The remote branch has commits yours replaced, usually after a rebase. Force push with lease? It is refused if the remote holds commits you never fetched and integrated."
1454
- }),
1455
- /* @__PURE__ */ jsx(Button, {
1456
- variant: "danger-outline",
1457
- size: "xs",
1458
- "data-testid": "git-push-force",
1459
- disabled: busy,
1460
- onClick: onForcePush,
1461
- children: "force push"
1462
- }),
1463
- /* @__PURE__ */ jsx(Button, {
1464
- variant: "ghost",
1465
- size: "xs",
1466
- "data-testid": "git-push-force-cancel",
1467
- onClick: onDismissError,
1468
- children: "cancel"
1469
- })
1470
- ]
1471
- }) : null,
1472
- changes?.rebase === void 0 ? null : /* @__PURE__ */ jsxs("div", {
1473
- "data-testid": "git-rebase-banner",
1474
- className: "flex min-w-0 flex-col gap-1.5 rounded-md border border-doom-edge-red bg-doom-tint-red px-2.5 py-2",
1475
- children: [
1476
- /* @__PURE__ */ jsxs("span", {
1477
- className: "flex min-w-0 items-center gap-1.5",
1478
- children: [/* @__PURE__ */ jsx(StatusBadge, {
1479
- tone: "error",
1480
- size: "xs",
1481
- children: "rebase paused"
1482
- }), /* @__PURE__ */ jsx("span", {
1483
- className: "text-xs text-doom-hi",
1484
- children: changes.rebase.conflicts.length === 0 ? "git stopped the rebase" : `conflicts in ${String(changes.rebase.conflicts.length)} ${changes.rebase.conflicts.length === 1 ? "file" : "files"}`
1485
- })]
1486
- }),
1487
- changes.rebase.conflicts.length === 0 ? null : /* @__PURE__ */ jsx("ul", {
1488
- className: "flex flex-col gap-0.5 pl-1",
1489
- children: changes.rebase.conflicts.map((file) => /* @__PURE__ */ jsx("li", {
1490
- className: "truncate font-mono text-2xs text-doom-dim",
1491
- children: file
1492
- }, file))
1493
- }),
1494
- /* @__PURE__ */ jsxs("span", {
1495
- className: "flex flex-wrap items-center gap-1.5",
1496
- children: [
1497
- /* @__PURE__ */ jsx(Button, {
1498
- variant: "primary",
1499
- size: "xs",
1500
- "data-testid": "git-rebase-ask-agent",
1501
- disabled: busy,
1502
- onClick: onAskAgent,
1503
- children: "ask agent to resolve"
1504
- }),
1505
- /* @__PURE__ */ jsx(Button, {
1506
- variant: "danger-outline",
1507
- size: "xs",
1508
- "data-testid": "git-rebase-abort",
1509
- disabled: busy,
1510
- onClick: () => onSync("abort-rebase"),
1511
- children: "abort rebase"
1512
- }),
1513
- /* @__PURE__ */ jsx("span", {
1514
- className: "text-2xs text-doom-faint",
1515
- children: "abort discards any conflict resolution in progress"
1516
- })
1517
- ]
1518
- })
1519
- ]
1520
- })
1521
- ]
1157
+ const gitChanges = defineSessionStore({
1158
+ changes: void 0,
1159
+ pending: void 0,
1160
+ error: void 0,
1161
+ errorTarget: void 0
1162
+ });
1163
+ /** The page's hub socket sender; undefined before start and after dispose, when a command is dropped. */
1164
+ const changesSender = defineGlobalStore(void 0);
1165
+ /** Binds the page's hub socket for sync commands. The plugin host calls this once per page. */
1166
+ function startGitChangesRuntime(runtime) {
1167
+ const send = (frame) => {
1168
+ runtime.sendHubFrame(frame);
1169
+ };
1170
+ changesSender.update(() => send);
1171
+ return () => {
1172
+ changesSender.update((current) => current === send ? void 0 : current);
1173
+ };
1174
+ }
1175
+ /** Whether a sync command is running for this session. */
1176
+ const gitChangesActivitySource = {
1177
+ subscribe(listener) {
1178
+ const subscription = gitChanges.store.subscribe(listener);
1179
+ return () => subscription.unsubscribe();
1180
+ },
1181
+ isActive(sessionId) {
1182
+ return gitChanges.select(gitChanges.store.state, sessionId).pending !== void 0;
1183
+ }
1184
+ };
1185
+ function isRecord$1(value) {
1186
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1187
+ }
1188
+ function optionalText(value) {
1189
+ return typeof value === "string" && value !== "" ? value : void 0;
1190
+ }
1191
+ function count(value) {
1192
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : void 0;
1193
+ }
1194
+ /** The page never trusts a frame's shape: a malformed field drops, the rest still renders. */
1195
+ const MAX_CONFLICTS = 100;
1196
+ function optionalChanges(value) {
1197
+ if (!isRecord$1(value)) return void 0;
1198
+ const added = count(value.added);
1199
+ const removed = count(value.removed);
1200
+ const files = count(value.files);
1201
+ if (added === void 0 || removed === void 0 || files === void 0) return void 0;
1202
+ const branch = optionalText(value.branch);
1203
+ const base = optionalText(value.base);
1204
+ const upstream = isRecord$1(value.upstream) ? value.upstream : void 0;
1205
+ const ahead = count(upstream?.ahead);
1206
+ const behind = count(upstream?.behind);
1207
+ const upstreamRef = optionalText(upstream?.ref);
1208
+ const conflicts = isRecord$1(value.rebase) && Array.isArray(value.rebase.conflicts) ? value.rebase.conflicts.filter((file) => typeof file === "string").slice(0, MAX_CONFLICTS) : void 0;
1209
+ return {
1210
+ added,
1211
+ removed,
1212
+ files,
1213
+ ...branch === void 0 ? {} : { branch },
1214
+ ...base === void 0 ? {} : { base },
1215
+ ...value.truncated === true ? { truncated: true } : {},
1216
+ ...upstreamRef === void 0 || ahead === void 0 || behind === void 0 ? {} : { upstream: {
1217
+ ref: upstreamRef,
1218
+ ahead,
1219
+ behind
1220
+ } },
1221
+ ...conflicts === void 0 ? {} : { rebase: { conflicts } }
1222
+ };
1223
+ }
1224
+ function optionalErrorTarget(value) {
1225
+ if (!isRecord$1(value)) return void 0;
1226
+ if (value.action === "pull" || value.action === "rebase" || value.action === "abort-rebase") return { action: value.action };
1227
+ if (value.action === "push") return value.forceRequired === true ? {
1228
+ action: "push",
1229
+ forceRequired: true
1230
+ } : { action: "push" };
1231
+ }
1232
+ /** The session data channel: 'git_changes' payloads into the store. */
1233
+ const gitChangesChannel = gitChanges.channel({
1234
+ channel: GIT_CHANGES_TYPE,
1235
+ parse(input) {
1236
+ if (!isRecord$1(input)) return null;
1237
+ return {
1238
+ changes: optionalChanges(input.changes),
1239
+ pending: optionalText(input.pending),
1240
+ error: optionalText(input.error),
1241
+ errorTarget: optionalErrorTarget(input.errorTarget)
1242
+ };
1243
+ },
1244
+ reduce(_current, next) {
1245
+ return next;
1246
+ }
1247
+ });
1248
+ /**
1249
+ * Asks the hub to pull, push, rebase or abort a paused rebase. `force` is only
1250
+ * ever sent after the reader confirmed a lease force for a rejected push.
1251
+ */
1252
+ function requestGitSync(sessionId, action, force = false) {
1253
+ const payload = action === "push" && force ? {
1254
+ action: "push",
1255
+ force: true
1256
+ } : { action };
1257
+ changesSender.store.state?.({
1258
+ type: GIT_CHANGES_TYPE,
1259
+ sessionId,
1260
+ payload
1261
+ });
1262
+ }
1263
+ //#endregion
1264
+ //#region src/extensions/workspaces/sessions/(frontend)/activity-group/git.web.ts
1265
+ var git_web_default = defineActivityGroup({
1266
+ keys: "g w",
1267
+ activeSource: {
1268
+ subscribe(listener) {
1269
+ const stopWorktrees = worktreeActivitySource.subscribe(listener);
1270
+ const stopChanges = gitChangesActivitySource.subscribe(listener);
1271
+ return () => {
1272
+ stopWorktrees();
1273
+ stopChanges();
1274
+ };
1275
+ },
1276
+ isActive(sessionId) {
1277
+ return worktreeActivitySource.isActive(sessionId) || gitChangesActivitySource.isActive(sessionId);
1278
+ }
1279
+ },
1280
+ marksBackgroundWork: false,
1281
+ transientTab: worktreesTab,
1282
+ order: 30
1283
+ });
1284
+ //#endregion
1285
+ //#region src/extensions/workspaces/sessions/(frontend)/channel/gitChanges.web.ts
1286
+ var gitChanges_web_default = defineChannelFile(gitChangesChannel);
1287
+ //#endregion
1288
+ //#region src/extensions/workspaces/sessions/(frontend)/channel/gitWorktrees.web.ts
1289
+ var gitWorktrees_web_default = defineChannelFile(worktreesChannel);
1290
+ //#endregion
1291
+ //#region src/extensions/workspaces/sessions/(frontend)/fill/_components/WorktreeCreateDialog.tsx
1292
+ function WorktreeSessionMenu({ sessionId, sessionReservationId }) {
1293
+ return /* @__PURE__ */ jsx(DropdownMenuItem, {
1294
+ disabled: sessionId === null,
1295
+ "data-testid": `session-worktree-${sessionReservationId ?? sessionId}`,
1296
+ onSelect: () => {
1297
+ if (sessionId !== null) worktreeCreateDialog.update(() => ({
1298
+ sessionId,
1299
+ ...sessionReservationId === void 0 ? {} : { reservationId: sessionReservationId }
1300
+ }));
1301
+ },
1302
+ children: "create worktree session…"
1522
1303
  });
1523
1304
  }
1524
- const NO_FILES = [];
1525
- /** Stable, URL-safe element id for one file's section. */
1526
- function sectionId(index) {
1527
- return `git-review-file-${String(index)}`;
1528
- }
1529
- function sideOf(row) {
1530
- return row.marker === "-" ? "old" : "new";
1531
- }
1532
- function GitReviewView({ sync, summary, files, comments, onLoadFile, onAddComment, onRemoveComment, onSendReview, onDiscard, sendError, initialDraft }) {
1533
- const entries = summary.state === "ready" ? summary.summary.files : NO_FILES;
1534
- const [openOverride, setOpenOverride] = useState({});
1535
- const [picked, setPicked] = useState();
1536
- const activePath = picked ?? entries[0]?.path;
1537
- const [draft, setDraft] = useState(initialDraft);
1305
+ function WorktreeCreateOverlay(props) {
1306
+ const target = useStore(worktreeCreateDialog.store);
1307
+ const { holdSessionChannels } = props;
1538
1308
  useEffect(() => {
1539
- for (const file of entries.slice(0, 20)) if (files[file.path] === void 0) onLoadFile(file.path);
1540
- }, [
1541
- entries,
1542
- files,
1543
- onLoadFile
1544
- ]);
1545
- const commentCounts = {};
1546
- for (const comment of comments) commentCounts[comment.path] = (commentCounts[comment.path] ?? 0) + 1;
1547
- const isOpen = (path, index) => openOverride[path] ?? index < 20;
1548
- const open = (path) => {
1549
- setOpenOverride((current) => current[path] === true ? current : {
1550
- ...current,
1551
- [path]: true
1309
+ if (target === null) return;
1310
+ return holdSessionChannels?.(target.sessionId, (frame) => {
1311
+ if (frame.type !== worktreesChannel.channel) return;
1312
+ const payload = worktreesChannel.parse(frame.payload);
1313
+ if (payload !== null) worktreesChannel.apply(target.sessionId, payload);
1552
1314
  });
1553
- if (files[path] === void 0) onLoadFile(path);
1554
- };
1555
- const pick = (path) => {
1556
- setPicked(path);
1557
- open(path);
1558
- const index = entries.findIndex((file) => file.path === path);
1559
- globalThis.document?.getElementById(sectionId(index))?.scrollIntoView({ block: "start" });
1315
+ }, [target, holdSessionChannels]);
1316
+ if (target === null) return null;
1317
+ return /* @__PURE__ */ jsx(Dialog, {
1318
+ open: true,
1319
+ onOpenChange: (open) => {
1320
+ if (!open) worktreeCreateDialog.update(() => null);
1321
+ },
1322
+ children: /* @__PURE__ */ jsxs(DialogContent, {
1323
+ width: "lg",
1324
+ "data-testid": "session-worktree-dialog",
1325
+ children: [/* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(DialogTitle, { children: "create worktree session" }) }), /* @__PURE__ */ jsxs(DialogBody, { children: [/* @__PURE__ */ jsx(DialogDescription, { children: "Creates a separate checkout and a new session. Existing sessions stay in their current directories." }), /* @__PURE__ */ createElement(WorktreesPanel, {
1326
+ ...props,
1327
+ key: `${target.sessionId}:${target.reservationId ?? ""}`,
1328
+ sessionId: target.sessionId,
1329
+ sessionReservationId: target.reservationId
1330
+ })] })]
1331
+ })
1332
+ });
1333
+ }
1334
+ //#endregion
1335
+ //#region src/extensions/workspaces/sessions/(frontend)/fill/Worktree.session-menu.web.tsx
1336
+ var Worktree_session_menu_web_default = defineFill({ component: WorktreeSessionMenu });
1337
+ //#endregion
1338
+ //#region src/extensions/workspaces/sessions/(frontend)/fill/WorktreeCreate.overlay.web.tsx
1339
+ var WorktreeCreate_overlay_web_default = defineFill({ component: WorktreeCreateOverlay });
1340
+ //#endregion
1341
+ //#region src/extensions/workspaces/sessions/(frontend)/_lib/gitReviewApi.ts
1342
+ /**
1343
+ * The review tab's half of the session API: the changed-file list and one
1344
+ * file's hunks.
1345
+ *
1346
+ * The generated client owns the URL and the sealed transport, so nothing here
1347
+ * spells a route or reaches for `fetch`. What travels is file contents, which a
1348
+ * tunnel's relay must never see in the clear.
1349
+ */
1350
+ const UNREACHABLE = "The session is unreachable.";
1351
+ function isRecord(value) {
1352
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1353
+ }
1354
+ /** The message a reader is shown: the route's own words, or why there were none. */
1355
+ function messageOf(result) {
1356
+ if (result.status === 0) return UNREACHABLE;
1357
+ return result.error === "" ? `The session answered ${String(result.status)}.` : result.error;
1358
+ }
1359
+ async function fetchReviewSummary(sessionId, signal) {
1360
+ const result = await api.session(sessionId).review(signal === void 0 ? {} : { signal });
1361
+ if (!result.ok) return {
1362
+ ok: false,
1363
+ error: messageOf(result)
1560
1364
  };
1561
- const toggle = (path, index) => {
1562
- if (isOpen(path, index)) setOpenOverride((current) => ({
1563
- ...current,
1564
- [path]: false
1565
- }));
1566
- else open(path);
1365
+ if (!isRecord(result.data) || !Array.isArray(result.data.files)) return {
1366
+ ok: false,
1367
+ error: "The session answered with no file list."
1567
1368
  };
1568
- const submitDraft = (body) => {
1569
- if (draft === void 0) return;
1570
- onAddComment({
1571
- path: draft.path,
1572
- relPath: draft.path,
1573
- side: draft.selection.side,
1574
- startLine: draft.selection.startLine,
1575
- endLine: draft.selection.endLine,
1576
- snippet: draft.selection.snippet,
1577
- body: body.trim()
1578
- });
1579
- setDraft(void 0);
1369
+ return {
1370
+ ok: true,
1371
+ summary: result.data
1580
1372
  };
1581
- return /* @__PURE__ */ jsxs("div", {
1582
- "data-testid": "git-review-panel",
1583
- className: "flex min-h-0 flex-1 flex-col",
1584
- children: [
1585
- /* @__PURE__ */ jsx(GitSyncBar, {
1586
- ...sync,
1587
- ...summary.state === "ready" && summary.summary.mergeBase !== void 0 ? { mergeBase: summary.summary.mergeBase } : {}
1588
- }),
1589
- summary.state === "loading" ? /* @__PURE__ */ jsxs("div", {
1590
- "data-testid": "git-review-loading",
1591
- className: "flex flex-1 items-start gap-2 px-4 py-6",
1592
- children: [/* @__PURE__ */ jsx(Spinner, {
1593
- className: "h-3 w-3 text-doom-faint",
1594
- label: "reading changes"
1595
- }), /* @__PURE__ */ jsx("span", {
1596
- className: "text-xs text-doom-dim",
1597
- children: "reading changes…"
1598
- })]
1599
- }) : summary.state === "error" ? /* @__PURE__ */ jsx("p", {
1600
- role: "alert",
1601
- "data-testid": "git-review-error",
1602
- className: "flex-1 px-4 py-6 text-xs text-doom-red",
1603
- children: summary.error
1604
- }) : !summary.summary.repository ? /* @__PURE__ */ jsx(EmptyState, {
1605
- "data-testid": "git-review-no-repository",
1606
- className: "px-4 py-8",
1607
- title: "not a git checkout",
1608
- description: "this session's folder is not inside a git repository, so there is nothing to review."
1609
- }) : entries.length === 0 ? /* @__PURE__ */ jsx(EmptyState, {
1610
- "data-testid": "git-review-empty",
1611
- className: "px-4 py-8",
1612
- title: "no changes",
1613
- description: summary.summary.base === void 0 ? "nothing uncommitted in this checkout." : `nothing differs from ${summary.summary.base}, committed or not.`
1614
- }) : /* @__PURE__ */ jsxs("div", {
1615
- className: "flex min-h-0 flex-1",
1616
- children: [/* @__PURE__ */ jsx(GitReviewBrowser, {
1617
- files: entries,
1618
- commentCounts,
1619
- onPick: pick,
1620
- ...activePath === void 0 ? {} : { activePath }
1621
- }), /* @__PURE__ */ jsxs("div", {
1622
- "data-testid": "git-review-diffs",
1623
- className: "min-h-0 min-w-0 flex-1 overflow-y-auto",
1624
- children: [summary.summary.truncated ? /* @__PURE__ */ jsx("p", {
1625
- className: "px-4 pt-2 text-2xs text-doom-faint",
1626
- children: "more files changed than the review lists; the rest are left out."
1627
- }) : null, entries.map((file, index) => /* @__PURE__ */ jsx(FileSection, {
1628
- id: sectionId(index),
1629
- entry: file,
1630
- state: files[file.path],
1631
- collapsed: !isOpen(file.path, index),
1632
- comments: comments.filter((comment) => comment.path === file.path),
1633
- draft: draft?.path === file.path ? draft.selection : void 0,
1634
- onToggle: () => toggle(file.path, index),
1635
- onSelect: (selection) => {
1636
- setPicked(file.path);
1637
- setDraft({
1638
- path: file.path,
1639
- selection
1640
- });
1641
- },
1642
- onSubmitDraft: submitDraft,
1643
- onCancelDraft: () => setDraft(void 0),
1644
- onRemoveComment,
1645
- onRetry: () => onLoadFile(file.path)
1646
- }, file.path))]
1647
- })]
1648
- }),
1649
- /* @__PURE__ */ jsxs("div", {
1650
- "data-testid": "git-review-footer",
1651
- className: "flex shrink-0 flex-wrap items-center gap-2 border-t border-doom-border-soft bg-doom-panel px-3 py-2 sm:px-[26px]",
1652
- children: [
1653
- /* @__PURE__ */ jsx(MessageIcon, {
1654
- "aria-hidden": true,
1655
- className: "h-3 w-3 text-doom-faint"
1656
- }),
1657
- /* @__PURE__ */ jsx("span", {
1658
- "data-testid": "git-review-comment-count",
1659
- className: "text-xs text-doom-dim",
1660
- children: comments.length === 0 ? "no comments yet: select lines or click a line number" : `${String(comments.length)} ${comments.length === 1 ? "comment" : "comments"}`
1661
- }),
1662
- /* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }),
1663
- sendError === void 0 ? null : /* @__PURE__ */ jsx("span", {
1664
- role: "alert",
1665
- "data-testid": "git-review-send-error",
1666
- className: "min-w-0 truncate text-2xs text-doom-red",
1667
- children: sendError
1668
- }),
1669
- /* @__PURE__ */ jsx(Button, {
1670
- variant: "ghost",
1671
- size: "xs",
1672
- "data-testid": "git-review-discard",
1673
- disabled: comments.length === 0,
1674
- onClick: onDiscard,
1675
- children: "discard"
1676
- }),
1677
- /* @__PURE__ */ jsx(Button, {
1678
- variant: "primary",
1679
- size: "sm",
1680
- "data-testid": "git-review-send",
1681
- disabled: comments.length === 0,
1682
- onClick: onSendReview,
1683
- children: "send review"
1684
- })
1685
- ]
1686
- })
1687
- ]
1373
+ }
1374
+ async function fetchReviewFile(sessionId, path, signal) {
1375
+ const result = await api.session(sessionId).reviewFile({
1376
+ query: { path },
1377
+ ...signal === void 0 ? {} : { signal }
1688
1378
  });
1379
+ if (!result.ok) return {
1380
+ ok: false,
1381
+ error: messageOf(result)
1382
+ };
1383
+ if (!isRecord(result.data) || !Array.isArray(result.data.hunks)) return {
1384
+ ok: false,
1385
+ error: "The session answered with no diff."
1386
+ };
1387
+ return {
1388
+ ok: true,
1389
+ diff: result.data
1390
+ };
1391
+ }
1392
+ //#endregion
1393
+ //#region src/extensions/workspaces/sessions/(frontend)/_lib/gitReviewStore.ts
1394
+ const gitReview = defineSessionStore({ comments: [] });
1395
+ function addReviewComment(sessionId, comment) {
1396
+ const id = globalThis.crypto.randomUUID();
1397
+ gitReview.update(sessionId, (current) => ({ comments: [...current.comments, {
1398
+ ...comment,
1399
+ id
1400
+ }] }));
1401
+ }
1402
+ function removeReviewComment(sessionId, id) {
1403
+ gitReview.update(sessionId, (current) => ({ comments: current.comments.filter((comment) => comment.id !== id) }));
1404
+ }
1405
+ function clearReviewComments(sessionId) {
1406
+ gitReview.update(sessionId, () => ({ comments: [] }));
1407
+ }
1408
+ //#endregion
1409
+ //#region src/extensions/workspaces/sessions/(frontend)/_lib/gitReviewView.ts
1410
+ /**
1411
+ * Pure wording for what the review tab sends the agent, and the one send.
1412
+ *
1413
+ * Both messages go as `enqueue_automatic`: the host stores it and starts a turn
1414
+ * at once when the agent is idle, or after the current one. A `prompt` frame
1415
+ * instead fails while a turn runs, and a review lost to that is the worst kind
1416
+ * of lost work.
1417
+ */
1418
+ /** The opening line of a review, naming what was reviewed. */
1419
+ function reviewHeading(count, branch, base) {
1420
+ return `I reviewed ${branch === void 0 ? "this checkout" : `branch ${branch}`} (${base === void 0 ? "its uncommitted changes" : `its changes against ${base}`}) and left ${count === 1 ? "one review comment" : `${String(count)} review comments`}. ${count === 1 ? "Please address it." : "Please address them all."}`;
1689
1421
  }
1690
- const STATUS_WORD = {
1691
- added: "added",
1692
- modified: "modified",
1693
- deleted: "deleted",
1694
- untracked: "new, untracked",
1695
- conflicted: "conflicted"
1696
- };
1697
- /** One file: a pinned header, then its diff with comments drawn under the lines they are about. */
1698
- function FileSection({ id, entry, state, collapsed, comments, draft, onToggle, onSelect, onSubmitDraft, onCancelDraft, onRemoveComment, onRetry }) {
1699
- const renderAfterRow = (row) => {
1700
- const side = sideOf(row);
1701
- const here = comments.filter((comment) => (comment.side ?? "new") === side && (comment.endLine ?? comment.startLine) === row.line);
1702
- const drafting = draft !== void 0 && draft.side === side && draft.endLine === row.line;
1703
- if (here.length === 0 && !drafting) return null;
1704
- return /* @__PURE__ */ jsxs("div", {
1705
- className: "flex flex-col gap-1.5 border-y border-doom-border-soft bg-doom-bg px-2 py-1.5 whitespace-normal",
1706
- children: [here.map((comment) => /* @__PURE__ */ jsxs("div", {
1707
- "data-testid": `git-review-comment-${comment.id}`,
1708
- className: "flex min-w-0 items-start gap-2 rounded-md border border-doom-border-soft bg-doom-panel px-2 py-1.5",
1709
- children: [
1710
- /* @__PURE__ */ jsx(MessageIcon, {
1711
- "aria-hidden": true,
1712
- className: "mt-0.5 h-3 w-3 shrink-0 text-doom-blue"
1713
- }),
1714
- /* @__PURE__ */ jsxs("span", {
1715
- className: "flex min-w-0 flex-1 flex-col gap-0.5",
1716
- children: [/* @__PURE__ */ jsx("span", {
1717
- className: "text-2xs text-doom-faint",
1718
- children: reviewCommentAnchor(comment)
1719
- }), /* @__PURE__ */ jsx("span", {
1720
- className: "text-xs whitespace-pre-wrap text-doom-hi",
1721
- children: comment.body
1722
- })]
1723
- }),
1724
- /* @__PURE__ */ jsx(Button, {
1725
- variant: "ghost",
1726
- size: "icon",
1727
- "aria-label": "remove comment",
1728
- title: "remove comment",
1729
- onClick: () => onRemoveComment(comment.id),
1730
- children: /* @__PURE__ */ jsx(CloseIcon, { className: "h-3 w-3" })
1731
- })
1732
- ]
1733
- }, comment.id)), drafting ? /* @__PURE__ */ jsx("div", {
1734
- className: "rounded-md border border-doom-border bg-doom-panel",
1735
- children: /* @__PURE__ */ jsx(ReviewCommentDraft, {
1736
- snippet: draft.snippet,
1737
- startLine: draft.startLine,
1738
- endLine: draft.endLine,
1739
- side: draft.side,
1740
- testIdPrefix: "git-review-comment",
1741
- onSubmit: onSubmitDraft,
1742
- onCancel: onCancelDraft
1743
- })
1744
- }) : null]
1422
+ /** What "ask agent to resolve" sends: the files, and the steps that finish a paused rebase. */
1423
+ function conflictPrompt(branch, conflicts) {
1424
+ const files = conflicts.length === 0 ? "- (git reports no unmerged paths yet)" : conflicts.map((file) => `- ${file}`).join("\n");
1425
+ return [
1426
+ `A rebase of ${branch ?? "this checkout"} stopped on conflicts in:`,
1427
+ "",
1428
+ files,
1429
+ "",
1430
+ "Please resolve them:",
1431
+ "1. Fix the conflict markers in each file, keeping the intent of both sides.",
1432
+ "2. `git add` each resolved file.",
1433
+ "3. Run `GIT_EDITOR=true git rebase --continue`, and repeat if it stops again.",
1434
+ "",
1435
+ "Do not push. I will push from the cockpit once the rebase is done."
1436
+ ].join("\n");
1437
+ }
1438
+ /**
1439
+ * Hands one message to the session's queue. Returns the reason it failed, or
1440
+ * undefined once the host took it. The sender throws when the session is not
1441
+ * connected, which is the only failure a caller can still act on.
1442
+ */
1443
+ function deliverToAgent(send, sessionId, message) {
1444
+ try {
1445
+ send(sessionId, {
1446
+ type: "enqueue_automatic",
1447
+ message
1745
1448
  });
1449
+ return;
1450
+ } catch (error) {
1451
+ return error instanceof Error ? error.message : "The session did not take the message.";
1452
+ }
1453
+ }
1454
+ //#endregion
1455
+ //#region src/extensions/workspaces/sessions/(frontend)/_lib/gitReviewTree.ts
1456
+ function buildTree(files) {
1457
+ const root = {
1458
+ path: "",
1459
+ dirs: /* @__PURE__ */ new Map(),
1460
+ files: []
1746
1461
  };
1747
- return /* @__PURE__ */ jsxs("section", {
1748
- id,
1749
- "data-testid": `git-review-file-${entry.path}`,
1750
- className: "border-b border-doom-border-soft",
1751
- children: [/* @__PURE__ */ jsxs(Button, {
1752
- variant: "ghost",
1753
- size: "card",
1754
- "aria-expanded": !collapsed,
1755
- onClick: onToggle,
1756
- className: "sticky top-0 z-10 flex-row items-center gap-1.5 rounded-none border-b border-doom-border-soft bg-doom-rail px-3 py-1.5 hover:bg-doom-panel sm:px-4",
1757
- children: [
1758
- collapsed ? /* @__PURE__ */ jsx(ChevronRightIcon, {
1759
- "aria-hidden": true,
1760
- className: "h-3 w-3 shrink-0 text-doom-faint"
1761
- }) : /* @__PURE__ */ jsx(ChevronDownIcon, {
1762
- "aria-hidden": true,
1763
- className: "h-3 w-3 shrink-0 text-doom-faint"
1764
- }),
1765
- /* @__PURE__ */ jsx("span", {
1766
- className: "min-w-0 flex-1 truncate font-mono text-xs font-bold text-doom-hi",
1767
- children: entry.path
1768
- }),
1769
- /* @__PURE__ */ jsx("span", {
1770
- className: "shrink-0 text-2xs text-doom-faint",
1771
- children: STATUS_WORD[entry.status]
1772
- }),
1773
- comments.length === 0 ? null : /* @__PURE__ */ jsxs("span", {
1774
- className: "flex shrink-0 items-center gap-0.5 text-2xs text-doom-blue",
1775
- children: [/* @__PURE__ */ jsx(MessageIcon, {
1776
- "aria-hidden": true,
1777
- className: "h-2.5 w-2.5"
1778
- }), comments.length]
1779
- }),
1780
- entry.binary ? null : /* @__PURE__ */ jsxs("span", {
1781
- className: "shrink-0 text-2xs",
1782
- children: [
1783
- /* @__PURE__ */ jsxs("span", {
1784
- className: "text-doom-green",
1785
- children: ["+", entry.added]
1786
- }),
1787
- " ",
1788
- /* @__PURE__ */ jsxs("span", {
1789
- className: "text-doom-red",
1790
- children: ["-", entry.removed]
1791
- })
1792
- ]
1793
- })
1794
- ]
1795
- }), collapsed ? null : state === void 0 || state.state === "loading" ? /* @__PURE__ */ jsxs("div", {
1796
- className: "flex items-center gap-2 px-4 py-3",
1797
- children: [/* @__PURE__ */ jsx(Spinner, {
1798
- className: "h-3 w-3 text-doom-faint",
1799
- label: `loading ${entry.path}`
1800
- }), /* @__PURE__ */ jsx("span", {
1801
- className: "text-2xs text-doom-faint",
1802
- children: "loading diff…"
1803
- })]
1804
- }) : state.state === "error" ? /* @__PURE__ */ jsxs("div", {
1805
- className: "flex items-center gap-2 px-4 py-3",
1806
- children: [/* @__PURE__ */ jsx("span", {
1807
- role: "alert",
1808
- className: "text-2xs text-doom-red",
1809
- children: state.error
1810
- }), /* @__PURE__ */ jsx(Button, {
1811
- variant: "ghost",
1812
- size: "xs",
1813
- onClick: onRetry,
1814
- children: "retry"
1815
- })]
1816
- }) : state.diff.binary ? /* @__PURE__ */ jsx("p", {
1817
- className: "px-4 py-3 text-2xs text-doom-faint",
1818
- children: "binary file, not drawn"
1819
- }) : state.diff.tooLarge ? /* @__PURE__ */ jsx("p", {
1820
- className: "px-4 py-3 text-2xs text-doom-faint",
1821
- children: "this diff is too large to draw here"
1822
- }) : /* @__PURE__ */ jsx(DiffView, {
1823
- hunks: state.diff.hunks,
1824
- testId: `git-review-diff-${id}`,
1825
- onSelect,
1826
- renderAfterRow,
1827
- className: "py-1"
1828
- })]
1829
- });
1462
+ for (const file of files) {
1463
+ const parts = file.path.split("/");
1464
+ parts.pop();
1465
+ let dir = root;
1466
+ for (const part of parts) {
1467
+ let next = dir.dirs.get(part);
1468
+ if (next === void 0) {
1469
+ next = {
1470
+ path: dir.path === "" ? part : `${dir.path}/${part}`,
1471
+ dirs: /* @__PURE__ */ new Map(),
1472
+ files: []
1473
+ };
1474
+ dir.dirs.set(part, next);
1475
+ }
1476
+ dir = next;
1477
+ }
1478
+ dir.files.push(file);
1479
+ }
1480
+ return root;
1481
+ }
1482
+ /** Folders first, then files, each alphabetical; single-child folder chains render as one row. */
1483
+ function flattenTree(files, isOpen) {
1484
+ const rows = [];
1485
+ const walk = (dir, depth) => {
1486
+ for (const [segment, child] of [...dir.dirs].sort(([left], [right]) => left.localeCompare(right))) {
1487
+ let name = segment;
1488
+ let node = child;
1489
+ while (node.files.length === 0 && node.dirs.size === 1) {
1490
+ const [[nextName, next]] = [...node.dirs];
1491
+ name = `${name}/${nextName}`;
1492
+ node = next;
1493
+ }
1494
+ const open = isOpen(node.path);
1495
+ rows.push({
1496
+ kind: "dir",
1497
+ path: node.path,
1498
+ name,
1499
+ depth,
1500
+ open
1501
+ });
1502
+ if (open) walk(node, depth + 1);
1503
+ }
1504
+ for (const file of [...dir.files].sort((left, right) => left.path.localeCompare(right.path))) rows.push({
1505
+ kind: "file",
1506
+ file,
1507
+ depth
1508
+ });
1509
+ };
1510
+ walk(buildTree(files), 0);
1511
+ return rows;
1830
1512
  }
1831
1513
  //#endregion
1832
- //#region src/extensions/workspaces/sessions/(frontend)/_components/GitReviewPanel.tsx
1514
+ //#region src/extensions/workspaces/sessions/(frontend)/_components/GitReviewBrowser.tsx
1833
1515
  /**
1834
- * The review tab: this session's changes against its base, the comments on
1835
- * them, and the manual sync beside them.
1516
+ * The review tab's file list: every changed file as a folder tree (the VS Code
1517
+ * explorer shape), with its status, its line counts and its comments.
1836
1518
  *
1837
1519
  * DESIGN PATTERNS:
1838
- * - Opened from the '# diff' group as a transient tab, one per session.
1839
- * - The hub's channel says when something changed (counts, base, a paused
1840
- * rebase); the panel refetches the file list then, and each file's hunks
1841
- * when it is opened. Nothing polls.
1842
- * - Comments live in a session store and are cleared only after the host took
1843
- * the message. Both messages go as `enqueue_automatic`, which queues behind a
1844
- * running turn instead of failing.
1520
+ * - Picking a row scrolls the diff pane to that file; the list never opens a
1521
+ * tab of its own. One tab holds the whole review.
1522
+ * - Rows show only the file name, so a long path never truncates away the part
1523
+ * that tells files apart. Folders say the rest; the full path is the tooltip.
1524
+ * - Folder chains with a single child collapse into one row (`src/web/lib`),
1525
+ * like VS Code's compact folders, so deep trees stay shallow.
1526
+ * - Folders fold on click; a search shows every match with its folders open.
1527
+ * - Each row is a button, reachable with Tab; arrow keys move and pick in one
1528
+ * step across the visible files, so the diff follows the cursor.
1845
1529
  *
1846
1530
  * AVOID:
1847
- * - Sending sync as a session frame. Sync is a channel command; the hub runs
1848
- * it with the configured auth, one command per session.
1531
+ * - Colour classes outside the token set.
1849
1532
  */
1850
- const REVIEW_TAB_ID = "git-review";
1851
- function reviewTab() {
1852
- return {
1853
- id: REVIEW_TAB_ID,
1854
- label: "review",
1855
- panel: GitReviewPanel
1856
- };
1857
- }
1858
- function GitReviewPanel({ sessionId, sendSessionFrame }) {
1859
- const activity = useStore(gitChanges.store, (state) => gitChanges.select(state, sessionId));
1860
- const comments = useStore(gitReview.store, (state) => gitReview.select(state, sessionId).comments);
1861
- const [summary, setSummary] = useState({ state: "loading" });
1862
- const [files, setFiles] = useState({});
1863
- const [sendError, setSendError] = useState();
1864
- const [dismissedError, setDismissedError] = useState();
1865
- const changes = activity.changes;
1866
- const fingerprint = JSON.stringify([
1867
- changes?.branch,
1868
- changes?.base,
1869
- changes?.added,
1870
- changes?.removed,
1871
- changes?.files,
1872
- changes?.rebase?.conflicts.length
1873
- ]);
1533
+ const STATUS_MARK = {
1534
+ added: {
1535
+ letter: "A",
1536
+ tone: "text-doom-green",
1537
+ label: "added"
1538
+ },
1539
+ modified: {
1540
+ letter: "M",
1541
+ tone: "text-doom-yellow",
1542
+ label: "modified"
1543
+ },
1544
+ deleted: {
1545
+ letter: "D",
1546
+ tone: "text-doom-red",
1547
+ label: "deleted"
1548
+ },
1549
+ untracked: {
1550
+ letter: "U",
1551
+ tone: "text-doom-blue",
1552
+ label: "untracked"
1553
+ },
1554
+ conflicted: {
1555
+ letter: "C",
1556
+ tone: "text-doom-red",
1557
+ label: "conflicted"
1558
+ }
1559
+ };
1560
+ const baseName = (path) => path.slice(path.lastIndexOf("/") + 1);
1561
+ /** Left padding per depth level; tight so deep files keep room for their names. */
1562
+ const indent = (depth) => ({ paddingLeft: `${String(.75 + depth * .5)}rem` });
1563
+ function GitReviewBrowser({ files, activePath, commentCounts, onPick }) {
1564
+ const [query, setQuery] = useState("");
1565
+ const [collapsed, setCollapsed] = useState(/* @__PURE__ */ new Set());
1566
+ const needle = query.trim().toLocaleLowerCase();
1567
+ const shown = needle === "" ? files : files.filter((file) => file.path.toLocaleLowerCase().includes(needle));
1568
+ const rows = flattenTree(shown, (dirPath) => needle !== "" || !collapsed.has(dirPath));
1569
+ const ordered = rows.flatMap((row) => row.kind === "file" ? [row.file] : []);
1570
+ const listRef = useRef(null);
1874
1571
  useEffect(() => {
1875
- if (sessionId === null) return;
1876
- const controller = new AbortController();
1877
- fetchReviewSummary(sessionId, controller.signal).then((result) => {
1878
- if (controller.signal.aborted) return;
1879
- setSummary(result.ok ? {
1880
- state: "ready",
1881
- summary: result.summary
1882
- } : {
1883
- state: "error",
1884
- error: result.error
1885
- });
1886
- setFiles({});
1887
- }).catch((error) => {
1888
- if (controller.signal.aborted) return;
1889
- setSummary({
1890
- state: "error",
1891
- error: error instanceof Error ? error.message : "The changes could not be read."
1892
- });
1572
+ if (activePath === void 0) return;
1573
+ (listRef.current?.querySelector(`[data-path="${CSS.escape(activePath)}"]`))?.scrollIntoView({ block: "nearest" });
1574
+ }, [activePath]);
1575
+ const toggle = (dirPath) => {
1576
+ setCollapsed((prev) => {
1577
+ const next = new Set(prev);
1578
+ if (!next.delete(dirPath)) next.add(dirPath);
1579
+ return next;
1893
1580
  });
1894
- return () => controller.abort();
1895
- }, [sessionId, fingerprint]);
1896
- const loadFile = useCallback((path) => {
1897
- if (sessionId === null) return;
1898
- setFiles((current) => ({
1899
- ...current,
1900
- [path]: { state: "loading" }
1901
- }));
1902
- const settle = (state) => setFiles((current) => ({
1903
- ...current,
1904
- [path]: state
1905
- }));
1906
- fetchReviewFile(sessionId, path).then((result) => settle(result.ok ? {
1907
- state: "ready",
1908
- diff: result.diff
1909
- } : {
1910
- state: "error",
1911
- error: result.error
1912
- })).catch((error) => settle({
1913
- state: "error",
1914
- error: error instanceof Error ? error.message : "The diff could not be read."
1915
- }));
1916
- }, [sessionId]);
1917
- if (sessionId === null) return null;
1918
- const branch = summary.state === "ready" ? summary.summary.branch : changes?.branch;
1919
- const base = summary.state === "ready" ? summary.summary.base : changes?.base;
1920
- const error = activity.error === dismissedError ? void 0 : activity.error;
1921
- const send = (message, onSent) => {
1922
- const failure = deliverToAgent(sendSessionFrame, sessionId, message);
1923
- setSendError(failure);
1924
- if (failure === void 0) onSent?.();
1925
1581
  };
1926
- return /* @__PURE__ */ jsx(GitReviewView, {
1927
- sync: {
1928
- ...changes === void 0 ? {} : { changes },
1929
- ...activity.pending === void 0 ? {} : { pending: activity.pending },
1930
- ...error === void 0 ? {} : { error },
1931
- ...activity.errorTarget === void 0 || error === void 0 ? {} : { errorTarget: activity.errorTarget },
1932
- onSync: (action) => requestGitSync(sessionId, action),
1933
- onForcePush: () => requestGitSync(sessionId, "push", true),
1934
- onAskAgent: () => send(conflictPrompt(branch, changes?.rebase?.conflicts ?? [])),
1935
- onDismissError: () => setDismissedError(activity.error)
1936
- },
1937
- summary,
1938
- files,
1939
- comments,
1940
- onLoadFile: loadFile,
1941
- onAddComment: (comment) => addReviewComment(sessionId, comment),
1942
- onRemoveComment: (id) => removeReviewComment(sessionId, id),
1943
- onSendReview: () => send(buildReviewPrompt(comments, reviewHeading(comments.length, branch, base)), () => clearReviewComments(sessionId)),
1944
- onDiscard: () => clearReviewComments(sessionId),
1945
- ...sendError === void 0 ? {} : { sendError }
1582
+ const allDirs = () => new Set(flattenTree(files, () => true).flatMap((row) => row.kind === "dir" ? [row.path] : []));
1583
+ /** Arrow keys pick the neighbouring row and move focus with it, so the diff follows the cursor. */
1584
+ const onKeyDown = (event) => {
1585
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
1586
+ if (ordered.length === 0) return;
1587
+ event.preventDefault();
1588
+ const current = ordered.findIndex((file) => file.path === activePath);
1589
+ const step = event.key === "ArrowDown" ? 1 : -1;
1590
+ const next = current === -1 ? 0 : (current + step + ordered.length) % ordered.length;
1591
+ const file = ordered[next];
1592
+ if (file === void 0) return;
1593
+ onPick(file.path);
1594
+ event.currentTarget.closest("[data-git-review-list]")?.querySelectorAll("[data-git-review-row]")[next]?.focus();
1595
+ };
1596
+ return /* @__PURE__ */ jsxs("nav", {
1597
+ "data-testid": "git-review-browser",
1598
+ "aria-label": "changed files",
1599
+ className: "flex min-h-0 w-64 shrink-0 flex-col border-r border-doom-border-soft",
1600
+ children: [/* @__PURE__ */ jsxs("div", {
1601
+ className: "flex shrink-0 flex-col gap-1 px-3 pt-2.5 pb-1.5",
1602
+ children: [/* @__PURE__ */ jsx(Input, {
1603
+ "data-testid": "git-review-search",
1604
+ "aria-label": "search changed file paths",
1605
+ size: "sm",
1606
+ value: query,
1607
+ placeholder: "search paths…",
1608
+ onChange: (event) => setQuery(event.target.value)
1609
+ }), /* @__PURE__ */ jsxs("div", {
1610
+ className: "flex items-center justify-between gap-2",
1611
+ children: [/* @__PURE__ */ jsx("span", {
1612
+ className: "text-2xs text-doom-faint",
1613
+ children: needle === "" ? `${String(files.length)} changed` : `${String(shown.length)} of ${String(files.length)} match`
1614
+ }), needle === "" ? /* @__PURE__ */ jsx(Button, {
1615
+ variant: "link",
1616
+ size: "xs",
1617
+ "data-testid": "git-review-fold-all",
1618
+ className: "h-auto px-0",
1619
+ onClick: () => setCollapsed(collapsed.size === 0 ? allDirs() : /* @__PURE__ */ new Set()),
1620
+ children: collapsed.size === 0 ? "collapse all" : "expand all"
1621
+ }) : null]
1622
+ })]
1623
+ }), /* @__PURE__ */ jsxs("div", {
1624
+ ref: listRef,
1625
+ "data-git-review-list": true,
1626
+ className: "min-h-0 flex-1 overflow-y-auto pb-2",
1627
+ children: [rows.map((row) => {
1628
+ if (row.kind === "dir") {
1629
+ const Chevron = row.open ? ChevronDownIcon : ChevronRightIcon;
1630
+ return /* @__PURE__ */ jsxs(Button, {
1631
+ variant: "ghost",
1632
+ size: "card",
1633
+ "aria-expanded": row.open,
1634
+ "data-testid": `git-review-browser-dir-${row.path}`,
1635
+ title: row.path,
1636
+ onClick: () => toggle(row.path),
1637
+ style: indent(row.depth),
1638
+ className: "min-w-0 flex-row items-center gap-1 rounded-none border-l-2 border-transparent py-1 pr-3 hover:bg-doom-panel/60",
1639
+ children: [/* @__PURE__ */ jsx(Chevron, {
1640
+ "aria-hidden": true,
1641
+ className: "h-3 w-3 shrink-0 text-doom-faint"
1642
+ }), /* @__PURE__ */ jsx("span", {
1643
+ className: "min-w-0 flex-1 truncate text-xs text-doom-dim",
1644
+ children: row.name
1645
+ })]
1646
+ }, `dir:${row.path}`);
1647
+ }
1648
+ const { file } = row;
1649
+ const mark = STATUS_MARK[file.status];
1650
+ const comments = commentCounts[file.path] ?? 0;
1651
+ const active = file.path === activePath;
1652
+ return /* @__PURE__ */ jsxs(Button, {
1653
+ variant: "ghost",
1654
+ size: "card",
1655
+ "data-git-review-row": true,
1656
+ "data-path": file.path,
1657
+ "aria-current": active ? "true" : void 0,
1658
+ "data-testid": `git-review-browser-file-${file.path}`,
1659
+ title: `${file.path} (${mark.label})`,
1660
+ onClick: () => onPick(file.path),
1661
+ onKeyDown,
1662
+ style: indent(row.depth),
1663
+ className: `min-w-0 flex-row items-center gap-1.5 rounded-none border-l-2 py-1 pr-3 ${active ? "border-doom-blue bg-doom-panel" : "border-transparent hover:bg-doom-panel/60"}`,
1664
+ children: [
1665
+ /* @__PURE__ */ jsx("span", {
1666
+ "aria-label": mark.label,
1667
+ className: `w-3 shrink-0 text-center text-2xs font-bold ${mark.tone}`,
1668
+ children: mark.letter
1669
+ }),
1670
+ /* @__PURE__ */ jsx("span", {
1671
+ className: `min-w-0 flex-1 truncate text-xs text-doom-hi ${active ? "font-bold" : ""} ${file.status === "deleted" ? "line-through opacity-70" : ""}`,
1672
+ children: baseName(file.path)
1673
+ }),
1674
+ comments === 0 ? null : /* @__PURE__ */ jsxs("span", {
1675
+ className: "flex shrink-0 items-center gap-0.5 text-2xs text-doom-blue",
1676
+ title: `${String(comments)} comments`,
1677
+ children: [/* @__PURE__ */ jsx(MessageIcon, {
1678
+ "aria-hidden": true,
1679
+ className: "h-2.5 w-2.5"
1680
+ }), comments]
1681
+ }),
1682
+ file.binary ? /* @__PURE__ */ jsx("span", {
1683
+ className: "shrink-0 text-2xs text-doom-faint",
1684
+ children: "binary"
1685
+ }) : /* @__PURE__ */ jsxs("span", {
1686
+ className: "shrink-0 text-2xs",
1687
+ children: [
1688
+ /* @__PURE__ */ jsxs("span", {
1689
+ className: "text-doom-green",
1690
+ children: ["+", file.added]
1691
+ }),
1692
+ " ",
1693
+ /* @__PURE__ */ jsxs("span", {
1694
+ className: "text-doom-red",
1695
+ children: ["-", file.removed]
1696
+ })
1697
+ ]
1698
+ })
1699
+ ]
1700
+ }, file.path);
1701
+ }), ordered.length === 0 ? /* @__PURE__ */ jsx("p", {
1702
+ className: "px-3 py-2 text-2xs text-doom-faint",
1703
+ children: "no path matches"
1704
+ }) : null]
1705
+ })]
1946
1706
  });
1947
1707
  }
1948
1708
  //#endregion
1949
- //#region src/extensions/workspaces/sessions/(frontend)/activity-group/diff.web.ts
1950
- var diff_web_default = defineActivityGroup({
1951
- keys: "g d",
1952
- activeSource: gitChangesActivitySource,
1953
- marksBackgroundWork: false,
1954
- transientTab: reviewTab,
1955
- order: 28
1956
- });
1957
- //#endregion
1958
- //#region src/types/webWorktrees.ts
1959
- /**
1960
- * The cockpit's view of this session's worktrees.
1961
- *
1962
- * DESIGN PATTERNS:
1963
- * - Declarations only. The channel frame type is a constant so the hub and the
1964
- * page cannot drift apart on a string.
1965
- * - A view is the subset of a record the dock needs, not the record. The
1966
- * registry may hold paths and provenance the page has no business rendering.
1967
- *
1968
- * AVOID:
1969
- * - Adding behaviour here. This file is imported by browser code, so it must
1970
- * stay free of node builtins.
1971
- */
1972
- /** The data channel carrying one session's worktrees to the page. */
1973
- const GIT_WORKTREES_TYPE = "git_worktrees";
1974
- //#endregion
1975
- //#region src/extensions/workspaces/sessions/(frontend)/_lib/worktreesActivityStore.ts
1709
+ //#region src/extensions/workspaces/sessions/(frontend)/_components/GitSyncBar.tsx
1976
1710
  /**
1977
- * The dock's view of this session's worktrees, and the commands it sends.
1711
+ * The review tab's header: what is being reviewed, and the manual sync that
1712
+ * goes with it.
1978
1713
  *
1979
1714
  * DESIGN PATTERNS:
1980
- * - Session-scoped store fed by one hub channel. The hub is authoritative; the
1981
- * page never reads a registry and never guesses.
1982
- * - Actions are channel frames, not prompts. The hub half owns the tool's
1983
- * operations, so the panel names the work and the hub performs it; a
1984
- * worktree made from the dock is the same object the tool makes, without
1985
- * spending a turn of the conversation to ask for it.
1986
- * - The sender lives in a store rather than a module variable, because a web
1987
- * plugin keeps no mutable module state and the page may reconnect.
1715
+ * - Everything here is a report or a request. The hub runs the git, one
1716
+ * command per session, and answers on the channel: pending while it runs,
1717
+ * an error target when it fails, a paused rebase when it stops on conflicts.
1718
+ * - A push the remote rejects is not an error to shrug at: the strip asks
1719
+ * before forcing, and the force it sends is a lease, which git refuses if
1720
+ * the remote holds commits this branch never fetched and integrated.
1721
+ * - A paused rebase blocks the sync buttons. The two ways out are named on the
1722
+ * banner, and abort says what it throws away.
1988
1723
  *
1989
1724
  * AVOID:
1990
- * - Calling the hub package API from here. A dock slot gets no sealed request
1991
- * transport, and raw fetch breaks the remote cockpit.
1992
- * - `sendSessionFrame` for a command. It envelopes the frame as a session
1993
- * command bound for the agent, which never reaches a channel's `receive`.
1994
- */
1995
- const worktreeActivity = defineSessionStore({
1996
- worktrees: [],
1997
- pending: void 0,
1998
- error: void 0,
1999
- errorTarget: void 0
2000
- });
2001
- /** Captures the clicked card, so a focus change cannot retarget an open dialog. */
2002
- const worktreeCreateDialog = defineGlobalStore(null);
2003
- /**
2004
- * The page's hub socket sender, held for as long as the plugin runs.
2005
- *
2006
- * Undefined before `startWorktreeRuntime` and after it disposes; a command
2007
- * sent in that window is dropped rather than queued, because a queue would
2008
- * replay a worktree create against a socket that has since changed sessions.
2009
- */
2010
- const worktreeSender = defineGlobalStore(void 0);
2011
- /** Binds the page's hub socket. The plugin host calls this once per page. */
2012
- function startWorktreeRuntime(runtime) {
2013
- const send = (frame) => {
2014
- runtime.sendHubFrame(frame);
2015
- };
2016
- worktreeSender.update(() => send);
2017
- return () => {
2018
- worktreeSender.update((current) => current === send ? void 0 : current);
2019
- worktreeCreateDialog.update(() => null);
2020
- };
2021
- }
2022
- /**
2023
- * Whether this session has worktree work in flight.
2024
- *
2025
- * A standing worktree is state, not work: it sits there until someone closes
2026
- * it, so counting it would leave the session reading as busy forever, with a
2027
- * "background work is still running" notice under a conversation where
2028
- * nothing is running. Only a create or close actually in progress counts.
2029
- *
2030
- * The group stays visible either way. Declaring an active source at all is
2031
- * what keeps it in the dock; this answers how it is drawn, not whether it is
2032
- * there.
1725
+ * - Colour classes outside the token set.
2033
1726
  */
2034
- const worktreeActivitySource = {
2035
- subscribe(listener) {
2036
- const subscription = worktreeActivity.store.subscribe(listener);
2037
- return () => subscription.unsubscribe();
2038
- },
2039
- isActive(sessionId) {
2040
- return worktreeActivity.select(worktreeActivity.store.state, sessionId).pending !== void 0;
2041
- }
2042
- };
2043
- function isRecord(value) {
2044
- return typeof value === "object" && value !== null && !Array.isArray(value);
2045
- }
2046
- function optionalText(value) {
2047
- return typeof value === "string" && value !== "" ? value : void 0;
2048
- }
2049
- function optionalErrorTarget(value) {
2050
- if (!isRecord(value)) return void 0;
2051
- if (value.action === "create") return { action: "create" };
2052
- if (value.action === "close" && typeof value.id === "string" && value.id !== "") return {
2053
- action: "close",
2054
- id: value.id
2055
- };
2056
- }
2057
- /** The plugin's session data channel: 'git_worktrees' payloads into the store. */
2058
- const worktreesChannel = worktreeActivity.channel({
2059
- channel: GIT_WORKTREES_TYPE,
2060
- parse(input) {
2061
- if (!isRecord(input) || !Array.isArray(input.worktrees)) return null;
2062
- return {
2063
- worktrees: input.worktrees.filter(isRecord),
2064
- pending: optionalText(input.pending),
2065
- error: optionalText(input.error),
2066
- errorTarget: optionalErrorTarget(input.errorTarget)
2067
- };
2068
- },
2069
- reduce(_current, next) {
2070
- return next;
2071
- }
2072
- });
2073
- /** Sends one command to the session's worktrees channel. */
2074
- function command(sessionId, payload) {
2075
- worktreeSender.store.state?.({
2076
- type: GIT_WORKTREES_TYPE,
2077
- sessionId,
2078
- payload
1727
+ function GitSyncBar({ changes, mergeBase, pending, error, errorTarget, onSync, onForcePush, onAskAgent, onDismissError }) {
1728
+ const busy = pending !== void 0;
1729
+ const paused = changes?.rebase !== void 0;
1730
+ const forceRequired = errorTarget?.action === "push" && "forceRequired" in errorTarget && errorTarget.forceRequired === true;
1731
+ const syncError = error !== void 0 && !forceRequired ? error : void 0;
1732
+ const upstream = changes?.upstream;
1733
+ return /* @__PURE__ */ jsxs("div", {
1734
+ "data-testid": "git-sync-bar",
1735
+ className: "flex shrink-0 flex-col gap-2 border-b border-doom-border-soft px-3 py-2.5 sm:px-[26px]",
1736
+ children: [
1737
+ /* @__PURE__ */ jsxs("div", {
1738
+ className: "flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1.5",
1739
+ children: [
1740
+ /* @__PURE__ */ jsxs("span", {
1741
+ className: "flex min-w-0 items-center gap-1.5",
1742
+ children: [
1743
+ /* @__PURE__ */ jsx(BranchIcon, {
1744
+ "aria-hidden": true,
1745
+ className: "h-3.5 w-3.5 shrink-0 text-doom-faint"
1746
+ }),
1747
+ /* @__PURE__ */ jsx("span", {
1748
+ "data-testid": "git-review-branch",
1749
+ className: "truncate text-sm font-bold text-doom-hi",
1750
+ children: changes?.branch ?? "detached HEAD"
1751
+ }),
1752
+ /* @__PURE__ */ jsx("span", {
1753
+ className: "shrink-0 text-xs text-doom-faint",
1754
+ children: changes?.base === void 0 ? "uncommitted changes" : `vs ${changes.base}`
1755
+ }),
1756
+ mergeBase === void 0 ? null : /* @__PURE__ */ jsxs("span", {
1757
+ className: "shrink-0 text-2xs text-doom-faint",
1758
+ children: ["from ", mergeBase]
1759
+ })
1760
+ ]
1761
+ }),
1762
+ changes === void 0 ? null : /* @__PURE__ */ jsxs("span", {
1763
+ "data-testid": "git-review-totals",
1764
+ className: "flex shrink-0 items-center gap-1.5 text-xs",
1765
+ children: [
1766
+ /* @__PURE__ */ jsxs("span", {
1767
+ className: "font-bold text-doom-green",
1768
+ children: ["+", changes.added]
1769
+ }),
1770
+ /* @__PURE__ */ jsxs("span", {
1771
+ className: "font-bold text-doom-red",
1772
+ children: ["-", changes.removed]
1773
+ }),
1774
+ /* @__PURE__ */ jsxs("span", {
1775
+ className: "text-doom-faint",
1776
+ children: [
1777
+ changes.files,
1778
+ changes.truncated ? "+" : "",
1779
+ " ",
1780
+ changes.files === 1 ? "file" : "files"
1781
+ ]
1782
+ })
1783
+ ]
1784
+ }),
1785
+ /* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }),
1786
+ upstream === void 0 ? /* @__PURE__ */ jsx("span", {
1787
+ className: "shrink-0 text-2xs text-doom-faint",
1788
+ children: "no upstream yet"
1789
+ }) : /* @__PURE__ */ jsxs("span", {
1790
+ className: "shrink-0 text-2xs text-doom-faint",
1791
+ title: upstream.ref,
1792
+ children: [
1793
+ "↑",
1794
+ upstream.ahead,
1795
+ " ↓",
1796
+ upstream.behind,
1797
+ " ",
1798
+ upstream.ref
1799
+ ]
1800
+ }),
1801
+ /* @__PURE__ */ jsxs("span", {
1802
+ className: "flex shrink-0 items-center gap-1.5",
1803
+ children: [
1804
+ /* @__PURE__ */ jsx(Button, {
1805
+ size: "xs",
1806
+ "data-testid": "git-sync-pull",
1807
+ disabled: busy || paused || upstream === void 0,
1808
+ title: upstream === void 0 ? "push once to set an upstream" : `fetch, then rebase onto ${upstream.ref}`,
1809
+ onClick: () => onSync("pull"),
1810
+ children: "pull"
1811
+ }),
1812
+ /* @__PURE__ */ jsx(Button, {
1813
+ size: "xs",
1814
+ "data-testid": "git-sync-push",
1815
+ disabled: busy || paused || changes?.branch === void 0,
1816
+ title: upstream === void 0 ? "push and set the upstream" : `push to ${upstream.ref}`,
1817
+ onClick: () => onSync("push"),
1818
+ children: "push"
1819
+ }),
1820
+ /* @__PURE__ */ jsx(Button, {
1821
+ size: "xs",
1822
+ "data-testid": "git-sync-rebase",
1823
+ disabled: busy || paused || changes?.base === void 0,
1824
+ title: changes?.base === void 0 ? "no base branch to rebase onto" : `fetch, then rebase onto ${changes.base}`,
1825
+ onClick: () => onSync("rebase"),
1826
+ children: "rebase"
1827
+ })
1828
+ ]
1829
+ })
1830
+ ]
1831
+ }),
1832
+ pending === void 0 ? null : /* @__PURE__ */ jsxs("span", {
1833
+ "data-testid": "git-sync-pending",
1834
+ className: "flex min-w-0 items-center gap-1.5",
1835
+ children: [/* @__PURE__ */ jsx(Spinner, {
1836
+ className: "h-3 w-3 shrink-0 text-doom-faint",
1837
+ label: pending
1838
+ }), /* @__PURE__ */ jsx("span", {
1839
+ className: "min-w-0 truncate text-xs text-doom-dim",
1840
+ children: pending
1841
+ })]
1842
+ }),
1843
+ syncError === void 0 ? null : /* @__PURE__ */ jsxs("div", {
1844
+ role: "alert",
1845
+ "data-testid": "git-sync-error",
1846
+ className: "flex min-w-0 items-start gap-1.5 text-2xs text-doom-red",
1847
+ children: [
1848
+ /* @__PURE__ */ jsx(AlertIcon, {
1849
+ "aria-hidden": true,
1850
+ className: "mt-px h-3 w-3 shrink-0"
1851
+ }),
1852
+ /* @__PURE__ */ jsx("span", {
1853
+ className: "min-w-0 flex-1",
1854
+ children: syncError
1855
+ }),
1856
+ /* @__PURE__ */ jsx(Button, {
1857
+ variant: "ghost",
1858
+ size: "xs",
1859
+ className: "h-auto min-h-0 px-1 py-0 text-2xs",
1860
+ onClick: onDismissError,
1861
+ children: "dismiss"
1862
+ })
1863
+ ]
1864
+ }),
1865
+ forceRequired ? /* @__PURE__ */ jsxs("div", {
1866
+ "data-testid": "git-push-force-confirm",
1867
+ className: "flex min-w-0 flex-wrap items-center gap-2 rounded-md border border-doom-edge-yellow bg-doom-tint-yellow px-2.5 py-2",
1868
+ children: [
1869
+ /* @__PURE__ */ jsx("span", {
1870
+ className: "min-w-0 flex-1 text-xs text-doom-hi",
1871
+ children: "The remote branch has commits yours replaced, usually after a rebase. Force push with lease? It is refused if the remote holds commits you never fetched and integrated."
1872
+ }),
1873
+ /* @__PURE__ */ jsx(Button, {
1874
+ variant: "danger-outline",
1875
+ size: "xs",
1876
+ "data-testid": "git-push-force",
1877
+ disabled: busy,
1878
+ onClick: onForcePush,
1879
+ children: "force push"
1880
+ }),
1881
+ /* @__PURE__ */ jsx(Button, {
1882
+ variant: "ghost",
1883
+ size: "xs",
1884
+ "data-testid": "git-push-force-cancel",
1885
+ onClick: onDismissError,
1886
+ children: "cancel"
1887
+ })
1888
+ ]
1889
+ }) : null,
1890
+ changes?.rebase === void 0 ? null : /* @__PURE__ */ jsxs("div", {
1891
+ "data-testid": "git-rebase-banner",
1892
+ className: "flex min-w-0 flex-col gap-1.5 rounded-md border border-doom-edge-red bg-doom-tint-red px-2.5 py-2",
1893
+ children: [
1894
+ /* @__PURE__ */ jsxs("span", {
1895
+ className: "flex min-w-0 items-center gap-1.5",
1896
+ children: [/* @__PURE__ */ jsx(StatusBadge, {
1897
+ tone: "error",
1898
+ size: "xs",
1899
+ children: "rebase paused"
1900
+ }), /* @__PURE__ */ jsx("span", {
1901
+ className: "text-xs text-doom-hi",
1902
+ children: changes.rebase.conflicts.length === 0 ? "git stopped the rebase" : `conflicts in ${String(changes.rebase.conflicts.length)} ${changes.rebase.conflicts.length === 1 ? "file" : "files"}`
1903
+ })]
1904
+ }),
1905
+ changes.rebase.conflicts.length === 0 ? null : /* @__PURE__ */ jsx("ul", {
1906
+ className: "flex flex-col gap-0.5 pl-1",
1907
+ children: changes.rebase.conflicts.map((file) => /* @__PURE__ */ jsx("li", {
1908
+ className: "truncate font-mono text-2xs text-doom-dim",
1909
+ children: file
1910
+ }, file))
1911
+ }),
1912
+ /* @__PURE__ */ jsxs("span", {
1913
+ className: "flex flex-wrap items-center gap-1.5",
1914
+ children: [
1915
+ /* @__PURE__ */ jsx(Button, {
1916
+ variant: "primary",
1917
+ size: "xs",
1918
+ "data-testid": "git-rebase-ask-agent",
1919
+ disabled: busy,
1920
+ onClick: onAskAgent,
1921
+ children: "ask agent to resolve"
1922
+ }),
1923
+ /* @__PURE__ */ jsx(Button, {
1924
+ variant: "danger-outline",
1925
+ size: "xs",
1926
+ "data-testid": "git-rebase-abort",
1927
+ disabled: busy,
1928
+ onClick: () => onSync("abort-rebase"),
1929
+ children: "abort rebase"
1930
+ }),
1931
+ /* @__PURE__ */ jsx("span", {
1932
+ className: "text-2xs text-doom-faint",
1933
+ children: "abort discards any conflict resolution in progress"
1934
+ })
1935
+ ]
1936
+ })
1937
+ ]
1938
+ })
1939
+ ]
2079
1940
  });
2080
1941
  }
2081
- /** Asks the hub for a worktree. The hub answers on the same channel. */
2082
- function requestWorktreeCreate(sessionId, branch, baseRef, reservationId) {
2083
- const trimmedBase = baseRef.trim();
2084
- command(sessionId, {
2085
- action: "create",
2086
- branch: branch.trim(),
2087
- ...trimmedBase === "" ? {} : { baseRef: trimmedBase },
2088
- ...reservationId === void 0 ? {} : { reservationId }
2089
- });
1942
+ const NO_FILES = [];
1943
+ /** Stable, URL-safe element id for one file's section. */
1944
+ function sectionId(index) {
1945
+ return `git-review-file-${String(index)}`;
2090
1946
  }
2091
- /** Asks the hub to close one worktree. Refusal on a dirty tree stays the hub's call. */
2092
- function requestWorktreeClose(sessionId, id) {
2093
- command(sessionId, {
2094
- action: "close",
2095
- id
2096
- });
1947
+ function sideOf(row) {
1948
+ return row.marker === "-" ? "old" : "new";
2097
1949
  }
2098
- //#endregion
2099
- //#region src/extensions/workspaces/sessions/(frontend)/_components/WorktreesPanel.tsx
2100
- /**
2101
- * The '# git' group's tab: this session's worktrees, and the form that makes one.
2102
- *
2103
- * DESIGN PATTERNS:
2104
- * - Lives in the activity dock, opened by the group's name. A worktree is work
2105
- * in progress belonging to the session that started it, so it sits beside
2106
- * agents, runners and workflows rather than in a settings screen.
2107
- * - Header, form, then cards, the same rhythm the runners and workflows panels
2108
- * use. A worktree row is a card because it carries three facts and two
2109
- * actions, which a single line cannot hold without becoming a puzzle.
2110
- * - Local form state is never reset by a data refresh. Direct lifecycle events
2111
- * update the list without discarding a half-typed branch name.
2112
- * - Acts through the plugin's own hub channel, so a worktree made here and one
2113
- * made by the tool take exactly the same path.
2114
- * - Creating waits on the cockpit starting a session, so the hub reports each
2115
- * phase on that channel and the header shows it turning. A button that looks
2116
- * idle for two minutes reads as broken.
2117
- *
2118
- * AVOID:
2119
- * - Deriving form state from refreshed store data instead of the user's input.
2120
- * - Colour classes outside the token set. `doom-error`, `doom-line` and
2121
- * `doom-bright` are not tokens: they compile to nothing and the row silently
2122
- * loses its border.
2123
- */
2124
- const WORKTREES_TAB_ID = "git-worktrees";
2125
- function worktreesTab() {
2126
- return {
2127
- id: WORKTREES_TAB_ID,
2128
- label: "worktrees",
2129
- panel: WorktreesPanel
1950
+ function GitReviewView({ sync, summary, files, comments, onLoadFile, onAddComment, onRemoveComment, onSendReview, onDiscard, sendError, initialDraft }) {
1951
+ const entries = summary.state === "ready" ? summary.summary.files : NO_FILES;
1952
+ const [openOverride, setOpenOverride] = useState({});
1953
+ const [picked, setPicked] = useState();
1954
+ const activePath = picked ?? entries[0]?.path;
1955
+ const [draft, setDraft] = useState(initialDraft);
1956
+ useEffect(() => {
1957
+ for (const file of entries.slice(0, 20)) if (files[file.path] === void 0) onLoadFile(file.path);
1958
+ }, [
1959
+ entries,
1960
+ files,
1961
+ onLoadFile
1962
+ ]);
1963
+ const commentCounts = {};
1964
+ for (const comment of comments) commentCounts[comment.path] = (commentCounts[comment.path] ?? 0) + 1;
1965
+ const isOpen = (path, index) => openOverride[path] ?? index < 20;
1966
+ const open = (path) => {
1967
+ setOpenOverride((current) => current[path] === true ? current : {
1968
+ ...current,
1969
+ [path]: true
1970
+ });
1971
+ if (files[path] === void 0) onLoadFile(path);
2130
1972
  };
2131
- }
2132
- function WorktreesPanel({ sessionId, sessionReservationId }) {
2133
- const session = useStore(worktreeActivity.store, (state) => worktreeActivity.select(state, sessionId));
2134
- const [branch, setBranch] = useState("");
2135
- const [baseRef, setBaseRef] = useState("");
2136
- const busy = session.pending !== void 0;
2137
- const canCreate = sessionId !== null && branch.trim() !== "" && !busy;
2138
- const createError = session.errorTarget?.action === "create" ? session.error : void 0;
2139
- const closeErrorId = session.errorTarget?.action === "close" ? session.errorTarget.id : void 0;
2140
- const panelError = session.error !== void 0 && (session.errorTarget === void 0 || closeErrorId !== void 0 && !session.worktrees.some((worktree) => worktree.id === closeErrorId));
2141
- const create = () => {
2142
- if (sessionId === null || !canCreate) return;
2143
- requestWorktreeCreate(sessionId, branch, baseRef, sessionReservationId);
2144
- if (sessionReservationId === void 0) {
2145
- setBranch("");
2146
- setBaseRef("");
2147
- }
1973
+ const pick = (path) => {
1974
+ setPicked(path);
1975
+ open(path);
1976
+ const index = entries.findIndex((file) => file.path === path);
1977
+ globalThis.document?.getElementById(sectionId(index))?.scrollIntoView({ block: "start" });
1978
+ };
1979
+ const toggle = (path, index) => {
1980
+ if (isOpen(path, index)) setOpenOverride((current) => ({
1981
+ ...current,
1982
+ [path]: false
1983
+ }));
1984
+ else open(path);
1985
+ };
1986
+ const submitDraft = (body) => {
1987
+ if (draft === void 0) return;
1988
+ onAddComment({
1989
+ path: draft.path,
1990
+ relPath: draft.path,
1991
+ side: draft.selection.side,
1992
+ startLine: draft.selection.startLine,
1993
+ endLine: draft.selection.endLine,
1994
+ snippet: draft.selection.snippet,
1995
+ body: body.trim()
1996
+ });
1997
+ setDraft(void 0);
2148
1998
  };
2149
1999
  return /* @__PURE__ */ jsxs("div", {
2150
- "data-testid": "git-worktrees-panel",
2151
- className: "flex min-h-0 flex-1 flex-col overflow-auto",
2000
+ "data-testid": "git-review-panel",
2001
+ className: "flex min-h-0 flex-1 flex-col",
2152
2002
  children: [
2003
+ /* @__PURE__ */ jsx(GitSyncBar, {
2004
+ ...sync,
2005
+ ...summary.state === "ready" && summary.summary.mergeBase !== void 0 ? { mergeBase: summary.summary.mergeBase } : {}
2006
+ }),
2007
+ summary.state === "loading" ? /* @__PURE__ */ jsxs("div", {
2008
+ "data-testid": "git-review-loading",
2009
+ className: "flex flex-1 items-start gap-2 px-4 py-6",
2010
+ children: [/* @__PURE__ */ jsx(Spinner, {
2011
+ className: "h-3 w-3 text-doom-faint",
2012
+ label: "reading changes"
2013
+ }), /* @__PURE__ */ jsx("span", {
2014
+ className: "text-xs text-doom-dim",
2015
+ children: "reading changes…"
2016
+ })]
2017
+ }) : summary.state === "error" ? /* @__PURE__ */ jsx("p", {
2018
+ role: "alert",
2019
+ "data-testid": "git-review-error",
2020
+ className: "flex-1 px-4 py-6 text-xs text-doom-red",
2021
+ children: summary.error
2022
+ }) : !summary.summary.repository ? /* @__PURE__ */ jsx(EmptyState, {
2023
+ "data-testid": "git-review-no-repository",
2024
+ className: "px-4 py-8",
2025
+ title: "not a git checkout",
2026
+ description: "this session's folder is not inside a git repository, so there is nothing to review."
2027
+ }) : entries.length === 0 ? /* @__PURE__ */ jsx(EmptyState, {
2028
+ "data-testid": "git-review-empty",
2029
+ className: "px-4 py-8",
2030
+ title: "no changes",
2031
+ description: summary.summary.base === void 0 ? "nothing uncommitted in this checkout." : `nothing differs from ${summary.summary.base}, committed or not.`
2032
+ }) : /* @__PURE__ */ jsxs("div", {
2033
+ className: "flex min-h-0 flex-1",
2034
+ children: [/* @__PURE__ */ jsx(GitReviewBrowser, {
2035
+ files: entries,
2036
+ commentCounts,
2037
+ onPick: pick,
2038
+ ...activePath === void 0 ? {} : { activePath }
2039
+ }), /* @__PURE__ */ jsxs("div", {
2040
+ "data-testid": "git-review-diffs",
2041
+ className: "min-h-0 min-w-0 flex-1 overflow-y-auto",
2042
+ children: [summary.summary.truncated ? /* @__PURE__ */ jsx("p", {
2043
+ className: "px-4 pt-2 text-2xs text-doom-faint",
2044
+ children: "more files changed than the review lists; the rest are left out."
2045
+ }) : null, entries.map((file, index) => /* @__PURE__ */ jsx(FileSection, {
2046
+ id: sectionId(index),
2047
+ entry: file,
2048
+ state: files[file.path],
2049
+ collapsed: !isOpen(file.path, index),
2050
+ comments: comments.filter((comment) => comment.path === file.path),
2051
+ draft: draft?.path === file.path ? draft.selection : void 0,
2052
+ onToggle: () => toggle(file.path, index),
2053
+ onSelect: (selection) => {
2054
+ setPicked(file.path);
2055
+ setDraft({
2056
+ path: file.path,
2057
+ selection
2058
+ });
2059
+ },
2060
+ onSubmitDraft: submitDraft,
2061
+ onCancelDraft: () => setDraft(void 0),
2062
+ onRemoveComment,
2063
+ onRetry: () => onLoadFile(file.path)
2064
+ }, file.path))]
2065
+ })]
2066
+ }),
2153
2067
  /* @__PURE__ */ jsxs("div", {
2154
- className: "flex min-h-11 shrink-0 items-center gap-2.5 border-b border-doom-border-soft px-3 sm:px-[26px]",
2068
+ "data-testid": "git-review-footer",
2069
+ className: "flex shrink-0 flex-wrap items-center gap-2 border-t border-doom-border-soft bg-doom-panel px-3 py-2 sm:px-[26px]",
2155
2070
  children: [
2156
- /* @__PURE__ */ jsx("span", {
2157
- className: "text-sm font-bold text-doom-hi",
2158
- children: "worktrees"
2071
+ /* @__PURE__ */ jsx(MessageIcon, {
2072
+ "aria-hidden": true,
2073
+ className: "h-3 w-3 text-doom-faint"
2159
2074
  }),
2160
2075
  /* @__PURE__ */ jsx("span", {
2161
- "data-testid": "git-worktrees-count",
2162
- className: "text-xs text-doom-faint",
2163
- children: session.worktrees.length === 0 ? "none yet" : `${String(session.worktrees.length)} open`
2076
+ "data-testid": "git-review-comment-count",
2077
+ className: "text-xs text-doom-dim",
2078
+ children: comments.length === 0 ? "no comments yet: select lines or click a line number" : `${String(comments.length)} ${comments.length === 1 ? "comment" : "comments"}`
2164
2079
  }),
2165
2080
  /* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }),
2166
- session.pending === void 0 ? null : /* @__PURE__ */ jsxs("span", {
2167
- "data-testid": "git-worktree-pending",
2168
- className: "flex min-w-0 items-center gap-1.5",
2169
- children: [/* @__PURE__ */ jsx(Spinner, {
2170
- className: "h-3 w-3 shrink-0 text-doom-faint",
2171
- label: session.pending
2172
- }), /* @__PURE__ */ jsx("span", {
2173
- className: "min-w-0 truncate text-xs text-doom-dim",
2174
- children: session.pending
2175
- })]
2081
+ sendError === void 0 ? null : /* @__PURE__ */ jsx("span", {
2082
+ role: "alert",
2083
+ "data-testid": "git-review-send-error",
2084
+ className: "min-w-0 truncate text-2xs text-doom-red",
2085
+ children: sendError
2086
+ }),
2087
+ /* @__PURE__ */ jsx(Button, {
2088
+ variant: "ghost",
2089
+ size: "xs",
2090
+ "data-testid": "git-review-discard",
2091
+ disabled: comments.length === 0,
2092
+ onClick: onDiscard,
2093
+ children: "discard"
2094
+ }),
2095
+ /* @__PURE__ */ jsx(Button, {
2096
+ variant: "primary",
2097
+ size: "sm",
2098
+ "data-testid": "git-review-send",
2099
+ disabled: comments.length === 0,
2100
+ onClick: onSendReview,
2101
+ children: "send review"
2176
2102
  })
2177
2103
  ]
2178
- }),
2179
- /* @__PURE__ */ jsxs("div", {
2180
- className: "flex shrink-0 flex-col gap-1.5 border-b border-doom-border-soft px-3 py-2.5 sm:px-[26px]",
2181
- children: [/* @__PURE__ */ jsxs("div", {
2182
- className: "flex items-center gap-2",
2183
- children: [
2184
- /* @__PURE__ */ jsx(Input, {
2185
- "data-testid": "git-worktree-branch",
2186
- "aria-label": "Branch",
2187
- size: "sm",
2188
- className: "min-w-0 flex-1",
2189
- placeholder: "wt/fix-auth",
2190
- value: branch,
2191
- onChange: (event) => setBranch(event.target.value),
2192
- onKeyDown: (event) => {
2193
- if (event.key === "Enter") create();
2194
- }
2195
- }),
2196
- /* @__PURE__ */ jsx(Input, {
2197
- "data-testid": "git-worktree-base",
2198
- "aria-label": "Base ref",
2199
- size: "sm",
2200
- placeholder: "base (current)",
2201
- value: baseRef,
2202
- onChange: (event) => setBaseRef(event.target.value),
2203
- className: "w-32 shrink-0"
2204
- }),
2205
- /* @__PURE__ */ jsx(Button, {
2206
- "data-testid": "git-worktree-create",
2207
- size: "sm",
2208
- disabled: !canCreate,
2209
- onClick: create,
2210
- children: "Create"
2211
- })
2212
- ]
2213
- }), createError === void 0 ? /* @__PURE__ */ jsx("p", {
2214
- className: "text-2xs text-doom-faint",
2215
- children: "Creates the worktree outside the repository and starts a session nested under this one."
2216
- }) : /* @__PURE__ */ jsx("p", {
2217
- role: "alert",
2218
- "data-testid": "git-worktree-error",
2219
- className: "text-2xs text-doom-red",
2220
- children: createError
2221
- })]
2222
- }),
2223
- panelError ? /* @__PURE__ */ jsx("p", {
2224
- role: "alert",
2225
- "data-testid": "git-worktree-error",
2226
- className: "px-3 pt-2 text-2xs text-doom-red sm:px-[26px]",
2227
- children: session.error
2228
- }) : null,
2229
- session.worktrees.length === 0 ? /* @__PURE__ */ jsx(EmptyState, {
2230
- "data-testid": "git-worktrees-empty",
2231
- className: "px-4 py-8",
2232
- title: "no worktrees yet",
2233
- description: "a worktree is its own checkout on its own branch, with its own session nested under this one."
2234
- }) : /* @__PURE__ */ jsx("ul", {
2235
- "data-testid": "git-worktrees-list",
2236
- className: "flex flex-col gap-2.5 px-3 py-3 sm:px-[26px]",
2237
- children: session.worktrees.map((worktree) => /* @__PURE__ */ jsx(WorktreeCard, {
2238
- worktree,
2239
- sessionId,
2240
- busy,
2241
- error: closeErrorId === worktree.id ? session.error : void 0
2242
- }, worktree.id))
2243
2104
  })
2244
2105
  ]
2245
2106
  });
2246
2107
  }
2247
- /** One worktree: what it is, where it is, and the one thing you can do to it. */
2248
- function WorktreeCard({ worktree, sessionId, busy, error }) {
2249
- return /* @__PURE__ */ jsxs("li", {
2250
- "data-testid": `git-worktree-${worktree.id}`,
2251
- className: "flex min-w-0 flex-col gap-1.5 rounded-md border border-doom-border-soft bg-doom-panel p-2.5",
2252
- children: [
2253
- /* @__PURE__ */ jsxs("div", {
2254
- className: "flex min-w-0 items-center gap-1.5",
2108
+ const STATUS_WORD = {
2109
+ added: "added",
2110
+ modified: "modified",
2111
+ deleted: "deleted",
2112
+ untracked: "new, untracked",
2113
+ conflicted: "conflicted"
2114
+ };
2115
+ /** One file: a pinned header, then its diff with comments drawn under the lines they are about. */
2116
+ function FileSection({ id, entry, state, collapsed, comments, draft, onToggle, onSelect, onSubmitDraft, onCancelDraft, onRemoveComment, onRetry }) {
2117
+ const renderAfterRow = (row) => {
2118
+ const side = sideOf(row);
2119
+ const here = comments.filter((comment) => (comment.side ?? "new") === side && (comment.endLine ?? comment.startLine) === row.line);
2120
+ const drafting = draft !== void 0 && draft.side === side && draft.endLine === row.line;
2121
+ if (here.length === 0 && !drafting) return null;
2122
+ return /* @__PURE__ */ jsxs("div", {
2123
+ className: "flex flex-col gap-1.5 border-y border-doom-border-soft bg-doom-bg px-2 py-1.5 whitespace-normal",
2124
+ children: [here.map((comment) => /* @__PURE__ */ jsxs("div", {
2125
+ "data-testid": `git-review-comment-${comment.id}`,
2126
+ className: "flex min-w-0 items-start gap-2 rounded-md border border-doom-border-soft bg-doom-panel px-2 py-1.5",
2255
2127
  children: [
2256
- /* @__PURE__ */ jsx(Dot, { tone: worktree.orphaned ? "red" : worktree.sessionId === null ? "muted" : "blue" }),
2257
- /* @__PURE__ */ jsx("span", {
2258
- className: "min-w-0 flex-1 truncate text-sm font-bold text-doom-hi",
2259
- children: worktree.branch
2128
+ /* @__PURE__ */ jsx(MessageIcon, {
2129
+ "aria-hidden": true,
2130
+ className: "mt-0.5 h-3 w-3 shrink-0 text-doom-blue"
2260
2131
  }),
2261
- worktree.orphaned ? /* @__PURE__ */ jsx(StatusBadge, {
2262
- tone: "error",
2263
- size: "xs",
2264
- children: "orphaned"
2265
- }) : null,
2266
- worktree.unowned ? /* @__PURE__ */ jsx(StatusBadge, {
2267
- tone: "info",
2268
- size: "xs",
2269
- children: "unowned"
2270
- }) : null
2132
+ /* @__PURE__ */ jsxs("span", {
2133
+ className: "flex min-w-0 flex-1 flex-col gap-0.5",
2134
+ children: [/* @__PURE__ */ jsx("span", {
2135
+ className: "text-2xs text-doom-faint",
2136
+ children: reviewCommentAnchor(comment)
2137
+ }), /* @__PURE__ */ jsx("span", {
2138
+ className: "text-xs whitespace-pre-wrap text-doom-hi",
2139
+ children: comment.body
2140
+ })]
2141
+ }),
2142
+ /* @__PURE__ */ jsx(Button, {
2143
+ variant: "ghost",
2144
+ size: "icon",
2145
+ "aria-label": "remove comment",
2146
+ title: "remove comment",
2147
+ onClick: () => onRemoveComment(comment.id),
2148
+ children: /* @__PURE__ */ jsx(CloseIcon, { className: "h-3 w-3" })
2149
+ })
2271
2150
  ]
2272
- }),
2273
- /* @__PURE__ */ jsx("span", {
2274
- className: "min-w-0 truncate text-2xs text-doom-faint",
2275
- children: worktree.path
2276
- }),
2277
- /* @__PURE__ */ jsxs("div", {
2278
- className: "flex items-center gap-1.5 pt-0.5",
2279
- children: [/* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }), sessionId === null ? null : /* @__PURE__ */ jsx(Button, {
2280
- variant: "danger-outline",
2281
- size: "xs",
2282
- disabled: busy,
2283
- "data-testid": `git-worktree-close-${worktree.id}`,
2284
- className: "px-2 text-2xs font-bold",
2285
- onClick: () => requestWorktreeClose(sessionId, worktree.id),
2286
- children: "close"
2287
- })]
2288
- }),
2289
- error === void 0 ? null : /* @__PURE__ */ jsx("p", {
2151
+ }, comment.id)), drafting ? /* @__PURE__ */ jsx("div", {
2152
+ className: "rounded-md border border-doom-border bg-doom-panel",
2153
+ children: /* @__PURE__ */ jsx(ReviewCommentDraft, {
2154
+ snippet: draft.snippet,
2155
+ startLine: draft.startLine,
2156
+ endLine: draft.endLine,
2157
+ side: draft.side,
2158
+ testIdPrefix: "git-review-comment",
2159
+ onSubmit: onSubmitDraft,
2160
+ onCancel: onCancelDraft
2161
+ })
2162
+ }) : null]
2163
+ });
2164
+ };
2165
+ return /* @__PURE__ */ jsxs("section", {
2166
+ id,
2167
+ "data-testid": `git-review-file-${entry.path}`,
2168
+ className: "border-b border-doom-border-soft",
2169
+ children: [/* @__PURE__ */ jsxs(Button, {
2170
+ variant: "ghost",
2171
+ size: "card",
2172
+ "aria-expanded": !collapsed,
2173
+ onClick: onToggle,
2174
+ className: "sticky top-0 z-10 flex-row items-center gap-1.5 rounded-none border-b border-doom-border-soft bg-doom-rail px-3 py-1.5 hover:bg-doom-panel sm:px-4",
2175
+ children: [
2176
+ collapsed ? /* @__PURE__ */ jsx(ChevronRightIcon, {
2177
+ "aria-hidden": true,
2178
+ className: "h-3 w-3 shrink-0 text-doom-faint"
2179
+ }) : /* @__PURE__ */ jsx(ChevronDownIcon, {
2180
+ "aria-hidden": true,
2181
+ className: "h-3 w-3 shrink-0 text-doom-faint"
2182
+ }),
2183
+ /* @__PURE__ */ jsx("span", {
2184
+ className: "min-w-0 flex-1 truncate font-mono text-xs font-bold text-doom-hi",
2185
+ children: entry.path
2186
+ }),
2187
+ /* @__PURE__ */ jsx("span", {
2188
+ className: "shrink-0 text-2xs text-doom-faint",
2189
+ children: STATUS_WORD[entry.status]
2190
+ }),
2191
+ comments.length === 0 ? null : /* @__PURE__ */ jsxs("span", {
2192
+ className: "flex shrink-0 items-center gap-0.5 text-2xs text-doom-blue",
2193
+ children: [/* @__PURE__ */ jsx(MessageIcon, {
2194
+ "aria-hidden": true,
2195
+ className: "h-2.5 w-2.5"
2196
+ }), comments.length]
2197
+ }),
2198
+ entry.binary ? null : /* @__PURE__ */ jsxs("span", {
2199
+ className: "shrink-0 text-2xs",
2200
+ children: [
2201
+ /* @__PURE__ */ jsxs("span", {
2202
+ className: "text-doom-green",
2203
+ children: ["+", entry.added]
2204
+ }),
2205
+ " ",
2206
+ /* @__PURE__ */ jsxs("span", {
2207
+ className: "text-doom-red",
2208
+ children: ["-", entry.removed]
2209
+ })
2210
+ ]
2211
+ })
2212
+ ]
2213
+ }), collapsed ? null : state === void 0 || state.state === "loading" ? /* @__PURE__ */ jsxs("div", {
2214
+ className: "flex items-center gap-2 px-4 py-3",
2215
+ children: [/* @__PURE__ */ jsx(Spinner, {
2216
+ className: "h-3 w-3 text-doom-faint",
2217
+ label: `loading ${entry.path}`
2218
+ }), /* @__PURE__ */ jsx("span", {
2219
+ className: "text-2xs text-doom-faint",
2220
+ children: "loading diff…"
2221
+ })]
2222
+ }) : state.state === "error" ? /* @__PURE__ */ jsxs("div", {
2223
+ className: "flex items-center gap-2 px-4 py-3",
2224
+ children: [/* @__PURE__ */ jsx("span", {
2290
2225
  role: "alert",
2291
- "data-testid": "git-worktree-error",
2292
2226
  className: "text-2xs text-doom-red",
2293
- children: error
2294
- })
2295
- ]
2227
+ children: state.error
2228
+ }), /* @__PURE__ */ jsx(Button, {
2229
+ variant: "ghost",
2230
+ size: "xs",
2231
+ onClick: onRetry,
2232
+ children: "retry"
2233
+ })]
2234
+ }) : state.diff.binary ? /* @__PURE__ */ jsx("p", {
2235
+ className: "px-4 py-3 text-2xs text-doom-faint",
2236
+ children: "binary file, not drawn"
2237
+ }) : state.diff.tooLarge ? /* @__PURE__ */ jsx("p", {
2238
+ className: "px-4 py-3 text-2xs text-doom-faint",
2239
+ children: "this diff is too large to draw here"
2240
+ }) : /* @__PURE__ */ jsx(DiffView, {
2241
+ hunks: state.diff.hunks,
2242
+ testId: `git-review-diff-${id}`,
2243
+ onSelect,
2244
+ renderAfterRow,
2245
+ className: "py-1"
2246
+ })]
2296
2247
  });
2297
2248
  }
2298
2249
  //#endregion
2299
- //#region src/extensions/workspaces/sessions/(frontend)/activity-group/git.web.ts
2300
- var git_web_default = defineActivityGroup({
2301
- keys: "g w",
2302
- activeSource: worktreeActivitySource,
2303
- marksBackgroundWork: false,
2304
- transientTab: worktreesTab,
2305
- order: 30
2306
- });
2307
- //#endregion
2308
- //#region src/extensions/workspaces/sessions/(frontend)/channel/gitChanges.web.ts
2309
- var gitChanges_web_default = defineChannelFile(gitChangesChannel);
2310
- //#endregion
2311
- //#region src/extensions/workspaces/sessions/(frontend)/channel/gitWorktrees.web.ts
2312
- var gitWorktrees_web_default = defineChannelFile(worktreesChannel);
2250
+ //#region src/extensions/workspaces/sessions/(frontend)/_components/GitReviewPanel.tsx
2251
+ /**
2252
+ * The review tab: this session's changes against its base, the comments on
2253
+ * them, and the manual sync beside them.
2254
+ *
2255
+ * DESIGN PATTERNS:
2256
+ * - Opened from the '# diff' group as a transient tab, one per session.
2257
+ * - The hub's channel says when something changed (counts, base, a paused
2258
+ * rebase); the panel refetches the file list then, and each file's hunks
2259
+ * when it is opened. Nothing polls.
2260
+ * - Comments live in a session store and are cleared only after the host took
2261
+ * the message. Both messages go as `enqueue_automatic`, which queues behind a
2262
+ * running turn instead of failing.
2263
+ *
2264
+ * AVOID:
2265
+ * - Sending sync as a session frame. Sync is a channel command; the hub runs
2266
+ * it with the configured auth, one command per session.
2267
+ */
2268
+ const REVIEW_TAB_ID = "git-review";
2269
+ function reviewTab() {
2270
+ return {
2271
+ id: REVIEW_TAB_ID,
2272
+ label: "review",
2273
+ panel: GitReviewPanel
2274
+ };
2275
+ }
2276
+ function GitReviewPanel({ sessionId, sendSessionFrame }) {
2277
+ const activity = useStore(gitChanges.store, (state) => gitChanges.select(state, sessionId));
2278
+ const comments = useStore(gitReview.store, (state) => gitReview.select(state, sessionId).comments);
2279
+ const [summary, setSummary] = useState({ state: "loading" });
2280
+ const [files, setFiles] = useState({});
2281
+ const [sendError, setSendError] = useState();
2282
+ const [dismissedError, setDismissedError] = useState();
2283
+ const changes = activity.changes;
2284
+ const fingerprint = JSON.stringify([
2285
+ changes?.branch,
2286
+ changes?.base,
2287
+ changes?.added,
2288
+ changes?.removed,
2289
+ changes?.files,
2290
+ changes?.rebase?.conflicts.length
2291
+ ]);
2292
+ useEffect(() => {
2293
+ if (sessionId === null) return;
2294
+ const controller = new AbortController();
2295
+ fetchReviewSummary(sessionId, controller.signal).then((result) => {
2296
+ if (controller.signal.aborted) return;
2297
+ setSummary(result.ok ? {
2298
+ state: "ready",
2299
+ summary: result.summary
2300
+ } : {
2301
+ state: "error",
2302
+ error: result.error
2303
+ });
2304
+ setFiles({});
2305
+ }).catch((error) => {
2306
+ if (controller.signal.aborted) return;
2307
+ setSummary({
2308
+ state: "error",
2309
+ error: error instanceof Error ? error.message : "The changes could not be read."
2310
+ });
2311
+ });
2312
+ return () => controller.abort();
2313
+ }, [sessionId, fingerprint]);
2314
+ const loadFile = useCallback((path) => {
2315
+ if (sessionId === null) return;
2316
+ setFiles((current) => ({
2317
+ ...current,
2318
+ [path]: { state: "loading" }
2319
+ }));
2320
+ const settle = (state) => setFiles((current) => ({
2321
+ ...current,
2322
+ [path]: state
2323
+ }));
2324
+ fetchReviewFile(sessionId, path).then((result) => settle(result.ok ? {
2325
+ state: "ready",
2326
+ diff: result.diff
2327
+ } : {
2328
+ state: "error",
2329
+ error: result.error
2330
+ })).catch((error) => settle({
2331
+ state: "error",
2332
+ error: error instanceof Error ? error.message : "The diff could not be read."
2333
+ }));
2334
+ }, [sessionId]);
2335
+ if (sessionId === null) return null;
2336
+ const branch = summary.state === "ready" ? summary.summary.branch : changes?.branch;
2337
+ const base = summary.state === "ready" ? summary.summary.base : changes?.base;
2338
+ const error = activity.error === dismissedError ? void 0 : activity.error;
2339
+ const send = (message, onSent) => {
2340
+ const failure = deliverToAgent(sendSessionFrame, sessionId, message);
2341
+ setSendError(failure);
2342
+ if (failure === void 0) onSent?.();
2343
+ };
2344
+ return /* @__PURE__ */ jsx(GitReviewView, {
2345
+ sync: {
2346
+ ...changes === void 0 ? {} : { changes },
2347
+ ...activity.pending === void 0 ? {} : { pending: activity.pending },
2348
+ ...error === void 0 ? {} : { error },
2349
+ ...activity.errorTarget === void 0 || error === void 0 ? {} : { errorTarget: activity.errorTarget },
2350
+ onSync: (action) => requestGitSync(sessionId, action),
2351
+ onForcePush: () => requestGitSync(sessionId, "push", true),
2352
+ onAskAgent: () => send(conflictPrompt(branch, changes?.rebase?.conflicts ?? [])),
2353
+ onDismissError: () => setDismissedError(activity.error)
2354
+ },
2355
+ summary,
2356
+ files,
2357
+ comments,
2358
+ onLoadFile: loadFile,
2359
+ onAddComment: (comment) => addReviewComment(sessionId, comment),
2360
+ onRemoveComment: (id) => removeReviewComment(sessionId, id),
2361
+ onSendReview: () => send(buildReviewPrompt(comments, reviewHeading(comments.length, branch, base)), () => clearReviewComments(sessionId)),
2362
+ onDiscard: () => clearReviewComments(sessionId),
2363
+ ...sendError === void 0 ? {} : { sendError }
2364
+ });
2365
+ }
2313
2366
  //#endregion
2314
2367
  //#region src/extensions/workspaces/sessions/(frontend)/fill/_components/DiffActivitySection.tsx
2315
2368
  /**
2316
- * The '# diff' group's body: this session's code change against its base.
2369
+ * The '# git' group's branch row: this session's code change against its base.
2317
2370
  *
2318
2371
  * DESIGN PATTERNS:
2319
- * - One lane, `+A -R`. Files, base, upstream and a paused rebase live in the
2320
- * review tab's header, which this lane and the group's name both open.
2372
+ * - One lane, branch and `+A -R`. Files, base, upstream and a paused rebase live
2373
+ * in the review tab's header, which this lane opens.
2321
2374
  * - Stays clickable at zero, because the review tab is also where pull, push
2322
2375
  * and rebase live.
2323
- * - Separate from '# git': worktrees are repository management, this is the
2324
- * session's own change.
2376
+ * - Reads the focused session's checkout, whether it is a worktree or not.
2325
2377
  *
2326
2378
  * AVOID:
2327
2379
  * - Doing work here. This section reports and opens the tab.
@@ -2329,11 +2381,7 @@ var gitWorktrees_web_default = defineChannelFile(worktreesChannel);
2329
2381
  function DiffActivitySection({ sessionId, openTransientTab }) {
2330
2382
  const session = useStore(gitChanges.store, (state) => gitChanges.select(state, sessionId));
2331
2383
  const changes = session.changes;
2332
- if (sessionId === null || changes === void 0) return /* @__PURE__ */ jsx("p", {
2333
- "data-testid": "activity-summary-diff",
2334
- className: "px-2 text-xs text-doom-faint",
2335
- children: "idle"
2336
- });
2384
+ if (sessionId === null || changes === void 0) return null;
2337
2385
  const zero = changes.added === 0 && changes.removed === 0;
2338
2386
  const against = changes.base === void 0 ? "uncommitted" : `vs ${changes.base}`;
2339
2387
  return /* @__PURE__ */ jsxs("div", {
@@ -2342,17 +2390,24 @@ function DiffActivitySection({ sessionId, openTransientTab }) {
2342
2390
  variant: "ghost",
2343
2391
  size: "xs",
2344
2392
  "data-testid": "activity-diff-changes",
2345
- "aria-label": `review changes: ${String(changes.added)} added, ${String(changes.removed)} removed ${against}`,
2393
+ "aria-label": `review ${changes.branch ?? "detached HEAD"} changes: ${String(changes.added)} added, ${String(changes.removed)} removed ${against}`,
2346
2394
  title: `${String(changes.files)} ${changes.files === 1 ? "file" : "files"} ${against}`,
2347
- className: `gap-1.5 px-1 text-xs font-bold ${zero ? "text-doom-faint" : ""}`,
2395
+ className: `min-w-0 flex-1 shrink gap-1.5 px-1 text-xs font-bold ${zero ? "text-doom-faint" : ""}`,
2348
2396
  onClick: () => openTransientTab(reviewTab()),
2349
- children: [/* @__PURE__ */ jsxs("span", {
2350
- className: zero ? "" : "text-doom-green",
2351
- children: ["+", changes.added]
2352
- }), /* @__PURE__ */ jsxs("span", {
2353
- className: zero ? "" : "text-doom-red",
2354
- children: ["-", changes.removed]
2355
- })]
2397
+ children: [
2398
+ /* @__PURE__ */ jsx("span", {
2399
+ className: "min-w-0 flex-1 truncate text-left text-doom-hi",
2400
+ children: changes.branch ?? "detached HEAD"
2401
+ }),
2402
+ /* @__PURE__ */ jsxs("span", {
2403
+ className: zero ? "" : "text-doom-green",
2404
+ children: ["+", changes.added]
2405
+ }),
2406
+ /* @__PURE__ */ jsxs("span", {
2407
+ className: zero ? "" : "text-doom-red",
2408
+ children: ["-", changes.removed]
2409
+ })
2410
+ ]
2356
2411
  }), session.pending === void 0 ? null : /* @__PURE__ */ jsxs("span", {
2357
2412
  className: "flex min-w-0 items-center gap-1.5",
2358
2413
  children: [/* @__PURE__ */ jsx(Spinner, {
@@ -2366,64 +2421,12 @@ function DiffActivitySection({ sessionId, openTransientTab }) {
2366
2421
  });
2367
2422
  }
2368
2423
  //#endregion
2369
- //#region src/extensions/workspaces/sessions/(frontend)/fill/Diff.activity.diff.web.tsx
2370
- var Diff_activity_diff_web_default = defineFill({ component: DiffActivitySection });
2371
- //#endregion
2372
- //#region src/extensions/workspaces/sessions/(frontend)/fill/_components/WorktreeCreateDialog.tsx
2373
- function WorktreeSessionMenu({ sessionId, sessionReservationId }) {
2374
- return /* @__PURE__ */ jsx(DropdownMenuItem, {
2375
- disabled: sessionId === null,
2376
- "data-testid": `session-worktree-${sessionReservationId ?? sessionId}`,
2377
- onSelect: () => {
2378
- if (sessionId !== null) worktreeCreateDialog.update(() => ({
2379
- sessionId,
2380
- ...sessionReservationId === void 0 ? {} : { reservationId: sessionReservationId }
2381
- }));
2382
- },
2383
- children: "create worktree session…"
2384
- });
2385
- }
2386
- function WorktreeCreateOverlay(props) {
2387
- const target = useStore(worktreeCreateDialog.store);
2388
- const { holdSessionChannels } = props;
2389
- useEffect(() => {
2390
- if (target === null) return;
2391
- return holdSessionChannels?.(target.sessionId, (frame) => {
2392
- if (frame.type !== worktreesChannel.channel) return;
2393
- const payload = worktreesChannel.parse(frame.payload);
2394
- if (payload !== null) worktreesChannel.apply(target.sessionId, payload);
2395
- });
2396
- }, [target, holdSessionChannels]);
2397
- if (target === null) return null;
2398
- return /* @__PURE__ */ jsx(Dialog, {
2399
- open: true,
2400
- onOpenChange: (open) => {
2401
- if (!open) worktreeCreateDialog.update(() => null);
2402
- },
2403
- children: /* @__PURE__ */ jsxs(DialogContent, {
2404
- width: "lg",
2405
- "data-testid": "session-worktree-dialog",
2406
- children: [/* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(DialogTitle, { children: "create worktree session" }) }), /* @__PURE__ */ jsxs(DialogBody, { children: [/* @__PURE__ */ jsx(DialogDescription, { children: "Creates a separate checkout and a new session. Existing sessions stay in their current directories." }), /* @__PURE__ */ createElement(WorktreesPanel, {
2407
- ...props,
2408
- key: `${target.sessionId}:${target.reservationId ?? ""}`,
2409
- sessionId: target.sessionId,
2410
- sessionReservationId: target.reservationId
2411
- })] })]
2412
- })
2413
- });
2414
- }
2415
- //#endregion
2416
- //#region src/extensions/workspaces/sessions/(frontend)/fill/Worktree.session-menu.web.tsx
2417
- var Worktree_session_menu_web_default = defineFill({ component: WorktreeSessionMenu });
2418
- //#endregion
2419
- //#region src/extensions/workspaces/sessions/(frontend)/fill/WorktreeCreate.overlay.web.tsx
2420
- var WorktreeCreate_overlay_web_default = defineFill({ component: WorktreeCreateOverlay });
2421
- //#endregion
2422
2424
  //#region src/extensions/workspaces/sessions/(frontend)/fill/_components/WorktreesActivitySection.tsx
2423
2425
  /**
2424
2426
  * The '# git' group's body in the activity dock.
2425
2427
  *
2426
2428
  * DESIGN PATTERNS:
2429
+ * - The focused checkout's branch and diff stay visible, even with no child worktrees.
2427
2430
  * - One line per worktree, the same rule the runners rail follows: enough to
2428
2431
  * notice it, not enough to work with it. The group's own name opens the tab
2429
2432
  * that has room.
@@ -2434,9 +2437,16 @@ var WorktreeCreate_overlay_web_default = defineFill({ component: WorktreeCreateO
2434
2437
  * - Doing work here. This section reports and launches; the panel and the tool
2435
2438
  * own everything else.
2436
2439
  */
2437
- function WorktreesActivitySection({ sessionId, openTransientTab }) {
2440
+ function WorktreesActivitySection(props) {
2441
+ return /* @__PURE__ */ jsxs("div", {
2442
+ className: "flex min-w-0 flex-col gap-2",
2443
+ children: [/* @__PURE__ */ jsx(DiffActivitySection, { ...props }), /* @__PURE__ */ jsx(WorktreesSummary, { ...props })]
2444
+ });
2445
+ }
2446
+ function WorktreesSummary({ sessionId, openTransientTab }) {
2438
2447
  const session = useStore(worktreeActivity.store, (state) => worktreeActivity.select(state, sessionId));
2439
- if (session.worktrees.length === 0 && session.pending === void 0) return /* @__PURE__ */ jsxs("div", {
2448
+ const worktrees = session.worktrees.filter((worktree) => worktree.sessionId !== sessionId);
2449
+ if (worktrees.length === 0 && session.pending === void 0) return /* @__PURE__ */ jsxs("div", {
2440
2450
  className: "flex items-center gap-2 px-1",
2441
2451
  children: [/* @__PURE__ */ jsx("p", {
2442
2452
  "data-testid": "activity-summary-git",
@@ -2463,7 +2473,7 @@ function WorktreesActivitySection({ sessionId, openTransientTab }) {
2463
2473
  className: "min-w-0 truncate text-xs text-doom-dim",
2464
2474
  children: session.pending
2465
2475
  })]
2466
- }), session.worktrees.map((worktree) => /* @__PURE__ */ jsx(Button, {
2476
+ }), worktrees.map((worktree) => /* @__PURE__ */ jsx(Button, {
2467
2477
  variant: "ghost",
2468
2478
  size: "card",
2469
2479
  "data-testid": `activity-git-worktree-${worktree.id}`,
@@ -2496,6 +2506,19 @@ function WorktreesActivitySection({ sessionId, openTransientTab }) {
2496
2506
  //#region src/extensions/workspaces/sessions/(frontend)/fill/Worktrees.activity.git.web.tsx
2497
2507
  var Worktrees_activity_git_web_default = defineFill({ component: WorktreesActivitySection });
2498
2508
  //#endregion
2509
+ //#region src/extensions/workspaces/sessions/(frontend)/leader/review.web.ts
2510
+ var review_web_default = defineLeaderBinding({
2511
+ path: [{
2512
+ key: "g",
2513
+ label: "goal"
2514
+ }, {
2515
+ key: "d",
2516
+ label: "diff",
2517
+ detail: "review this checkout’s changes"
2518
+ }],
2519
+ run: (context) => context.openTransientTab(reviewTab())
2520
+ });
2521
+ //#endregion
2499
2522
  //#region src/extensions/workspaces/sessions/(frontend)/lifecycle/_lib/start.web.ts
2500
2523
  /** Binds both hub channels' senders: worktrees, and this session's diff and sync. */
2501
2524
  function startGitRuntime(runtime) {
@@ -2580,17 +2603,13 @@ const webPlugin = defineWebPlugin({
2580
2603
  },
2581
2604
  session: {
2582
2605
  get activityGroups() {
2583
- return [via({ name: "diff" }, diff_web_default), via({ name: "git" }, git_web_default)];
2606
+ return [via({ name: "git" }, git_web_default)];
2584
2607
  },
2585
2608
  get channels() {
2586
2609
  return [via({ channel: "git_changes" }, gitChanges_web_default), via({ channel: "git_worktrees" }, gitWorktrees_web_default)];
2587
2610
  },
2588
2611
  get fills() {
2589
2612
  return [
2590
- via({
2591
- slot: "activity.diff",
2592
- id: "diff"
2593
- }, Diff_activity_diff_web_default),
2594
2613
  via({
2595
2614
  slot: "session-menu",
2596
2615
  id: "worktree"
@@ -2605,6 +2624,9 @@ const webPlugin = defineWebPlugin({
2605
2624
  }, Worktrees_activity_git_web_default)
2606
2625
  ];
2607
2626
  },
2627
+ get leaderBindings() {
2628
+ return [via({ id: "git.review" }, review_web_default)];
2629
+ },
2608
2630
  get start() {
2609
2631
  return start_web_default;
2610
2632
  },