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.
This commit is contained in:
2026-10-09 15:51:19 +07:00
parent ce773a3f5a
commit fa156d6a59
114 changed files with 5831 additions and 885 deletions
@@ -5,7 +5,11 @@
* the message stream via MessageList. Combines a WebSocket subscription, a
* visibility-gated polling fallback, and a manual refresh button so the view
* stays caught up even when hooks miss frames or the user is mid-text-only
* turn (no PreToolUse fires until Stop).
* turn (no PreToolUse fires until Stop). The view sticks to its end while
* the reader is there: anything that makes it taller — a new line, the live
* step, an image loading, the pane shown again after being hidden by CSS
* (a `display:none` pane cannot scroll) — keeps the newest in sight, until the
* reader scrolls up; `follow` (a chat's send) sends it back to the end.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -79,12 +83,21 @@ interface ConversationViewProps {
/** Fill the parent as a chat (a workspace's session view): no toolbar, no
* page-height cap — the parent sizes it and puts a composer under it. */
fill?: boolean;
/** Opens a file named in a turn's "Edited N files" card. */
onOpenFile?: (path: string) => void;
/** The session runs in a pane: show the step in progress. */
live?: boolean;
/** Changed (a chat's send): back to the end, and stick to it. */
follow?: number;
}
export function ConversationView({
sessionId,
initialTranscriptId,
fill = false,
onOpenFile,
live = false,
follow = 0,
}: ConversationViewProps) {
const { t } = useTranslation("sessions");
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
@@ -105,6 +118,7 @@ export function ConversationView({
const lastLineRef = useRef(0);
const firstLineRef = useRef(0);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true);
const fetchingRef = useRef(false);
// When a fetch is in flight and a new trigger arrives (WS event, poll,
@@ -392,6 +406,30 @@ export function ConversationView({
}
}, [hasMore, loadingHistory, loadHistory]);
// Stick to the end while the reader is there. Scroll events say whether
// they are; a size change — of the content, or of the box itself when a
// hidden pane is shown — never fires one, so it is watched here.
useEffect(() => {
const container = scrollContainerRef.current;
const content = contentRef.current;
if (!container || !content || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
if (isAtBottomRef.current) container.scrollTop = container.scrollHeight;
});
observer.observe(container);
observer.observe(content);
return () => observer.disconnect();
}, []);
// The reader sent a message: show it, and what follows, as it lands.
useEffect(() => {
if (!follow) return;
isAtBottomRef.current = true;
setAwayFromEnd(false);
setShowNewMsg(false);
scrollToBottom();
}, [follow, scrollToBottom]);
// Auto-scroll to bottom after initial load
useEffect(() => {
if (!loading && messages.length > 0) {
@@ -450,38 +488,42 @@ export function ConversationView({
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto"
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
>
{/* History loading indicator */}
{loadingHistory && (
<div className="flex justify-center py-3">
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
<span className="text-xs text-fg-muted ml-2">{t("conversation.loadingHistory")}</span>
</div>
)}
<div ref={contentRef}>
{/* History loading indicator */}
{loadingHistory && (
<div className="flex justify-center py-3">
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
<span className="text-xs text-fg-muted ml-2">{t("conversation.loadingHistory")}</span>
</div>
)}
{/* Scroll-up for history hint */}
{hasMore && !loadingHistory && !loading && (
<div className="flex justify-center py-2">
<span className="text-[11px] text-fg-muted">↑ {t("conversation.scrollForOlder")}</span>
</div>
)}
{/* Scroll-up for history hint */}
{hasMore && !loadingHistory && !loading && (
<div className="flex justify-center py-2">
<span className="text-[11px] text-fg-muted">
↑ {t("conversation.scrollForOlder")}
</span>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
{t("conversation.loading")}
</div>
) : messages.length === 0 ? (
<div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
{t("conversation.emptyBody")}
</p>
</div>
) : (
<MessageList messages={messages} loading={false} />
)}
{loading ? (
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
{t("conversation.loading")}
</div>
) : messages.length === 0 ? (
<div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
{t("conversation.emptyBody")}
</p>
</div>
) : (
<MessageList messages={messages} loading={false} onOpenFile={onOpenFile} live={live} />
)}
</div>
</div>
{/* Claude Desktop's round "back to the end" button */}