Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
407 lines
15 KiB
TypeScript
407 lines
15 KiB
TypeScript
import { memo, useCallback } from "react";
|
|
import type { UiLanguage } from "../../../contexts/settings/domain/agentSettings";
|
|
import type { WorkspaceLinkAction } from "../../../actions/workspaceLinkActions";
|
|
import type { ConversationSection } from "../agentGuiNodeViewConversation";
|
|
import type { AgentGUIConversationRailLabels } from "./agentGUIConversationRailLabels";
|
|
import { AgentGUIConversationRailItem } from "./AgentGUIConversationRailItem";
|
|
import { AgentGUIConversationRailSectionHeader } from "./AgentGUIConversationRailSectionHeader";
|
|
import { insertConversationRailSectionOverlay } from "../model/agentGuiConversationRail";
|
|
import { AGENT_GUI_CONVERSATION_RAIL_SECTION_PAGE_SIZE } from "../model/agentGuiConversationRailViewState";
|
|
import {
|
|
useOptionalStableEventCallback,
|
|
useStableEventCallback
|
|
} from "./agentGUIViewUtils";
|
|
import styles from "../AgentGUINode.styles";
|
|
|
|
interface AgentGUIConversationRailSectionProps {
|
|
section: ConversationSection;
|
|
activeConversation: ConversationSection["items"][number] | null;
|
|
activeConversationCountsTowardTotal: boolean;
|
|
projectPath: string;
|
|
projectLabel: string;
|
|
isSectionCollapsed: boolean;
|
|
activeConversationId: string | null;
|
|
pendingDeleteConversationId: string | null;
|
|
isDeletingConversation: boolean;
|
|
isLoadingMoreConversations: boolean;
|
|
isRailInteractionLocked: () => boolean;
|
|
isProjectActionLocked: () => boolean;
|
|
projectDragging: boolean;
|
|
projectDropIndicator: "before" | "after" | null;
|
|
sectionHasMore: boolean;
|
|
sectionTotalCount: number;
|
|
visibleItemLimit: number;
|
|
createConversationDisabled: boolean;
|
|
labels: AgentGUIConversationRailLabels;
|
|
uiLanguage: UiLanguage;
|
|
workspaceId: string;
|
|
registerItemElement: (itemId: string, element: HTMLDivElement | null) => void;
|
|
onCreateConversation: (options?: {
|
|
projectPath?: string | null;
|
|
source?: string;
|
|
}) => void;
|
|
onToggleProjectSectionCollapsed: (sectionId: string) => void;
|
|
onVisibleItemLimitChange: (sectionId: string, limit: number) => void;
|
|
onRequestSectionBatchDeletion: (section: ConversationSection) => void;
|
|
onRequestProjectRemoval: (
|
|
section: ConversationSection,
|
|
path: string,
|
|
label: string
|
|
) => void;
|
|
onSelectConversation: (agentSessionId: string) => void;
|
|
onLoadMoreConversations: (section: ConversationSection) => void;
|
|
onToggleConversationPinned: (agentSessionId: string, pinned: boolean) => void;
|
|
onToggleProjectPinned: (projectId: string, pinned: boolean) => Promise<void>;
|
|
onMarkConversationUnread: (agentSessionId: string) => void;
|
|
onOpenProjectFiles?: ((action: WorkspaceLinkAction) => void) | null;
|
|
onOpenConversationWindow?: (agentSessionId: string) => void;
|
|
onRequestDeleteConversation: (agentSessionId: string) => void;
|
|
onRequestRenameConversation: (
|
|
conversation: ConversationSection["items"][number]
|
|
) => void;
|
|
onCancelDeleteConversation: () => void;
|
|
onConfirmDeleteConversation: () => void;
|
|
onProjectDragStart: (
|
|
section: ConversationSection,
|
|
event: React.DragEvent<HTMLElement>
|
|
) => void;
|
|
onProjectDragEnd: () => void;
|
|
onProjectDragOver: (
|
|
section: ConversationSection,
|
|
edge: "before" | "after",
|
|
event: React.DragEvent<HTMLElement>
|
|
) => void;
|
|
onProjectMenuOpenChange: (sectionId: string, open: boolean) => void;
|
|
}
|
|
|
|
export const AgentGUIConversationRailSection = memo(
|
|
function AgentGUIConversationRailSection({
|
|
section,
|
|
activeConversation,
|
|
activeConversationCountsTowardTotal,
|
|
projectPath,
|
|
projectLabel,
|
|
isSectionCollapsed,
|
|
activeConversationId,
|
|
pendingDeleteConversationId,
|
|
isDeletingConversation,
|
|
isLoadingMoreConversations,
|
|
isRailInteractionLocked,
|
|
isProjectActionLocked,
|
|
projectDragging,
|
|
projectDropIndicator,
|
|
sectionHasMore,
|
|
sectionTotalCount,
|
|
visibleItemLimit,
|
|
createConversationDisabled,
|
|
labels,
|
|
uiLanguage,
|
|
workspaceId,
|
|
registerItemElement,
|
|
onCreateConversation,
|
|
onToggleProjectSectionCollapsed,
|
|
onVisibleItemLimitChange,
|
|
onSelectConversation,
|
|
onLoadMoreConversations,
|
|
onRequestSectionBatchDeletion,
|
|
onRequestProjectRemoval,
|
|
onToggleConversationPinned,
|
|
onToggleProjectPinned,
|
|
onMarkConversationUnread,
|
|
onOpenProjectFiles,
|
|
onOpenConversationWindow,
|
|
onRequestDeleteConversation,
|
|
onRequestRenameConversation,
|
|
onCancelDeleteConversation,
|
|
onConfirmDeleteConversation,
|
|
onProjectDragStart,
|
|
onProjectDragEnd,
|
|
onProjectDragOver,
|
|
onProjectMenuOpenChange
|
|
}: AgentGUIConversationRailSectionProps): React.JSX.Element {
|
|
"use memo";
|
|
const projectPinned = (section.project?.pinnedAtUnixMs ?? 0) > 0;
|
|
const projectId = section.project?.id?.trim() ?? "";
|
|
const hasProjectPath = Boolean(projectPath);
|
|
const pageableItems = section.items.filter(
|
|
(item) => item.projectionSource === undefined
|
|
);
|
|
const visibleItemCount = isSectionCollapsed
|
|
? 0
|
|
: Math.min(visibleItemLimit, pageableItems.length);
|
|
const baseItems = isSectionCollapsed
|
|
? []
|
|
: section.items
|
|
.filter((item) => item.projectionSource === undefined)
|
|
.slice(0, visibleItemCount);
|
|
let visibleItems = isSectionCollapsed
|
|
? []
|
|
: [
|
|
...section.items.filter(
|
|
(item) => item.projectionSource !== undefined
|
|
),
|
|
...baseItems
|
|
];
|
|
// Pagination must never make live work disappear. Pending activations are
|
|
// already projected above; keep every durable working conversation visible
|
|
// as an overlay as well, even when it falls outside the current history
|
|
// page or is not the selected conversation.
|
|
for (const conversation of section.items) {
|
|
if (
|
|
!isSectionCollapsed &&
|
|
conversation.projectionSource !== "pending_activation" &&
|
|
(conversation.status === "working" ||
|
|
conversation.status === "waiting") &&
|
|
!visibleItems.some((item) => item.id === conversation.id)
|
|
) {
|
|
visibleItems = insertConversationRailSectionOverlay(
|
|
section.kind,
|
|
visibleItems,
|
|
conversation
|
|
);
|
|
}
|
|
}
|
|
const activeId = activeConversation?.id.trim() ?? "";
|
|
if (
|
|
activeConversation &&
|
|
activeId &&
|
|
activeId === (activeConversationId?.trim() ?? "") &&
|
|
!visibleItems.some((item) => item.id === activeId)
|
|
) {
|
|
visibleItems = insertConversationRailSectionOverlay(
|
|
section.kind,
|
|
visibleItems,
|
|
activeConversation
|
|
);
|
|
}
|
|
const visiblePageableIds = new Set(
|
|
visibleItems
|
|
.filter((item) => item.projectionSource !== "pending_activation")
|
|
.map((item) => item.id)
|
|
);
|
|
const visibleCountTowardTotal =
|
|
visiblePageableIds.size +
|
|
(activeConversationCountsTowardTotal &&
|
|
activeConversation &&
|
|
visibleItems.some((item) => item.id === activeConversation.id) &&
|
|
!visiblePageableIds.has(activeConversation.id)
|
|
? 1
|
|
: 0);
|
|
const canShowMore =
|
|
!isSectionCollapsed &&
|
|
visibleCountTowardTotal < sectionTotalCount &&
|
|
(visibleItemCount < pageableItems.length || sectionHasMore);
|
|
const canShowLess =
|
|
!isSectionCollapsed &&
|
|
visibleItemCount > AGENT_GUI_CONVERSATION_RAIL_SECTION_PAGE_SIZE;
|
|
const showMoreConversations = useCallback(() => {
|
|
if (isRailInteractionLocked()) return;
|
|
if (visibleItemCount >= pageableItems.length && sectionHasMore) {
|
|
onLoadMoreConversations(section);
|
|
onVisibleItemLimitChange(
|
|
section.id,
|
|
visibleItemLimit + AGENT_GUI_CONVERSATION_RAIL_SECTION_PAGE_SIZE
|
|
);
|
|
return;
|
|
}
|
|
onVisibleItemLimitChange(
|
|
section.id,
|
|
Math.min(
|
|
pageableItems.length,
|
|
visibleItemLimit + AGENT_GUI_CONVERSATION_RAIL_SECTION_PAGE_SIZE
|
|
)
|
|
);
|
|
}, [
|
|
onLoadMoreConversations,
|
|
onVisibleItemLimitChange,
|
|
isRailInteractionLocked,
|
|
pageableItems.length,
|
|
section,
|
|
sectionHasMore,
|
|
visibleItemCount,
|
|
visibleItemLimit
|
|
]);
|
|
const showLessConversations = useCallback(() => {
|
|
if (isRailInteractionLocked()) return;
|
|
onVisibleItemLimitChange(
|
|
section.id,
|
|
AGENT_GUI_CONVERSATION_RAIL_SECTION_PAGE_SIZE
|
|
);
|
|
}, [isRailInteractionLocked, onVisibleItemLimitChange, section.id]);
|
|
|
|
const canCreateConversationFromSection =
|
|
section.kind === "conversations" || Boolean(projectPath);
|
|
const createConversationLabel = projectPath
|
|
? labels.projectSectionEdit
|
|
: labels.newConversation;
|
|
const handleCreateConversation = useStableEventCallback(() => {
|
|
if (isRailInteractionLocked()) return;
|
|
if (projectPath) {
|
|
onCreateConversation({ projectPath, source: "project_section" });
|
|
return;
|
|
}
|
|
onCreateConversation({
|
|
projectPath: null,
|
|
source: "unscoped_section"
|
|
});
|
|
});
|
|
const handleToggleCollapsed = useStableEventCallback(() => {
|
|
if (!isRailInteractionLocked()) {
|
|
onToggleProjectSectionCollapsed(section.id);
|
|
}
|
|
});
|
|
const handleProjectDragStart = useStableEventCallback(
|
|
(event: React.DragEvent<HTMLElement>) =>
|
|
onProjectDragStart(section, event)
|
|
);
|
|
const handleProjectDragEnd = useStableEventCallback(() =>
|
|
onProjectDragEnd()
|
|
);
|
|
const handleProjectDragOver = useStableEventCallback(
|
|
(event: React.DragEvent<HTMLElement>) => {
|
|
const header = event.currentTarget.firstElementChild;
|
|
const rect = (
|
|
header instanceof HTMLElement ? header : event.currentTarget
|
|
).getBoundingClientRect();
|
|
onProjectDragOver(
|
|
section,
|
|
event.clientY < rect.top + rect.height / 2 ? "before" : "after",
|
|
event
|
|
);
|
|
}
|
|
);
|
|
const handleProjectMenuOpenChange = useStableEventCallback(
|
|
(open: boolean) => onProjectMenuOpenChange(section.id, open)
|
|
);
|
|
const handleOpenProjectFiles = useOptionalStableEventCallback(
|
|
onOpenProjectFiles
|
|
? () => {
|
|
if (isRailInteractionLocked()) return;
|
|
onOpenProjectFiles({
|
|
directoryPath: projectPath,
|
|
mode: "open",
|
|
path: projectPath,
|
|
source: "agent-project-menu",
|
|
type: "open-workspace-file",
|
|
workspaceRoot: projectPath
|
|
});
|
|
}
|
|
: null
|
|
);
|
|
const handleToggleProjectPinned = useStableEventCallback(() => {
|
|
if (!projectId || isProjectActionLocked()) return;
|
|
void onToggleProjectPinned(projectId, !projectPinned);
|
|
});
|
|
const handleRequestBatchDeletion = useStableEventCallback(() => {
|
|
if (!isRailInteractionLocked()) {
|
|
onRequestSectionBatchDeletion(section);
|
|
}
|
|
});
|
|
const handleRemoveProject = useStableEventCallback(() => {
|
|
if (isProjectActionLocked()) return;
|
|
onRequestProjectRemoval(
|
|
section,
|
|
projectPath,
|
|
projectLabel || projectPath
|
|
);
|
|
});
|
|
return (
|
|
<section
|
|
className={styles.conversationSection}
|
|
data-collapsed={isSectionCollapsed}
|
|
data-kind={section.kind}
|
|
data-testid={
|
|
projectId ? `agent-gui-project-section-${projectId}` : undefined
|
|
}
|
|
data-project-dragging={projectDragging ? "true" : undefined}
|
|
data-project-drop-indicator={projectDropIndicator ?? undefined}
|
|
onDragOver={
|
|
section.kind === "project" ? handleProjectDragOver : undefined
|
|
}
|
|
>
|
|
<AgentGUIConversationRailSectionHeader
|
|
canCreateConversation={canCreateConversationFromSection}
|
|
createConversationDisabled={createConversationDisabled}
|
|
createConversationLabel={createConversationLabel}
|
|
hasProjectId={Boolean(projectId)}
|
|
hasProjectPath={hasProjectPath}
|
|
isSectionCollapsed={isSectionCollapsed}
|
|
kind={section.kind}
|
|
labels={labels}
|
|
onCreateConversation={handleCreateConversation}
|
|
onOpenProjectFiles={handleOpenProjectFiles}
|
|
onProjectDragEnd={handleProjectDragEnd}
|
|
onProjectDragStart={handleProjectDragStart}
|
|
onProjectMenuOpenChange={handleProjectMenuOpenChange}
|
|
onRemoveProject={handleRemoveProject}
|
|
onRequestBatchDeletion={handleRequestBatchDeletion}
|
|
onToggleCollapsed={handleToggleCollapsed}
|
|
onToggleProjectPinned={handleToggleProjectPinned}
|
|
projectPinned={projectPinned}
|
|
projectId={projectId || null}
|
|
sectionLabel={section.label}
|
|
/>
|
|
<div
|
|
className={styles.conversationSectionItems}
|
|
aria-hidden={isSectionCollapsed ? "true" : undefined}
|
|
>
|
|
<div className={styles.conversationSectionItemsInner}>
|
|
{!isSectionCollapsed && visibleItems.length === 0 ? (
|
|
<div className={styles.conversationSectionEmpty}>
|
|
{labels.emptyProjectConversations}
|
|
</div>
|
|
) : null}
|
|
{visibleItems.map((item) => (
|
|
<AgentGUIConversationRailItem
|
|
key={item.id}
|
|
active={item.id === activeConversationId}
|
|
isDeletingConversation={isDeletingConversation}
|
|
isPendingDeleteConversation={
|
|
pendingDeleteConversationId === item.id
|
|
}
|
|
isRailInteractionLocked={isRailInteractionLocked}
|
|
item={item}
|
|
labels={labels}
|
|
registerItemElement={registerItemElement}
|
|
uiLanguage={uiLanguage}
|
|
workspaceId={workspaceId}
|
|
onCancelDeleteConversation={onCancelDeleteConversation}
|
|
onConfirmDeleteConversation={onConfirmDeleteConversation}
|
|
onRequestDeleteConversation={onRequestDeleteConversation}
|
|
onRequestRenameConversation={onRequestRenameConversation}
|
|
onSelectConversation={onSelectConversation}
|
|
onToggleConversationPinned={onToggleConversationPinned}
|
|
onMarkConversationUnread={onMarkConversationUnread}
|
|
onOpenConversationWindow={onOpenConversationWindow}
|
|
/>
|
|
))}
|
|
{canShowMore || canShowLess ? (
|
|
<div className={styles.conversationSectionPagination}>
|
|
{canShowMore ? (
|
|
<button
|
|
type="button"
|
|
className={styles.conversationSectionPaginationButton}
|
|
disabled={isLoadingMoreConversations}
|
|
onClick={showMoreConversations}
|
|
>
|
|
{labels.showMoreConversations}
|
|
</button>
|
|
) : null}
|
|
{canShowLess ? (
|
|
<button
|
|
type="button"
|
|
className={styles.conversationSectionPaginationButton}
|
|
onClick={showLessConversations}
|
|
>
|
|
{labels.showLessConversations}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
);
|