mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 19:43:15 +00:00
203 lines
8.2 KiB
TypeScript
203 lines
8.2 KiB
TypeScript
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||
|
|
import { ChevronDown } from "lucide-react";
|
||
|
|
|
||
|
|
import { AgentPromptBar } from "@/components/agents/AgentPromptBar";
|
||
|
|
import { AgentsShell } from "@/components/agents/AgentsSidebar";
|
||
|
|
import {
|
||
|
|
MessageView,
|
||
|
|
summarizeChangedFiles,
|
||
|
|
type MessageViewScrollControl,
|
||
|
|
} from "@/components/agents/ported";
|
||
|
|
import type { SessionUser } from "@/lib/api";
|
||
|
|
import type { AgentThread, Message } from "@/lib/agents/types";
|
||
|
|
import { useSendAgentMessage } from "@/lib/agents/queries";
|
||
|
|
import {
|
||
|
|
dropPendingPrompts,
|
||
|
|
getPendingPrompts,
|
||
|
|
type PendingPrompt,
|
||
|
|
} from "@/lib/agents/pendingPrompts";
|
||
|
|
import { useAgentThreadStream } from "@/lib/agents/useThreadStream";
|
||
|
|
import { useModelOptions, type ModelSelection } from "@/lib/agents/useModelOptions";
|
||
|
|
|
||
|
|
interface AgentThreadViewProps {
|
||
|
|
user: SessionUser;
|
||
|
|
thread: AgentThread;
|
||
|
|
}
|
||
|
|
|
||
|
|
const PROMPT_OVERLAY_INSET = 128;
|
||
|
|
|
||
|
|
export function AgentThreadView({ user, thread }: AgentThreadViewProps) {
|
||
|
|
const sendMessage = useSendAgentMessage(thread.id);
|
||
|
|
useAgentThreadStream(thread.id, thread.status === "running");
|
||
|
|
const scrollControlRef = useRef<MessageViewScrollControl | null>(null);
|
||
|
|
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||
|
|
const [pendingPrompts, setPendingPrompts] = useState<PendingPrompt[]>(() =>
|
||
|
|
getPendingPrompts(thread.id),
|
||
|
|
);
|
||
|
|
|
||
|
|
const { models, defaultSelection } = useModelOptions();
|
||
|
|
const threadSelection = useMemo<ModelSelection | null>(() => {
|
||
|
|
if (!thread.model || !thread.effort) return null;
|
||
|
|
const supported = models.some(
|
||
|
|
(m) => m.id === thread.model && m.efforts.includes(thread.effort ?? ""),
|
||
|
|
);
|
||
|
|
if (!supported) return null;
|
||
|
|
return { modelId: thread.model, effort: thread.effort };
|
||
|
|
}, [models, thread.model, thread.effort]);
|
||
|
|
const [selection, setSelection] = useState<ModelSelection | null>(null);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (selection !== null) return;
|
||
|
|
if (threadSelection) setSelection(threadSelection);
|
||
|
|
else if (defaultSelection) setSelection(defaultSelection);
|
||
|
|
}, [defaultSelection, selection, threadSelection]);
|
||
|
|
|
||
|
|
const userMessageTexts = useMemo(() => {
|
||
|
|
return new Set(
|
||
|
|
thread.messages
|
||
|
|
.filter((m) => m.author === "user")
|
||
|
|
.map((m) =>
|
||
|
|
m.chunks
|
||
|
|
.filter((c) => c.kind === "text")
|
||
|
|
.map((c) => (c as { kind: "text"; text: string }).text)
|
||
|
|
.join(""),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}, [thread.messages]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
setPendingPrompts((prev) => {
|
||
|
|
if (prev.length === 0) return prev;
|
||
|
|
const next = dropPendingPrompts(thread.id, (entry) =>
|
||
|
|
userMessageTexts.has(entry.prompt),
|
||
|
|
);
|
||
|
|
return next.length === prev.length ? prev : next;
|
||
|
|
});
|
||
|
|
}, [thread.id, userMessageTexts]);
|
||
|
|
|
||
|
|
const displayMessages = useMemo<Message[]>(() => {
|
||
|
|
if (pendingPrompts.length === 0) return thread.messages;
|
||
|
|
const baseTimestamp = new Date().toISOString();
|
||
|
|
const result = thread.messages.slice();
|
||
|
|
pendingPrompts.forEach((entry, i) => {
|
||
|
|
const synth: Message = {
|
||
|
|
id: `pending-user-${i}`,
|
||
|
|
author: "user",
|
||
|
|
timestamp: baseTimestamp,
|
||
|
|
chunks: [{ kind: "text", text: entry.prompt }],
|
||
|
|
};
|
||
|
|
const at = Math.min(Math.max(entry.insertAt, 0), result.length);
|
||
|
|
result.splice(at, 0, synth);
|
||
|
|
});
|
||
|
|
return result;
|
||
|
|
}, [thread.messages, pendingPrompts]);
|
||
|
|
|
||
|
|
const changedFiles = useMemo(() => {
|
||
|
|
const agentMessages = thread.messages.filter((m) => m.author === "agent");
|
||
|
|
const allChunks = agentMessages.flatMap((m) => m.chunks);
|
||
|
|
return summarizeChangedFiles(allChunks);
|
||
|
|
}, [thread.messages]);
|
||
|
|
|
||
|
|
const hasMessages = displayMessages.length > 0;
|
||
|
|
const isStreaming = thread.status === "running" || pendingPrompts.length > 0;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<AgentsShell user={user} activeThreadId={thread.id}>
|
||
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
||
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
||
|
|
{hasMessages ? (
|
||
|
|
<div className="relative flex min-h-0 flex-1 flex-col">
|
||
|
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||
|
|
<MessageView
|
||
|
|
messages={displayMessages}
|
||
|
|
isStreaming={isStreaming}
|
||
|
|
contentWidthClass="max-w-3xl"
|
||
|
|
bottomInset={PROMPT_OVERLAY_INSET}
|
||
|
|
scrollButtonSlot="external"
|
||
|
|
scrollControlRef={scrollControlRef}
|
||
|
|
onShowScrollToBottomChange={setShowScrollToBottom}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{changedFiles.length > 0 && (
|
||
|
|
<div className="mx-auto mt-4 w-full max-w-3xl shrink-0 px-6">
|
||
|
|
<div className="rounded-lg border border-[var(--ui-border)] bg-[var(--ui-panel)] p-3">
|
||
|
|
<div className="mb-2 text-xs font-medium text-[var(--ui-text-muted)]">
|
||
|
|
{changedFiles.length} Files Changed
|
||
|
|
</div>
|
||
|
|
<div className="space-y-1">
|
||
|
|
{changedFiles.map((file) => (
|
||
|
|
<div
|
||
|
|
key={file.filePath}
|
||
|
|
className="flex items-center justify-between rounded px-2 py-1 text-xs hover:bg-[var(--ui-panel-2)]"
|
||
|
|
>
|
||
|
|
<span className="font-mono text-[var(--ui-text)]">{file.filePath}</span>
|
||
|
|
<span className="text-[var(--ui-success)]">+{file.additions}</span>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 px-6 pb-4">
|
||
|
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-[var(--ui-bg)] via-[var(--ui-bg)]/80 to-transparent" />
|
||
|
|
<div className="pointer-events-auto relative mx-auto max-w-3xl">
|
||
|
|
{showScrollToBottom && (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => scrollControlRef.current?.scrollToBottom()}
|
||
|
|
aria-label="Scroll to bottom"
|
||
|
|
className="absolute bottom-full left-1/2 z-30 mb-2 inline-flex h-8 w-8 -translate-x-1/2 items-center justify-center rounded-full bg-[var(--ui-panel-2)] text-[color:var(--ui-text-muted)] shadow-md transition-colors hover:bg-[var(--ui-panel)] hover:text-[color:var(--ui-text)]"
|
||
|
|
>
|
||
|
|
<ChevronDown className="h-3.5 w-3.5" />
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
<AgentPromptBar
|
||
|
|
placeholder="Add a follow up"
|
||
|
|
compact
|
||
|
|
busy={isStreaming}
|
||
|
|
disabled={sendMessage.isPending}
|
||
|
|
onSubmit={(content) =>
|
||
|
|
sendMessage.mutate({
|
||
|
|
content,
|
||
|
|
model_id: selection?.modelId ?? null,
|
||
|
|
effort: selection?.effort ?? null,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
models={models}
|
||
|
|
selection={selection ?? threadSelection ?? defaultSelection}
|
||
|
|
onSelectionChange={setSelection}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6">
|
||
|
|
<p className="text-sm text-[var(--ui-text-dim)]">This thread has no messages yet.</p>
|
||
|
|
<div className="w-full max-w-3xl">
|
||
|
|
<AgentPromptBar
|
||
|
|
placeholder="Send the first message"
|
||
|
|
compact
|
||
|
|
busy={isStreaming}
|
||
|
|
disabled={sendMessage.isPending}
|
||
|
|
onSubmit={(content) =>
|
||
|
|
sendMessage.mutate({
|
||
|
|
content,
|
||
|
|
model_id: selection?.modelId ?? null,
|
||
|
|
effort: selection?.effort ?? null,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
models={models}
|
||
|
|
selection={selection ?? threadSelection ?? defaultSelection}
|
||
|
|
onSelectionChange={setSelection}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</AgentsShell>
|
||
|
|
);
|
||
|
|
}
|