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:
2026-10-02 12:05:00 +07:00
parent 5b269698f6
commit a6bd33bcff
121 changed files with 2699 additions and 1552 deletions
+143 -46
View File
@@ -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>
);
}