Files
AgentHub/client/src/components/lanes/LaneConflictView.tsx
T
nntrivi2001 fa156d6a59 feat: v2.6.0 — Chat UI composer (+ menu, project files, activity panel, permission mode, Stop), git on a workspace's extra folders (#70), long paste as a file (#68), S3 server mode (experimental)
Chat UI:
- Each Claude/Codex session keeps its own chat/terminal view; Settings only
  sets how new sessions open.
- Composer + menu: upload, project files and folders (#54, also dragged from
  the Files/Changes tree), context files, recent uploads, skills, plugins.
- Activity side panel (progress, outputs, files read, uploads, memory,
  skills, connectors) with a file preview; edited-file counts per turn.
- Permission-mode chip (Shift+Tab steps, read back from the pane:
  server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode).
- Stop (Esc) while the main agent works; the status is checked per agent
  (with session_id /api/agents ignores its status filter) and re-read every
  5 s while Stop shows.
- Dropped images shown in the conversation (GET /api/run/drop/:name, the
  requester's own drop dir), full-size viewer.
- A plain-text paste over 5,000 characters is attached as pasted-text.txt,
  so no prompt reaches tmux/argv limits (#68).

Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources
id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the
whole panel to a workspace's extra folder (#70).

Server mode S3 (one Linux user per account) — experimental, verified with
fake sudo plus a P6 managed-settings spike on a test account:
- member tmux listing runs in the background (cached, 15 s => unknown);
- transcript snapshot copy is async, chunked, temp file + rename;
- Express Layer patch only on Express 4;
- tmux capturePane/sendKey go to the member's own server.

Docs: the multi-user design moved in as docs/plans/server-mode/README.md
(the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
2026-10-09 16:35:07 +07:00

294 lines
10 KiB
TypeScript

/**
* @file LaneConflictView.tsx
* @description The Changes panel's view of ONE conflicted file during a merge
* (issue #45): the file with each conflict block drawn as its two sides —
* "mine" and "theirs", labelled from git's own markers and tinted apart — and
* a Keep mine / Take theirs / Keep both choice per block, undoable. Save and
* mark resolved writes the chosen text over exactly the version it opened
* (`gitSaveFile` with its `sha`, 409 ESTALE otherwise) and then runs
* `resolve done`, which re-checks that no marker is left. Take all mine /
* theirs is `resolve ours|theirs` for the whole file; Edit by hand hands the
* file to the panel's plain editor.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp } from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import {
applyChoices,
parseConflicts,
type ConflictChoice,
type ConflictPart,
} from "../../lib/conflicts";
const BASE =
"rounded border px-1.5 py-0.5 text-[11px] hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-40";
const BTN = `${BASE} border-border-light text-fg-secondary hover:text-fg-primary`;
function Lines({ lines, className = "" }: { lines: string[]; className?: string }) {
return (
<pre className={`whitespace-pre px-2 font-mono text-[11px] leading-5 ${className}`}>
{lines.length ? lines.join("\n") : " "}
</pre>
);
}
export function LaneConflictView({
laneId,
session,
source,
path,
onResolved,
onEdit,
}: {
laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number;
path: string;
/** The file left the conflict (resolved, or a whole side taken). */
onResolved: () => void;
/** Open the file in the panel's plain editor instead. */
onEdit: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const [file, setFile] = useState<{ lines: string[]; sha?: string; usable: boolean } | null>(null);
const [choices, setChoices] = useState<(ConflictChoice | undefined)[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const blockRefs = useRef<(HTMLDivElement | null)[]>([]);
const [at, setAt] = useState(0);
useEffect(() => {
let live = true;
setFile(null);
setChoices([]);
setError(null);
api.lanes
.gitFile(gitTarget(laneId, session, source), path, "work")
.then((r) => {
if (!live) return;
setFile(
r.available
? { lines: r.lines, sha: r.sha, usable: !r.binary && !r.truncated && !!r.sha }
: { lines: [], usable: false }
);
})
.catch((e) => live && setError(e instanceof Error ? e.message : String(e)));
return () => {
live = false;
};
}, [laneId, session, source, path]);
const parts: ConflictPart[] | null = file?.usable ? parseConflicts(file.lines) : null;
const total = parts ? parts.filter((p) => p.kind === "conflict").length : 0;
const chosen = choices.filter(Boolean).length;
const done = total > 0 && chosen === total;
const run = async (fn: () => Promise<unknown>) => {
setBusy(true);
setError(null);
try {
await fn();
onResolved();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const save = () =>
run(async () => {
if (!parts || !file?.sha) return;
await api.lanes.gitSaveFile(
gitTarget(laneId, session, source),
path,
applyChoices(parts, choices).join("\n"),
file.sha
);
await api.lanes.gitAction(gitTarget(laneId, session, source), "resolve", {
path,
side: "done",
});
});
const takeAll = (side: "ours" | "theirs") =>
run(() => api.lanes.gitAction(gitTarget(laneId, session, source), "resolve", { path, side }));
const choose = (i: number, c: ConflictChoice | undefined) =>
setChoices((prev) => {
const next = [...prev];
next[i] = c;
return next;
});
const jump = (step: number) => {
if (!total) return;
const i = (at + step + total) % total;
setAt(i);
blockRefs.current[i]?.scrollIntoView({ block: "center" });
};
let blockIndex = -1;
return (
<div data-testid="lane-conflict-view" className="flex min-h-0 flex-1 flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-1.5">
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-primary">
{path}
</span>
{total > 0 && (
<span className="text-[11px] text-status-warning">
{t("conflict.left", { left: total - chosen, total })}
</span>
)}
<button
type="button"
className={BTN}
aria-label={t("conflict.prev")}
disabled={!total}
onClick={() => jump(-1)}
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
className={BTN}
aria-label={t("conflict.next")}
disabled={!total}
onClick={() => jump(1)}
>
<ChevronDown className="h-3 w-3" />
</button>
<button type="button" className={BTN} disabled={busy} onClick={() => void takeAll("ours")}>
{t("conflict.allOurs")}
</button>
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => void takeAll("theirs")}
>
{t("conflict.allTheirs")}
</button>
<button type="button" className={BTN} disabled={busy} onClick={onEdit}>
{t("conflict.editByHand")}
</button>
</div>
{error && (
<p role="alert" className="whitespace-pre-wrap text-[11px] text-status-danger">
{error}
</p>
)}
{!file ? (
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
) : !parts ? (
<p className="text-[11px] text-status-warning">
{file.usable ? t("conflict.unbalanced") : t("conflict.unusable")}
</p>
) : (
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0">
{parts.map((p, n) => {
if (p.kind === "text")
return <Lines key={n} lines={p.lines} className="text-fg-primary" />;
const i = ++blockIndex;
const c = choices[i];
return (
<div
key={n}
ref={(el) => {
blockRefs.current[i] = el;
}}
data-testid="conflict-block"
className="my-1 border-y-2 border-status-warning/70"
>
<div className="flex flex-wrap items-center gap-1 bg-surface-2 px-2 py-0.5 text-[11px]">
{c ? (
<>
<span className="text-fg-muted">{t(`conflict.chose.${c}`)}</span>
<button type="button" className={BTN} onClick={() => choose(i, undefined)}>
{t("conflict.undo")}
</button>
</>
) : (
<>
<button
type="button"
className={`${BASE} border-status-success/70 font-medium text-status-success`}
onClick={() => choose(i, "ours")}
>
{t("conflict.keepOurs")}
</button>
<button
type="button"
className={`${BASE} border-accent/70 font-medium text-accent`}
onClick={() => choose(i, "theirs")}
>
{t("conflict.takeTheirs")}
</button>
<button type="button" className={BTN} onClick={() => choose(i, "both")}>
{t("conflict.keepBoth")}
</button>
</>
)}
<span className="ml-auto text-fg-muted">
{i + 1}/{total}
</span>
</div>
{c ? (
<Lines
lines={
c === "ours" ? p.ours : c === "theirs" ? p.theirs : [...p.ours, ...p.theirs]
}
className="bg-surface-2 text-fg-primary"
/>
) : (
<>
<div className="border-l-4 border-status-success bg-status-success/20">
<div className="bg-status-success/30 px-2 py-0.5 text-[11px] font-semibold text-status-success">
{t("conflict.ours", { label: p.oursLabel || "HEAD" })}
</div>
<Lines lines={p.ours} className="text-fg-primary" />
</div>
<div className="border-l-4 border-accent bg-accent/20">
<div className="bg-accent/30 px-2 py-0.5 text-[11px] font-semibold text-accent">
{t("conflict.theirs", { label: p.theirsLabel || "?" })}
</div>
<Lines lines={p.theirs} className="text-fg-primary" />
</div>
</>
)}
</div>
);
})}
</div>
)}
{parts && total > 0 && (
<div className="flex items-center justify-end gap-1.5">
<button
type="button"
className={BTN}
disabled={busy || !chosen}
onClick={() => setChoices([])}
>
{t("conflict.reset")}
</button>
<button
type="button"
disabled={busy || !done}
onClick={() => void save()}
className="rounded border border-accent/40 bg-accent/15 px-2 py-0.5 text-[11px] text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
>
{t("conflict.save")}
</button>
</div>
)}
</div>
);
}