Files
kandev/apps/web/components/task/task-center-panel.tsx
T
Daniel Brown c0eaafaf29 fix(mobile): isPassthroughMode falls back to TaskChatPanel when snapshot is missing (closes #1031) (#1034)
* fix(mobile): passthrough TUI rendering + touch-scroll

Two related mobile-UI fixes for cli_passthrough sessions, both
filed in #1031.

1. task-center-panel.tsx: align isPassthroughMode with the rest
   of the codebase by checking session.is_passthrough first. The
   memo previously only consulted agent_profile_snapshot.cli_passthrough,
   which can be undefined in the Redux store on re-mount (the snapshot
   isn't always present on the leaner session-list responses). When
   that happened on mobile, the chat panel rendered the persisted-
   messages TaskChatPanel fallback instead of the live PassthroughTerminal.
   Desktop was immune because dockview-desktop-layout.tsx already uses
   the session.is_passthrough field directly. is_passthrough is the
   source of truth used by manager_startup.go's StartAgentProcess
   routing — keeping the frontend consistent with that invariant.

2. passthrough-terminal.tsx: wire touch-drag scrolling on the xterm
   viewport. xterm.js handles mouse wheel natively but not touch
   gestures, so swiping inside the terminal on mobile bubbled up
   and scrolled the page instead of the terminal scrollback —
   effectively making everything that scrolled off-screen unreachable
   on mobile. The new hook attaches touchstart/touchmove/touchend
   handlers that map vertical drag delta to term.scrollLines(),
   gated on (pointer: coarse) so desktop xterm behaviour stays
   identical. Row-height comes from xterm's internal render dimensions
   with a measured fallback (xterm 5.x layout) and a 16px constant
   fallback if both fail.

Closes #1031

* fix(mobile): also fix useSessionLayoutState's isPassthroughMode + tighten touch-handling

Three follow-on fixes after Dan's mobile QA surfaced more cases:

1. apps/web/hooks/use-session-layout-state.ts — the mobile layout actually
   uses THIS hook's isPassthroughMode memo (not the one in
   task-center-panel.tsx that I previously fixed). It had the identical
   snapshot-only bug. Aligned with the same is_passthrough-first priority.

2. apps/web/components/task/passthrough-terminal.tsx — always preventDefault
   on touchmove (was only doing so when the row-delta calculation rounded
   to non-zero, so sub-row drags slipped through to parent scroll). Added
   touch-action: none + overscroll-behavior: contain inline on the terminal
   element so the browser never claims the gesture.

3. apps/web/app/globals.css — overscroll-behavior: none on body to block
   pull-to-refresh. iOS Safari ignores this in some configurations but
   blocking it where possible reduces the page-reload race that was
   triggering the stuck-on-loading-messages state.

* fix(mobile): trim PR to minimum required changes

After QA showed the user-visible mobile bug is fully addressed by the
useSessionLayoutState memo fix alone, revert the parallel changes to
the desktop hook (task-center-panel.tsx's useSessionApprove) and the
body-level overscroll-behavior — neither was required for the
reported bug, and Dan asked for the smallest possible diff.

Remaining changes:
- apps/web/hooks/use-session-layout-state.ts — actual mobile fix
- apps/web/components/task/passthrough-terminal.tsx — touch-scroll feature

* fix(mobile): tighten isPassthroughMode + drop terminal touch-scroll from this PR

Adversarial-review feedback applied:
- Change `is_passthrough === true` to `!== undefined` so an explicit
  server-side false isn't overridden by a stale snapshot.
- Comment now cites the specific WS handler quirk in
  ws/handlers/agent-session.ts where snapshot writes are truthy-guarded
  but is_passthrough writes are !== undefined-guarded — that's the
  concrete path where partial events land one without the other.

Dropped: the useMobileTouchScroll hook. Terminal scroll on mobile is a
separate feature that deserves its own discussion (xterm renders the
canvas above the scrollable viewport so native scroll doesn't reach the
viewport; the right fix likely lives in xterm itself or a small
event-redirect addon). Keeping this PR scoped to the rendering bug.

* fix(mobile): add minimal touch-scroll fallback for xterm viewport

xterm.js renders .xterm-screen (canvas) above .xterm-viewport, so iOS /
Android native scroll on the viewport never sees the touches and the
terminal is unscrollable on mobile.

useTouchScrollFallback wires touchstart/touchmove/touchend to
Terminal.scrollLines via an accumulated fractional-row delta. No
inertia — the proper fix is upstream in xterm-js. Gated on
(pointer: coarse) so desktop xterm behaviour is unchanged.

Sets touch-action: none + overscroll-behavior: contain inline on the
terminal element so the browser doesn't claim the gesture for
pull-to-refresh.

* fix(mobile): inertia decay on touch-scroll release for native-scroll feel

* fix(mobile): address adversarial review of touch-scroll hook

- Read xterm from ref inside each handler so renderer recreate doesn't
  strand the handlers on a stale Terminal instance (React refs are
  stable so dep array doesn't catch .current swaps).
- Drop the internal _core._renderService private-API path for row
  height in favor of public term.rows + measured element height. The
  internal path was API-unstable across xterm majors.
- Velocity window 80ms → 50ms so a direction reversal at the tail of
  the gesture is the release velocity, not averaged against the early
  motion.
- Reset pendingRows fractional accumulator on direction reversal — was
  causing perceptible stickiness when reversing from slow-up to fast-down.
- Style restoration now uses removeProperty when the inline style was
  never set, rather than leaking an empty string in its place.

* fix(mobile): revert touch-scroll hook from this PR — separate concern

After several iterations the touch-scroll fallback kept surfacing new
issues (iOS pull-to-refresh leaking through preventDefault, scroll
direction reversal edge cases) that suggest a more involved upstream
discussion is needed about whether to ship a workaround in
PassthroughTerminal at all vs fixing xterm.js's mobile story upstream.

Splitting that work out so this PR can land the rendering bug fix
cleanly. Will file a separate issue for the touch-scroll work with
the diagnosis + alternatives we explored.

* fix(mobile): honor is_passthrough when snapshot missing

---------

Co-authored-by: Carlos Florêncio <carlosmflorencio@gmail.com>
2026-05-25 22:09:17 +01:00

545 lines
17 KiB
TypeScript

"use client";
import { memo, useCallback, useState, useEffect, useMemo } from "react";
import { IconCheck, IconChevronDown, IconX } from "@tabler/icons-react";
import { TabsContent } from "@kandev/ui/tabs";
import { Button } from "@kandev/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@kandev/ui/dropdown-menu";
import { SessionPanel } from "@kandev/ui/pannel-session";
import { TaskChatPanel } from "./task-chat-panel";
import { TaskChangesPanel } from "./task-changes-panel";
import { FileTabContent } from "./file-tab-content";
import { PassthroughToolbar } from "./passthrough-toolbar";
import type { OpenFileTab, FileContentResponse } from "@/lib/types/backend";
import { useAppStore } from "@/components/state-provider";
import { SessionTabs, type SessionTab } from "@/components/session-tabs";
import { getWebSocketClient } from "@/lib/ws/connection";
import { executeApprove } from "@/lib/services/session-approve";
import { requestFileContent } from "@/lib/ws/workspace-files";
import {
setOpenFileTabs as saveOpenFileTabs,
getActiveTabForSession,
setActiveTabForSession,
} from "@/lib/local-storage";
import { isPassthroughSession } from "@/lib/session/is-passthrough-session";
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
import { useSessionCommits } from "@/hooks/domains/session/use-session-commits";
import { calculateHash } from "@/lib/utils/file-diff";
import { useToast } from "@/components/toast-provider";
import { useFileTabRestoration, useFileSaveDelete } from "./task-center-panel-restoration";
import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr";
import { PRDetailContent } from "@/components/github/pr-detail-panel";
import type { SelectedDiff } from "./task-layout";
type TaskCenterPanelProps = {
selectedDiff: SelectedDiff | null;
openFileRequest: OpenFileTab | null;
onDiffHandled: () => void;
onFileOpenHandled: () => void;
onActiveFileChange?: (filePath: string | null) => void;
sessionId?: string | null;
};
function useSessionApprove(activeSessionId: string | null, activeTaskId: string | null) {
const activeSession = useAppStore((state) =>
activeSessionId ? (state.taskSessions.items[activeSessionId] ?? null) : null,
);
const setTaskSession = useAppStore((state) => state.setTaskSession);
const isAgentWorking = activeSession?.state === "STARTING" || activeSession?.state === "RUNNING";
const isPassthroughMode = useMemo(() => isPassthroughSession(activeSession), [activeSession]);
const showApproveButton =
!!activeSession?.review_status && activeSession.review_status !== "approved" && !isAgentWorking;
const handleApprove = useCallback(async () => {
if (!activeSessionId || !activeTaskId) return;
try {
await executeApprove(activeSessionId, activeTaskId, setTaskSession);
} catch (error) {
console.error("Failed to approve session:", error);
}
}, [activeSessionId, activeTaskId, setTaskSession]);
return { activeSession, isPassthroughMode, showApproveButton, handleApprove };
}
function useLeftTabState(
activeSessionId: string | null,
hasChanges: boolean | undefined,
onActiveFileChange?: (filePath: string | null) => void,
) {
const [leftTab, setLeftTab] = useState(() => {
if (typeof window !== "undefined" && activeSessionId) {
const savedTab = getActiveTabForSession(activeSessionId, "chat");
if (savedTab === "chat" || savedTab === "changes") return savedTab;
}
return "chat";
});
const [showRequestChangesTooltip, setShowRequestChangesTooltip] = useState(false);
useEffect(() => {
if (leftTab === "changes" && !hasChanges) queueMicrotask(() => setLeftTab("chat"));
}, [leftTab, hasChanges]);
useEffect(() => {
const handler = () => {
if (hasChanges) setLeftTab("changes");
};
window.addEventListener("switch-to-changes-tab", handler);
return () => window.removeEventListener("switch-to-changes-tab", handler);
}, [hasChanges]);
useEffect(() => {
if (leftTab.startsWith("file:")) onActiveFileChange?.(leftTab.replace("file:", ""));
else onActiveFileChange?.(null);
}, [leftTab, onActiveFileChange]);
const handleTabChange = useCallback(
(tab: string) => {
setLeftTab(tab);
if (activeSessionId) setActiveTabForSession(activeSessionId, tab);
},
[activeSessionId],
);
const handleRequestChanges = useCallback(() => {
setLeftTab("chat");
setShowRequestChangesTooltip(true);
setTimeout(() => setShowRequestChangesTooltip(false), 5000);
}, []);
return {
leftTab,
setLeftTab,
showRequestChangesTooltip,
setShowRequestChangesTooltip,
handleTabChange,
handleRequestChanges,
};
}
type FileTabOperationsOptions = {
activeSessionId: string | null;
openFileTabs: OpenFileTab[];
setOpenFileTabs: React.Dispatch<React.SetStateAction<OpenFileTab[]>>;
setSavingFiles: React.Dispatch<React.SetStateAction<Set<string>>>;
setLeftTab: (tab: string) => void;
handleTabChange: (tab: string) => void;
leftTab: string;
};
function useFileTabOperations({
activeSessionId,
openFileTabs,
setOpenFileTabs,
setSavingFiles,
setLeftTab,
handleTabChange,
leftTab,
}: FileTabOperationsOptions) {
const { toast } = useToast();
const addFileTab = useCallback(
(fileTab: OpenFileTab) => {
setOpenFileTabs((prev) => {
if (prev.some((t) => t.path === fileTab.path)) return prev;
const maxTabs = 4;
return prev.length >= maxTabs ? [...prev.slice(1), fileTab] : [...prev, fileTab];
});
setLeftTab(`file:${fileTab.path}`);
},
[setOpenFileTabs, setLeftTab],
);
const handleOpenFileFromChat = useCallback(
async (filePath: string) => {
const client = getWebSocketClient();
if (!client || !activeSessionId) return;
try {
const response: FileContentResponse = await requestFileContent(
client,
activeSessionId,
filePath,
);
const fileName = filePath.split("/").pop() || filePath;
const hash = await calculateHash(response.content);
addFileTab({
path: filePath,
name: fileName,
content: response.content,
originalContent: response.content,
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
});
} catch (error) {
toast({
title: "Failed to open file",
description: error instanceof Error ? error.message : "Unknown error",
variant: "error",
});
}
},
[activeSessionId, toast, addFileTab],
);
const handleCloseFileTab = useCallback(
(path: string) => {
setOpenFileTabs((prev) => prev.filter((t) => t.path !== path));
if (leftTab === `file:${path}`) handleTabChange("chat");
},
[leftTab, handleTabChange, setOpenFileTabs],
);
const handleFileChange = useCallback(
(path: string, newContent: string) => {
setOpenFileTabs((prev) =>
prev.map((tab) =>
tab.path === path
? { ...tab, content: newContent, isDirty: newContent !== tab.originalContent }
: tab,
),
);
},
[setOpenFileTabs],
);
const { handleFileSave, handleFileDelete } = useFileSaveDelete({
activeSessionId,
openFileTabs,
setOpenFileTabs,
setSavingFiles,
handleCloseFileTab,
});
return {
handleOpenFileFromChat,
handleCloseFileTab,
handleFileChange,
handleFileSave,
handleFileDelete,
addFileTab,
};
}
function useCenterPanelTabs(
openFileTabs: OpenFileTab[],
handleCloseFileTab: (path: string) => void,
hasChanges: boolean | undefined,
hasPR: boolean,
) {
const tabs: SessionTab[] = useMemo(() => {
const staticTabs: SessionTab[] = [
...(hasChanges ? [{ id: "changes", label: "All changes" }] : []),
{ id: "chat", label: "Chat" },
...(hasPR ? [{ id: "pr", label: "Pull Request" }] : []),
];
const fileTabs: SessionTab[] = openFileTabs.map((tab) => ({
id: `file:${tab.path}`,
label: tab.isDirty ? `${tab.name} *` : tab.name,
icon: tab.isDirty ? <span className="h-2 w-2 rounded-full bg-yellow-500" /> : undefined,
closable: true,
onClose: (e: React.MouseEvent) => {
e.stopPropagation();
handleCloseFileTab(tab.path);
},
className: "cursor-pointer group gap-1.5 data-[state=active]:bg-muted",
}));
return [...staticTabs, ...fileTabs];
}, [openFileTabs, handleCloseFileTab, hasChanges, hasPR]);
const separatorAfterIndex = useMemo(() => {
if (openFileTabs.length === 0) return undefined;
const staticCount = (hasChanges ? 1 : 0) + 1 + (hasPR ? 1 : 0); // changes + chat + pr
return staticCount - 1;
}, [openFileTabs.length, hasChanges, hasPR]);
return { tabs, separatorAfterIndex };
}
function useCenterPanelState(props: TaskCenterPanelProps) {
const {
selectedDiff: externalSelectedDiff,
openFileRequest,
onDiffHandled,
onFileOpenHandled,
onActiveFileChange,
} = props;
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const gitStatus = useSessionGitStatus(activeSessionId);
const { commits } = useSessionCommits(activeSessionId);
const hasChanges = useMemo(() => {
const hasUncommittedChanges = gitStatus?.files && Object.keys(gitStatus.files).length > 0;
return (hasUncommittedChanges || (commits && commits.length > 0)) as boolean;
}, [gitStatus, commits]);
const { activeSession, isPassthroughMode, showApproveButton, handleApprove } = useSessionApprove(
activeSessionId,
activeTaskId,
);
const taskPR = useActiveTaskPR();
const hasPR = !!taskPR;
const [openFileTabs, setOpenFileTabs] = useState<OpenFileTab[]>([]);
const [savingFiles, setSavingFiles] = useState<Set<string>>(new Set());
const [selectedDiff, setSelectedDiff] = useState<SelectedDiff | null>(null);
const {
leftTab,
setLeftTab,
showRequestChangesTooltip,
setShowRequestChangesTooltip,
handleTabChange,
handleRequestChanges,
} = useLeftTabState(activeSessionId, hasChanges, onActiveFileChange);
useFileTabRestoration({ activeSessionId, leftTab, setLeftTab, setOpenFileTabs });
useEffect(() => {
if (!activeSessionId) return;
saveOpenFileTabs(
activeSessionId,
openFileTabs.map(({ path, name }) => ({ path, name })),
);
}, [activeSessionId, openFileTabs]);
const fileTabOps = useFileTabOperations({
activeSessionId,
openFileTabs,
setOpenFileTabs,
setSavingFiles,
setLeftTab,
handleTabChange,
leftTab,
});
const { tabs, separatorAfterIndex } = useCenterPanelTabs(
openFileTabs,
fileTabOps.handleCloseFileTab,
hasChanges,
hasPR,
);
useEffect(() => {
if (externalSelectedDiff) {
queueMicrotask(() => {
setSelectedDiff(externalSelectedDiff);
setLeftTab("changes");
onDiffHandled();
});
}
}, [externalSelectedDiff, onDiffHandled, setLeftTab]);
useEffect(() => {
if (!openFileRequest) return;
queueMicrotask(async () => {
const hash = openFileRequest.originalHash || (await calculateHash(openFileRequest.content));
fileTabOps.addFileTab({
...openFileRequest,
originalContent: openFileRequest.originalContent || openFileRequest.content,
originalHash: hash,
isDirty: openFileRequest.isDirty ?? false,
});
onFileOpenHandled();
});
}, [openFileRequest, onFileOpenHandled, fileTabOps.addFileTab]); // eslint-disable-line react-hooks/exhaustive-deps
return {
activeTaskId,
activeSessionId,
activeSession,
isPassthroughMode,
showApproveButton,
handleApprove,
taskPR,
openFileTabs,
savingFiles,
selectedDiff,
setSelectedDiff,
leftTab,
showRequestChangesTooltip,
setShowRequestChangesTooltip,
handleTabChange,
handleRequestChanges,
fileTabOps,
tabs,
separatorAfterIndex,
};
}
export const TaskCenterPanel = memo(function TaskCenterPanel(props: TaskCenterPanelProps) {
const { sessionId = null } = props;
const state = useCenterPanelState(props);
const {
activeTaskId,
activeSessionId,
activeSession,
isPassthroughMode,
showApproveButton,
handleApprove,
taskPR,
openFileTabs,
savingFiles,
selectedDiff,
setSelectedDiff,
leftTab,
showRequestChangesTooltip,
setShowRequestChangesTooltip,
handleTabChange,
handleRequestChanges,
fileTabOps,
tabs,
separatorAfterIndex,
} = state;
const { handleOpenFileFromChat, handleFileChange, handleFileSave, handleFileDelete } = fileTabOps;
const approveContent = showApproveButton ? (
<ApproveButtonGroup onApprove={handleApprove} onRequestChanges={handleRequestChanges} />
) : undefined;
return (
<SessionPanel borderSide="right" margin="right">
<SessionTabs
tabs={tabs}
activeTab={leftTab}
onTabChange={handleTabChange}
separatorAfterIndex={separatorAfterIndex}
className="flex-1 min-h-0 flex flex-col gap-2"
rightContent={approveContent}
>
<TabsContent value="changes" className="flex-1 min-h-0">
<TaskChangesPanel
selectedDiff={selectedDiff}
onClearSelected={() => setSelectedDiff(null)}
onOpenFile={handleOpenFileFromChat}
/>
</TabsContent>
<ChatTabContent
activeTaskId={activeTaskId}
isPassthroughMode={isPassthroughMode}
sessionId={sessionId}
showRequestChangesTooltip={showRequestChangesTooltip}
onDismissTooltip={() => setShowRequestChangesTooltip(false)}
onOpenFile={handleOpenFileFromChat}
/>
{taskPR && activeSessionId && (
<TabsContent value="pr" className="flex-1 min-h-0" data-testid="pr-detail-panel">
<PRDetailContent taskPR={taskPR} sessionId={activeSessionId} />
</TabsContent>
)}
{openFileTabs.map((tab) => (
<FileTabContent
key={tab.path}
tab={tab}
activeSession={activeSession}
activeSessionId={activeSessionId}
isSaving={savingFiles.has(tab.path)}
onFileChange={handleFileChange}
onFileSave={handleFileSave}
onFileDelete={handleFileDelete}
/>
))}
</SessionTabs>
</SessionPanel>
);
});
// --- Extracted sub-components ---
function ApproveButtonGroup({
onApprove,
onRequestChanges,
}: {
onApprove: () => void;
onRequestChanges: () => void;
}) {
return (
<div className="flex items-center gap-0.5">
<Button
type="button"
size="sm"
className="h-6 gap-1.5 px-2.5 cursor-pointer bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-medium rounded-r-none border-r border-emerald-700/30"
onClick={onApprove}
>
<IconCheck className="h-3.5 w-3.5" />
Approve
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="sm"
className="h-6 w-6 p-0 cursor-pointer bg-emerald-600 hover:bg-emerald-700 text-white rounded-l-none"
>
<IconChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={onApprove} className="cursor-pointer">
<IconCheck className="h-4 w-4 mr-2" />
Approve and continue
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onRequestChanges}
className="cursor-pointer text-amber-600 dark:text-amber-500"
>
<IconX className="h-4 w-4 mr-2" />
Request changes
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
function ChatTabContent({
activeTaskId,
isPassthroughMode,
sessionId,
showRequestChangesTooltip,
onDismissTooltip,
onOpenFile,
}: {
activeTaskId: string | null;
isPassthroughMode: boolean;
sessionId: string | null | undefined;
showRequestChangesTooltip: boolean;
onDismissTooltip: () => void;
onOpenFile: (filePath: string) => void;
}) {
if (!activeTaskId) {
return (
<TabsContent
value="chat"
className="flex flex-col min-h-0 flex-1"
style={{ minHeight: "200px" }}
>
<div className="flex items-center justify-center h-full text-muted-foreground">
No task selected
</div>
</TabsContent>
);
}
if (isPassthroughMode) {
return (
<TabsContent
value="chat"
className="flex flex-col min-h-0 flex-1"
style={{ minHeight: "200px" }}
>
<div className="flex-1 min-h-0 h-full" style={{ minHeight: "150px" }}>
<PassthroughToolbar key={activeTaskId} sessionId={sessionId} taskId={activeTaskId} />
</div>
</TabsContent>
);
}
return (
<TabsContent
value="chat"
className="flex flex-col min-h-0 flex-1"
style={{ minHeight: "200px" }}
>
<TaskChatPanel
sessionId={sessionId}
onOpenFile={onOpenFile}
showRequestChangesTooltip={showRequestChangesTooltip}
onRequestChangesTooltipDismiss={onDismissTooltip}
onOpenFileAtLine={onOpenFile}
/>
</TabsContent>
);
}