@agimon-ai/doompi-git 0.0.1-alpha.61 → 0.0.1-alpha.63

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,6 +1,6 @@
1
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
- import { createElement, useCallback, useEffect, useRef, useState } from "react";
3
+ import { createElement, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
4
4
  import { sealedTransport } from "@agimon-ai/doompi-web-security/browser";
5
5
  import { jsx, jsxs } from "react/jsx-runtime";
6
6
  import { useStore } from "@tanstack/react-store";
@@ -1937,7 +1937,31 @@ function GitSyncBar({ changes, mergeBase, pending, error, errorTarget, onSync, o
1937
1937
  ]
1938
1938
  });
1939
1939
  }
1940
+ //#endregion
1941
+ //#region src/extensions/workspaces/sessions/(frontend)/_components/GitReviewView.tsx
1942
+ /**
1943
+ * The review tab, drawn from what it is handed: the sync header, the changed
1944
+ * files on the left, every diff stacked on the right, and one footer that
1945
+ * sends every comment as a single message.
1946
+ *
1947
+ * DESIGN PATTERNS:
1948
+ * - Like a pull request's file view. Picking a file in the list scrolls to it;
1949
+ * each file's header stays pinned while its diff scrolls past.
1950
+ * - Only nearby file bodies mount and load; distant files retain their headers
1951
+ * and measured-height placeholders.
1952
+ * - A comment is drawn under the line it is about, on the side it is about.
1953
+ * The open draft sits in the same place, so writing happens in context.
1954
+ * - Only UI state lives here (the open draft, which files are collapsed, which
1955
+ * is active). Data and comments come in as props; the panel owns fetching.
1956
+ *
1957
+ * AVOID:
1958
+ * - Clearing comments here. The owner clears them once the host took the send.
1959
+ */
1960
+ const PICK_ALIGNMENT_MS = 300;
1961
+ const ESTIMATED_ROW_HEIGHT = 21;
1962
+ const MAX_DIFF_ROWS = 5e3;
1940
1963
  const NO_FILES = [];
1964
+ const NO_PATHS = /* @__PURE__ */ new Set();
1941
1965
  /** Stable, URL-safe element id for one file's section. */
