From 6e128119b300284f8073105ee4a079d76a51f8bf Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Wed, 3 Jun 2026 18:06:56 +0800 Subject: [PATCH 1/5] fix(project-tools): stabilize terminal input and touch scrolling --- .../project-tools/ProjectToolsPanel.tsx | 257 ++++++++++++++---- crates/agent-gateway/web/src/styles.css | 12 + .../project-tools/ProjectToolsPanel.tsx | 181 ++++++++---- 3 files changed, 337 insertions(+), 113 deletions(-) diff --git a/crates/agent-gateway/web/src/components/project-tools/ProjectToolsPanel.tsx b/crates/agent-gateway/web/src/components/project-tools/ProjectToolsPanel.tsx index 315a9e7e..c01212a2 100644 --- a/crates/agent-gateway/web/src/components/project-tools/ProjectToolsPanel.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/ProjectToolsPanel.tsx @@ -342,16 +342,27 @@ function terminalTheme(theme: "light" | "dark") { }; } +function terminalContainerHasSize(container: HTMLElement) { + const rect = container.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; +} + function XTermViewport({ client, session, theme, + isActive, + initialSnapshot, onError, + onInitialSnapshotConsumed, }: { client: TerminalClient; session: TerminalSession; theme: "light" | "dark"; + isActive: boolean; + initialSnapshot?: TerminalSnapshot; onError: (message: string | null) => void; + onInitialSnapshotConsumed?: (sessionId: string) => void; }) { const containerRef = useRef(null); const resizeTimerRef = useRef(null); @@ -359,18 +370,33 @@ function XTermViewport({ const sessionRef = useRef(session); const themeRef = useRef(theme); const onErrorRef = useRef(onError); + const initialSnapshotRef = useRef(initialSnapshot); + const onInitialSnapshotConsumedRef = useRef(onInitialSnapshotConsumed); clientRef.current = client; sessionRef.current = session; themeRef.current = theme; onErrorRef.current = onError; + onInitialSnapshotConsumedRef.current = onInitialSnapshotConsumed; const termRef = useRef(null); + const fitAndResizeRef = useRef<(() => void) | null>(null); useEffect(() => { if (!termRef.current) return; termRef.current.options.theme = terminalTheme(theme); }, [theme]); + useEffect(() => { + if (!isActive) { + termRef.current?.blur(); + return; + } + termRef.current?.focus(); + window.setTimeout(() => { + fitAndResizeRef.current?.(); + }, 0); + }, [isActive]); + useEffect(() => { const container = containerRef.current; if (!container) return; @@ -382,7 +408,7 @@ function XTermViewport({ const bufferedEvents: TerminalEvent[] = []; const term = new XTerm({ cursorBlink: true, - disableStdin: true, + disableStdin: !sessionRef.current.running, fontFamily: '"SF Mono", SFMono-Regular, Menlo, Monaco, "Cascadia Code", Consolas, "Liberation Mono", monospace', fontSize: 12, @@ -397,9 +423,15 @@ function XTermViewport({ const fit = new FitAddon(); term.loadAddon(fit); term.open(container); + let touchScrollActive = false; + let touchScrollCancelled = false; + let lastTouchX = 0; + let lastTouchY = 0; + let touchScrollRemainder = 0; const fitAndResize = () => { if (disposed) return; + if (!terminalContainerHasSize(container)) return; try { fit.fit(); const s = sessionRef.current; @@ -410,6 +442,7 @@ function XTermViewport({ // xterm fit can throw while the panel is hidden or measuring at zero size. } }; + fitAndResizeRef.current = fitAndResize; const resizeObserver = new ResizeObserver(() => { if (resizeTimerRef.current !== null) { @@ -421,13 +454,85 @@ function XTermViewport({ window.setTimeout(fitAndResize, 0); const dataDisposable = term.onData((data) => { - if (!snapshotLoaded) return; const s = sessionRef.current; + if (!s.running) return; void clientRef.current.input(s.id, data, s.projectPathKey).catch((error) => { onErrorRef.current(error instanceof Error ? error.message : String(error)); }); }); + const getTouchScrollRowHeight = () => + Math.max(8, Math.floor(container.clientHeight / Math.max(1, term.rows))); + + const handleTouchStart = (event: TouchEvent) => { + if (event.touches.length !== 1) { + touchScrollCancelled = true; + touchScrollActive = false; + touchScrollRemainder = 0; + return; + } + const touch = event.touches[0]; + if (!touch) return; + touchScrollCancelled = false; + touchScrollActive = false; + touchScrollRemainder = 0; + lastTouchX = touch.clientX; + lastTouchY = touch.clientY; + }; + + const handleTouchMove = (event: TouchEvent) => { + if (touchScrollCancelled || event.touches.length !== 1) return; + const touch = event.touches[0]; + if (!touch) return; + + const deltaX = touch.clientX - lastTouchX; + const deltaY = touch.clientY - lastTouchY; + const absX = Math.abs(deltaX); + const absY = Math.abs(deltaY); + if (!touchScrollActive) { + if (absX > absY && absX > 8) { + touchScrollCancelled = true; + return; + } + if (absY < 8) return; + touchScrollActive = true; + } + + lastTouchX = touch.clientX; + lastTouchY = touch.clientY; + touchScrollRemainder += -deltaY; + const rowHeight = getTouchScrollRowHeight(); + const rows = Math.trunc(touchScrollRemainder / rowHeight); + if (rows !== 0) { + term.scrollLines(rows); + touchScrollRemainder -= rows * rowHeight; + } + event.preventDefault(); + }; + + const handleTouchEnd = () => { + touchScrollActive = false; + touchScrollCancelled = false; + touchScrollRemainder = 0; + }; + + container.addEventListener("touchstart", handleTouchStart, { passive: true }); + container.addEventListener("touchmove", handleTouchMove, { passive: false }); + container.addEventListener("touchend", handleTouchEnd); + container.addEventListener("touchcancel", handleTouchEnd); + + const applySnapshot = (snapshot: TerminalSnapshot) => { + if (snapshot.output) { + term.write(snapshot.output); + } + lastOutputOffset = terminalSnapshotEndOffset(snapshot); + snapshotLoaded = true; + loadingSnapshot = false; + term.options.disableStdin = !snapshot.session.running; + replayBufferedEvents(); + window.setTimeout(fitAndResize, 0); + }; + const replayBufferedEvents = () => { const events = bufferedEvents.splice(0); for (const event of events) { @@ -444,26 +549,27 @@ function XTermViewport({ const loadSnapshot = () => { if (disposed || loadingSnapshot) return; + const initial = initialSnapshotRef.current; + if (initial?.session.id === sessionRef.current.id) { + initialSnapshotRef.current = undefined; + onInitialSnapshotConsumedRef.current?.(initial.session.id); + applySnapshot(initial); + return; + } loadingSnapshot = true; const s = sessionRef.current; void clientRef.current .snapshot(s.id, undefined, s.projectPathKey) .then((snapshot) => { if (disposed) return; - if (snapshot.output) { - term.write(snapshot.output); - } - lastOutputOffset = terminalSnapshotEndOffset(snapshot); - snapshotLoaded = true; - loadingSnapshot = false; - term.options.disableStdin = !snapshot.session.running; - replayBufferedEvents(); - window.setTimeout(fitAndResize, 0); + applySnapshot(snapshot); }) .catch((error) => { loadingSnapshot = false; if (!disposed) { onErrorRef.current(error instanceof Error ? error.message : String(error)); + snapshotLoaded = true; + replayBufferedEvents(); } }); }; @@ -494,6 +600,7 @@ function XTermViewport({ return () => { disposed = true; termRef.current = null; + fitAndResizeRef.current = null; unsubscribe(); dataDisposable.dispose(); resizeObserver.disconnect(); @@ -501,6 +608,10 @@ function XTermViewport({ window.clearTimeout(resizeTimerRef.current); resizeTimerRef.current = null; } + container.removeEventListener("touchstart", handleTouchStart); + container.removeEventListener("touchmove", handleTouchMove); + container.removeEventListener("touchend", handleTouchEnd); + container.removeEventListener("touchcancel", handleTouchEnd); const s = sessionRef.current; void clientRef.current.detach(s.id, s.projectPathKey).catch(() => undefined); term.dispose(); @@ -508,7 +619,12 @@ function XTermViewport({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [session.id, session.projectPathKey]); - return
; + return ( +
+ ); } function terminalSnapshotEndOffset(snapshot: TerminalSnapshot) { @@ -642,6 +758,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { const [widthCollapsed, setWidthCollapsed] = useState(!isOpen); const [isResizing, setIsResizing] = useState(false); const panelRef = useRef(null); + const initialTerminalSnapshotsRef = useRef>(new Map()); const [maxPanelWidth, setMaxPanelWidth] = useState(getFallbackMaxPanelWidth); const projectReady = projectPathKey.trim() !== "" && cwd.trim() !== "" && !disabledMessage; const terminalReady = projectReady && !terminalDisabledMessage; @@ -1021,6 +1138,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { useEffect(() => { if (lastProjectPathKeyRef.current === projectPathKey) return; lastProjectPathKeyRef.current = projectPathKey; + initialTerminalSnapshotsRef.current.clear(); setPendingCloseSessionId(""); setClosingSessionId(""); setDraftTabOrder(null); @@ -1064,6 +1182,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { rows: DEFAULT_TERMINAL_ROWS, }) .then((snapshot) => { + initialTerminalSnapshotsRef.current.set(snapshot.session.id, snapshot); setSessions((current) => { const next = sortSessions([ ...current.filter((session) => session.id !== snapshot.session.id), @@ -1093,6 +1212,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { void client .close(session.id, session.projectPathKey) .then(() => { + initialTerminalSnapshotsRef.current.delete(session.id); setPendingCloseSessionId((current) => (current === session.id ? "" : current)); setSessions((current) => { const next = sortSessions(current.filter((item) => item.id !== session.id)); @@ -1106,6 +1226,10 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { [client, closingSessionId, onSessionsChange], ); + const handleInitialTerminalSnapshotConsumed = useCallback((sessionId: string) => { + initialTerminalSnapshotsRef.current.delete(sessionId); + }, []); + const handleCloseRequest = useCallback( (session: TerminalSession) => { setError(null); @@ -1980,58 +2104,77 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { />
) : null} - {currentActiveTab === "terminal" ? ( - activeSession ? ( -
- {error ? ( -
- {error} -
- ) : null} -
- + {sessions.length > 0 ? ( +
+ {error ? ( +
+ {error}
+ ) : null} +
+ {sessions.map((session) => { + const isActiveTerminal = + currentActiveTab === "terminal" && activeSession?.id === session.id; + return ( +
+ +
+ ); + })} +
+
+ ) : currentActiveTab === "terminal" ? ( +
+
+
- ) : ( -
-
- +
+
+ {t("projectTools.newTerminal")}
-
-
- {t("projectTools.newTerminal")} + {error ? ( +
{error}
+ ) : terminalDisabledMessage ? ( +
+ {terminalDisabledMessage}
- {terminalDisabledMessage ? ( -
- {terminalDisabledMessage} -
- ) : ( -
- {t("projectTools.terminalDescription")} -
- )} -
- - {loading ? ( + ) : (
- {t("projectTools.loading")} + {t("projectTools.terminalDescription")}
- ) : null} - {error ?
{error}
: null} + )}
- ) + + {loading ? ( +
+ {t("projectTools.loading")} +
+ ) : null} +
) : null} )} diff --git a/crates/agent-gateway/web/src/styles.css b/crates/agent-gateway/web/src/styles.css index 016936f7..9534497c 100644 --- a/crates/agent-gateway/web/src/styles.css +++ b/crates/agent-gateway/web/src/styles.css @@ -1848,6 +1848,18 @@ html[data-liveagent-webui="gateway"] .external-link-modal-overlay[data-state="cl min-width: 0; } + html[data-liveagent-webui="gateway"] .project-terminal-viewport, + html[data-liveagent-webui="gateway"] .project-terminal-viewport .xterm, + html[data-liveagent-webui="gateway"] .project-terminal-viewport .xterm-screen, + html[data-liveagent-webui="gateway"] .project-terminal-viewport .xterm-scrollable-element { + overscroll-behavior: contain; + touch-action: none; + } + + html[data-liveagent-webui="gateway"] .project-terminal-viewport .xterm-scrollable-element { + -webkit-overflow-scrolling: touch; + } + html[data-liveagent-webui="gateway"] .project-tools-panel-handle { display: block; width: 36px; diff --git a/crates/agent-gui/src/components/project-tools/ProjectToolsPanel.tsx b/crates/agent-gui/src/components/project-tools/ProjectToolsPanel.tsx index f0b59194..1a816234 100644 --- a/crates/agent-gui/src/components/project-tools/ProjectToolsPanel.tsx +++ b/crates/agent-gui/src/components/project-tools/ProjectToolsPanel.tsx @@ -341,16 +341,27 @@ function terminalTheme(theme: "light" | "dark") { }; } +function terminalContainerHasSize(container: HTMLElement) { + const rect = container.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; +} + function XTermViewport({ client, session, theme, + isActive, + initialSnapshot, onError, + onInitialSnapshotConsumed, }: { client: TerminalClient; session: TerminalSession; theme: "light" | "dark"; + isActive: boolean; + initialSnapshot?: TerminalSnapshot; onError: (message: string | null) => void; + onInitialSnapshotConsumed?: (sessionId: string) => void; }) { const containerRef = useRef(null); const resizeTimerRef = useRef(null); @@ -358,18 +369,33 @@ function XTermViewport({ const sessionRef = useRef(session); const themeRef = useRef(theme); const onErrorRef = useRef(onError); + const initialSnapshotRef = useRef(initialSnapshot); + const onInitialSnapshotConsumedRef = useRef(onInitialSnapshotConsumed); clientRef.current = client; sessionRef.current = session; themeRef.current = theme; onErrorRef.current = onError; + onInitialSnapshotConsumedRef.current = onInitialSnapshotConsumed; const termRef = useRef(null); + const fitAndResizeRef = useRef<(() => void) | null>(null); useEffect(() => { if (!termRef.current) return; termRef.current.options.theme = terminalTheme(theme); }, [theme]); + useEffect(() => { + if (!isActive) { + termRef.current?.blur(); + return; + } + termRef.current?.focus(); + window.setTimeout(() => { + fitAndResizeRef.current?.(); + }, 0); + }, [isActive]); + useEffect(() => { const container = containerRef.current; if (!container) return; @@ -381,7 +407,7 @@ function XTermViewport({ const bufferedEvents: TerminalEvent[] = []; const term = new XTerm({ cursorBlink: true, - disableStdin: true, + disableStdin: !sessionRef.current.running, fontFamily: '"SF Mono", SFMono-Regular, Menlo, Monaco, "Cascadia Code", Consolas, "Liberation Mono", monospace', fontSize: 12, @@ -399,6 +425,7 @@ function XTermViewport({ const fitAndResize = () => { if (disposed) return; + if (!terminalContainerHasSize(container)) return; try { fit.fit(); const s = sessionRef.current; @@ -409,6 +436,7 @@ function XTermViewport({ // xterm fit can throw while the panel is hidden or measuring at zero size. } }; + fitAndResizeRef.current = fitAndResize; const resizeObserver = new ResizeObserver(() => { if (resizeTimerRef.current !== null) { @@ -420,13 +448,25 @@ function XTermViewport({ window.setTimeout(fitAndResize, 0); const dataDisposable = term.onData((data) => { - if (!snapshotLoaded) return; const s = sessionRef.current; + if (!s.running) return; void clientRef.current.input(s.id, data, s.projectPathKey).catch((error) => { onErrorRef.current(error instanceof Error ? error.message : String(error)); }); }); + const applySnapshot = (snapshot: TerminalSnapshot) => { + if (snapshot.output) { + term.write(snapshot.output); + } + lastOutputOffset = terminalSnapshotEndOffset(snapshot); + snapshotLoaded = true; + loadingSnapshot = false; + term.options.disableStdin = !snapshot.session.running; + replayBufferedEvents(); + window.setTimeout(fitAndResize, 0); + }; + const replayBufferedEvents = () => { const events = bufferedEvents.splice(0); for (const event of events) { @@ -443,26 +483,27 @@ function XTermViewport({ const loadSnapshot = () => { if (disposed || loadingSnapshot) return; + const initial = initialSnapshotRef.current; + if (initial?.session.id === sessionRef.current.id) { + initialSnapshotRef.current = undefined; + onInitialSnapshotConsumedRef.current?.(initial.session.id); + applySnapshot(initial); + return; + } loadingSnapshot = true; const s = sessionRef.current; void clientRef.current .snapshot(s.id, undefined, s.projectPathKey) .then((snapshot) => { if (disposed) return; - if (snapshot.output) { - term.write(snapshot.output); - } - lastOutputOffset = terminalSnapshotEndOffset(snapshot); - snapshotLoaded = true; - loadingSnapshot = false; - term.options.disableStdin = !snapshot.session.running; - replayBufferedEvents(); - window.setTimeout(fitAndResize, 0); + applySnapshot(snapshot); }) .catch((error) => { loadingSnapshot = false; if (!disposed) { onErrorRef.current(error instanceof Error ? error.message : String(error)); + snapshotLoaded = true; + replayBufferedEvents(); } }); }; @@ -493,6 +534,7 @@ function XTermViewport({ return () => { disposed = true; termRef.current = null; + fitAndResizeRef.current = null; unsubscribe(); dataDisposable.dispose(); resizeObserver.disconnect(); @@ -637,6 +679,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { const [widthCollapsed, setWidthCollapsed] = useState(!isOpen); const [isResizing, setIsResizing] = useState(false); const panelRef = useRef(null); + const initialTerminalSnapshotsRef = useRef>(new Map()); const [maxPanelWidth, setMaxPanelWidth] = useState(getFallbackMaxPanelWidth); const projectReady = projectPathKey.trim() !== "" && cwd.trim() !== "" && !disabledMessage; const terminalReady = projectReady && !terminalDisabledMessage; @@ -899,6 +942,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { useEffect(() => { if (lastProjectPathKeyRef.current === projectPathKey) return; lastProjectPathKeyRef.current = projectPathKey; + initialTerminalSnapshotsRef.current.clear(); setPendingCloseSessionId(""); setClosingSessionId(""); setDraftTabOrder(null); @@ -942,6 +986,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { rows: DEFAULT_TERMINAL_ROWS, }) .then((snapshot) => { + initialTerminalSnapshotsRef.current.set(snapshot.session.id, snapshot); setSessions((current) => { const next = sortSessions([ ...current.filter((session) => session.id !== snapshot.session.id), @@ -971,6 +1016,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { void client .close(session.id, session.projectPathKey) .then(() => { + initialTerminalSnapshotsRef.current.delete(session.id); setPendingCloseSessionId((current) => (current === session.id ? "" : current)); setSessions((current) => { const next = sortSessions(current.filter((item) => item.id !== session.id)); @@ -984,6 +1030,10 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { [client, closingSessionId, onSessionsChange], ); + const handleInitialTerminalSnapshotConsumed = useCallback((sessionId: string) => { + initialTerminalSnapshotsRef.current.delete(sessionId); + }, []); + const handleCloseRequest = useCallback( (session: TerminalSession) => { setError(null); @@ -1734,58 +1784,77 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { />
) : null} - {currentActiveTab === "terminal" ? ( - activeSession ? ( -
- {error ? ( -
- {error} -
- ) : null} -
- + {sessions.length > 0 ? ( +
+ {error ? ( +
+ {error}
+ ) : null} +
+ {sessions.map((session) => { + const isActiveTerminal = + currentActiveTab === "terminal" && activeSession?.id === session.id; + return ( +
+ +
+ ); + })}
- ) : ( -
-
- +
+ ) : currentActiveTab === "terminal" ? ( +
+
+ +
+
+
+ {t("projectTools.newTerminal")}
-
-
- {t("projectTools.newTerminal")} + {error ? ( +
{error}
+ ) : terminalDisabledMessage ? ( +
+ {terminalDisabledMessage}
- {terminalDisabledMessage ? ( -
- {terminalDisabledMessage} -
- ) : ( -
- {t("projectTools.terminalDescription")} -
- )} -
- - {loading ? ( + ) : (
- {t("projectTools.loading")} + {t("projectTools.terminalDescription")}
- ) : null} - {error ?
{error}
: null} + )}
- ) + + {loading ? ( +
+ {t("projectTools.loading")} +
+ ) : null} +
) : null} )} From cdcee104252d5a6083f1e445ff9283b7cbed0fb7 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Sun, 7 Jun 2026 11:13:29 +0800 Subject: [PATCH 2/5] chore(gitignore): ignore chatroom workspace --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c4a5c37b..898cbf38 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ target target bin .serena +chatroom # Editor directories and files .vscode/* From c881275c941d1ecbb4ed9f6bbbda4aa68e35c252 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Sun, 7 Jun 2026 12:09:20 +0800 Subject: [PATCH 3/5] fix(git-review): mark deleted changes with strikethrough --- .../project-tools/GitReviewPanel.tsx | 27 +++++++++++++++++-- .../project-tools/GitReviewPanel.tsx | 27 +++++++++++++++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx b/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx index 303ea6ca..47d78856 100644 --- a/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/GitReviewPanel.tsx @@ -1657,10 +1657,20 @@ function DiffReviewCard(props: { function statusTone(entry: GitStatusEntry) { if (entry.conflicted) return "text-destructive"; if (entry.untracked) return "text-sky-600 dark:text-sky-300"; + if (isDeletedStatusEntry(entry)) return "text-rose-600 dark:text-rose-300"; if (entry.staged) return "text-emerald-600 dark:text-emerald-300"; return "text-amber-600 dark:text-amber-300"; } +function isDeletedStatusEntry(entry: GitStatusEntry) { + if (entry.untracked) return false; + return ( + entry.kind === "deleted" || + entry.indexStatus === "D" || + entry.worktreeStatus === "D" + ); +} + function statusLabel(entry: GitStatusEntry) { if (entry.conflicted) return "U"; if (entry.untracked) return "U"; @@ -4158,6 +4168,7 @@ export const GitReviewPanel = memo(function GitReviewPanel(props: GitReviewPanel const TypeIcon = getFileTypeIcon(entry.path, "file"); const fileName = basename(entry.path); const filePath = parentPath(entry.path); + const deleted = isDeletedStatusEntry(entry); return (