feat: workspaces own their sessions; desktop parity; sidebar and settings cleanup
Workspace sessions (docs/plans/desktop-parity, phases 1-5 and 7): - sessions.lane_id: a workspace lists only the sessions its panes held, stamped by bindRunSession (first writer wins, nested `claude -p` excluded); GET /api/sessions?lane=&archived= - session row state dot, double-click rename, archive (sessions.archived_at, hides only); the activity bar's duplicate Sessions tab removed - Fork beside Resume (--fork-session, Claude only), Ctrl/Cmd+click a live session opens it in a second pane, a diff line's comment is pasted into the workspace's last-focused pane (lib/panePaste.ts) dashboard_runs stability: - a pane's previous run is archived as <pane>@<started_at> instead of being overwritten by INSERT OR REPLACE - a run is flagged abandoned only once its tmux session is gone, at boot and on the run-heal beat (panes outlive a server restart) UI: - sidebar: no footer, language or theme block; small collapse button in the brand corner; version + update check in the connection details modal, a status-bar dot while a release is out; language in Settings -> General - one Switch component for every switch, the themed Checkbox for every checkbox (no native checkboxes) Also: AI Tools catalog trimmed to 10 built-ins (custom-file prompt modes tested through fixtures), and the workspace plans under docs/plans/ (the multi-user server plan lives in the AgentHub-Server repo).
This commit is contained in:
@@ -262,7 +262,28 @@ export function analyseHunk(hunk: LaneDiffHunk): Analysed {
|
||||
return out;
|
||||
}
|
||||
|
||||
function Gutter({ n }: { n: number | null }) {
|
||||
/** Set by a LaneDiffView given `onComment`: a click on a NEW line's number
|
||||
* opens the comment box for that line. */
|
||||
const CommentLine = createContext<{
|
||||
open: (line: number) => void;
|
||||
title: (line: number) => string;
|
||||
} | null>(null);
|
||||
|
||||
function Gutter({ n, commentable = false }: { n: number | null; commentable?: boolean }) {
|
||||
const comment = useContext(CommentLine);
|
||||
if (commentable && comment && n != null) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`diff-comment-line-${n}`}
|
||||
onClick={() => comment.open(n)}
|
||||
title={comment.title(n)}
|
||||
className="w-10 shrink-0 select-none px-1 text-right text-fg-muted/60 hover:bg-accent/20 hover:text-accent"
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="w-10 shrink-0 select-none px-1 text-right text-fg-muted/60">{n ?? ""}</span>
|
||||
);
|
||||
@@ -462,7 +483,7 @@ function UnifiedLine({
|
||||
return (
|
||||
<Row tint={rowTint(line.type)} style={style} anchor={anchor}>
|
||||
<Gutter n={line.oldLine} />
|
||||
<Gutter n={line.newLine} />
|
||||
<Gutter n={line.newLine} commentable />
|
||||
<span className="whitespace-pre px-2">
|
||||
<span className={`select-none ${markerClass(line.type)}`}>
|
||||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||||
@@ -534,7 +555,10 @@ function SplitLine({
|
||||
const anchor = useAnchor(start);
|
||||
return (
|
||||
<Row tint={rowTint(line?.type ?? null)} style={style} anchor={anchor}>
|
||||
<Gutter n={(side === "left" ? line?.oldLine : line?.newLine) ?? null} />
|
||||
<Gutter
|
||||
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
|
||||
commentable={side === "right"}
|
||||
/>
|
||||
<span className="whitespace-pre px-1">
|
||||
{line ? (
|
||||
<Code
|
||||
@@ -700,6 +724,7 @@ export function LaneDiffView({
|
||||
layout,
|
||||
fill = false,
|
||||
hunkActions,
|
||||
onComment,
|
||||
}: {
|
||||
file: LaneChangeFile;
|
||||
layout: DiffLayout;
|
||||
@@ -707,9 +732,35 @@ export function LaneDiffView({
|
||||
fill?: boolean;
|
||||
/** Controls drawn in each change block's review bar. */
|
||||
hunkActions?: (change: LaneDiffChange) => ReactNode;
|
||||
/** Makes each new line's number a button that writes a comment on that line
|
||||
* and hands `<path>:<line> — <text>` here; false = it reached no pane. */
|
||||
onComment?: (text: string) => boolean;
|
||||
}) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [commentAt, setCommentAt] = useState<number | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [undelivered, setUndelivered] = useState(false);
|
||||
const openComment = useMemo(
|
||||
() =>
|
||||
onComment
|
||||
? {
|
||||
open: (line: number) => {
|
||||
setCommentAt(line);
|
||||
setUndelivered(false);
|
||||
},
|
||||
title: (line: number) => t("changes.commentLine", { n: line }),
|
||||
}
|
||||
: null,
|
||||
[onComment, t]
|
||||
);
|
||||
const sendComment = () => {
|
||||
const text = draft.trim();
|
||||
if (!text || commentAt == null || !onComment) return;
|
||||
if (!onComment(`${file.path}:${commentAt} — ${text}`)) return setUndelivered(true);
|
||||
setCommentAt(null);
|
||||
setDraft("");
|
||||
};
|
||||
const lang = langFor(file.path);
|
||||
const hunks = file.hunks;
|
||||
const items = useMemo(() => (hunks ? flatten(hunks, "unified") : []), [hunks]);
|
||||
@@ -755,50 +806,96 @@ export function LaneDiffView({
|
||||
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.noHunks")}</p>;
|
||||
|
||||
return (
|
||||
<HunkActions.Provider value={review}>
|
||||
<div
|
||||
ref={root}
|
||||
data-testid="lane-diff-view"
|
||||
data-layout={layout}
|
||||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||||
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
|
||||
layout === "split"
|
||||
? boxHeight(fill, splitWindowed)
|
||||
: `overflow-auto ${boxHeight(fill, win.on)}`
|
||||
}`}
|
||||
>
|
||||
{layout === "split" ? (
|
||||
<SplitView hunks={file.hunks} lang={lang} fill={fill} />
|
||||
) : win.on ? (
|
||||
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
|
||||
{items
|
||||
.slice(win.range.start, win.range.end)
|
||||
.map(({ hunk, i }, k) =>
|
||||
i < 0 ? (
|
||||
<HunkHeader key={win.range.start + k} hunk={hunk} fixed />
|
||||
) : (
|
||||
<UnifiedLine
|
||||
key={win.range.start + k}
|
||||
line={hunk.lines[i]!}
|
||||
lang={lang}
|
||||
emph={analyseHunk(hunk).emph.get(i)}
|
||||
style={FIXED}
|
||||
bar={barAtLine(hunk, i)}
|
||||
start={startAtLine(hunk, i)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Windowed>
|
||||
) : (
|
||||
file.hunks.map((hunk, i) => (
|
||||
<UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} />
|
||||
))
|
||||
<CommentLine.Provider value={openComment}>
|
||||
<HunkActions.Provider value={review}>
|
||||
<div
|
||||
ref={root}
|
||||
data-testid="lane-diff-view"
|
||||
data-layout={layout}
|
||||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||||
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
|
||||
layout === "split"
|
||||
? boxHeight(fill, splitWindowed)
|
||||
: `overflow-auto ${boxHeight(fill, win.on)}`
|
||||
}`}
|
||||
>
|
||||
{layout === "split" ? (
|
||||
<SplitView hunks={file.hunks} lang={lang} fill={fill} />
|
||||
) : win.on ? (
|
||||
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
|
||||
{items
|
||||
.slice(win.range.start, win.range.end)
|
||||
.map(({ hunk, i }, k) =>
|
||||
i < 0 ? (
|
||||
<HunkHeader key={win.range.start + k} hunk={hunk} fixed />
|
||||
) : (
|
||||
<UnifiedLine
|
||||
key={win.range.start + k}
|
||||
line={hunk.lines[i]!}
|
||||
lang={lang}
|
||||
emph={analyseHunk(hunk).emph.get(i)}
|
||||
style={FIXED}
|
||||
bar={barAtLine(hunk, i)}
|
||||
start={startAtLine(hunk, i)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Windowed>
|
||||
) : (
|
||||
file.hunks.map((hunk, i) => (
|
||||
<UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} />
|
||||
))
|
||||
)}
|
||||
{file.truncated && (
|
||||
<p className="px-2 py-1 text-[10px] text-status-warning">
|
||||
{t("changes.fileTruncated")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{commentAt != null && (
|
||||
<form
|
||||
data-testid="diff-comment-box"
|
||||
className="flex flex-col gap-1 border-t border-border bg-surface-2 p-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
sendComment();
|
||||
}}
|
||||
>
|
||||
<label className="text-[11px] text-fg-muted">
|
||||
{t("changes.commentOn", { at: `${file.path}:${commentAt}` })}
|
||||
<textarea
|
||||
autoFocus
|
||||
rows={2}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setCommentAt(null);
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) sendComment();
|
||||
}}
|
||||
className="mt-1 block w-full rounded border border-border bg-surface-1 px-2 py-1 font-sans text-xs text-fg-primary"
|
||||
/>
|
||||
</label>
|
||||
{undelivered && (
|
||||
<p role="alert" className="text-[11px] text-status-danger">
|
||||
{t("changes.commentNoPane")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
onClick={() => setCommentAt(null)}
|
||||
>
|
||||
{t("changes.commentCancel")}
|
||||
</button>
|
||||
<button type="submit" className="btn-primary text-xs" disabled={!draft.trim()}>
|
||||
{t("changes.commentSend")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{file.truncated && (
|
||||
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
|
||||
)}
|
||||
</div>
|
||||
</HunkActions.Provider>
|
||||
</HunkActions.Provider>
|
||||
</CommentLine.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user