1942
1966
  function sectionId(index) {
1943
1967
  return `git-review-file-${String(index)}`;
@@ -1945,37 +1969,117 @@ function sectionId(index) {
1945
1969
  function sideOf(row) {
1946
1970
  return row.marker === "-" ? "old" : "new";
1947
1971
  }
1948
- function GitReviewView({ sync, summary, files, comments, onLoadFile, onAddComment, onRemoveComment, onSendReview, onDiscard, sendError, initialDraft }) {
1972
+ function GitReviewView({ sync, summary, files, comments, onLoadFile, onAddComment, onRemoveComment, onSendReview, onDiscard, onDraftChange, sendError, initialDraft }) {
1949
1973
  const entries = summary.state === "ready" ? summary.summary.files : NO_FILES;
1950
1974
  const [openOverride, setOpenOverride] = useState({});
1951
1975
  const [picked, setPicked] = useState();
1952
- const activePath = picked ?? entries[0]?.path;
1976
+ const [navigation, setNavigation] = useState();
1977
+ const [proximity, setProximity] = useState({
1978
+ entries: NO_FILES,
1979
+ paths: NO_PATHS
1980
+ });
1981
+ const near = proximity.entries === entries ? proximity.paths : NO_PATHS;
1982
+ const paneRef = useRef(null);
1983
+ const [heights, setHeights] = useState(/* @__PURE__ */ new Map());
1984
+ const pickedAt = useRef(0);
1985
+ const activePath = entries.some((entry) => entry.path === picked) ? picked : entries[0]?.path;
1953
1986
  const [draft, setDraft] = useState(initialDraft);
1954
1987
  useEffect(() => {
1955
- for (const file of entries.slice(0, 20)) if (files[file.path] === void 0) onLoadFile(file.path);
1988
+ const pane = paneRef.current;
1989
+ if (pane === null || typeof IntersectionObserver === "undefined") return;
1990
+ setHeights(/* @__PURE__ */ new Map());
1991
+ let observer;
1992
+ const observe = () => {
1993
+ observer?.disconnect();
1994
+ observer = new IntersectionObserver((changes) => {
1995
+ for (const change of changes) {
1996
+ if (change.isIntersecting) continue;
1997
+ const section = change.target;
1998
+ const path = section.dataset.reviewPath;
1999
+ const body = section.querySelector("[data-review-body=\"measured\"]");
2000
+ if (path !== void 0 && body !== null) {
2001
+ const height = body.offsetHeight;
2002
+ setHeights((measured) => new Map(measured).set(path, height));
2003
+ }
2004
+ }
2005
+ setProximity((current) => {
2006
+ const next = new Set(current.entries === entries ? current.paths : NO_PATHS);
2007
+ for (const change of changes) {
2008
+ const path = change.target.dataset.reviewPath;
2009
+ if (path === void 0) continue;
2010
+ if (change.isIntersecting) next.add(path);
2011
+ else next.delete(path);
2012
+ }
2013
+ return {
2014
+ entries,
2015
+ paths: next
2016
+ };
2017
+ });
2018
+ }, {
2019
+ root: pane,
2020
+ rootMargin: `${String(pane.clientHeight)}px 0px`
2021
+ });
2022
+ for (const section of pane.querySelectorAll("[data-review-path]")) observer.observe(section);
2023
+ };
2024
+ observe();
2025
+ const resize = typeof ResizeObserver === "undefined" ? void 0 : new ResizeObserver(observe);
2026
+ resize?.observe(pane);
2027
+ let frame = 0;
2028
+ const scroll = () => {
2029
+ cancelAnimationFrame(frame);
2030
+ frame = requestAnimationFrame(() => {
2031
+ if (Date.now() - pickedAt.current < PICK_ALIGNMENT_MS) return;
2032
+ const rect = pane.getBoundingClientRect();
2033
+ const section = document.elementFromPoint(rect.left + 8, rect.top + 1)?.closest("[data-review-path]");
2034
+ if (section !== null && section !== void 0 && pane.contains(section)) setPicked(section.dataset.reviewPath);
2035
+ });
2036
+ };
2037
+ pane.addEventListener("scroll", scroll, { passive: true });
2038
+ return () => {
2039
+ observer.disconnect();
2040
+ resize?.disconnect();
2041
+ pane.removeEventListener("scroll", scroll);
2042
+ cancelAnimationFrame(frame);
2043
+ };
2044
+ }, [entries]);
2045
+ useLayoutEffect(() => {
2046
+ if (navigation === void 0) return;
2047
+ [...paneRef.current?.querySelectorAll("[data-review-path]") ?? []].find((element) => element.dataset.reviewPath === navigation)?.scrollIntoView({ block: "start" });
2048
+ }, [navigation, openOverride]);
2049
+ useEffect(() => {
2050
+ if (navigation === void 0) return;
2051
+ const timeout = setTimeout(() => setNavigation(void 0), PICK_ALIGNMENT_MS);
2052
+ return () => clearTimeout(timeout);
2053
+ }, [navigation]);
2054
+ useEffect(() => {
2055
+ for (const file of entries) if ((openOverride[file.path] ?? true) && (near.has(file.path) || navigation === file.path || draft?.path === file.path) && files[file.path] === void 0) onLoadFile(file.path);
1956
2056
  }, [
1957
2057
  entries,
1958
2058
  files,
2059
+ near,
2060
+ navigation,
2061
+ draft,
2062
+ openOverride,
1959
2063
  onLoadFile
1960
2064
  ]);
1961
2065
  const commentCounts = {};
1962
2066
  for (const comment of comments) commentCounts[comment.path] = (commentCounts[comment.path] ?? 0) + 1;
1963
- const isOpen = (path, index) => openOverride[path] ?? index < 20;
2067
+ const isOpen = (path) => openOverride[path] ?? true;
1964
2068
  const open = (path) => {
1965
2069
  setOpenOverride((current) => current[path] === true ? current : {
1966
2070
  ...current,
1967
2071
  [path]: true
1968
2072
  });
1969
- if (files[path] === void 0) onLoadFile(path);
1970
2073
  };
1971
2074
  const pick = (path) => {
2075
+ if (!entries.some((entry) => entry.path === path)) return;
2076
+ pickedAt.current = Date.now();
1972
2077
  setPicked(path);
2078
+ setNavigation(path);
1973
2079
  open(path);
1974
- const index = entries.findIndex((file) => file.path === path);
1975
- globalThis.document?.getElementById(sectionId(index))?.scrollIntoView({ block: "start" });
1976
2080
  };
1977
- const toggle = (path, index) => {
1978
- if (isOpen(path, index)) setOpenOverride((current) => ({
2081
+ const toggle = (path) => {
2082
+ if (isOpen(path)) setOpenOverride((current) => ({
1979
2083
  ...current,
1980
2084
  [path]: false
1981
2085
  }));
@@ -1993,6 +2097,7 @@ function GitReviewView({ sync, summary, files, comments, onLoadFile, onAddCommen
1993
2097
  body: body.trim()
1994
2098
  });
1995
2099
  setDraft(void 0);
2100
+ onDraftChange?.(false);
1996
2101
  };
1997
2102
  return /* @__PURE__ */ jsxs("div", {
1998
2103
  "data-testid": "git-review-panel",
@@ -2035,6 +2140,7 @@ function GitReviewView({ sync, summary, files, comments, onLoadFile, onAddCommen
2035
2140
  onPick: pick,
2036
2141
  ...activePath === void 0 ? {} : { activePath }
2037
2142
  }), /* @__PURE__ */ jsxs("div", {
2143
+ ref: paneRef,
2038
2144
  "data-testid": "git-review-diffs",
2039
2145
  className: "min-h-0 min-w-0 flex-1 overflow-y-auto",
2040
2146
  children: [summary.summary.truncated ? /* @__PURE__ */ jsx("p", {
@@ -2044,19 +2150,25 @@ function GitReviewView({ sync, summary, files, comments, onLoadFile, onAddCommen
2044
2150
  id: sectionId(index),
2045
2151
  entry: file,
2046
2152
  state: files[file.path],
2047
- collapsed: !isOpen(file.path, index),
2153
+ collapsed: !isOpen(file.path),
2154
+ mounted: near.has(file.path) || navigation === file.path || draft?.path === file.path,
2155
+ placeholderHeight: heights.get(file.path) ?? (file.binary ? 40 : Math.min(MAX_DIFF_ROWS, file.added + file.removed + 6) * ESTIMATED_ROW_HEIGHT),
2048
2156
  comments: comments.filter((comment) => comment.path === file.path),
2049
2157
  draft: draft?.path === file.path ? draft.selection : void 0,
2050
- onToggle: () => toggle(file.path, index),
2158
+ onToggle: () => toggle(file.path),
2051
2159
  onSelect: (selection) => {
2052
2160
  setPicked(file.path);
2053
2161
  setDraft({
2054
2162
  path: file.path,
2055
2163
  selection
2056
2164
  });
2165
+ onDraftChange?.(true);
2057
2166
  },
2058
2167
  onSubmitDraft: submitDraft,
2059
- onCancelDraft: () => setDraft(void 0),
2168
+ onCancelDraft: () => {
2169
+ setDraft(void 0);
2170
+ onDraftChange?.(false);
2171
+ },
2060
2172
  onRemoveComment,
2061
2173
  onRetry: () => onLoadFile(file.path)
2062
2174
  }, file.path))]
@@ -2111,7 +2223,7 @@ const STATUS_WORD = {
2111
2223
  conflicted: "conflicted"
2112
2224
  };
2113
2225
  /** One file: a pinned header, then its diff with comments drawn under the lines they are about. */
2114
- function FileSection({ id, entry, state, collapsed, comments, draft, onToggle, onSelect, onSubmitDraft, onCancelDraft, onRemoveComment, onRetry }) {
2226
+ function FileSection({ id, entry, state, collapsed, mounted, placeholderHeight, comments, draft, onToggle, onSelect, onSubmitDraft, onCancelDraft, onRemoveComment, onRetry }) {
2115
2227
  const renderAfterRow = (row) => {
2116
2228
  const side = sideOf(row);
2117
2229
  const here = comments.filter((comment) => (comment.side ?? "new") === side && (comment.endLine ?? comment.startLine) === row.line);
@@ -2162,6 +2274,7 @@ function FileSection({ id, entry, state, collapsed, comments, draft, onToggle, o
2162
2274
  };
2163
2275
  return /* @__PURE__ */ jsxs("section", {
2164
2276
  id,
2277
+ "data-review-path": entry.path,
2165
2278
  "data-testid": `git-review-file-${entry.path}`,
2166
2279
  className: "border-b border-doom-border-soft",
2167
2280
  children: [/* @__PURE__ */ jsxs(Button, {
@@ -2208,39 +2321,46 @@ function FileSection({ id, entry, state, collapsed, comments, draft, onToggle, o
2208
2321
  ]
2209
2322
  })
2210
2323
  ]
2211
- }), collapsed ? null : state === void 0 || state.state === "loading" ? /* @__PURE__ */ jsxs("div", {
2212
- className: "flex items-center gap-2 px-4 py-3",
2213
- children: [/* @__PURE__ */ jsx(Spinner, {
2214
- className: "h-3 w-3 text-doom-faint",
2215
- label: `loading ${entry.path}`
2216
- }), /* @__PURE__ */ jsx("span", {
2217
- className: "text-2xs text-doom-faint",
2218
- children: "loading diff…"
2219
- })]
2220
- }) : state.state === "error" ? /* @__PURE__ */ jsxs("div", {
2221
- className: "flex items-center gap-2 px-4 py-3",
2222
- children: [/* @__PURE__ */ jsx("span", {
2223
- role: "alert",
2224
- className: "text-2xs text-doom-red",
2225
- children: state.error
2226
- }), /* @__PURE__ */ jsx(Button, {
2227
- variant: "ghost",
2228
- size: "xs",
2229
- onClick: onRetry,
2230
- children: "retry"
2231
- })]
2232
- }) : state.diff.binary ? /* @__PURE__ */ jsx("p", {
2233
- className: "px-4 py-3 text-2xs text-doom-faint",
2234
- children: "binary file, not drawn"
2235
- }) : state.diff.tooLarge ? /* @__PURE__ */ jsx("p", {
2236
- className: "px-4 py-3 text-2xs text-doom-faint",
2237
- children: "this diff is too large to draw here"
2238
- }) : /* @__PURE__ */ jsx(DiffView, {
2239
- hunks: state.diff.hunks,
2240
- testId: `git-review-diff-${id}`,
2241
- onSelect,
2242
- renderAfterRow,
2243
- className: "py-1"
2324
+ }), collapsed ? null : !mounted ? /* @__PURE__ */ jsx("div", {
2325
+ "aria-hidden": true,
2326
+ style: { height: placeholderHeight }
2327
+ }) : /* @__PURE__ */ jsx("div", {
2328
+ "data-review-body": state === void 0 || state.state === "loading" ? "loading" : "measured",
2329
+ style: state === void 0 || state.state === "loading" ? { minHeight: placeholderHeight } : void 0,
2330
+ children: state === void 0 || state.state === "loading" ? /* @__PURE__ */ jsxs("div", {
2331
+ className: "flex items-center gap-2 px-4 py-3",
2332
+ children: [/* @__PURE__ */ jsx(Spinner, {
2333
+ className: "h-3 w-3 text-doom-faint",
2334
+ label: `loading ${entry.path}`
2335
+ }), /* @__PURE__ */ jsx("span", {
2336
+ className: "text-2xs text-doom-faint",
2337
+ children: "loading diff…"
2338
+ })]
2339
+ }) : state.state === "error" ? /* @__PURE__ */ jsxs("div", {
2340
+ className: "flex items-center gap-2 px-4 py-3",
2341
+ children: [/* @__PURE__ */ jsx("span", {
2342
+ role: "alert",
2343
+ className: "text-2xs text-doom-red",
2344
+ children: state.error
2345
+ }), /* @__PURE__ */ jsx(Button, {
2346
+ variant: "ghost",
2347
+ size: "xs",
2348
+ onClick: onRetry,
2349
+ children: "retry"
2350
+ })]
2351
+ }) : state.diff.binary ? /* @__PURE__ */ jsx("p", {
2352
+ className: "px-4 py-3 text-2xs text-doom-faint",
2353
+ children: "binary file, not drawn"
2354
+ }) : state.diff.tooLarge ? /* @__PURE__ */ jsx("p", {
2355
+ className: "px-4 py-3 text-2xs text-doom-faint",
2356
+ children: "this diff is too large to draw here"
2357
+ }) : /* @__PURE__ */ jsx(DiffView, {
2358
+ hunks: state.diff.hunks,
2359
+ testId: `git-review-diff-${id}`,
2360
+ onSelect,
2361
+ renderAfterRow,
2362
+ className: "py-1"
2363
+ })
2244
2364
  })]
2245
2365
  });
2246
2366
  }
@@ -2278,7 +2398,11 @@ function GitReviewPanel({ sessionId, sendSessionFrame }) {
2278
2398
  const [files, setFiles] = useState({});
2279
2399
  const [sendError, setSendError] = useState();
2280
2400
  const [dismissedError, setDismissedError] = useState();
2401
+ const [draftSnapshot, setDraftSnapshot] = useState();
2281
2402
  const changes = activity.changes;
2403
+ const generation = useRef(0);
2404
+ const pendingFiles = useRef(/* @__PURE__ */ new Map());
2405
+ const readyGeneration = useRef(false);
2282
2406
  const fingerprint = JSON.stringify([
2283
2407
  changes?.branch,
2284
2408
  changes?.base,
@@ -2287,11 +2411,20 @@ function GitReviewPanel({ sessionId, sendSessionFrame }) {
2287
2411
  changes?.files,
2288
2412
  changes?.rebase?.conflicts.length
2289
2413
  ]);
2414
+ const refreshFingerprint = draftSnapshot?.sessionId === sessionId ? draftSnapshot.fingerprint : fingerprint;
2290
2415
  useEffect(() => {
2416
+ generation.current += 1;
2417
+ readyGeneration.current = false;
2418
+ const pending = pendingFiles.current;
2419
+ for (const request of pending.values()) request.abort();
2420
+ pending.clear();
2421
+ setSummary({ state: "loading" });
2422
+ setFiles({});
2291
2423
  if (sessionId === null) return;
2292
2424
  const controller = new AbortController();
2293
2425
  fetchReviewSummary(sessionId, controller.signal).then((result) => {
2294
2426
  if (controller.signal.aborted) return;
2427
+ readyGeneration.current = result.ok;
2295
2428
  setSummary(result.ok ? {
2296
2429
  state: "ready",
2297
2430
  summary: result.summary
@@ -2307,19 +2440,31 @@ function GitReviewPanel({ sessionId, sendSessionFrame }) {
2307
2440
  error: error instanceof Error ? error.message : "The changes could not be read."
2308
2441
  });
2309
2442
  });
2310
- return () => controller.abort();
2311
- }, [sessionId, fingerprint]);
2443
+ return () => {
2444
+ controller.abort();
2445
+ generation.current += 1;
2446
+ readyGeneration.current = false;
2447
+ for (const request of pending.values()) request.abort();
2448
+ pending.clear();
2449
+ };
2450
+ }, [sessionId, refreshFingerprint]);
2312
2451
  const loadFile = useCallback((path) => {
2313
- if (sessionId === null) return;
2452
+ if (sessionId === null || !readyGeneration.current || pendingFiles.current.has(path)) return;
2453
+ const requestGeneration = generation.current;
2454
+ const controller = new AbortController();
2455
+ pendingFiles.current.set(path, controller);
2314
2456
  setFiles((current) => ({
2315
2457
  ...current,
2316
2458
  [path]: { state: "loading" }
2317
2459
  }));
2318
- const settle = (state) => setFiles((current) => ({
2319
- ...current,
2320
- [path]: state
2321
- }));
2322
- fetchReviewFile(sessionId, path).then((result) => settle(result.ok ? {
2460
+ const settle = (state) => {
2461
+ if (controller.signal.aborted || requestGeneration !== generation.current) return;
2462
+ setFiles((current) => ({
2463
+ ...current,
2464
+ [path]: state
2465
+ }));
2466
+ };
2467
+ fetchReviewFile(sessionId, path, controller.signal).then((result) => settle(result.ok ? {
2323
2468
  state: "ready",
2324
2469
  diff: result.diff
2325
2470
  } : {
@@ -2328,7 +2473,9 @@ function GitReviewPanel({ sessionId, sendSessionFrame }) {
2328
2473
  })).catch((error) => settle({
2329
2474
  state: "error",
2330
2475
  error: error instanceof Error ? error.message : "The diff could not be read."
2331
- }));
2476
+ })).finally(() => {
2477
+ if (pendingFiles.current.get(path) === controller) pendingFiles.current.delete(path);
2478
+ });
2332
2479
  }, [sessionId]);
2333
2480
  if (sessionId === null) return null;
2334
2481
  const branch = summary.state === "ready" ? summary.summary.branch : changes?.branch;
@@ -2340,6 +2487,10 @@ function GitReviewPanel({ sessionId, sendSessionFrame }) {
2340
2487
  if (failure === void 0) onSent?.();
2341
2488
  };
2342
2489
  return /* @__PURE__ */ jsx(GitReviewView, {
2490
+ onDraftChange: (open) => setDraftSnapshot((current) => open ? current?.sessionId === sessionId ? current : {
2491
+ sessionId,
2492
+ fingerprint
2493
+ } : void 0),
2343
2494
  sync: {
2344
2495
  ...changes === void 0 ? {} : { changes },
2345
2496
  ...activity.pending === void 0 ? {} : { pending: activity.pending },
@@ -2359,7 +2510,7 @@ function GitReviewPanel({ sessionId, sendSessionFrame }) {
2359
2510
  onSendReview: () => send(buildReviewPrompt(comments, reviewHeading(comments.length, branch, base)), () => clearReviewComments(sessionId)),
2360
2511
  onDiscard: () => clearReviewComments(sessionId),
2361
2512
  ...sendError === void 0 ? {} : { sendError }
2362
- });
2513
+ }, sessionId);
2363
2514
  }
2364
2515
  //#endregion
2365
2516
  //#region src/extensions/workspaces/sessions/(frontend)/fill/_components/DiffActivitySection.tsx