`: keeps paragraph spacing without the nesting violation. */
+function ReasoningParagraphSafe({ children }: { children: ReactNode }) {
+ return
+}
import { normalizeMathDelimiters } from "./message"
import { remarkTrimCjkAutolinkTail } from "./remark-cjk-autolink-tail"
import { remarkRewriteFileUriLinks } from "./remark-file-uri-links"
@@ -257,8 +263,15 @@ export const ReasoningContent = memo(
plugins={plugins}
remarkPlugins={remarkPlugins}
{...props}
- // Enforce the link icon + safety override after spreading props.
- components={markdownLinkComponents}
+ // Enforce the link icon + safety override after spreading props,
+ // and render paragraphs as
: reasoning text frequently
+ // embeds raw HTML/SVG, and Streamdown's default
for the
+ // wrapper plus a nested element rendered as
trips React's
+ // nested-paragraph hydration error.
+ components={{
+ ...markdownLinkComponents,
+ p: ReasoningParagraphSafe as Components["p"],
+ }}
>
{normalized}
diff --git a/src/components/chat/composer/composer-add-menu.tsx b/src/components/chat/composer/composer-add-menu.tsx
index 4c51a8a5a..8c5110a75 100644
--- a/src/components/chat/composer/composer-add-menu.tsx
+++ b/src/components/chat/composer/composer-add-menu.tsx
@@ -14,6 +14,7 @@ import {
Plus,
Search,
Sparkles,
+ Swords,
Upload,
} from "lucide-react"
@@ -31,6 +32,8 @@ import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item-
import { rankByTextMatch } from "@/lib/fuzzy-text-match"
import { isImeCompositionKey } from "@/lib/ime-composition"
import { cn } from "@/lib/utils"
+import { usePkArenaStore } from "@/stores/pk-arena-store"
+import { useTabStore } from "@/stores/tab-store"
import type { AvailableCommandInfo } from "@/lib/types"
import { commandInvocationToken } from "@/components/chat/composer/invocation-reference"
@@ -396,6 +399,29 @@ export function ComposerAddMenu({
>
)}
+ {
+ const state = usePkArenaStore.getState()
+ // With history to revisit, the menu reopens the ARENA directly
+ // (the dialog otherwise has no way back after it closes);
+ // "新一局" inside the arena opens the launcher.
+ if (state.rounds.length > 0 && state.activeRoundId) {
+ const round = state.rounds.find(
+ (item) => item.id === state.activeRoundId
+ )
+ if (round) {
+ useTabStore
+ .getState()
+ .openPkRoundTab(round.id, round.folderId, round.task)
+ }
+ } else {
+ state.setLauncherOpen(true)
+ }
+ }}
+ >
+
+ {t("startPk")}
+
)
diff --git a/src/components/chat/conversation-context-bar.test.tsx b/src/components/chat/conversation-context-bar.test.tsx
index 2c0b5c84a..469a8c9ac 100644
--- a/src/components/chat/conversation-context-bar.test.tsx
+++ b/src/components/chat/conversation-context-bar.test.tsx
@@ -28,6 +28,7 @@ vi.mock("sonner", () => ({
// branches) is seeded into the real zustand store in beforeEach.
let tabs: Array<{
id: string
+ kind: "conversation"
folderId: number
conversationId: number | null
isChat?: boolean
@@ -93,7 +94,14 @@ describe("ConversationHeaderFolderPicker", () => {
folders: [repo, other],
allFolders: [repo, other],
})
- tabs = [{ id: "tab-draft", folderId: 1, conversationId: null }]
+ tabs = [
+ {
+ id: "tab-draft",
+ kind: "conversation",
+ folderId: 1,
+ conversationId: null,
+ },
+ ]
activeTabId = "tab-draft"
const user = userEvent.setup()
@@ -111,7 +119,14 @@ describe("ConversationHeaderFolderPicker", () => {
folders: [repo, other],
allFolders: [repo, other],
})
- tabs = [{ id: "tab-1", folderId: 1, conversationId: 42 }]
+ tabs = [
+ {
+ id: "tab-1",
+ kind: "conversation",
+ folderId: 1,
+ conversationId: 42,
+ },
+ ]
activeTabId = "tab-1"
const user = userEvent.setup()
@@ -125,7 +140,13 @@ describe("ConversationHeaderFolderPicker", () => {
it("shows the chat-mode label for a folderless chat tab", () => {
tabs = [
- { id: "tab-chat", folderId: 999, conversationId: null, isChat: true },
+ {
+ id: "tab-chat",
+ kind: "conversation",
+ folderId: 999,
+ conversationId: null,
+ isChat: true,
+ },
]
activeTabId = "tab-chat"
diff --git a/src/components/chat/conversation-context-bar.tsx b/src/components/chat/conversation-context-bar.tsx
index e9d11424b..98a8dbf20 100644
--- a/src/components/chat/conversation-context-bar.tsx
+++ b/src/components/chat/conversation-context-bar.tsx
@@ -7,6 +7,7 @@ import { Check, ChevronDown, Folder, MessageSquare } from "lucide-react"
import type { OverlayScrollbarsComponentRef } from "overlayscrollbars-react"
import { useAppWorkspaceStore } from "@/stores/app-workspace-store"
import { useTabActions, useTabStore } from "@/contexts/tab-context"
+import { isConversationWorkspaceTab } from "@/lib/workspace-tab"
import { Button } from "@/components/ui/button"
import {
Popover,
@@ -125,7 +126,8 @@ export const ConversationHeaderFolderPicker = memo(
const ownTab = useMemo(() => {
const lookupId = tabId ?? activeTabId
- return tabs.find((x) => x.id === lookupId) ?? null
+ const tab = tabs.find((x) => x.id === lookupId)
+ return tab && isConversationWorkspaceTab(tab) ? tab : null
}, [tabs, tabId, activeTabId])
const ownFolder = useMemo(
@@ -250,7 +252,8 @@ export const ConversationFolderBranchPicker = memo(
const ownTab = useMemo(() => {
const lookupId = tabId ?? activeTabId
- return tabs.find((x) => x.id === lookupId) ?? null
+ const tab = tabs.find((x) => x.id === lookupId)
+ return tab && isConversationWorkspaceTab(tab) ? tab : null
}, [tabs, tabId, activeTabId])
const ownFolder = useMemo(
@@ -369,7 +372,9 @@ export function useConversationFolderBranchPickerVisible(
const activeTabId = useTabStore((s) => s.activeTabId)
const allFolders = useAppWorkspaceStore((s) => s.allFolders)
const lookupId = tabId ?? activeTabId
- const ownTab = tabs.find((x) => x.id === lookupId) ?? null
+ const matchedTab = tabs.find((x) => x.id === lookupId)
+ const ownTab =
+ matchedTab && isConversationWorkspaceTab(matchedTab) ? matchedTab : null
const ownFolder = ownTab
? (allFolders.find((f) => f.id === ownTab.folderId) ?? null)
: null
diff --git a/src/components/chunk-load-recovery.test.tsx b/src/components/chunk-load-recovery.test.tsx
new file mode 100644
index 000000000..5bf1e437f
--- /dev/null
+++ b/src/components/chunk-load-recovery.test.tsx
@@ -0,0 +1,36 @@
+import { render } from "@testing-library/react"
+import { afterEach, describe, expect, it, vi } from "vitest"
+import { ChunkLoadRecovery, isChunkLoadError } from "./chunk-load-recovery"
+
+afterEach(() => {
+ window.sessionStorage.clear()
+})
+
+describe("ChunkLoadRecovery", () => {
+ it("recognizes async chunk failures without matching unrelated errors", () => {
+ expect(
+ isChunkLoadError(
+ new Error(
+ "Failed to load chunk http://localhost:3000/_next/static/chunks/opener.js"
+ )
+ )
+ ).toBe(true)
+ expect(isChunkLoadError(new Error("Permission denied"))).toBe(false)
+ })
+
+ it("reloads once when a lazy chunk is stale", () => {
+ const reloadPage = vi.fn()
+ render()
+
+ const rejection = new Event("unhandledrejection")
+ Object.defineProperty(rejection, "reason", {
+ value: Object.assign(new Error("Failed to load chunk /opener.js"), {
+ name: "ChunkLoadError",
+ }),
+ })
+ window.dispatchEvent(rejection)
+ window.dispatchEvent(rejection)
+
+ expect(reloadPage).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/components/chunk-load-recovery.tsx b/src/components/chunk-load-recovery.tsx
new file mode 100644
index 000000000..a5721ed73
--- /dev/null
+++ b/src/components/chunk-load-recovery.tsx
@@ -0,0 +1,86 @@
+"use client"
+
+import { useEffect, useRef } from "react"
+
+const RECOVERY_MARKER = "codeg:chunk-load-recovery"
+const HEALTHY_WINDOW_MS = 10_000
+const CHUNK_LOAD_PATTERN =
+ /chunkloaderror|failed to load chunk|loading chunk .+ failed|failed to fetch dynamically imported module|importing a module script failed/i
+
+function errorText(value: unknown): string {
+ if (typeof value === "string") return value
+ if (value == null || typeof value !== "object") return ""
+ const record = value as { name?: unknown; message?: unknown }
+ return [record.name, record.message]
+ .filter((part): part is string => typeof part === "string")
+ .join(": ")
+}
+
+export function isChunkLoadError(value: unknown): boolean {
+ return CHUNK_LOAD_PATTERN.test(errorText(value))
+}
+
+function reloadWindow(): void {
+ window.location.reload()
+}
+
+/**
+ * Recover from a stale Next.js runtime after a deploy or dev-server rebuild.
+ * Lazy chunks surface only when their feature is first used (for example,
+ * opening a generated file), so the initial page can look healthy while its
+ * chunk graph is already invalid. One guarded reload obtains a coherent graph;
+ * sessionStorage prevents a broken deployment from entering a reload loop.
+ */
+export function ChunkLoadRecovery({
+ reloadPage = reloadWindow,
+}: {
+ reloadPage?: () => void
+}) {
+ const attemptedRef = useRef(false)
+
+ useEffect(() => {
+ const pageKey = `${window.location.pathname}${window.location.search}`
+
+ const recover = (reason: unknown) => {
+ if (attemptedRef.current || !isChunkLoadError(reason)) return
+
+ try {
+ if (window.sessionStorage.getItem(RECOVERY_MARKER) === pageKey) return
+ window.sessionStorage.setItem(RECOVERY_MARKER, pageKey)
+ } catch {
+ // A local in-memory guard still prevents repeated reload attempts in
+ // this document when storage is unavailable.
+ }
+
+ attemptedRef.current = true
+ reloadPage()
+ }
+
+ const onError = (event: ErrorEvent) => {
+ recover(event.error ?? event.message)
+ }
+ const onUnhandledRejection = (event: PromiseRejectionEvent) => {
+ recover(event.reason)
+ }
+
+ window.addEventListener("error", onError)
+ window.addEventListener("unhandledrejection", onUnhandledRejection)
+ const healthyTimer = window.setTimeout(() => {
+ try {
+ if (window.sessionStorage.getItem(RECOVERY_MARKER) === pageKey) {
+ window.sessionStorage.removeItem(RECOVERY_MARKER)
+ }
+ } catch {
+ // Storage is optional; there is nothing to clear when it is blocked.
+ }
+ }, HEALTHY_WINDOW_MS)
+
+ return () => {
+ window.removeEventListener("error", onError)
+ window.removeEventListener("unhandledrejection", onUnhandledRejection)
+ window.clearTimeout(healthyTimer)
+ }
+ }, [reloadPage])
+
+ return null
+}
diff --git a/src/components/conversations/conversation-detail-panel-layout.test.ts b/src/components/conversations/conversation-detail-panel-layout.test.ts
index f55ee8700..6bb7748bf 100644
--- a/src/components/conversations/conversation-detail-panel-layout.test.ts
+++ b/src/components/conversations/conversation-detail-panel-layout.test.ts
@@ -284,10 +284,12 @@ describe("ConversationDetailPanel split-group render model", () => {
it("pairs every split group with its own title bar and gates the global one", () => {
const shellStart = source.indexOf("const renderGroupShell = (groupId")
const shellBody = source.slice(shellStart, shellStart + 6000)
- expect(shellBody).toContain("{isSplit && selTab && (")
+ expect(shellBody).toContain("{isSplit && selConversationTab && (")
expect(shellBody).toContain(" s.tabs.find((tab) => tab.id === tabId) ?? null
- )
+ const ownTab = useTabStore((s) => {
+ const tab = s.tabs.find((item) => item.id === tabId)
+ return tab && isConversationWorkspaceTab(tab) ? tab : null
+ })
// Resolve this panel's folder from ITS OWN tab, not the global active folder.
// A keep-alive panel for a background tab must NOT re-render when the active
// tab switches to a different folder. For the active tab this equals the old
@@ -2228,7 +2231,8 @@ export function ConversationDetailPanel() {
} = useTabActions()
const newConversation = useMemo(() => {
const activeTab = tabs.find((tab) => tab.id === activeTabId)
- if (!activeTab || activeTab.conversationId != null) return null
+ if (!activeTab || !isConversationWorkspaceTab(activeTab)) return null
+ if (activeTab.conversationId != null) return null
const workingDir = activeTab.workingDir ?? folder?.path
if (!workingDir) return null
return { workingDir, folderId: activeTab.folderId }
@@ -2294,9 +2298,10 @@ export function ConversationDetailPanel() {
const dbId2 = summary?.id
const isOpenInTabs = tabs.some(
(tab) =>
- tab.conversationId === matchedConversationId ||
- tab.runtimeConversationId === matchedConversationId ||
- (dbId2 != null && tab.conversationId === dbId2)
+ isConversationWorkspaceTab(tab) &&
+ (tab.conversationId === matchedConversationId ||
+ tab.runtimeConversationId === matchedConversationId ||
+ (dbId2 != null && tab.conversationId === dbId2))
)
if (isOpenInTabs) return
@@ -2315,13 +2320,12 @@ export function ConversationDetailPanel() {
)
const hasNoTabs = tabs.length === 0 && !activeTabId
- const activeConversationTab = useMemo(
- () =>
- tabs.find(
- (tab) => tab.id === activeTabId && tab.conversationId != null
- ) ?? null,
- [tabs, activeTabId]
- )
+ const activeConversationTab = useMemo(() => {
+ const tab = tabs.find((item) => item.id === activeTabId)
+ return tab && isConversationWorkspaceTab(tab) && tab.conversationId != null
+ ? tab
+ : null
+ }, [tabs, activeTabId])
const canReloadActiveConversation = activeConversationTab != null
const handleReloadActiveConversation = useCallback(() => {
if (!activeConversationTab) return
@@ -2547,18 +2551,21 @@ export function ConversationDetailPanel() {
// Visible = tiled (all group members shown) or the group's selected tab.
const visible = canTileG || tab.id === groupSelection[groupId]
const folderPath = allFolders.find((f) => f.id === tab.folderId)?.path
- const view = (
-
- )
+ const view =
+ tab.kind === "pk" ? (
+
+ ) : (
+
+ )
return (
tab.id === groupSelection[groupId]) ??
groupTabs[0] ??
null
- const selTabFolder = selTab
- ? allFolders.find((f) => f.id === selTab.folderId)
+ const selConversationTab =
+ selTab && isConversationWorkspaceTab(selTab) ? selTab : null
+ const selTabFolder = selConversationTab
+ ? allFolders.find((f) => f.id === selConversationTab.folderId)
: undefined
// NOTE: the strip / header / content stay PLAIN SIBLING SLOTS (no fragment
// around any pair) — a `false` conditional is a reconciliation hole, so the
@@ -2650,7 +2659,7 @@ export function ConversationDetailPanel() {
{touchesRight && }
)}
- {isSplit && selTab && (
+ {isSplit && selConversationTab && (
)}
@@ -2710,7 +2723,7 @@ export function ConversationDetailPanel() {
return (
<>
- {!isSplit && activeTab && (
+ {!isSplit && activeTab && isConversationWorkspaceTab(activeTab) && (
{
expect(shellStart).toBeGreaterThan(-1)
const shellBody = source.slice(shellStart, shellStart + 6000)
const stripIdx = shellBody.indexOf("{isSplit && (")
- const headerIdx = shellBody.indexOf("{isSplit && selTab && (")
+ const headerIdx = shellBody.indexOf("{isSplit && selConversationTab && (")
const contentIdx = shellBody.indexOf(
''
)
diff --git a/src/components/conversations/sidebar-conversation-grouping.ts b/src/components/conversations/sidebar-conversation-grouping.ts
index 18882b924..78dc8650f 100644
--- a/src/components/conversations/sidebar-conversation-grouping.ts
+++ b/src/components/conversations/sidebar-conversation-grouping.ts
@@ -262,6 +262,54 @@ export function selectChatConversationsWithReuse(
return arraysShallowEqual(prev, next) ? prev : next
}
+/**
+ * Select PK-arena contestant conversations (`kind === "pk"`), grouped by their
+ * `pk_round_id`. Returns a Map keyed by round id → conversations (newest-first
+ * within each round). Excludes pinned conversations (they surface in the
+ * Pinned section). `prev` is the Map returned last call for reference reuse.
+ *
+ * Each round's conversations are sorted newest-first; the rounds themselves are
+ * ordered by their newest conversation's `updated_at` (hottest round first) so
+ * the active PK sits at the top.
+ */
+export function selectPkConversationsWithReuse(
+ conversations: readonly DbConversationSummary[],
+ prev: Map
+): Map {
+ const grouped = new Map()
+ for (const conv of conversations) {
+ if (conv.pinned_at != null) continue
+ if (conv.kind !== "pk") continue
+ const rid = conv.pk_round_id
+ if (rid == null) continue
+ const bucket = grouped.get(rid)
+ if (bucket) bucket.push(conv)
+ else grouped.set(rid, [conv])
+ }
+ // Sort conversations within each round newest-first.
+ for (const bucket of grouped.values()) {
+ bucket.sort(compareByUpdatedAtDesc)
+ }
+ // Sort rounds by hottest conversation (newest updated_at among their
+ // conversations) first.
+ const sortedEntries = [...grouped.entries()].sort((a, b) => {
+ const aMax = a[1][0]?.updated_at ?? ""
+ const bMax = b[1][0]?.updated_at ?? ""
+ return bMax.localeCompare(aMax)
+ })
+ const next = new Map(sortedEntries)
+ return mapsShallowEqual(prev, next) ? prev : next
+}
+
+/** Shallow-equal for Map — same keys and same array refs. */
+function mapsShallowEqual(a: Map, b: Map): boolean {
+ if (a.size !== b.size) return false
+ for (const [k, v] of a) {
+ if (b.get(k) !== v) return false
+ }
+ return true
+}
+
/**
* Select the flat "Recent" bucket: every conversation the sidebar can reach,
* folder-bound and chat alike, newest first — the whole point of the section is
@@ -297,6 +345,7 @@ export function selectRecentConversationsWithReuse(
for (const conv of conversations) {
if (conv.pinned_at != null) continue
if (!showCompleted && conv.status === "completed") continue
+ if (conv.kind === "pk") continue
if (conv.kind !== "chat" && !openFolderIds.has(conv.folder_id)) continue
next.push(conv)
}
@@ -494,13 +543,33 @@ export interface RecentMoreRow {
}
/**
- * A collapsible section heading. Four exist: "pinned" (always on top, shown only
- * when there are pinned conversations) plus the three user-reorderable ones —
- * "folders" (wraps the whole folder list), "chats" (a flat list of folderless
- * chat-mode conversations), and "recent" (a flat, folder-agnostic list of the
- * newest conversations, shown only when the user keeps it enabled). All live in
- * the same flat row array so the single Virtualizer windows them like any other
- * row — there is no separate, un-virtualized list.
+ * A PK-arena round sub-group heading inside the "pk" section: shows the round's
+ * task summary and gates its contestant conversations. Follows the section
+ * header; each round is one collapsible group.
+ */
+export interface PkRoundHeaderRow {
+ kind: "pk-round"
+ roundId: number
+ /** Task preview (truncated for the header) — identifies the round. */
+ task: string
+ /** Number of contestant conversations in this round. */
+ count: number
+}
+
+/** Empty hint for the PK section (no PK arena conversations at all). */
+export interface PkEmptyRow {
+ kind: "pk-empty"
+}
+
+/**
+ * A collapsible section heading. Five exist: "pinned" and "pk" (always on top,
+ * shown only when conversations of that kind exist) plus the three
+ * user-reorderable ones — "folders" (wraps the whole folder list), "chats" (a
+ * flat list of folderless chat-mode conversations), and "recent" (a flat,
+ * folder-agnostic list of the newest conversations, shown only when the user
+ * keeps it enabled). All live in the same flat row array so the single
+ * Virtualizer windows them like any other row — there is no separate,
+ * un-virtualized list.
*/
export interface SectionHeaderRow {
kind: "section"
@@ -538,6 +607,8 @@ export type SidebarRow =
| RecentEmptyRow
| RecentMoreRow
| SubsessionLoadingRow
+ | PkRoundHeaderRow
+ | PkEmptyRow
const MAX_RENDER_DEPTH = 32
@@ -555,6 +626,9 @@ const EMPTY_CONTAINER_CHILDREN: ReadonlyMap =
// the row output stays identical to the pre-Recent model for callers that don't
// pass it.
const EMPTY_CONVERSATIONS: readonly DbConversationSummary[] = []
+const EMPTY_PK_MAP: ReadonlyMap =
+ new Map()
+const EMPTY_ROUND_TASKS: ReadonlyMap = new Map()
/**
* Merge a freshly-fetched children snapshot with child summaries already applied
@@ -684,6 +758,16 @@ function pushConversationRow(
export function buildRows(args: {
pinned: readonly DbConversationSummary[]
pinnedExpanded: boolean
+ /** PK-arena conversations grouped by round id (hottest round first). Empty
+ * Map = no PK section. */
+ pkConversations?: Map
+ /** Whether the PK section's rows are shown. Optional — defaults to expanded. */
+ pkExpanded?: boolean
+ /** Round metadata for the PK section headers: id → task preview. Absent
+ * entries fall back to a generic "Round N" label. */
+ pkRoundTasks?: ReadonlyMap
+ /** Ids whose PK round sub-group is collapsed. Absent = expanded. */
+ pkRoundCollapsed?: ReadonlySet
orderedFolderIds: readonly number[]
byFolder: Map
folderExpanded: Record
@@ -740,6 +824,10 @@ export function buildRows(args: {
const {
pinned,
pinnedExpanded,
+ pkConversations = EMPTY_PK_MAP,
+ pkExpanded = true,
+ pkRoundTasks = EMPTY_ROUND_TASKS,
+ pkRoundCollapsed = EMPTY_EXPANDED,
orderedFolderIds,
byFolder,
folderExpanded,
@@ -812,6 +900,41 @@ export function buildRows(args: {
}
}
+ const pushPk = () => {
+ if (pkConversations.size === 0) return
+ const totalConvs = [...pkConversations.values()].reduce(
+ (n, bucket) => n + bucket.length,
+ 0
+ )
+ rows.push({
+ kind: "section",
+ section: "pk",
+ expanded: pkExpanded,
+ count: totalConvs,
+ })
+ if (!pkExpanded) return
+ for (const [roundId, convs] of pkConversations) {
+ const task = pkRoundTasks.get(roundId) ?? `Round #${roundId}`
+ rows.push({
+ kind: "pk-round",
+ roundId,
+ task,
+ count: convs.length,
+ })
+ if (pkRoundCollapsed.has(roundId)) continue
+ for (const conv of convs) {
+ pushConversationRow(
+ rows,
+ conv,
+ 0,
+ conversationExpanded,
+ childrenByParent,
+ childrenLoading
+ )
+ }
+ }
+ }
+
const pushFolders = () => {
// The Folders section header is always present (a permanent entry point),
// mirroring the Chat section — so a workspace with chats but no open folders
@@ -916,6 +1039,11 @@ export function buildRows(args: {
if (remaining > 0) rows.push({ kind: "recent-more", remaining })
}
+ // The PK section sits right below Pinned (above the reorderable sections).
+ // It is not part of `sectionOrder` — it is always-on-top like Pinned, shown
+ // only when PK arena conversations exist.
+ pushPk()
+
// Normalized (not consumed raw) so a truncated / repeated / unknown-entry
// order can never drop a section off the sidebar or emit one twice.
for (const section of normalizeSectionOrder(sectionOrder)) {
diff --git a/src/components/conversations/sidebar-conversation-list.test.tsx b/src/components/conversations/sidebar-conversation-list.test.tsx
index 8b4ede4ef..ad041db3c 100644
--- a/src/components/conversations/sidebar-conversation-list.test.tsx
+++ b/src/components/conversations/sidebar-conversation-list.test.tsx
@@ -36,6 +36,7 @@ const store = vi.hoisted(() => ({
activeTabId: null as string | null,
tabSpec: [] as Array<{
id: string
+ kind?: string
conversationId: number | null
agentType: string
folderId: number
@@ -735,6 +736,7 @@ describe("SidebarConversationList — scrollToActive across a worktree merge", (
store.activeTabId = "tab-21"
store.tabSpec = [
{
+ kind: "conversation",
id: "tab-21",
conversationId: 21,
agentType: "claude_code",
diff --git a/src/components/conversations/sidebar-conversation-list.tsx b/src/components/conversations/sidebar-conversation-list.tsx
index 8aa06f888..de0d784f2 100644
--- a/src/components/conversations/sidebar-conversation-list.tsx
+++ b/src/components/conversations/sidebar-conversation-list.tsx
@@ -21,6 +21,7 @@ import {
ChevronDown,
ChevronRight,
Download,
+ Archive,
ExternalLink,
FolderClosed,
FolderGit2,
@@ -41,7 +42,9 @@ import {
} from "lucide-react"
import { useActiveFolder } from "@/contexts/active-folder-context"
import { useAppWorkspaceStore } from "@/stores/app-workspace-store"
+import { usePkArenaStore } from "@/stores/pk-arena-store"
import { useTabActions, useTabStore } from "@/contexts/tab-context"
+import { isConversationWorkspaceTab } from "@/lib/workspace-tab"
import { useWorkbenchRoute } from "@/contexts/workbench-route-context"
import { useTerminalContext } from "@/contexts/terminal-context"
import { useThemeColor, useZoomLevel } from "@/hooks/use-appearance"
@@ -111,6 +114,7 @@ import {
reuseSet,
selectChatConversationsWithReuse,
selectPinnedWithReuse,
+ selectPkConversationsWithReuse,
selectRecentConversationsWithReuse,
worktreeChildrenByParent,
worktreeHeaderAlias,
@@ -797,6 +801,8 @@ export function SidebarConversationList({
const tabs = useTabStore((s) => s.tabs)
const {
openTab,
+ openPkRoundTab,
+ closePkRoundTab,
closeConversationTab,
closeTabsByFolder,
openNewConversationTab,
@@ -837,7 +843,9 @@ export function SidebarConversationList({
const selectedConversation = useMemo(() => {
const activeTab = tabs.find((tab) => tab.id === activeTabId)
const next =
- !activeTab || activeTab.conversationId == null
+ !activeTab ||
+ !isConversationWorkspaceTab(activeTab) ||
+ activeTab.conversationId == null
? null
: { id: activeTab.conversationId, agentType: activeTab.agentType }
const reused = reuseSelected(selectedConvRef.current, next)
@@ -849,7 +857,7 @@ export function SidebarConversationList({
const openTabKeys = useMemo(() => {
const next = new Set()
for (const tab of tabs) {
- if (tab.conversationId != null) {
+ if (isConversationWorkspaceTab(tab) && tab.conversationId != null) {
next.add(`${tab.agentType}:${tab.conversationId}`)
}
}
@@ -876,6 +884,7 @@ export function SidebarConversationList({
const [sectionCollapsed, setSectionCollapsed] =
useState({})
const pinnedExpanded = !sectionCollapsed.pinned
+ const pkExpanded = !sectionCollapsed.pk
const foldersExpanded = !sectionCollapsed.folders
const chatsExpanded = !sectionCollapsed.chats
const recentExpanded = !sectionCollapsed.recent
@@ -1100,7 +1109,7 @@ export function SidebarConversationList({
// section, so exclude both here; then apply the completed filter as before.
const folderConversations = useMemo(() => {
const base = conversations.filter(
- (c) => c.pinned_at == null && c.kind !== "chat"
+ (c) => c.pinned_at == null && c.kind !== "chat" && c.kind !== "pk"
)
if (showCompleted) return base
return base.filter((c) => c.status !== "completed")
@@ -1120,6 +1129,84 @@ export function SidebarConversationList({
return next
}, [conversations, showCompleted])
+ // PK-arena conversations grouped by round (hottest round first). Each round
+ // renders as a collapsible sub-group under the "PK" section header. The round
+ // task labels come from the PK arena store; a round not yet hydrated shows a
+ // generic "Round #N" fallback.
+ const pkConvsRef = useRef
)
}
+ if (row.kind === "pk-empty") {
+ return (
+
+ {t("noPk")}
+
+ )
+ }
+ if (row.kind === "pk-round") {
+ const collapsed = pkRoundCollapsed.has(row.roundId)
+ const roundStatus = pkRounds.find(
+ (round) => Number(round.id) === row.roundId
+ )?.status
+ const canArchive = roundStatus !== "running" && roundStatus !== "ready"
+ return (
+
+
+
+ {canArchive ? (
+
+ ) : null}
+
+ )
+ }
if (row.kind === "recent-more") {
// Footer of the paged Recent section — a row, not a hint: each click
// reveals another page. Its geometry is the conversation card's, so the
@@ -2510,6 +2661,8 @@ export function SidebarConversationList({
if (row.kind === "folders-empty") return "folders-empty"
if (row.kind === "recent-empty") return "recent-empty"
if (row.kind === "recent-more") return "recent-more"
+ if (row.kind === "pk-empty") return "pk-empty"
+ if (row.kind === "pk-round") return `pk-round-${row.roundId}`
const prefix = row.recent ? "recent-" : ""
if (row.kind === "subsession-loading") {
return `${prefix}subloading-${row.parentId}`
diff --git a/src/components/conversations/sidebar-section-header.tsx b/src/components/conversations/sidebar-section-header.tsx
index 2556f8ac4..e0ef0b0a3 100644
--- a/src/components/conversations/sidebar-section-header.tsx
+++ b/src/components/conversations/sidebar-section-header.tsx
@@ -82,11 +82,13 @@ export const SidebarSectionHeader = memo(function SidebarSectionHeader({
const label =
section === "pinned"
? t("sectionPinned")
- : section === "chats"
- ? t("sectionChats")
- : section === "recent"
- ? t("sectionRecent")
- : t("sectionFolders")
+ : section === "pk"
+ ? t("sectionPk")
+ : section === "chats"
+ ? t("sectionChats")
+ : section === "recent"
+ ? t("sectionRecent")
+ : t("sectionFolders")
// "Recent" gets the same right-edge affordance as "Chats": it is a section
// people scan to resume work, so "start a new one" belongs at its head too.
// The label differs — Chats starts a folderless chat, Recent starts a
diff --git a/src/components/layout/aux-panel-file-tree-tab.tsx b/src/components/layout/aux-panel-file-tree-tab.tsx
index 7d3333c46..ec75b4afd 100644
--- a/src/components/layout/aux-panel-file-tree-tab.tsx
+++ b/src/components/layout/aux-panel-file-tree-tab.tsx
@@ -12,7 +12,7 @@ import {
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from "react"
-import { revealItemInDir, subscribe } from "@/lib/platform"
+import { isLocalDesktop, revealItemInDir, subscribe } from "@/lib/platform"
import ignore from "ignore"
import { Check, ChevronRight, Link2 } from "lucide-react"
import { useTranslations } from "next-intl"
@@ -822,11 +822,13 @@ function RenderNode({
{t("openIn")}
- void handleOpenInSystemExplorer()}
- >
- {systemExplorerLabel}
-
+ {isLocalDesktop() && (
+ void handleOpenInSystemExplorer()}
+ >
+ {systemExplorerLabel}
+
+ )}
void onOpenDirInTerminal(dirPath, node.name)}
>
@@ -1058,11 +1060,13 @@ function RenderNode({
{t("openIn")}
- void handleOpenDirInSystemExplorer()}
- >
- {systemExplorerLabel}
-
+ {isLocalDesktop() && (
+ void handleOpenDirInSystemExplorer()}
+ >
+ {systemExplorerLabel}
+
+ )}
void onOpenDirInTerminal(absolutePath, node.name)}
>
@@ -3002,13 +3006,15 @@ export function FileTreeTab() {
{t("openIn")}
- {
- void revealItemInDir(folder.path)
- }}
- >
- {systemExplorerLabel}
-
+ {isLocalDesktop() && (
+ {
+ void revealItemInDir(folder.path)
+ }}
+ >
+ {systemExplorerLabel}
+
+ )}
{
void handleOpenDirInTerminal(
diff --git a/src/components/layout/aux-panel-session-details-tab.test.tsx b/src/components/layout/aux-panel-session-details-tab.test.tsx
index 70a540a13..763f51de0 100644
--- a/src/components/layout/aux-panel-session-details-tab.test.tsx
+++ b/src/components/layout/aux-panel-session-details-tab.test.tsx
@@ -44,6 +44,7 @@ const mockWorkspace = useAppWorkspaceStore as unknown as Mock
type TabSlice = {
tabs: Array<{
id: number
+ kind: "conversation"
conversationId: number | null
runtimeConversationId?: number
}>
@@ -79,7 +80,9 @@ function setupScene(opts: { hasActiveConversation: boolean }) {
mockAux.mockReturnValue({ isOpen: true, activeTab: "session_details" })
const tabState: TabSlice = {
- tabs: opts.hasActiveConversation ? [{ id: 1, conversationId: 7 }] : [],
+ tabs: opts.hasActiveConversation
+ ? [{ id: 1, kind: "conversation", conversationId: 7 }]
+ : [],
activeTabId: opts.hasActiveConversation ? 1 : null,
}
mockTabs.mockImplementation((sel: (s: TabSlice) => unknown) => sel(tabState))
diff --git a/src/components/layout/aux-panel-session-details-tab.tsx b/src/components/layout/aux-panel-session-details-tab.tsx
index 1335f014a..0c75bb372 100644
--- a/src/components/layout/aux-panel-session-details-tab.tsx
+++ b/src/components/layout/aux-panel-session-details-tab.tsx
@@ -11,6 +11,7 @@ import { resolveActiveSessionDetails } from "@/components/conversations/active-s
import { SessionDetailsContent } from "@/components/conversations/session-details-content"
import { ScrollArea } from "@/components/ui/scroll-area"
import { useAuxPanelContext } from "@/contexts/aux-panel-context"
+import { isConversationWorkspaceTab } from "@/lib/workspace-tab"
// Stable empty-turns reference so the `useShallow` slice below stays
// reference-equal when there's no active session — otherwise a fresh `[]` each
@@ -34,13 +35,12 @@ export function SessionDetailsTab() {
const tabs = useTabStore((s) => s.tabs)
const activeTabId = useTabStore((s) => s.activeTabId)
- const activeConversationTab = useMemo(
- () =>
- tabs.find(
- (tab) => tab.id === activeTabId && tab.conversationId != null
- ) ?? null,
- [tabs, activeTabId]
- )
+ const activeConversationTab = useMemo(() => {
+ const tab = tabs.find((item) => item.id === activeTabId)
+ return tab && isConversationWorkspaceTab(tab) && tab.conversationId != null
+ ? tab
+ : null
+ }, [tabs, activeTabId])
// A brand-new conversation streams under its virtual `runtimeConversationId`
// until it reconciles; key the live-session lookup on it first (mirrors the
diff --git a/src/components/pk/pk-arena-host.tsx b/src/components/pk/pk-arena-host.tsx
new file mode 100644
index 000000000..8f7183612
--- /dev/null
+++ b/src/components/pk/pk-arena-host.tsx
@@ -0,0 +1,143 @@
+"use client"
+
+import { useEffect, useRef } from "react"
+import { PkLauncherDialog } from "@/components/pk/pk-launcher-dialog"
+import { PkMinimizedPill } from "@/components/pk/pk-minimized-pill"
+import { usePkRound, fetchUsage } from "@/hooks/use-pk-round"
+import {
+ usePkArenaStore,
+ dbRoundToStoreRound,
+ type PkRound,
+} from "@/stores/pk-arena-store"
+import { pkRoundList, updateConversationStatus } from "@/lib/api"
+import { getPkConversationStatusRepairs } from "@/lib/pk-conversation-reconciliation"
+import { useAppWorkspaceStore } from "@/stores/app-workspace-store"
+
+/**
+ * Arena mount point — renders global launch/minimized controls and drives the
+ * orchestrator for rounds created by the launcher. Must live inside
+ * `AcpConnectionsProvider` (the workspace layout provides it): the
+ * orchestrator calls `connect`/`sendPrompt` and subscribes to `acp://event`.
+ *
+ * The launcher only writes the round into the store; this host picks it up,
+ * so round creation works from anywhere (composer menu, future entries)
+ * without prop-drilling.
+ *
+ * On mount, hydrates the store from the DB so finished rounds' scoreboards and
+ * diffs remain viewable after a restart. The folder's path is needed to map
+ * each DB round's folderId to its workingDir.
+ */
+export function PkArenaHost() {
+ const { startRound } = usePkRound()
+ const rounds = usePkArenaStore((s) => s.rounds)
+ const hydrating = usePkArenaStore((s) => s.hydrating)
+ const hydrateFromDb = usePkArenaStore((s) => s.hydrateFromDb)
+ const folders = useAppWorkspaceStore((s) => s.allFolders)
+ const conversations = useAppWorkspaceStore((s) => s.conversations)
+ const conversationsLoading = useAppWorkspaceStore(
+ (s) => s.conversationsLoading
+ )
+ const reconciledConversationIdsRef = useRef(new Set())
+
+ // Repair persisted PK conversation rows whose lifecycle no longer agrees
+ // with the authoritative round. This covers both legacy judge rows and
+ // contestant rows left live by an older cancellation path. The normal
+ // conversation event updates the sidebar in place.
+ useEffect(() => {
+ for (const repair of getPkConversationStatusRepairs(
+ rounds,
+ conversations
+ )) {
+ if (reconciledConversationIdsRef.current.has(repair.conversationId)) {
+ continue
+ }
+ reconciledConversationIdsRef.current.add(repair.conversationId)
+ void updateConversationStatus(repair.conversationId, repair.status).catch(
+ () => {
+ reconciledConversationIdsRef.current.delete(repair.conversationId)
+ }
+ )
+ }
+ }, [conversations, rounds])
+
+ // Hydrate each store instance once. Fast Refresh can replace the Zustand
+ // store while preserving this host's React refs; keying the guard by the
+ // store's rounds array lets the replacement hydrate again without issuing
+ // duplicate requests during React Strict Mode's repeated effects.
+ const hydrationSourceRef = useRef(null)
+ useEffect(() => {
+ if (
+ !hydrating ||
+ hydrationSourceRef.current === rounds ||
+ folders.length === 0 ||
+ conversationsLoading
+ ) {
+ return
+ }
+ hydrationSourceRef.current = rounds
+ void (async () => {
+ try {
+ const dbRounds = await pkRoundList()
+ const storeRounds = dbRounds
+ .map((info) => {
+ const folder = folders.find((f) => f.id === info.folder_id)
+ const workingDir = folder?.path ?? ""
+ return dbRoundToStoreRound(info, workingDir, conversations)
+ })
+ .filter((r) => r.workingDir !== "")
+ hydrateFromDb(storeRounds)
+ // Backfill usage for finished contestants — usage is live-only in
+ // the store (issue #4 / #16), so after a restart it's null. Fetch
+ // it from the conversation turns for any contestant that has a
+ // conversationId and is done/error/canceled.
+ for (const round of storeRounds) {
+ for (const c of round.contestants) {
+ if (
+ c.conversationId != null &&
+ (c.status === "done" ||
+ c.status === "error" ||
+ c.status === "canceled")
+ ) {
+ const usage = await fetchUsage(c.conversationId)
+ if (usage) {
+ usePkArenaStore
+ .getState()
+ .updateContestant(round.id, c.slot, { usage })
+ }
+ }
+ }
+ }
+ } catch {
+ hydrateFromDb([])
+ }
+ })()
+ }, [
+ conversations,
+ conversationsLoading,
+ folders,
+ hydrateFromDb,
+ hydrating,
+ rounds,
+ ])
+
+ // Drive any round that still has contestants in "preparing" — exactly the
+ // state the launcher leaves behind. Restarted (interrupted) rounds come
+ // back with settled statuses, so they are never re-driven.
+ const drivenRef = useRef(new Set())
+ useEffect(() => {
+ if (hydrating) return
+ for (const round of rounds) {
+ if (drivenRef.current.has(round.id)) continue
+ if (!round.contestants.some((c) => c.status === "preparing")) continue
+ drivenRef.current.add(round.id)
+ void startRound(round)
+ }
+ }, [rounds, startRound, hydrating])
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/src/components/pk/pk-arena-policy.test.ts b/src/components/pk/pk-arena-policy.test.ts
new file mode 100644
index 000000000..7d97bb4c8
--- /dev/null
+++ b/src/components/pk/pk-arena-policy.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest"
+import type { PkRound } from "@/stores/pk-arena-store"
+import { getArenaPillRound, getEffortControl } from "./pk-arena-policy"
+
+describe("PK arena lifecycle policy", () => {
+ it.each(["finished", "canceled", "interrupted"] as const)(
+ "does not show a %s round in the minimized entry",
+ (status) => {
+ const terminal = { id: "7", status } as PkRound
+ expect(getArenaPillRound([terminal], "7")).toBeNull()
+ }
+ )
+
+ it("prefers a live round when the active round is terminal", () => {
+ const finished = { id: "7", status: "finished" } as PkRound
+ const running = { id: "8", status: "running" } as PkRound
+ expect(getArenaPillRound([finished, running], "7")).toBe(running)
+ })
+})
+
+describe("PK contestant reasoning capability", () => {
+ it("shows the exact levels advertised by Qoder", () => {
+ expect(getEffortControl(["low", "medium"], "reasoning_effort")).toEqual({
+ kind: "select",
+ configId: "reasoning_effort",
+ options: ["low", "medium"],
+ })
+ })
+
+ it("shows an unsupported state instead of silently hiding the field", () => {
+ expect(getEffortControl([], null)).toEqual({ kind: "unsupported" })
+ })
+})
diff --git a/src/components/pk/pk-arena-policy.ts b/src/components/pk/pk-arena-policy.ts
new file mode 100644
index 000000000..734cc49c3
--- /dev/null
+++ b/src/components/pk/pk-arena-policy.ts
@@ -0,0 +1,31 @@
+import type { PkRound } from "@/stores/pk-arena-store"
+
+const isLiveRound = (round: PkRound) =>
+ round.status === "ready" || round.status === "running"
+
+/** Pick the live round represented by the minimized entry. */
+export function getArenaPillRound(
+ rounds: readonly PkRound[],
+ activeRoundId: string | null
+): PkRound | null {
+ const activeRound = rounds.find((round) => round.id === activeRoundId)
+ if (activeRound && isLiveRound(activeRound)) return activeRound
+ return rounds.find(isLiveRound) ?? null
+}
+
+export type PkEffortControl =
+ | {
+ kind: "select"
+ configId: string
+ options: readonly string[]
+ }
+ | { kind: "unsupported" }
+
+/** Preserve each agent's advertised effort levels; never invent global ones. */
+export function getEffortControl(
+ options: readonly string[],
+ configId: string | null
+): PkEffortControl {
+ if (!configId || options.length === 0) return { kind: "unsupported" }
+ return { kind: "select", configId, options }
+}
diff --git a/src/components/pk/pk-arena-view.tsx b/src/components/pk/pk-arena-view.tsx
new file mode 100644
index 000000000..c7877664f
--- /dev/null
+++ b/src/components/pk/pk-arena-view.tsx
@@ -0,0 +1,564 @@
+"use client"
+
+import { memo, useEffect, useMemo, useState } from "react"
+import { useLocale, useTranslations } from "next-intl"
+import { toast } from "sonner"
+import { ExternalLink } from "lucide-react"
+import { LiveTranscriptView } from "@/components/message/live-transcript-view"
+import { PkDiffView } from "@/components/pk/pk-diff-view"
+import { PkJudgePanel } from "@/components/pk/pk-judge-panel"
+import { PkScoreboard } from "@/components/pk/pk-scoreboard"
+import { PkHistoryPicker } from "@/components/pk/pk-history-picker"
+import { getEffortControl } from "@/components/pk/pk-arena-policy"
+import { usePkRound } from "@/hooks/use-pk-round"
+import { AgentIcon } from "@/components/agent-icon"
+import { getAgentLabel } from "@/lib/custom-agents"
+import { buildPkReportHtml } from "@/lib/pk-report"
+import { preparePkReportData } from "@/lib/pk-report-data"
+import { savePkReportHtml } from "@/lib/pk-report-export"
+import { openPkRoundWindow } from "@/lib/api"
+import type { PkContestant, PkRound } from "@/stores/pk-arena-store"
+import { cn } from "@/lib/utils"
+import { usePkArenaStore } from "@/stores/pk-arena-store"
+import { useTabActions } from "@/stores/tab-store"
+
+/**
+ * The arena itself: scoreboard on top, one live transcript column per
+ * contestant, a diff tab once the round settles, and a share button that
+ * saves a complete self-contained HTML battle report. Round switching comes from the store's
+ * The view is keyed by `roundId`, so multiple rounds can stay open in separate
+ * workspace tabs or split groups without fighting over one global active id.
+ */
+
+export function PkArenaView({
+ roundId,
+ tabId,
+}: {
+ roundId: string
+ tabId: string
+}) {
+ const t = useTranslations("PkArena.arena")
+ const tWindow = useTranslations("SkillsSettings.actions")
+ const locale = useLocale()
+ const setPillDismissed = usePkArenaStore((s) => s.setPillDismissed)
+ const rounds = usePkArenaStore((s) => s.rounds)
+ const { closeTab } = useTabActions()
+
+ const round = useMemo(
+ () => rounds.find((r) => r.id === roundId) ?? null,
+ [rounds, roundId]
+ )
+
+ const {
+ cancelRound,
+ cleanupRound,
+ fetchDiff,
+ disconnectFinished,
+ startPrompt,
+ sendFollowUp,
+ applyContestantSelection,
+ runJudge,
+ } = usePkRound()
+ const markRound = usePkArenaStore((s) => s.markRound)
+ const retryPersistence = usePkArenaStore((s) => s.retryPersistence)
+ const [tab, setTab] = useState<"battle" | "diff">("battle")
+ const [reportExporting, setReportExporting] = useState(false)
+ const [diffLoading, setDiffLoading] = useState(false)
+
+ // Literal keys — next-intl's typed messages reject dynamic concatenation.
+ const roundStatusLabel = useMemo(
+ () => ({
+ ready: t("roundStatus.ready"),
+ running: t("roundStatus.running"),
+ finished: t("roundStatus.finished"),
+ canceled: t("roundStatus.canceled"),
+ interrupted: t("roundStatus.interrupted"),
+ }),
+ [t]
+ )
+ const tabLabel = useMemo(
+ () => ({ battle: t("tabs.battle"), diff: t("tabs.diff") }) as const,
+ [t]
+ )
+
+ // Diff tab: fetch each contestant's worktree diff once per visit.
+ useEffect(() => {
+ if (tab !== "diff" || !round) return
+ let cancelled = false
+ const pending = round.contestants.filter(
+ (c) => c.diff == null && c.worktreePath
+ )
+ if (pending.length === 0) return
+ setDiffLoading(true)
+ void Promise.allSettled(pending.map((c) => fetchDiff(round, c))).then(
+ () => {
+ if (!cancelled) setDiffLoading(false)
+ }
+ )
+ return () => {
+ cancelled = true
+ }
+ }, [tab, round, fetchDiff])
+
+ // 状态自愈:任何原因导致回合停在 ready/running 而选手已全部结算
+ // (settle 事件漏一帧、重启后回放等),打开竞技场时立即收敛到 finished
+ // 并断开残留连接——否则顶部状态永远停在"就绪"。
+ useEffect(() => {
+ if (!round) return
+ const settled = (s: PkContestant["status"]) =>
+ s === "done" || s === "error" || s === "canceled"
+ if (round.status === "ready" || round.status === "running") {
+ if (
+ round.contestants.length > 0 &&
+ round.contestants.every((c) => settled(c.status))
+ ) {
+ markRound(round.id, "finished")
+ void disconnectFinished(round)
+ }
+ }
+ }, [round, markRound, disconnectFinished])
+
+ const handleExportReport = async () => {
+ if (!round || reportExporting) return
+ setReportExporting(true)
+ try {
+ const fresh = usePkArenaStore
+ .getState()
+ .rounds.find((r) => r.id === round.id)
+ const reportData = await preparePkReportData(fresh ?? round)
+ const html = buildPkReportHtml(
+ reportData.round,
+ reportData.artifactsBySlot,
+ locale
+ )
+ const result = await savePkReportHtml(html, round.id)
+ if (result === "saved") toast.success(t("reportSaved"))
+ } catch (error) {
+ toast.error(t("reportFailed", { message: String(error) }))
+ } finally {
+ setReportExporting(false)
+ }
+ }
+
+ const roundLive = round != null && round.status === "running"
+ const persistenceError = round
+ ? Object.values(round.persistenceErrors ?? {})
+ .filter(Boolean)
+ .join(" · ")
+ : ""
+
+ return (
+
+ {round ? (
+
+
+
+ PK
+
+
+
+ {round.task}
+
+
+ {roundStatusLabel[round.status]} ·{" "}
+ {new Date(round.createdAt).toLocaleString()}
+
+
+
+ {round.status === "ready" || roundLive ? (
+
+ ) : round.contestants.some((c) => c.worktreePath) ? (
+
+ ) : null}
+
+
+
+
+
+
+ {persistenceError ? (
+
+ {t("persistenceFailed")}
+
+
+ ) : null}
+
+ {round.status === "ready" ? (
+
+
+ {t("readyNote")}
+
+
+
+ ) : null}
+
+
+
+ {/* Judge verdict panel — shown when a judge is configured. */}
+ {round.judgeAgent ? (
+
void runJudge(round)
+ : undefined
+ }
+ />
+ ) : null}
+
+
+
+ {(["battle", "diff"] as const).map((key) => (
+
+ ))}
+
+
+
+
+ {tab === "battle"
+ ? round.contestants.map((contestant) =>
+ round.status === "ready" ? (
+
+ ) : (
+
+ void sendFollowUp(round, contestant, message)
+ }
+ />
+ )
+ )
+ : round.contestants.map((contestant) => (
+
+ ))}
+
+
+
+ ) : (
+
+ {t("noRound")}
+
+ )}
+
+ )
+}
+
+/**
+ * One battle column. Memoized on stable props: the dialog re-renders on
+ * every contestant store update (status/usage/diff of ANY contestant), and
+ * an unmemoized pane re-rendered four streaming markdown transcripts each
+ * time — the field-reported arena lag.
+ */
+const PkBattlePane = memo(function PkBattlePane({
+ contestant,
+ conversationId,
+ connectionId,
+ agentType,
+ task,
+ statusDetail,
+ preparingLabel,
+ followUpLabel,
+ followUpPlaceholder,
+ onFollowUp,
+}: {
+ contestant: PkContestant
+ conversationId: number | null
+ connectionId: string | null
+ agentType: PkContestant["agentType"]
+ task: string
+ statusDetail: string | null
+ preparingLabel: string
+ followUpLabel: string
+ followUpPlaceholder: string
+ onFollowUp: (message: string) => void
+}) {
+ // The follow-up box shows when the contestant finished its last turn AND
+ // its connection is still alive (contextKey set). A disconnected contestant
+ // can't receive a new prompt.
+ const canFollowUp =
+ contestant.status === "done" && contestant.contextKey != null
+ const [followUpText, setFollowUpText] = useState("")
+ const [sending, setSending] = useState(false)
+
+ const handleSend = async () => {
+ const trimmed = followUpText.trim()
+ if (!trimmed || sending) return
+ setSending(true)
+ setFollowUpText("")
+ try {
+ await onFollowUp(trimmed)
+ } finally {
+ setSending(false)
+ }
+ }
+
+ return (
+
+ {conversationId != null ? (
+
+ ) : (
+
+ {statusDetail ?? preparingLabel}
+
+ )}
+ {canFollowUp ? (
+
+ ) : null}
+
+ )
+})
+
+/**
+ * 准备阶段的面板:模型 + 思考等级选择器(选项来自握手通告的 configOptions)。
+ * 只读竞技场 store 的选项表,变更经 onSelect 直接下发给后端连接。
+ */
+const PkReadyPane = memo(function PkReadyPane({
+ round,
+ contestant,
+ onSelect,
+}: {
+ round: PkRound
+ contestant: PkContestant
+ onSelect: (
+ round: PkRound,
+ contestant: PkContestant,
+ configId: string,
+ value: string
+ ) => Promise
+}) {
+ const t = useTranslations("PkArena.arena")
+ const effortControl = getEffortControl(
+ contestant.effortOptions,
+ contestant.effortConfigId
+ )
+ const effortOptionLabels = {
+ off: t("effortOptions.off"),
+ minimal: t("effortOptions.minimal"),
+ low: t("effortOptions.low"),
+ medium: t("effortOptions.medium"),
+ high: t("effortOptions.high"),
+ max: t("effortOptions.max"),
+ } as const
+ return (
+
+
+
+
+
+
+
+ {getAgentLabel(contestant.agentType)}
+ {contestant.label ? ` · ${contestant.label}` : ""}
+
+
+
+ {t("readyTag")}
+
+
+
+
+ {contestant.modelOptions.length > 0 && contestant.modelConfigId ? (
+
+ ) : null}
+ {effortControl.kind === "select" ? (
+
+ ) : (
+
+
+ {t("effortLabel")}
+
+
+ {t("effortUnsupported")}
+
+
+ )}
+
+
+ )
+})
diff --git a/src/components/pk/pk-diff-view.test.ts b/src/components/pk/pk-diff-view.test.ts
new file mode 100644
index 000000000..465656ff1
--- /dev/null
+++ b/src/components/pk/pk-diff-view.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest"
+import { parseUnifiedDiff } from "./pk-diff-view"
+
+describe("parseUnifiedDiff", () => {
+ it("classifies add, delete, hunk and context lines", () => {
+ const diff = [
+ "diff --git a/main.py b/main.py",
+ "index 123..456 100644",
+ "--- a/main.py",
+ "+++ b/main.py",
+ "@@ -1,3 +1,4 @@",
+ " context line",
+ "-removed line",
+ "+added line",
+ "+another addition",
+ ].join("\n")
+
+ const lines = parseUnifiedDiff(diff)
+ // File header lines are dropped entirely.
+ expect(lines.map((l) => l.kind)).toEqual([
+ "hunk",
+ "ctx",
+ "del",
+ "add",
+ "add",
+ ])
+ expect(lines[0].text).toBe("@@ -1,3 +1,4 @@")
+ })
+
+ it("keeps blank context lines as renderable entries", () => {
+ const lines = parseUnifiedDiff("+x\n\n-y")
+ expect(lines).toHaveLength(3)
+ expect(lines[1]).toEqual({ kind: "ctx", text: "" })
+ })
+
+ it("handles an empty diff without inventing lines", () => {
+ expect(parseUnifiedDiff("")).toEqual([])
+ })
+})
diff --git a/src/components/pk/pk-diff-view.tsx b/src/components/pk/pk-diff-view.tsx
new file mode 100644
index 000000000..8c7a668d6
--- /dev/null
+++ b/src/components/pk/pk-diff-view.tsx
@@ -0,0 +1,120 @@
+"use client"
+
+import { useMemo } from "react"
+import { useTranslations } from "next-intl"
+import { AgentIcon } from "@/components/agent-icon"
+import { getAgentLabel } from "@/lib/custom-agents"
+import type { AgentType } from "@/lib/types"
+import { cn } from "@/lib/utils"
+
+/**
+ * Lightweight unified-diff renderer for one contestant's worktree diff —
+ * line-level red/green with hunk headers, deliberately NOT the three-pane
+ * merge editor (that is for conflict resolution, not comparison). Each
+ * contestant renders in its own scrollable column.
+ */
+
+interface DiffLine {
+ kind: "add" | "del" | "hunk" | "ctx"
+ text: string
+}
+
+export function parseUnifiedDiff(diff: string): DiffLine[] {
+ if (diff.trim() === "") return []
+ const lines: DiffLine[] = []
+ for (const raw of diff.split("\n")) {
+ if (
+ raw.startsWith("+++") ||
+ raw.startsWith("---") ||
+ raw.startsWith("diff ") ||
+ raw.startsWith("index ")
+ ) {
+ continue
+ }
+ if (raw.startsWith("@@")) {
+ lines.push({ kind: "hunk", text: raw })
+ } else if (raw.startsWith("+")) {
+ lines.push({ kind: "add", text: raw })
+ } else if (raw.startsWith("-")) {
+ lines.push({ kind: "del", text: raw })
+ } else {
+ lines.push({ kind: "ctx", text: raw })
+ }
+ }
+ return lines
+}
+
+function diffStats(lines: DiffLine[]): { added: number; removed: number } {
+ let added = 0
+ let removed = 0
+ for (const line of lines) {
+ if (line.kind === "add") added += 1
+ if (line.kind === "del") removed += 1
+ }
+ return { added, removed }
+}
+
+const LINE_STYLE: Record = {
+ add: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
+ del: "bg-red-500/10 text-red-700 dark:text-red-400",
+ hunk: "bg-muted text-muted-foreground",
+ ctx: "text-foreground/80",
+}
+
+export function PkDiffView({
+ agentType,
+ diff,
+ loading,
+}: {
+ agentType: AgentType
+ diff: string | null
+ loading: boolean
+}) {
+ const t = useTranslations("PkArena.diff")
+ const lines = useMemo(() => (diff ? parseUnifiedDiff(diff) : []), [diff])
+ const stats = useMemo(() => diffStats(lines), [lines])
+ const empty = diff != null && diff.trim() === ""
+
+ return (
+
+
+
+
+ {getAgentLabel(agentType)}
+
+ {diff != null && !empty ? (
+
+
+ +{stats.added}
+
+
+ −{stats.removed}
+
+
+ ) : null}
+
+
+ {loading ? (
+
+ {t("loading")}
+
+ ) : empty ? (
+
+ {t("empty")}
+
+ ) : (
+
+ {lines.map((line, index) => (
+
+ {line.text === "" ? " " : line.text}
+
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/src/components/pk/pk-history-picker.tsx b/src/components/pk/pk-history-picker.tsx
new file mode 100644
index 000000000..3482e3594
--- /dev/null
+++ b/src/components/pk/pk-history-picker.tsx
@@ -0,0 +1,201 @@
+"use client"
+
+import { useMemo, useState } from "react"
+import { Archive, ChevronRight, History, Search, Trophy } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { toast } from "sonner"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { cn } from "@/lib/utils"
+import { assignJudgeScoreSlots, contestantForJudgeScore } from "@/lib/pk-judge"
+import { useAppWorkspaceStore } from "@/stores/app-workspace-store"
+import { useTabActions } from "@/contexts/tab-context"
+import { usePkArenaStore, type PkRound } from "@/stores/pk-arena-store"
+
+const STATUS_TONE: Record = {
+ ready: "bg-amber-500",
+ running: "bg-emerald-500",
+ finished: "bg-sky-500",
+ canceled: "bg-muted-foreground",
+ interrupted: "bg-orange-500",
+}
+
+export function PkHistoryPicker({ activeRound }: { activeRound: PkRound }) {
+ const t = useTranslations("PkArena.history")
+ const rounds = usePkArenaStore((s) => s.rounds)
+ const setActiveRound = usePkArenaStore((s) => s.setActiveRound)
+ const archiveRound = usePkArenaStore((s) => s.archiveRound)
+ const refreshConversations = useAppWorkspaceStore(
+ (s) => s.refreshConversations
+ )
+ const conversations = useAppWorkspaceStore((s) => s.conversations)
+ const { closeConversationTab, closePkRoundTab, openPkRoundTab } =
+ useTabActions()
+ const [open, setOpen] = useState(false)
+ const [query, setQuery] = useState("")
+ const [archivingId, setArchivingId] = useState(null)
+
+ const filtered = useMemo(() => {
+ const needle = query.trim().toLocaleLowerCase()
+ if (!needle) return rounds
+ return rounds.filter((round) => {
+ const agents = round.contestants.map((c) => c.agentType).join(" ")
+ return `${round.task} ${agents}`.toLocaleLowerCase().includes(needle)
+ })
+ }, [query, rounds])
+
+ const statusLabel = (status: PkRound["status"]) =>
+ ({
+ ready: t("status.ready"),
+ running: t("status.running"),
+ finished: t("status.finished"),
+ canceled: t("status.canceled"),
+ interrupted: t("status.interrupted"),
+ })[status]
+
+ const handleArchive = async (round: PkRound) => {
+ if (!window.confirm(t("archiveConfirm", { task: round.task }))) return
+ setArchivingId(round.id)
+ try {
+ await archiveRound(round.id)
+ closePkRoundTab(round.id)
+ for (const conversation of conversations) {
+ if (conversation.pk_round_id !== Number(round.id)) continue
+ closeConversationTab(
+ conversation.folder_id,
+ conversation.id,
+ conversation.agent_type
+ )
+ }
+ await refreshConversations()
+ const remaining = usePkArenaStore.getState().rounds
+ if (round.id === activeRound.id) {
+ if (remaining[0]) setActiveRound(remaining[0].id)
+ }
+ toast.success(t("archiveSuccess"))
+ } catch (error) {
+ toast.error(t("archiveFailed", { message: String(error) }))
+ } finally {
+ setArchivingId(null)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
{t("title")}
+
{t("hint")}
+
+
+
+
+ {filtered.length === 0 ? (
+
+ {t("empty")}
+
+ ) : (
+ filtered.map((round) => {
+ const winner = assignJudgeScoreSlots(
+ round.judgeResult?.scores ?? [],
+ round.contestants.filter(
+ (contestant) => contestant.status === "done"
+ )
+ ).find((score) => score.rank === 1)
+ const winnerContestant = winner
+ ? contestantForJudgeScore(winner, round.contestants)
+ : undefined
+ const active = round.id === activeRound.id
+ return (
+
+
+ {round.status !== "running" && round.status !== "ready" ? (
+
+ ) : null}
+
+ )
+ })
+ )}
+
+
+
+ )
+}
diff --git a/src/components/pk/pk-judge-panel.test.tsx b/src/components/pk/pk-judge-panel.test.tsx
new file mode 100644
index 000000000..01e1cff70
--- /dev/null
+++ b/src/components/pk/pk-judge-panel.test.tsx
@@ -0,0 +1,68 @@
+import { render, screen } from "@testing-library/react"
+import { describe, expect, it, vi } from "vitest"
+import type { PkContestant } from "@/stores/pk-arena-store"
+import { PkJudgePanel } from "./pk-judge-panel"
+
+vi.mock("next-intl", () => ({
+ useTranslations: () => (key: string) => key,
+}))
+
+vi.mock("@/components/agent-icon", () => ({
+ AgentIcon: ({ agentType }: { agentType: string }) => (
+
+ ),
+}))
+
+describe("PkJudgePanel", () => {
+ it("renders repeated agent types as distinct contestant slots", () => {
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => undefined)
+
+ render(
+
+ )
+
+ expect(consoleError.mock.calls.flat().join(" ")).not.toContain("same key")
+ expect(screen.getByText("Model A")).toBeInTheDocument()
+ expect(screen.getByText("Model B")).toBeInTheDocument()
+ consoleError.mockRestore()
+ })
+})
diff --git a/src/components/pk/pk-judge-panel.tsx b/src/components/pk/pk-judge-panel.tsx
new file mode 100644
index 000000000..f3ff90560
--- /dev/null
+++ b/src/components/pk/pk-judge-panel.tsx
@@ -0,0 +1,148 @@
+"use client"
+
+import { useTranslations } from "next-intl"
+import { AgentIcon } from "@/components/agent-icon"
+import { getAgentLabel } from "@/lib/custom-agents"
+import { assignJudgeScoreSlots, contestantForJudgeScore } from "@/lib/pk-judge"
+import { cn } from "@/lib/utils"
+import type {
+ PkContestant,
+ PkJudgeResult,
+ PkJudgeStatus,
+} from "@/stores/pk-arena-store"
+import type { AgentType } from "@/lib/types"
+
+/**
+ * Judge verdict panel — appears below the scoreboard when a judge agent was
+ * configured for the round. Shows the judge's structured scores and rankings
+ * once the judge has finished, or a spinner while it runs.
+ */
+
+function scoreColor(score: number): string {
+ if (score >= 80) return "text-emerald-600 dark:text-emerald-400"
+ if (score >= 60) return "text-amber-600 dark:text-amber-400"
+ if (score >= 40) return "text-orange-600 dark:text-orange-400"
+ return "text-red-600 dark:text-red-400"
+}
+
+function rankBadge(rank: number): string {
+ if (rank === 1) return "🥇"
+ if (rank === 2) return "🥈"
+ if (rank === 3) return "🥉"
+ return `#${rank}`
+}
+
+export function PkJudgePanel({
+ judgeStatus,
+ judgeResult,
+ judgeAgent,
+ contestants,
+ onRerun,
+}: {
+ judgeStatus: PkJudgeStatus
+ judgeResult: PkJudgeResult | null
+ judgeAgent: string
+ contestants: readonly PkContestant[]
+ onRerun?: () => void
+}) {
+ const t = useTranslations("PkArena.judge")
+ const completedContestants = contestants.filter(
+ (contestant) => contestant.status === "done"
+ )
+ const scores = assignJudgeScoreSlots(
+ judgeResult?.scores ?? [],
+ completedContestants
+ )
+
+ if (judgeStatus === "idle" || judgeStatus === "skipped") return null
+
+ return (
+
+
+
+
+ {t("title")}
+
+
+ {getAgentLabel(judgeAgent as AgentType)}
+
+ {judgeStatus === "running" ? (
+
+
+ {t("running")}
+
+ ) : null}
+ {judgeStatus === "error" ? (
+
+ {t("error")}
+
+ ) : null}
+ {onRerun ? (
+
+ ) : null}
+
+
+ {judgeResult ? (
+
+ {scores.length > 0 ? (
+
+ {scores
+ .slice()
+ .sort((a, b) => a.rank - b.rank)
+ .map((score, index) => {
+ const contestant = contestantForJudgeScore(score, contestants)
+ return (
+
+
{rankBadge(score.rank)}
+
+
+ {getAgentLabel(score.agentType as AgentType)}
+
+ {contestant?.label ? (
+
+ {contestant.label}
+
+ ) : null}
+
+ {score.score}
+
+ {score.comment ? (
+
+ {score.comment}
+
+ ) : null}
+
+ )
+ })}
+
+ ) : null}
+ {judgeResult.summary ? (
+
+ {judgeResult.summary}
+
+ ) : null}
+
+ ) : null}
+
+ )
+}
diff --git a/src/components/pk/pk-launcher-dialog.test.tsx b/src/components/pk/pk-launcher-dialog.test.tsx
new file mode 100644
index 000000000..7c6283ecb
--- /dev/null
+++ b/src/components/pk/pk-launcher-dialog.test.tsx
@@ -0,0 +1,172 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import { render, screen, waitFor } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { NextIntlClientProvider } from "next-intl"
+import enMessages from "@/i18n/messages/en.json"
+import { usePkArenaStore } from "@/stores/pk-arena-store"
+import { PkLauncherDialog } from "./pk-launcher-dialog"
+
+const apiMocks = vi.hoisted(() => ({
+ acpGetAgentStatus: vi.fn(),
+ getGitBranch: vi.fn(),
+ pkRoundCreate: vi.fn(),
+}))
+
+vi.mock("@/lib/api", () => ({
+ acpGetAgentStatus: apiMocks.acpGetAgentStatus,
+ getFolder: vi.fn(),
+ getGitBranch: apiMocks.getGitBranch,
+ gitInit: vi.fn(),
+ gitLog: vi.fn(),
+ pkRoundCreate: apiMocks.pkRoundCreate,
+ pkRoundUpdateStatus: vi.fn(),
+ pkRoundDelete: vi.fn(),
+ pkRoundUpdateJudge: vi.fn(),
+}))
+
+vi.mock("@/hooks/use-acp-agents", () => ({
+ useAcpAgents: () => ({
+ agents: [
+ {
+ agent_type: "claude_code",
+ name: "Claude Code",
+ enabled: true,
+ available: true,
+ installed_version: "1.0.0",
+ },
+ ],
+ }),
+}))
+
+vi.mock("@/components/automations/use-agent-options", () => ({
+ useAgentOptions: () => ({
+ snapshot: {
+ modes: null,
+ config_options: [
+ {
+ id: "model",
+ name: "Model",
+ category: "model",
+ kind: {
+ type: "select",
+ current_value: "sonnet",
+ options: [
+ { value: "sonnet", name: "Sonnet" },
+ { value: "opus", name: "Opus" },
+ ],
+ groups: [],
+ },
+ },
+ ],
+ available_commands: [],
+ },
+ loading: false,
+ error: null,
+ reload: vi.fn(),
+ ensure: vi.fn(),
+ }),
+}))
+
+vi.mock("@/stores/tab-store", () => ({
+ useTabStore: (selector: (state: unknown) => unknown) =>
+ selector({
+ activeTabId: "tab-1",
+ openPkRoundTab: vi.fn(),
+ tabs: [
+ {
+ id: "tab-1",
+ kind: "conversation",
+ folderId: 7,
+ conversationId: null,
+ agentType: "claude-code",
+ title: "New conversation",
+ isPinned: true,
+ workingDir: "/tmp/repo",
+ },
+ ],
+ }),
+}))
+
+describe("PkLauncherDialog", () => {
+ beforeEach(() => {
+ window.localStorage.clear()
+ apiMocks.getGitBranch.mockResolvedValue("main")
+ apiMocks.acpGetAgentStatus.mockResolvedValue({
+ enabled: true,
+ available: true,
+ installed_version: "1.0.0",
+ })
+ apiMocks.pkRoundCreate.mockResolvedValue({
+ id: 12,
+ folder_id: 7,
+ task: "compare models",
+ config: {
+ agents: [],
+ permission_mode: "default",
+ bare_mode: false,
+ effort: "default",
+ },
+ status: "ready",
+ failure_reason: null,
+ judge_status: "idle",
+ created_at: "2026-08-20T00:00:00Z",
+ updated_at: "2026-08-20T00:00:00Z",
+ finished_at: null,
+ })
+ usePkArenaStore.setState({
+ rounds: [],
+ activeRoundId: null,
+ launcherOpen: true,
+ pillDismissed: false,
+ hydrating: false,
+ })
+ })
+
+ it("pins the model selected inside each contestant slot", async () => {
+ const user = userEvent.setup()
+ render(
+
+
+
+ )
+
+ const addContestant = await screen.findByRole("button", {
+ name: "Add Claude Code as contestant",
+ })
+ await user.click(addContestant)
+ await user.click(addContestant)
+
+ const modelSelectors = await screen.findAllByRole("combobox", {
+ name: "Model",
+ })
+ expect(modelSelectors).toHaveLength(2)
+
+ await user.click(modelSelectors[1])
+ await user.click(await screen.findByRole("option", { name: "Opus" }))
+ await user.type(screen.getByLabelText("Task"), "compare models")
+
+ const start = screen.getByRole("button", { name: "Start match" })
+ await waitFor(() => expect(start).toBeEnabled())
+ await user.click(start)
+
+ await waitFor(() => expect(apiMocks.pkRoundCreate).toHaveBeenCalled())
+ expect(apiMocks.pkRoundCreate).toHaveBeenCalledWith(
+ 7,
+ "compare models",
+ expect.objectContaining({
+ agents: [
+ {
+ agent: "claude_code",
+ label: "Sonnet",
+ config_values: { model: "sonnet" },
+ },
+ {
+ agent: "claude_code",
+ label: "Opus",
+ config_values: { model: "opus" },
+ },
+ ],
+ })
+ )
+ })
+})
diff --git a/src/components/pk/pk-launcher-dialog.tsx b/src/components/pk/pk-launcher-dialog.tsx
new file mode 100644
index 000000000..98264ee50
--- /dev/null
+++ b/src/components/pk/pk-launcher-dialog.tsx
@@ -0,0 +1,965 @@
+"use client"
+
+import { useEffect, useMemo, useRef, useState } from "react"
+import { useTranslations } from "next-intl"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { AgentIcon } from "@/components/agent-icon"
+import { Loader2, RefreshCw, X } from "lucide-react"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { useAgentOptions } from "@/components/automations/use-agent-options"
+import { useAcpAgents } from "@/hooks/use-acp-agents"
+import {
+ acpGetAgentStatus,
+ getFolder,
+ getGitBranch,
+ gitInit,
+ gitLog,
+} from "@/lib/api"
+import { getAgentLabel } from "@/lib/custom-agents"
+import { PK_TEMPLATES } from "@/lib/pk-templates"
+import type {
+ AgentType,
+ GitLogEntry,
+ SessionConfigOptionInfo,
+} from "@/lib/types"
+import { cn } from "@/lib/utils"
+import {
+ loadLastLauncherConfig,
+ saveLastLauncherConfig,
+ usePkArenaStore,
+ type PkEffortLevel,
+ type PkPermissionMode,
+} from "@/stores/pk-arena-store"
+import { useTabStore } from "@/stores/tab-store"
+
+/**
+ * Arena launcher: pick 2-8 installed agents, write the task, start the round.
+ * Reads the ACTIVE tab for the target folder (an arena needs a real folder —
+ * its git repo provides the per-contestant worktrees; chat mode has none).
+ */
+
+const MIN_CONTESTANTS = 2
+const MAX_CONTESTANTS = 8
+
+interface LauncherSlot {
+ id: number
+ agentType: AgentType
+ label: string
+ configValues: Record
+}
+
+export function PkLauncherDialog() {
+ const t = useTranslations("PkArena.launcher")
+ const open = usePkArenaStore((s) => s.launcherOpen)
+ const setLauncherOpen = usePkArenaStore((s) => s.setLauncherOpen)
+ const createRound = usePkArenaStore((s) => s.createRound)
+ const openPkRoundTab = useTabStore((s) => s.openPkRoundTab)
+ const { agents: rawAgents } = useAcpAgents()
+ const nextSlotId = useRef(0)
+ const activeTab = useTabStore((s) =>
+ s.activeTabId
+ ? (s.tabs.find((tab) => tab.id === s.activeTabId) ?? null)
+ : null
+ )
+
+ const [slots, setSlots] = useState([])
+ const [task, setTask] = useState("")
+ const [workingDir, setWorkingDir] = useState(null)
+ const [folderId, setFolderId] = useState(null)
+ // null = unknown (still checking); false disables Start — worktrees need a
+ // real git repo, and `git worktree add` in a plain folder fails instantly.
+ const [isGitRepo, setIsGitRepo] = useState(null)
+ const [initializing, setInitializing] = useState(false)
+ const [permissionMode, setPermissionMode] =
+ useState("default")
+ const [bareMode, setBareMode] = useState(false)
+ const [effort, setEffort] = useState("default")
+ const [judgeAgent, setJudgeAgent] = useState(null)
+ const [judgeDimensions, setJudgeDimensions] = useState("")
+ const [startError, setStartError] = useState(null)
+ const [commitPickerOpen, setCommitPickerOpen] = useState(false)
+ const [commits, setCommits] = useState([])
+ const [commitsLoading, setCommitsLoading] = useState(false)
+ const [commitSkip, setCommitSkip] = useState(0)
+ const [commitsExhausted, setCommitsExhausted] = useState(false)
+ /** The commit chosen as the task source. null = start from current HEAD.
+ * When set, the worktree branches from `^` (one commit before), so
+ * contestants never see this commit's changes — only its message as the
+ * task. */
+ const [selectedCommit, setSelectedCommit] = useState(null)
+
+ const checkGitRepo = (dir: string, cancelledRef: { current: boolean }) => {
+ setIsGitRepo(null)
+ getGitBranch(dir)
+ .then((branch) => {
+ if (!cancelledRef.current) setIsGitRepo(branch != null)
+ })
+ .catch(() => {
+ if (!cancelledRef.current) setIsGitRepo(false)
+ })
+ }
+
+ useEffect(() => {
+ if (!open) return
+ setSlots([])
+ setTask("")
+ setWorkingDir(null)
+ setFolderId(null)
+ setIsGitRepo(null)
+ setPermissionMode("default")
+ setBareMode(false)
+ setEffort("default")
+ setJudgeAgent(null)
+ setJudgeDimensions("")
+ setStartError(null)
+ setCommitPickerOpen(false)
+ setSelectedCommit(null)
+ // 复赛预填:上次配置的选手若仍可参与则沿用。
+ const last = loadLastLauncherConfig()
+ if (last && last.agents.length > 0) {
+ setSlots((prev) =>
+ prev.length > 0
+ ? prev
+ : last.agents.map((a) => ({
+ id: ++nextSlotId.current,
+ agentType: a.agentType,
+ label: a.label ?? "",
+ configValues: a.configValues ?? {},
+ }))
+ )
+ setTask(last.task ?? "")
+ setPermissionMode(last.permissionMode)
+ setBareMode(last.bareMode)
+ setEffort(last.effort)
+ setJudgeAgent(last.judgeAgent ?? null)
+ setJudgeDimensions(last.judgeDimensions?.join("\n") ?? "")
+ }
+ // The active tab decides where the arena runs. Draft tabs may lack a
+ // workingDir; fall back to the folder's own path.
+ if (activeTab?.folderId == null || activeTab.folderId < 0) return
+ const cancelled = { current: false }
+ const resolve = (id: number, dir: string) => {
+ setFolderId(id)
+ setWorkingDir(dir)
+ checkGitRepo(dir, cancelled)
+ }
+ if (activeTab.kind === "conversation" && activeTab.workingDir) {
+ resolve(activeTab.folderId, activeTab.workingDir)
+ } else {
+ getFolder(activeTab.folderId)
+ .then((folder) => {
+ if (!cancelled.current) resolve(folder.id, folder.path)
+ })
+ .catch(() => undefined)
+ }
+ return () => {
+ cancelled.current = true
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open])
+
+ const handleInitGit = async () => {
+ if (!workingDir || initializing) return
+ setInitializing(true)
+ try {
+ await gitInit(workingDir)
+ setIsGitRepo(true)
+ } catch {
+ setIsGitRepo(false)
+ } finally {
+ setInitializing(false)
+ }
+ }
+
+ // 只列真正能跑的:安装到位(installed_version) + 未禁用 + 可用。
+ // 未安装的 agent 在 connect 的 preflight 会被拦,但那时回合/会话已创建,
+ // 留下的宿主会话会一直空转——所以 PK 干脆只收已就绪的选手。
+ const agents = useMemo(
+ () =>
+ rawAgents.filter(
+ (a) => a.enabled && a.available && a.installed_version != null
+ ),
+ [rawAgents]
+ )
+
+ const noFolder = open && folderId == null && activeTab != null
+ const taskValid = task.trim().length > 0
+ const selectionValid =
+ slots.length >= MIN_CONTESTANTS && slots.length <= MAX_CONTESTANTS
+ const canStart =
+ taskValid &&
+ selectionValid &&
+ folderId != null &&
+ workingDir != null &&
+ isGitRepo === true
+
+ const addSlot = (agentType: AgentType) => {
+ setSlots((prev) =>
+ prev.length >= MAX_CONTESTANTS
+ ? prev
+ : [
+ ...prev,
+ {
+ id: ++nextSlotId.current,
+ agentType,
+ label: "",
+ configValues: {},
+ },
+ ]
+ )
+ }
+
+ const removeSlot = (index: number) => {
+ setSlots((prev) => prev.filter((_, i) => i !== index))
+ }
+
+ const updateSlotModel = (
+ index: number,
+ configId: string,
+ value: string,
+ label: string
+ ) => {
+ setSlots((prev) =>
+ prev.map((slot, i) =>
+ i === index
+ ? {
+ ...slot,
+ label,
+ configValues: { ...slot.configValues, [configId]: value },
+ }
+ : slot
+ )
+ )
+ }
+
+ const handleStart = async () => {
+ if (!canStart || folderId == null || workingDir == null) return
+ // 开赛前预检:任何选手不可用就中止,不建回合、不留残留会话。
+ // Deduplicate agent types — the same agent in two slots only needs one check.
+ const uniqueAgents = Array.from(new Set(slots.map((s) => s.agentType)))
+ for (const agentType of uniqueAgents) {
+ try {
+ const status = await acpGetAgentStatus(agentType)
+ if (!status.enabled || !status.available || !status.installed_version) {
+ setStartError(t("agentNotReady", { agent: getAgentLabel(agentType) }))
+ return
+ }
+ } catch {
+ setStartError(
+ t("agentCheckFailed", { agent: getAgentLabel(agentType) })
+ )
+ return
+ }
+ }
+ setStartError(null)
+ const parsedDimensions = judgeDimensions
+ .split("\n")
+ .map((d) => d.trim())
+ .filter(Boolean)
+ const agentsPayload = slots.map((s) =>
+ s.label.trim() || Object.keys(s.configValues).length > 0
+ ? {
+ agentType: s.agentType,
+ ...(s.label.trim() ? { label: s.label.trim() } : {}),
+ configValues: s.configValues,
+ }
+ : { agentType: s.agentType }
+ )
+ saveLastLauncherConfig({
+ agents: agentsPayload,
+ permissionMode,
+ bareMode,
+ effort,
+ task: task.trim(),
+ judgeAgent,
+ judgeDimensions: parsedDimensions.length > 0 ? parsedDimensions : null,
+ })
+ // Selected a commit → worktree branches from its PARENT, so contestants
+ // start before that commit and never see its changes. null = current HEAD.
+ const baseCommit = selectedCommit ? `${selectedCommit.hash}^` : null
+ const round = await createRound({
+ task: task.trim(),
+ folderId,
+ workingDir,
+ agents: agentsPayload,
+ permissionMode,
+ bareMode,
+ effort,
+ judgeAgent,
+ judgeDimensions: parsedDimensions.length > 0 ? parsedDimensions : null,
+ baseCommit,
+ })
+ setLauncherOpen(false)
+ openPkRoundTab(round.id, round.folderId, round.task)
+ // The orchestrator (in PkArenaHost) picks the new round up from the store.
+ }
+
+ return (
+
+ )
+}
+
+function findModelOption(
+ options: SessionConfigOptionInfo[]
+): SessionConfigOptionInfo | null {
+ return (
+ options.find(
+ (option) =>
+ option.kind.type === "select" &&
+ (option.id === "model" ||
+ option.id === "model_id" ||
+ option.category === "model")
+ ) ?? null
+ )
+}
+
+function modelValueLabel(
+ option: SessionConfigOptionInfo,
+ value: string
+): string {
+ if (option.kind.type !== "select") return value
+ for (const group of option.kind.groups) {
+ const match = group.options.find((item) => item.value === value)
+ if (match) return match.name
+ }
+ return option.kind.options.find((item) => item.value === value)?.name ?? value
+}
+
+function hasModelValue(
+ option: SessionConfigOptionInfo,
+ value: string
+): boolean {
+ if (option.kind.type !== "select") return false
+ return (
+ option.kind.options.some((item) => item.value === value) ||
+ option.kind.groups.some((group) =>
+ group.options.some((item) => item.value === value)
+ )
+ )
+}
+
+function SlotModelSelect({
+ agentType,
+ workingDir,
+ configValues,
+ onChange,
+ loadingLabel,
+ unavailableLabel,
+ failedLabel,
+ retryLabel,
+}: {
+ agentType: AgentType
+ workingDir: string | null
+ configValues: Record
+ onChange: (configId: string, value: string, label: string) => void
+ loadingLabel: string
+ unavailableLabel: string
+ failedLabel: string
+ retryLabel: string
+}) {
+ const { snapshot, loading, error, reload } = useAgentOptions(
+ agentType,
+ workingDir
+ )
+ const modelOption = useMemo(
+ () => findModelOption(snapshot?.config_options ?? []),
+ [snapshot]
+ )
+ const configuredValue = modelOption ? configValues[modelOption.id] : null
+ const currentValue =
+ modelOption?.kind.type === "select"
+ ? configuredValue && hasModelValue(modelOption, configuredValue)
+ ? configuredValue
+ : modelOption.kind.current_value
+ : null
+
+ useEffect(() => {
+ if (
+ modelOption?.kind.type !== "select" ||
+ !currentValue ||
+ configValues[modelOption.id] === currentValue
+ ) {
+ return
+ }
+ onChange(
+ modelOption.id,
+ currentValue,
+ modelValueLabel(modelOption, currentValue)
+ )
+ }, [configValues, currentValue, modelOption, onChange])
+
+ if (loading) {
+ return (
+
+
+ {loadingLabel}
+
+ )
+ }
+ if (error) {
+ return (
+
+
+ {failedLabel}
+
+
+
+ )
+ }
+ if (!modelOption || modelOption.kind.type !== "select") {
+ return (
+
+ {unavailableLabel}
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/pk/pk-minimized-pill.tsx b/src/components/pk/pk-minimized-pill.tsx
new file mode 100644
index 000000000..4a59cb65a
--- /dev/null
+++ b/src/components/pk/pk-minimized-pill.tsx
@@ -0,0 +1,96 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import { useTranslations } from "next-intl"
+import { Swords, X } from "lucide-react"
+import { getArenaPillRound } from "@/components/pk/pk-arena-policy"
+import { cn } from "@/lib/utils"
+import { usePkArenaStore } from "@/stores/pk-arena-store"
+import { useTabStore } from "@/stores/tab-store"
+
+/**
+ * 竞技场最小化胶囊。大窗关闭后,只要还有进行中(ready/running)的回合,
+ * 右下角常驻这个小胶囊:显示 ⚔ + 已完成/总数,随时点开回到全屏——
+ * 比赛在后台继续,用户该干嘛干嘛。手动 ✕ 只把它藏起来,回合不受影响;
+ * 左上角 ⚔ 或新回合会自动把它唤回来。
+ */
+export function PkMinimizedPill() {
+ const t = useTranslations("PkArena.minimized")
+ const rounds = usePkArenaStore((s) => s.rounds)
+ const activeRoundId = usePkArenaStore((s) => s.activeRoundId)
+ const pillDismissed = usePkArenaStore((s) => s.pillDismissed)
+ const setPillDismissed = usePkArenaStore((s) => s.setPillDismissed)
+ const activeTabId = useTabStore((s) => s.activeTabId)
+ const openPkRoundTab = useTabStore((s) => s.openPkRoundTab)
+
+ const round = getArenaPillRound(rounds, activeRoundId)
+ const isRoundTabActive =
+ round != null && activeTabId === `pk-round-${round.id}`
+ const roundLive = round?.status === "ready" || round?.status === "running"
+ const tStatus = useTranslations("PkArena.arena.roundStatus")
+ const [now, setNow] = useState(() => Date.now())
+ useEffect(() => {
+ if (!round || !roundLive || isRoundTabActive) return
+ const timer = window.setInterval(() => setNow(Date.now()), 1000)
+ return () => window.clearInterval(timer)
+ }, [round, roundLive, isRoundTabActive])
+
+ const visible =
+ round != null && roundLive && !isRoundTabActive && !pillDismissed
+ if (!visible || !round) {
+ return null
+ }
+
+ const done = round.contestants.filter(
+ (c) =>
+ c.status === "done" || c.status === "error" || c.status === "canceled"
+ ).length
+ const total = round.contestants.length
+ const elapsed = Math.round((now - round.createdAt) / 1000)
+
+ return (
+
+
+
+
+ )
+}
diff --git a/src/components/pk/pk-scoreboard.tsx b/src/components/pk/pk-scoreboard.tsx
new file mode 100644
index 000000000..1448e2596
--- /dev/null
+++ b/src/components/pk/pk-scoreboard.tsx
@@ -0,0 +1,160 @@
+"use client"
+
+import { forwardRef, useEffect, useState } from "react"
+import { useTranslations } from "next-intl"
+import { AgentIcon } from "@/components/agent-icon"
+import { getAgentLabel } from "@/lib/custom-agents"
+import { cn } from "@/lib/utils"
+import type { PkContestant } from "@/stores/pk-arena-store"
+
+/**
+ * One row per contestant: identity, live status, and the three scoreboard
+ * numbers (duration / output tokens / turns). Forwarded as a ref so the
+ * arena can export it as a share image without the dialog chrome.
+ */
+
+function formatDuration(ms: number): string {
+ const seconds = Math.round(ms / 1000)
+ if (seconds < 60) return `${seconds}s`
+ const minutes = Math.floor(seconds / 60)
+ return `${minutes}m${String(seconds % 60).padStart(2, "0")}s`
+}
+
+function formatTokens(n: number): string {
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
+ return String(n)
+}
+
+const STATUS_DOT: Record = {
+ preparing: "bg-muted-foreground/50",
+ connecting: "bg-sky-500 animate-pulse",
+ ready: "bg-amber-400",
+ running: "bg-emerald-500 animate-pulse",
+ done: "bg-emerald-600",
+ error: "bg-red-500",
+ canceled: "bg-muted-foreground/50",
+}
+
+/** Literal keys — next-intl's typed messages reject dynamic concatenation. */
+function useContestantStatusLabel() {
+ const t = useTranslations("PkArena.scoreboard")
+ const labels = {
+ preparing: t("status.preparing"),
+ connecting: t("status.connecting"),
+ ready: t("status.ready"),
+ running: t("status.running"),
+ done: t("status.done"),
+ error: t("status.error"),
+ canceled: t("status.canceled"),
+ }
+ return (status: PkContestant["status"]) => labels[status]
+}
+
+export const PkScoreboard = forwardRef<
+ HTMLDivElement,
+ { contestants: PkContestant[] }
+>(function PkScoreboard({ contestants }, ref) {
+ const t = useTranslations("PkArena.scoreboard")
+ const statusLabel = useContestantStatusLabel()
+
+ // The ticking clock lives HERE, not in the dialog: a dialog-level `now`
+ // re-rendered four streaming transcript panes once a second and made the
+ // arena crawl (field report: "非常卡"). Scoped to the scoreboard, only
+ // these small cards re-render.
+ const anyLive = contestants.some(
+ (c) => c.status === "running" || c.status === "connecting"
+ )
+ const [now, setNow] = useState(() => Date.now())
+ useEffect(() => {
+ if (!anyLive) return
+ const timer = window.setInterval(() => setNow(Date.now()), 1000)
+ return () => window.clearInterval(timer)
+ }, [anyLive])
+
+ return (
+
+ {contestants.map((contestant) => {
+ const elapsed =
+ contestant.durationMs ??
+ (contestant.startedAt != null ? now - contestant.startedAt : null)
+ const live =
+ contestant.status === "running" || contestant.status === "connecting"
+ return (
+
+
+
+
+
+
+
+ {getAgentLabel(contestant.agentType)}
+ {contestant.label ? ` · ${contestant.label}` : ""}
+
+
+
+ {statusLabel(contestant.status)}
+
+ {elapsed != null ? (
+ <>
+ ·
+
+ {formatDuration(elapsed)}
+
+ >
+ ) : null}
+ {contestant.usage ? (
+ <>
+ ·
+
+ {contestant.usage.tokensReported
+ ? `${formatTokens(contestant.usage.outputTokens)} ${t("tokensUnit")}`
+ : t("tokensUnavailable")}
+
+ ·
+
+ {contestant.usage.turnCount} {t("turnsUnit")}
+
+ >
+ ) : null}
+
+ {contestant.statusDetail ? (
+
+ {contestant.statusDetail}
+
+ ) : null}
+
+
+ )
+ })}
+
+ )
+})
diff --git a/src/components/tabs/tab-bar.tsx b/src/components/tabs/tab-bar.tsx
index fbdae3482..0a9264970 100644
--- a/src/components/tabs/tab-bar.tsx
+++ b/src/components/tabs/tab-bar.tsx
@@ -9,7 +9,7 @@ import { cn } from "@/lib/utils"
import { useAppWorkspaceStore } from "@/stores/app-workspace-store"
import { useActiveFolder } from "@/contexts/active-folder-context"
import { useTabActions, useTabStore } from "@/contexts/tab-context"
-import type { TabItem as TabItemData } from "@/contexts/tab-context"
+import type { WorkspaceTabItem as TabItemData } from "@/contexts/tab-context"
import { groupOfTab } from "@/stores/tab-store"
import {
firstLeafId,
@@ -210,14 +210,18 @@ export function TabBar({ groupId }: TabBarProps) {
const selFolder = selTab
? allFolders.find((f) => f.id === selTab.folderId)
: undefined
- if (selTab?.isChat === true || selFolder?.kind === "chat") {
+ if (
+ (selTab?.kind === "conversation" && selTab.isChat === true) ||
+ selFolder?.kind === "chat"
+ ) {
openChatModeTab(groupOptions)
return
}
if (selTab && selFolder) {
openNewConversationTab(
selFolder.id,
- selTab.workingDir ?? selFolder.path,
+ (selTab.kind === "conversation" ? selTab.workingDir : undefined) ??
+ selFolder.path,
groupOptions
)
return
@@ -320,7 +324,8 @@ export function TabBar({ groupId }: TabBarProps) {
// Drafts are group-bound: no cross-group drag, no move / split-and-move
// menu items. Within-group sorting (the Reorder.Group itself) is
// untouched. See `moveTabToGroup` for why.
- const isDraft = tab.conversationId == null
+ const isDraft =
+ tab.kind === "conversation" && tab.conversationId == null
// Neighbours of the active tab inset their workspace-bg baseline so the
// active tab's transparent reverse-corner foot (which flares over them)
// doesn't leave a stray line under it (globals.css `data-adjacent-active`).
diff --git a/src/components/tabs/tab-item.tsx b/src/components/tabs/tab-item.tsx
index 88a9f63b3..107f18f5f 100644
--- a/src/components/tabs/tab-item.tsx
+++ b/src/components/tabs/tab-item.tsx
@@ -23,7 +23,7 @@ import {
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { useLongPressDrag } from "@/hooks/use-long-press-drag"
-import type { TabItem as TabItemData } from "@/contexts/tab-context"
+import type { WorkspaceTabItem as TabItemData } from "@/contexts/tab-context"
import type { SplitDirection } from "@/lib/tab-group-layout"
/** A group this tab could move to (every group EXCEPT the tab's own), labeled
@@ -326,9 +326,15 @@ export const TabItem = memo(function TabItem({
]
)}
>
-
+ {tab.kind === "pk" ? (
+
+ PK
+
+ ) : (
+
+ )}
{
it("keeps drafts out of every cross-group affordance", () => {
- expect(tabBar).toContain("const isDraft = tab.conversationId == null")
+ expect(tabBar).toContain(
+ 'tab.kind === "conversation" && tab.conversationId == null'
+ )
expect(tabBar).toContain("canSplitMove={canSplitMove && !isDraft}")
expect(tabBar).toContain("canMoveToGroup={!isDraft}")
// Both drag callbacks are withheld for drafts, so a draft drag can never
diff --git a/src/contexts/acp-connections-context.test.tsx b/src/contexts/acp-connections-context.test.tsx
index 409b9b814..98f4db0a7 100644
--- a/src/contexts/acp-connections-context.test.tsx
+++ b/src/contexts/acp-connections-context.test.tsx
@@ -354,7 +354,7 @@ describe("AcpConnectionsProvider cross-client viewer lifecycle", () => {
// Start the viewer connect; it suspends on the pending snapshot AFTER
// dispatching CONNECTION_CREATED (the entry now exists in the store).
- let connectPromise: Promise | undefined
+ let connectPromise: Promise | undefined
await act(async () => {
connectPromise = h.actions!.connect(TAB, "claude_code", "/tmp/x", "s", 42)
})
@@ -926,7 +926,7 @@ describe("AcpConnectionsProvider reconnect (status-icon button)", () => {
})
)
- let firstConnect: Promise | undefined
+ let firstConnect: Promise | undefined
await act(async () => {
firstConnect = h.actions!.connect(
TAB,
@@ -1197,7 +1197,7 @@ describe("AcpConnectionsProvider abandoned connect tears down only what it creat
resolveConnect = res
})
)
- let connectPromise: Promise | undefined
+ let connectPromise: Promise | undefined
await act(async () => {
connectPromise = h.actions!.connect(
OTHER_TAB,
@@ -1227,7 +1227,7 @@ describe("AcpConnectionsProvider abandoned connect tears down only what it creat
resolveConnect = res
})
)
- let connectPromise: Promise | undefined
+ let connectPromise: Promise | undefined
await act(async () => {
connectPromise = h.actions!.connect(
OTHER_TAB,
@@ -3206,7 +3206,7 @@ describe("connect() teardown races", () => {
resolveProbe = res
})
)
- let connectPromise: Promise | undefined
+ let connectPromise: Promise | undefined
await act(async () => {
connectPromise = h.actions!.connect(
TAB,
@@ -3251,7 +3251,7 @@ describe("connect() teardown races", () => {
resolveConnect = res
})
)
- let connectPromise: Promise | undefined
+ let connectPromise: Promise | undefined
await act(async () => {
connectPromise = h.actions!.connect(
TAB,
@@ -3283,7 +3283,7 @@ describe("connect() teardown races", () => {
mountDesktop()
await act(async () => {})
- let connectPromise: Promise | undefined
+ let connectPromise: Promise | undefined
await act(async () => {
connectPromise = h.actions!.connect(
TAB,
@@ -3390,7 +3390,7 @@ describe("connect() teardown races", () => {
resolveProbe = res
})
)
- let stalePromise: Promise | undefined
+ let stalePromise: Promise | undefined
await act(async () => {
stalePromise = h.actions!.connect(TAB, "claude_code", "/tmp/x", "sess-1")
})
@@ -3455,7 +3455,7 @@ describe("connect() teardown races", () => {
})
)
h.acpConnect.mockResolvedValue("rebuilt-conn")
- let connectPromise: Promise | undefined
+ let connectPromise: Promise | undefined
await act(async () => {
connectPromise = h.actions!.connect(
TAB,
@@ -3515,7 +3515,7 @@ describe("connect() teardown races", () => {
})
)
h.acpConnect.mockResolvedValue("rebuilt-conn")
- let connectPromise: Promise | undefined
+ let connectPromise: Promise | undefined
await act(async () => {
connectPromise = h.actions!.connect(
TAB,
@@ -3576,7 +3576,7 @@ describe("connect() teardown races", () => {
resolveProbe = res
})
)
- let stalePromise: Promise | undefined
+ let stalePromise: Promise | undefined
await act(async () => {
stalePromise = h.actions!.connect(TAB, "claude_code", "/tmp/x", "sess-1")
})
@@ -3616,7 +3616,7 @@ describe("connect() teardown races", () => {
resolvePreflight = res
})
)
- let inflight: Promise | undefined
+ let inflight: Promise | undefined
await act(async () => {
inflight = h.actions!.connect(TAB, "claude_code", "/tmp/x")
})
diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx
index c6614cba4..d1bf91d35 100644
--- a/src/contexts/acp-connections-context.tsx
+++ b/src/contexts/acp-connections-context.tsx
@@ -2493,8 +2493,10 @@ export interface AcpActionsValue {
agentType: AgentType,
workingDir?: string,
sessionId?: string,
- conversationId?: number
- ): Promise
+ conversationId?: number,
+ modeIdOverride?: string | null,
+ configValuesOverride?: Record | null
+ ): Promise
/**
* Release the connection for `contextKey`. The LOCAL entry always goes away
* — a stranded one would make the next `connect()` take its "already
@@ -4841,7 +4843,9 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
agentType: AgentType,
workingDir?: string,
sessionId?: string,
- conversationId?: number
+ conversationId?: number,
+ modeIdOverride?: string | null,
+ configValuesOverride?: Record | null
) => {
const request: ConnectRequest = {
agentType,
@@ -4972,7 +4976,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
existing.status !== "disconnected" &&
existing.status !== "error"
) {
- return
+ return existing.connectionId
}
if (
existing.status !== "disconnected" &&
@@ -5062,7 +5066,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
orphanConn.connectionId,
orphanCursor
)
- return
+ return orphanConn.connectionId
}
}
@@ -5122,7 +5126,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
// Attached (or superseded) — done. Otherwise the connection died
// between discovery and the attach, so fall through and spawn one
// rather than leaving a viewer bound to a dead id.
- if (attached) return
+ if (attached) return discovered.connection_id
}
}
@@ -5145,12 +5149,18 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
// re-open (the snapshot frame doesn't carry a `session_modes` event,
// so the apply-on-event hook never fired).
const savedPrefs = getSavedPrefsForConnect(agentType)
+ const initialModeId =
+ modeIdOverride !== undefined ? modeIdOverride : savedPrefs.modeId
+ const initialConfigValues =
+ configValuesOverride !== undefined
+ ? configValuesOverride
+ : savedPrefs.configValues
const connectionId = await acpConnect(
agentType,
workingDir,
sessionId,
- savedPrefs.modeId,
- savedPrefs.configValues
+ initialModeId,
+ initialConfigValues
)
// If disconnect was requested while connect was in flight, tear down
@@ -5260,6 +5270,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
}
}
}
+ return connectionId
} catch (err) {
const pendingRequest = pendingConnectRequestsRef.current.get(contextKey)
const superseded =
@@ -5681,7 +5692,11 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
)
const setMode = useCallback(async (contextKey: string, modeId: string) => {
- const conn = storeRef.current.connections.get(contextKey)
+ const conn =
+ storeRef.current.connections.get(contextKey) ??
+ Array.from(storeRef.current.connections.values()).find(
+ (c) => c.connectionId === contextKey
+ )
if (!conn) return
// Persist user's mode selection to localStorage
const modes =
@@ -5698,7 +5713,11 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
const setConfigOption = useCallback(
async (contextKey: string, configId: string, valueId: string) => {
- const conn = storeRef.current.connections.get(contextKey)
+ const conn =
+ storeRef.current.connections.get(contextKey) ??
+ Array.from(storeRef.current.connections.values()).find(
+ (c) => c.connectionId === contextKey
+ )
if (!conn) return
dispatch({
type: "CONFIG_OPTION_CHANGED",
diff --git a/src/contexts/tab-context.test.tsx b/src/contexts/tab-context.test.tsx
index 6bab472c2..effa941a9 100644
--- a/src/contexts/tab-context.test.tsx
+++ b/src/contexts/tab-context.test.tsx
@@ -25,6 +25,10 @@ import {
type OpenedDraftTarget,
} from "@/stores/tab-store"
import { leafIds } from "@/lib/tab-group-layout"
+import {
+ isConversationDraft,
+ type ConversationWorkspaceTab,
+} from "@/lib/workspace-tab"
import {
buildNewConversationDraftStorageKey,
loadMessageInputDraftV2,
@@ -563,8 +567,12 @@ describe("TabProvider tab state transitions", () => {
// Explicit caller intent, so the provisional-agent correction pass must not
// "fix" it back to the resolved default once the agent list goes fresh.
expect(
- useTabStore.getState().rawTabs.find((t) => t.id === target?.tabId)
- ?.agentTypeProvisional
+ useTabStore
+ .getState()
+ .rawTabs.find(
+ (t): t is ConversationWorkspaceTab =>
+ t.kind === "conversation" && t.id === target?.tabId
+ )?.agentTypeProvisional
).toBe(false)
})
@@ -630,8 +638,12 @@ describe("TabProvider tab state transitions", () => {
expect(draft?.agentType).toBe("codex")
expect(draft?.isChat).toBe(true)
expect(
- useTabStore.getState().rawTabs.find((t) => t.id === chatDraftId)
- ?.agentTypeProvisional
+ useTabStore
+ .getState()
+ .rawTabs.find(
+ (t): t is ConversationWorkspaceTab =>
+ t.kind === "conversation" && t.id === chatDraftId
+ )?.agentTypeProvisional
).toBe(false)
})
@@ -1984,7 +1996,7 @@ describe("TabProvider tab groups", () => {
expect(leaves()).toHaveLength(2)
const g1 = newLeafBeside(home)
- const draft = store().rawTabs.find((t) => t.conversationId == null)
+ const draft = store().rawTabs.find(isConversationDraft)
expect(draft).toBeDefined()
expect(draft?.folderId).toBe(1)
expect(draft?.agentType).toBe("codex")
@@ -2008,7 +2020,7 @@ describe("TabProvider tab groups", () => {
store().splitTab(chatDraftId, "right", { move: false })
})
- const drafts = store().rawTabs.filter((t) => t.conversationId == null)
+ const drafts = store().rawTabs.filter(isConversationDraft)
expect(drafts).toHaveLength(2)
const newDraft = drafts.find((t) => t.id !== chatDraftId)
expect(newDraft?.isChat).toBe(true)
@@ -2219,26 +2231,61 @@ describe("TabProvider tab groups", () => {
store().splitTab("conv-1-codex-1", "right", { move: false })
})
const g1 = newLeafBeside(home)
- const draftInG1 = store().rawTabs.find((t) => t.conversationId == null)!
+ const draftInG1 = store().rawTabs.find(isConversationDraft)!
// Focused group is g1 (the draft) — reuses that draft, no new tab.
act(() => {
store().openNewConversationTab(1, "/repo")
})
- expect(
- store().rawTabs.filter((t) => t.conversationId == null)
- ).toHaveLength(1)
+ expect(store().rawTabs.filter(isConversationDraft)).toHaveLength(1)
expect(store().activeTabId).toBe(draftInG1.id)
// Explicitly targeting the home group creates a second, per-group draft.
act(() => {
store().openNewConversationTab(1, "/repo", { targetGroup: home })
})
- const drafts = store().rawTabs.filter((t) => t.conversationId == null)
+ const drafts = store().rawTabs.filter(isConversationDraft)
expect(drafts).toHaveLength(2)
expect(drafts.map((d) => groupOfId(d.id)).sort()).toEqual([home, g1].sort())
})
+ it("keeps multiple PK rounds as local workspace tabs that can be split", async () => {
+ const first = await renderWithTabs([tabItem(1, 1, true)])
+ const home = leaves()[0]
+
+ act(() => {
+ store().openPkRoundTab("41", 1, "First battle")
+ store().openPkRoundTab("42", 1, "Second battle")
+ })
+
+ expect(
+ store()
+ .rawTabs.filter((tab) => tab.kind === "pk")
+ .map((tab) => tab.id)
+ ).toEqual(["pk-round-41", "pk-round-42"])
+
+ act(() => {
+ store().splitTab("pk-round-42", "right", { move: true })
+ })
+
+ expect(selectIsSplit(store())).toBe(true)
+ expect(groupOfId("pk-round-42")).not.toBe(home)
+ const blob = JSON.parse(localStorage.getItem("workspace:tab-groups:v1")!)
+ expect(blob.pkTabs).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ roundId: "41", group: home }),
+ expect.objectContaining({ roundId: "42" }),
+ ])
+ )
+
+ first.unmount()
+ act(() => resetTabStore())
+ await renderWithTabs([tabItem(1, 1, true)])
+
+ expect(store().rawTabs.filter((tab) => tab.kind === "pk")).toHaveLength(2)
+ expect(groupOfId("pk-round-42")).not.toBe(home)
+ })
+
it("persists the split layout and restores it across a restart", async () => {
const first = await renderWithTabs([tabItem(1, 1, true), tabItem(1, 2)])
@@ -2281,7 +2328,7 @@ describe("TabProvider tab groups", () => {
store().splitTab("conv-1-codex-1", "right", { move: false })
})
const g1 = newLeafBeside(home)
- const draft = store().rawTabs.find((t) => t.conversationId == null)!
+ const draft = store().rawTabs.find(isConversationDraft)!
expect(groupOfId(draft.id)).toBe(g1)
const blob = JSON.parse(localStorage.getItem("workspace:tab-groups:v1")!)
@@ -2296,7 +2343,7 @@ describe("TabProvider tab groups", () => {
await renderWithTabs([tabItem(1, 1, true)])
expect(selectIsSplit(store())).toBe(true)
- const restoredDraft = store().rawTabs.find((t) => t.conversationId == null)
+ const restoredDraft = store().rawTabs.find(isConversationDraft)
expect(restoredDraft?.id).toBe(draft.id)
expect(restoredDraft?.folderId).toBe(1)
expect(groupOfId(draft.id)).toBe(g1)
@@ -2316,7 +2363,7 @@ describe("TabProvider tab groups", () => {
store().splitTab("conv-1-codex-1", "right", { move: false })
})
const g1 = newLeafBeside(home)
- const draft = store().rawTabs.find((t) => t.conversationId == null)!
+ const draft = store().rawTabs.find(isConversationDraft)!
const goodBlob = localStorage.getItem("workspace:tab-groups:v1")!
// Unsent text in BOTH drafts' composers (per-tab keys).
saveMessageInputDraftV2(buildNewConversationDraftStorageKey(draft.id), {
@@ -2369,7 +2416,7 @@ describe("TabProvider tab groups", () => {
store().splitTab("conv-1-codex-1", "right", { move: false })
})
const g1 = newLeafBeside(home)
- const draft = store().rawTabs.find((t) => t.conversationId == null)!
+ const draft = store().rawTabs.find(isConversationDraft)!
first.unmount()
act(() => {
@@ -2427,7 +2474,7 @@ describe("TabProvider tab groups", () => {
// Draft on folder 2, moved into its own group.
store().openNewConversationTab(2, "/other", { targetGroup: home })
})
- const draft = store().rawTabs.find((t) => t.conversationId == null)!
+ const draft = store().rawTabs.find(isConversationDraft)!
act(() => {
store().splitTab("conv-1-codex-1", "right", { move: true })
})
@@ -2466,7 +2513,7 @@ describe("TabProvider tab groups", () => {
expect(selectIsSplit(store())).toBe(true)
expect(groupOfId("conv-1-codex-2")).toBe(movedGroup)
- expect(store().rawTabs.some((t) => t.conversationId == null)).toBe(false)
+ expect(store().rawTabs.some(isConversationDraft)).toBe(false)
})
it("prunes a restored group whose tabs no longer exist after hydration", async () => {
@@ -2504,7 +2551,7 @@ describe("TabProvider tab groups", () => {
store().splitTab("conv-1-codex-1", "right", { move: false })
})
const g1 = newLeafBeside(home)
- const draft = store().rawTabs.find((t) => t.conversationId == null)!
+ const draft = store().rawTabs.find(isConversationDraft)!
expect(groupOfId(draft.id)).toBe(g1)
act(() => {
@@ -2527,7 +2574,7 @@ describe("TabProvider tab groups", () => {
store().splitTab("conv-1-codex-1", "right", { move: false })
})
const g1 = newLeafBeside(home)
- const draft = store().rawTabs.find((t) => t.conversationId == null)!
+ const draft = store().rawTabs.find(isConversationDraft)!
act(() => {
tabsChangedHandler?.({
@@ -2664,7 +2711,8 @@ describe("TabProvider tab groups", () => {
})
const g1 = newLeafBeside(home)
const g1Draft = store().rawTabs.find(
- (t) => t.conversationId == null && groupOfId(t.id) === g1
+ (t): t is ConversationWorkspaceTab & { conversationId: null } =>
+ isConversationDraft(t) && groupOfId(t.id) === g1
)!
expect(g1Draft).toBeTruthy()
@@ -2694,7 +2742,7 @@ describe("TabProvider tab groups", () => {
act(() => {
store().openNewConversationTab(1, "/w1", { targetGroup: home })
})
- const draft = store().rawTabs.find((t) => t.conversationId == null)!
+ const draft = store().rawTabs.find(isConversationDraft)!
const homeTabs = store().tabs.filter((t) => groupOfId(t.id) === home)
expect(homeTabs.map((t) => t.id)).toEqual([
"conv-1-codex-1",
diff --git a/src/contexts/tab-context.tsx b/src/contexts/tab-context.tsx
index b66f2af15..bf9c28393 100644
--- a/src/contexts/tab-context.tsx
+++ b/src/contexts/tab-context.tsx
@@ -15,15 +15,17 @@ import {
useTabStore,
type OpenedDraftTarget,
type TabItem,
+ type WorkspaceTabItem,
} from "@/stores/tab-store"
import {
CONVERSATION_CHANGED_EVENT,
TABS_CHANGED_EVENT,
+ type AgentType,
type ConversationChange,
type TabsChanged,
} from "@/lib/types"
-export type { OpenedDraftTarget, TabItem }
+export type { OpenedDraftTarget, TabItem, WorkspaceTabItem }
export { useTabStore, useTabActions } from "@/stores/tab-store"
interface TabProviderProps {
@@ -215,15 +217,22 @@ export interface TabContextValue {
openTab: (
folderId: number,
conversationId: number,
- agentType: TabItem["agentType"],
+ agentType: AgentType,
pin?: boolean,
title?: string
) => void
+ openPkRoundTab: (
+ roundId: string,
+ folderId: number,
+ title: string,
+ options?: { targetGroup?: string }
+ ) => void
+ closePkRoundTab: (roundId: string) => void
closeTab: (tabId: string) => void
closeConversationTab: (
folderId: number,
conversationId: number,
- agentType: TabItem["agentType"]
+ agentType: AgentType
) => void
closeOtherTabs: (tabId: string) => void
closeAllTabs: () => void
@@ -237,7 +246,7 @@ export interface TabContextValue {
workingDir: string,
options?: {
inheritFromActive?: boolean
- folderDefaultAgent?: TabItem["agentType"] | null
+ folderDefaultAgent?: AgentType | null
targetGroup?: string
forceAgent?: TabItem["agentType"]
}
@@ -247,15 +256,12 @@ export interface TabContextValue {
forceAgent?: TabItem["agentType"]
}) => OpenedDraftTarget
setChatDraftWorkingDir: (tabId: string, workingDir: string) => void
- confirmDraftAgent: (tabId: string, agentType: TabItem["agentType"]) => void
- setDraftAgentFromFallback: (
- tabId: string,
- agentType: TabItem["agentType"]
- ) => void
+ confirmDraftAgent: (tabId: string, agentType: AgentType) => void
+ setDraftAgentFromFallback: (tabId: string, agentType: AgentType) => void
bindConversationTab: (
tabId: string,
conversationId: number,
- agentType: TabItem["agentType"],
+ agentType: AgentType,
title: string,
runtimeConversationId?: number,
folderId?: number,
@@ -280,11 +286,16 @@ export interface TabContextValue {
export function useTabContext(): TabContextValue {
return useTabStore(
useShallow((s) => ({
- tabs: s.tabs,
+ // Legacy accessor retained for the conversation-only test harness. The
+ // production workspace consumes `useTabStore` directly and sees the full
+ // discriminated union without allocating a filtered array per snapshot.
+ tabs: s.tabs as TabItem[],
activeTabId: s.activeTabId,
tabsHydrated: s.tabsHydrated,
tileByGroup: s.tileByGroup,
openTab: s.openTab,
+ openPkRoundTab: s.openPkRoundTab,
+ closePkRoundTab: s.closePkRoundTab,
closeTab: s.closeTab,
closeConversationTab: s.closeConversationTab,
closeOtherTabs: s.closeOtherTabs,
diff --git a/src/hooks/use-connection.ts b/src/hooks/use-connection.ts
index 2fe092296..497832761 100644
--- a/src/hooks/use-connection.ts
+++ b/src/hooks/use-connection.ts
@@ -94,8 +94,10 @@ export interface UseConnectionReturn {
agentType: AgentType,
workingDir?: string,
sessionId?: string,
- conversationId?: number
- ) => Promise
+ conversationId?: number,
+ modeIdOverride?: string | null,
+ configValuesOverride?: Record | null
+ ) => Promise
disconnect: () => Promise
/** Restart the session (disconnect + resume same sessionId) so it picks up
* current agent/model settings. Returns `true` if it actually restarted,
@@ -239,14 +241,18 @@ export function useConnection(contextKey: string): UseConnectionReturn {
agentType: AgentType,
workingDir?: string,
sessionId?: string,
- conversationId?: number
+ conversationId?: number,
+ modeIdOverride?: string | null,
+ configValuesOverride?: Record | null
) =>
actions.connect(
contextKey,
agentType,
workingDir,
sessionId,
- conversationId
+ conversationId,
+ modeIdOverride,
+ configValuesOverride
),
[actions, contextKey]
)
diff --git a/src/hooks/use-is-active-chat-mode.ts b/src/hooks/use-is-active-chat-mode.ts
index 22c6865ed..062fbf300 100644
--- a/src/hooks/use-is-active-chat-mode.ts
+++ b/src/hooks/use-is-active-chat-mode.ts
@@ -2,6 +2,7 @@
import { useActiveFolder } from "@/contexts/active-folder-context"
import { useTabStore } from "@/contexts/tab-context"
+import { isConversationWorkspaceTab } from "@/lib/workspace-tab"
/**
* True when the active conversation is folderless "chat mode" — either a bound
@@ -18,5 +19,9 @@ export function useIsActiveChatMode(): boolean {
const activeTabId = useTabStore((s) => s.activeTabId)
if (activeFolder?.kind === "chat") return true
const activeTab = tabs.find((t) => t.id === activeTabId)
- return activeTab?.isChat === true
+ return Boolean(
+ activeTab &&
+ isConversationWorkspaceTab(activeTab) &&
+ activeTab.isChat === true
+ )
}
diff --git a/src/hooks/use-pk-round-judge.test.tsx b/src/hooks/use-pk-round-judge.test.tsx
new file mode 100644
index 000000000..d8b1b467d
--- /dev/null
+++ b/src/hooks/use-pk-round-judge.test.tsx
@@ -0,0 +1,166 @@
+import { act, renderHook, waitFor } from "@testing-library/react"
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import type { PkRound } from "@/stores/pk-arena-store"
+
+const mocks = vi.hoisted(() => ({
+ eventHandler: null as ((event: Record) => void) | null,
+ updateConversationStatus: vi.fn(),
+ updateJudge: vi.fn(),
+ disconnect: vi.fn().mockResolvedValue(undefined),
+}))
+
+vi.mock("next-intl", () => ({ useLocale: () => "zh-CN" }))
+
+vi.mock("@/lib/api", () => ({
+ acpRespondPermission: vi.fn(),
+ createPkConversation: vi.fn().mockResolvedValue(81),
+ getFolderConversation: vi.fn().mockResolvedValue({
+ turns: [
+ {
+ role: "assistant",
+ blocks: [
+ {
+ type: "text",
+ text: '{"scores":[],"summary":"done"}',
+ },
+ ],
+ },
+ ],
+ }),
+ getFileTree: vi.fn().mockResolvedValue([]),
+ getGitBranch: vi.fn(),
+ gitDiff: vi.fn(),
+ gitDiffWithBranch: vi.fn(),
+ gitStatus: vi.fn().mockResolvedValue([]),
+ gitRemoveWorktree: vi.fn(),
+ gitWorktreeAdd: vi.fn(),
+ pkRoundCreate: vi.fn(),
+ pkRoundDelete: vi.fn(),
+ pkRoundGetReportSnapshot: vi.fn().mockResolvedValue(null),
+ pkRoundSaveReportSnapshot: vi.fn().mockResolvedValue(undefined),
+ pkRoundUpdateJudge: vi.fn().mockResolvedValue(undefined),
+ pkRoundUpdateStatus: vi.fn().mockResolvedValue(undefined),
+ readWorkspaceFileBase64: vi.fn(),
+ updateConversationStatus: mocks.updateConversationStatus,
+}))
+
+vi.mock("@/contexts/acp-connections-context", () => ({
+ useAcpActions: () => ({
+ connect: vi.fn().mockResolvedValue("judge-connection"),
+ sendPrompt: vi.fn().mockResolvedValue(undefined),
+ cancel: vi.fn(),
+ disconnect: mocks.disconnect,
+ setMode: vi.fn(),
+ setConfigOption: vi.fn(),
+ touchActivity: vi.fn(),
+ respondPermission: vi.fn(),
+ attachDelegationChild: vi.fn(),
+ detachDelegationChild: vi.fn(),
+ }),
+ useConnectionStore: () => ({ getConnection: vi.fn() }),
+ useAcpEvent: (handler: (event: Record) => void) => {
+ mocks.eventHandler = handler
+ },
+}))
+
+import { usePkRound } from "@/hooks/use-pk-round"
+import { usePkArenaStore } from "@/stores/pk-arena-store"
+
+describe("PK judge conversation lifecycle", () => {
+ beforeEach(() => {
+ mocks.eventHandler = null
+ mocks.updateConversationStatus.mockReset().mockResolvedValue(undefined)
+ mocks.disconnect.mockClear()
+ usePkArenaStore.setState({
+ rounds: [],
+ activeRoundId: null,
+ launcherOpen: false,
+ pillDismissed: false,
+ hydrating: false,
+ })
+ })
+
+ it("marks the judge conversation completed after turn_complete", async () => {
+ const round = {
+ id: "2",
+ task: "judge task",
+ folderId: 1,
+ workingDir: "/repo",
+ status: "finished",
+ judgeAgent: "codex",
+ judgeStatus: "idle",
+ judgeDimensions: null,
+ contestants: [
+ {
+ slot: 0,
+ agentType: "qoder",
+ status: "done",
+ diff: "+done",
+ },
+ ],
+ } as PkRound
+ usePkArenaStore.setState({ rounds: [round] })
+ const { result } = renderHook(() => usePkRound())
+
+ await act(async () => result.current.runJudge(round))
+ expect(mocks.eventHandler).not.toBeNull()
+ act(() => {
+ mocks.eventHandler?.({
+ type: "turn_complete",
+ connection_id: "judge-connection",
+ })
+ })
+
+ await waitFor(() => {
+ expect(mocks.updateConversationStatus).toHaveBeenCalledWith(
+ 81,
+ "completed"
+ )
+ })
+ act(() => {
+ mocks.eventHandler?.({
+ type: "status_changed",
+ status: "disconnected",
+ connection_id: "judge-connection",
+ })
+ })
+ expect(mocks.updateConversationStatus).not.toHaveBeenCalledWith(
+ 81,
+ "cancelled"
+ )
+ })
+
+ it("settles every live contestant conversation when a round is canceled", async () => {
+ const round = {
+ id: "4",
+ task: "cancel task",
+ folderId: 1,
+ workingDir: "/repo",
+ status: "ready",
+ judgeAgent: null,
+ judgeStatus: "idle",
+ judgeDimensions: null,
+ contestants: [
+ {
+ slot: 0,
+ agentType: "qoder",
+ status: "ready",
+ conversationId: 89,
+ },
+ {
+ slot: 1,
+ agentType: "codex",
+ status: "connecting",
+ conversationId: 90,
+ },
+ ],
+ } as PkRound
+ usePkArenaStore.setState({ rounds: [round] })
+ const { result } = renderHook(() => usePkRound())
+
+ await act(async () => result.current.cancelRound(round))
+
+ expect(mocks.updateConversationStatus).toHaveBeenCalledWith(89, "cancelled")
+ expect(mocks.updateConversationStatus).toHaveBeenCalledWith(90, "cancelled")
+ })
+})
diff --git a/src/hooks/use-pk-round.test.ts b/src/hooks/use-pk-round.test.ts
new file mode 100644
index 000000000..f5767e76c
--- /dev/null
+++ b/src/hooks/use-pk-round.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest"
+import { buildJudgePrompt, mapPermissionToAgentMode } from "./use-pk-round"
+
+describe("mapPermissionToAgentMode", () => {
+ it("never maps full auto to Claude's deny-without-asking mode", () => {
+ expect(
+ mapPermissionToAgentMode("bypassPermissions", [
+ "default",
+ "acceptEdits",
+ "dontAsk",
+ "auto",
+ ])
+ ).toBe("auto")
+ })
+})
+
+describe("buildJudgePrompt", () => {
+ it("requires judge prose to follow the current interface locale", () => {
+ const [block] = buildJudgePrompt(
+ "实现一个页面",
+ [{ slot: 2, agentType: "qoder", label: "Qwen3.8-Max", diff: "+hello" }],
+ null,
+ "zh-CN"
+ )
+
+ expect(block.type).toBe("text")
+ if (block.type === "text") {
+ expect(block.text).toContain("locale zh-CN")
+ expect(block.text).toContain("Keep JSON property names unchanged")
+ expect(block.text).toContain("Contestant slot 2: qoder · Qwen3.8-Max")
+ expect(block.text).toContain('"slot":')
+ }
+ })
+})
diff --git a/src/hooks/use-pk-round.ts b/src/hooks/use-pk-round.ts
new file mode 100644
index 000000000..8def5743f
--- /dev/null
+++ b/src/hooks/use-pk-round.ts
@@ -0,0 +1,1421 @@
+"use client"
+
+import { useCallback, useEffect, useRef } from "react"
+import { useLocale } from "next-intl"
+import {
+ acpRespondPermission,
+ createPkConversation,
+ getFolderConversation,
+ getGitBranch,
+ gitDiff,
+ gitDiffWithBranch,
+ gitRemoveWorktree,
+ gitWorktreeAdd,
+ updateConversationStatus,
+} from "@/lib/api"
+import type { PromptInputBlock, SessionConfigOptionInfo } from "@/lib/types"
+import { assignJudgeScoreSlots } from "@/lib/pk-judge"
+import { preparePkReportData } from "@/lib/pk-report-data"
+import {
+ useAcpActions,
+ useAcpEvent,
+ useConnectionStore,
+} from "@/contexts/acp-connections-context"
+import {
+ contestantBranchName,
+ contestantContextKey,
+ usePkArenaStore,
+ type PkContestant,
+ type PkContestantUsage,
+ type PkEffortLevel,
+ type PkJudgeResult,
+ type PkJudgeScore,
+ type PkPermissionMode,
+ type PkRound,
+} from "@/stores/pk-arena-store"
+
+/**
+ * Arena orchestrator — drives one round's contestants through the existing
+ * connection machinery (no broker, no parent agent):
+ *
+ * worktree → conversation row → connect(own contextKey) → same prompt
+ *
+ * Completion is detected from `status_changed` events per connection
+ * (`prompting` → a settled state means the contestant's single turn ended),
+ * which is exactly the signal the live transcript uses. Duration is measured
+ * client-side between the prompt send and that transition; token/turn stats
+ * are summed from the persisted conversation afterwards.
+ */
+
+/** 公平竞技规则块。裸机模式下追加到任务提示词——软约束(模型仍会看到
+ * 全局技能目录的内容),但统一施加于所有选手,对比保持 apples-to-apples。 */
+const BARE_MODE_RULES = [
+ "FAIR-PLAY RULES (mandatory):",
+ "This is a fair competition. Do NOT use any skills, slash commands, plugins,",
+ "custom agents, or custom instructions — including anything from",
+ "~/.claude/skills, ~/.codex/skills, ~/.agents/skills, or any other global",
+ "skill store, and any .claude/skills / .agents/skills / .codex/skills",
+ "directories in the repository. Use only your built-in capabilities",
+ "(file read/write, running commands, web access).",
+].join("\n")
+
+/** 裁判提示词——在所有选手完成后发送给裁判 agent。要求结构化 JSON 输出
+ * (每个选手:分数、排名、点评;以及总体总结)。裁判不需要 worktree,
+ * 只读取各选手的 diff 文本。 */
+/** Default judge evaluation dimensions, used when the round has no custom
+ * `judge_dimensions` configured. */
+const DEFAULT_JUDGE_DIMENSIONS = [
+ "Correctness — does it fulfill the task?",
+ "Code quality — readability, structure, edge cases",
+ "Completeness — how much of the task is done?",
+ "Efficiency — token count and time are NOT factors here; judge code-level efficiency only",
+]
+
+export function buildJudgePrompt(
+ task: string,
+ contestants: Array<{
+ slot: number
+ agentType: string
+ label?: string | null
+ diff: string
+ }>,
+ dimensions?: string[] | null,
+ outputLocale = "en"
+): PromptInputBlock[] {
+ const sections = contestants.map(
+ (c) =>
+ `--- Contestant slot ${c.slot}: ${c.agentType}${c.label ? ` · ${c.label}` : ""} ---\n${c.diff}\n--- End slot ${c.slot} ---`
+ )
+ const dims =
+ dimensions && dimensions.length > 0 ? dimensions : DEFAULT_JUDGE_DIMENSIONS
+ const numbered = dims.map((d, i) => `${i + 1}. ${d}`).join("\n")
+ const text = [
+ `You are the JUDGE of a coding PK arena.`,
+ "",
+ `Task given to all contestants:`,
+ `"${task}"`,
+ "",
+ `Below are the git diffs from each contestant. Evaluate each one on:`,
+ numbered,
+ "",
+ "Score each contestant 0-100. Rank them (1 = best).",
+ `Write every human-readable comment and summary in the language identified by locale ${outputLocale}. Keep JSON property names unchanged.`,
+ "",
+ "Respond with ONLY a JSON block (no markdown fences, no prose before or after):",
+ "Return exactly one score row for every contestant slot listed below. Preserve each numeric slot exactly.",
+ '{"scores":[{"slot":,"agentType":"","score":,"rank":,"comment":""}],"summary":""}',
+ "",
+ "Here are the diffs:",
+ "",
+ ...sections,
+ ].join("\n")
+ return [{ type: "text", text }]
+}
+
+/** 解析裁判 LLM 的文本输出,提取结构化 JSON 评分。
+ * 容忍 markdown 围栏和前后文本。 */
+function parseJudgeResult(
+ rawText: string,
+ contestants: readonly PkContestant[]
+): PkJudgeResult | null {
+ // Strip markdown code fences if present.
+ const cleaned = rawText
+ .replace(/^```(?:json)?\s*/i, "")
+ .replace(/\s*```\s*$/i, "")
+ .trim()
+ // Find the first { and last } — the JSON blob.
+ const start = cleaned.indexOf("{")
+ const end = cleaned.lastIndexOf("}")
+ if (start === -1 || end === -1 || end <= start) return null
+ const jsonStr = cleaned.slice(start, end + 1)
+ try {
+ const parsed = JSON.parse(jsonStr) as {
+ scores?: Array<{
+ slot?: number
+ agentType?: string
+ score?: number
+ rank?: number
+ comment?: string
+ }>
+ summary?: string
+ }
+ if (!parsed.scores || !Array.isArray(parsed.scores)) return null
+ const scores: PkJudgeScore[] = parsed.scores
+ .filter((s) => s.agentType != null)
+ .map((s) => ({
+ slot: typeof s.slot === "number" ? s.slot : undefined,
+ agentType: String(s.agentType),
+ score: typeof s.score === "number" ? s.score : 0,
+ rank: typeof s.rank === "number" ? s.rank : 0,
+ comment: s.comment ?? "",
+ }))
+ if (scores.length === 0) return null
+ return {
+ scores: assignJudgeScoreSlots(scores, contestants),
+ summary: parsed.summary ?? "",
+ rawText,
+ }
+ } catch {
+ return null
+ }
+}
+
+function taskPromptBlocks(
+ task: string,
+ worktreePath: string,
+ bareMode: boolean
+): PromptInputBlock[] {
+ return [
+ {
+ type: "text",
+ text: [
+ task,
+ "",
+ `Work inside this directory: ${worktreePath}`,
+ "It is a fresh git worktree created for you — this is your isolated arena, no other agent writes here. Commit your work when done.",
+ ...(bareMode ? ["", BARE_MODE_RULES] : []),
+ ].join("\n"),
+ },
+ ]
+}
+
+/**
+ * Apply the round's permission policy via `session/set_mode` once the agent
+ * has advertised its modes. The requested mode id is only sent when the
+ * agent actually advertises it: forcing an unknown id on an agent that would
+ * reject it would fail the whole connect sequence, and an agent without the
+ * mode simply keeps asking, exactly as before. "default" needs no call.
+ *
+ * The modes arrive as a `session_modes` EVENT shortly after session/new —
+ * not in connect()'s resolution. The arena attaches the contestant as a
+ * by-id delegation child right after connect, and the attach RE-ROUTES the
+ * reverseMap to the by-id entry, so the event lands there, never on the
+ * owner (contextKey) entry. Polling only the owner entry therefore times
+ * out and the mode is silently skipped (field report: presets "did not
+ * apply"). Poll both entries: pre-attach events land on the owner, post-
+ * attach on the by-id entry.
+ */
+type ModesStore = {
+ getConnection(key: string):
+ | {
+ modes?: { available_modes?: Array<{ id: string }> } | null
+ configOptions?: SessionConfigOptionInfo[] | null
+ }
+ | undefined
+}
+
+/** 统一的思考等级 → 各 agent 通告值的最近匹配。词表各不相同
+ * (claude: low/medium/high; codex: minimal/low/medium/high/max;
+ * deepseek: off/low/medium/high),按规范序取最近邻,平局取更高档
+ * (公平竞技下宁高勿低)。 */
+const EFFORT_RANK: Record = {
+ off: 0,
+ minimal: 1,
+ low: 2,
+ medium: 3,
+ high: 4,
+ max: 5,
+}
+
+function nearestEffort(requested: string, advertised: string[]): string | null {
+ if (advertised.length === 0) return null
+ const exact = advertised.find((v) => v === requested)
+ if (exact) return exact
+ const target = EFFORT_RANK[requested] ?? 3
+ let best: string | null = null
+ let bestDist = Number.POSITIVE_INFINITY
+ for (const value of advertised) {
+ const rank = EFFORT_RANK[value]
+ if (rank === undefined) continue
+ const dist = Math.abs(rank - target)
+ if (
+ dist < bestDist ||
+ (dist === bestDist && best !== null && rank > (EFFORT_RANK[best] ?? 0))
+ ) {
+ best = value
+ bestDist = dist
+ }
+ }
+ return best
+}
+
+/** 把通告的 configOptions 折成竞技场需要的两份选项表。 */
+function selectOptions(configOptions: SessionConfigOptionInfo[] | null): {
+ modelOptions: Array<{ value: string; name: string }>
+ effortOptions: string[]
+} {
+ const modelOptions: Array<{ value: string; name: string }> = []
+ const effortOptions: string[] = []
+ for (const option of configOptions ?? []) {
+ if (option.kind?.type !== "select") continue
+ if (option.id === "model" || option.id === "model_id") {
+ for (const item of option.kind.options) {
+ modelOptions.push({ value: item.value, name: item.name ?? item.value })
+ }
+ } else if (/effort|reasoning/i.test(option.id)) {
+ for (const item of option.kind.options) {
+ if (EFFORT_RANK[item.value] !== undefined)
+ effortOptions.push(item.value)
+ }
+ }
+ }
+ return { modelOptions, effortOptions }
+}
+
+/** 双条目轮询:modes/configOptions 都走 attach 后的 by-id 路由
+ * (见 applyPermissionMode 的注释)。按需等**特定字段**——统一等「任一字段」
+ * 会在 modes 先到时立即返回,此时 configOptions 往往还没到,消费方拿到
+ * null 就静默放弃(实测:模型/思考等级选择器永远不出现)。 */
+function waitForField(
+ connectionStore: ModesStore,
+ contextKey: string,
+ connectionId: string | null,
+ field: "modes" | "configOptions",
+ timeoutMs = 10000
+): Promise<{
+ modes?: { available_modes?: Array<{ id: string }> } | null
+ configOptions?: SessionConfigOptionInfo[] | null
+} | null> {
+ return new Promise((resolve) => {
+ const startedAt = Date.now()
+ const poll = () => {
+ const owner = connectionStore.getConnection(contextKey)
+ const byId =
+ connectionId != null
+ ? connectionStore.getConnection(connectionId)
+ : undefined
+ // 字段优先,不是条目优先:`owner ?? byId` 会在 owner 存在但缺该字段时
+ // 永远选 owner,把带字段的 byId 晾在一边——实测 owner=none byId=N,
+ // 选择器与权限预设全部静默丢失(同一个根).两个条目都查字段,谁有谁算。
+ if (owner != null && owner[field] != null) {
+ resolve(owner)
+ return
+ }
+ if (byId != null && byId[field] != null) {
+ resolve(byId)
+ return
+ }
+ if (Date.now() - startedAt >= timeoutMs) {
+ resolve(null)
+ return
+ }
+ setTimeout(poll, 200)
+ }
+ poll()
+ })
+}
+
+/**
+ * Apply the round's permission policy via `session/set_mode` once the agent
+ * has advertised its modes. The requested mode id is only sent when the
+ * agent actually advertises it: forcing an unknown id on an agent that would
+ * reject it would fail the whole connect sequence, and an agent without the
+ * mode simply keeps asking, exactly as before. "default" needs no call.
+ *
+ * The modes arrive as a `session_modes` EVENT shortly after session/new —
+ * not in connect()'s resolution. The arena attaches the contestant as a
+ * by-id delegation child right after connect, and the attach RE-ROUTES the
+ * reverseMap to the by-id entry, so the event lands there, never on the
+ * owner (contextKey) entry. Polling only the owner entry therefore times
+ * out and the mode is silently skipped (field report: presets "did not
+ * apply"). `waitForOptions` reads whichever entry the event landed on.
+ */
+export function mapPermissionToAgentMode(
+ mode: PkPermissionMode,
+ availableModes: string[]
+): string | null {
+ if (mode === "default") return null
+ if (availableModes.includes(mode)) return mode
+
+ if (mode === "bypassPermissions") {
+ const candidate = [
+ "agent-full-access",
+ "danger-full-access",
+ "full-access",
+ "auto",
+ "acceptEdits",
+ "agent",
+ ].find((m) => availableModes.includes(m))
+ return candidate ?? null
+ }
+
+ if (mode === "acceptEdits") {
+ const candidate = ["acceptEdits", "agent", "auto"].find((m) =>
+ availableModes.includes(m)
+ )
+ return candidate ?? null
+ }
+
+ return null
+}
+
+async function applyPermissionMode(
+ connectionStore: ModesStore,
+ setMode: (contextKey: string, modeId: string) => Promise,
+ setConfigOption: (
+ contextKey: string,
+ configId: string,
+ valueId: string
+ ) => Promise,
+ contextKey: string,
+ connectionId: string | null,
+ mode: PkPermissionMode
+): Promise {
+ if (mode === "default") return
+ const entry = await waitForField(
+ connectionStore,
+ contextKey,
+ connectionId,
+ "modes"
+ )
+ const advertised = entry?.modes?.available_modes?.map((m) => m.id) ?? []
+ const targetMode = mapPermissionToAgentMode(mode, advertised)
+ if (targetMode) {
+ try {
+ await setMode(contextKey, targetMode)
+ } catch {
+ // A rejected mode switch must not kill the round
+ }
+ }
+
+ // Also check if the agent (such as Codex) exposes an approval preset via configOptions
+ const configEntry = await waitForField(
+ connectionStore,
+ contextKey,
+ connectionId,
+ "configOptions"
+ )
+ const modeOption = configEntry?.configOptions?.find(
+ (o) =>
+ o.id === "mode" ||
+ o.id === "permission_mode" ||
+ o.id === "approval_policy"
+ )
+ if (modeOption && modeOption.kind?.type === "select") {
+ const optValues = modeOption.kind.options.map((o) => o.value)
+ if (mode === "bypassPermissions") {
+ const targetVal = [
+ "agent-full-access",
+ "danger-full-access",
+ "never",
+ "auto",
+ ].find((v) => optValues.includes(v))
+ if (targetVal) {
+ try {
+ await setConfigOption(contextKey, modeOption.id, targetVal)
+ } catch {
+ // ignore
+ }
+ }
+ } else if (mode === "acceptEdits") {
+ const targetVal = ["agent", "acceptEdits", "auto"].find((v) =>
+ optValues.includes(v)
+ )
+ if (targetVal) {
+ try {
+ await setConfigOption(contextKey, modeOption.id, targetVal)
+ } catch {
+ // ignore
+ }
+ }
+ }
+ }
+}
+
+async function applyPreparedOptions(
+ connectionStore: ModesStore,
+ setConfigOption: (
+ contextKey: string,
+ configId: string,
+ valueId: string
+ ) => Promise,
+ contextKey: string,
+ connectionId: string | null,
+ effort: PkEffortLevel
+): Promise<{
+ modelOptions: Array<{ value: string; name: string }>
+ modelConfigId: string | null
+ effortOptions: string[]
+ effortConfigId: string | null
+ selectedModel: string | null
+ selectedEffort: string | null
+ diagnostic: string
+}> {
+ const entry = await waitForField(
+ connectionStore,
+ contextKey,
+ connectionId,
+ "configOptions"
+ )
+ const options = entry?.configOptions ?? null
+ const { modelOptions, effortOptions } = selectOptions(options)
+ let selectedModel: string | null = null
+ let selectedEffort: string | null = null
+ const effortConfigId =
+ (options ?? []).find(
+ (o) => o.kind?.type === "select" && /effort|reasoning/i.test(o.id)
+ )?.id ?? null
+ if (effortConfigId) {
+ const option = (options ?? []).find((o) => o.id === effortConfigId)
+ const current =
+ option?.kind?.type === "select" ? option.kind.current_value : null
+ selectedEffort = current ?? null
+ }
+ const modelConfigId =
+ (options ?? []).find(
+ (o) =>
+ o.kind?.type === "select" && (o.id === "model" || o.id === "model_id")
+ )?.id ?? null
+ if (modelConfigId) {
+ const option = (options ?? []).find((o) => o.id === modelConfigId)
+ const current =
+ option?.kind?.type === "select" ? option.kind.current_value : null
+ selectedModel = current ?? null
+ }
+ if (effort !== "default" && effortConfigId) {
+ const target = nearestEffort(effort, effortOptions)
+ if (target) {
+ try {
+ await setConfigOption(contextKey, effortConfigId, target)
+ selectedEffort = target
+ } catch {
+ // 拒绝不致命——选手保持当前档位。
+ }
+ }
+ }
+ return {
+ modelOptions,
+ modelConfigId,
+ effortOptions,
+ effortConfigId,
+ selectedModel,
+ selectedEffort,
+ diagnostic:
+ options === null
+ ? "no configOptions advertised"
+ : `arrived (${options.length} options)`,
+ }
+}
+
+export async function fetchUsage(
+ conversationId: number
+): Promise {
+ try {
+ const detail = await getFolderConversation(conversationId)
+ let inputTokens = 0
+ let outputTokens = 0
+ let turnCount = 0
+ let tokensReported = false
+ for (const turn of detail.turns ?? []) {
+ if (turn.role !== "assistant") continue
+ turnCount += 1
+ inputTokens += turn.usage?.input_tokens ?? 0
+ outputTokens += turn.usage?.output_tokens ?? 0
+ if (
+ (turn.usage?.input_tokens ?? 0) > 0 ||
+ (turn.usage?.output_tokens ?? 0) > 0
+ ) {
+ tokensReported = true
+ }
+ }
+ return { inputTokens, outputTokens, turnCount, tokensReported }
+ } catch {
+ return null
+ }
+}
+
+export function usePkRound(): {
+ startRound: (round: PkRound) => Promise
+ startPrompt: (round: PkRound) => Promise
+ sendFollowUp: (
+ round: PkRound,
+ contestant: PkContestant,
+ message: string
+ ) => Promise
+ applyContestantSelection: (
+ round: PkRound,
+ contestant: PkContestant,
+ configId: string,
+ value: string
+ ) => Promise
+ cancelRound: (round: PkRound) => Promise
+ disconnectFinished: (round: PkRound) => Promise
+ cleanupRound: (round: PkRound, keepBranches: boolean) => Promise
+ fetchDiff: (round: PkRound, contestant: PkContestant) => Promise
+ runJudge: (round: PkRound) => Promise
+} {
+ const locale = useLocale()
+ const {
+ connect,
+ sendPrompt,
+ cancel,
+ disconnect,
+ setMode,
+ setConfigOption,
+ touchActivity,
+ respondPermission,
+ attachDelegationChild,
+ detachDelegationChild,
+ } = useAcpActions()
+ const connectionStore = useConnectionStore()
+ const updateContestant = usePkArenaStore((s) => s.updateContestant)
+ const markRound = usePkArenaStore((s) => s.markRound)
+ const roundsRef = useRef(usePkArenaStore.getState().rounds)
+ useEffect(() => {
+ const unsub = usePkArenaStore.subscribe((state) => {
+ roundsRef.current = state.rounds
+ })
+ return unsub
+ }, [])
+
+ // Map connectionId → {roundId, slot} so the event subscription can
+ // resolve envelopes without re-subscribing as rounds change.
+ // `isJudge: true` marks the judge agent connection — it uses the same
+ // event pipeline but settles into judgeResult instead of contestant state.
+ const contestantsByConnection = useRef(
+ new Map<
+ string,
+ {
+ roundId: string
+ slot: number
+ isJudge?: boolean
+ }
+ >()
+ )
+
+ const disconnectFinished = useCallback(
+ async (round: PkRound | null | undefined) => {
+ if (!round) return
+ await Promise.allSettled(
+ round.contestants
+ .filter((c) => c.connectionId != null)
+ .map(async (contestant) => {
+ if (contestant.connectionId) {
+ detachDelegationChild(contestant.connectionId)
+ }
+ if (contestant.contextKey) {
+ await disconnect(contestant.contextKey).catch(() => undefined)
+ }
+ })
+ )
+ },
+ [detachDelegationChild, disconnect]
+ )
+
+ // 裁判 settled 时的处理:从裁判的 conversation 轮次里提取最后一条
+ // assistant 消息文本,解析 JSON 评分。裁判连接用独立 contextKey,不跟
+ // 选手混在一起。
+ const updateJudge = usePkArenaStore((s) => s.updateJudge)
+
+ const settleJudge = useCallback(
+ async (roundId: string) => {
+ const round = roundsRef.current.find((r) => r.id === roundId)
+ if (!round || !round.judgeAgent) return
+ // 裁判的 conversationId 存在 store 里的 judgeResult 临时字段——但
+ // 我们没地方存 conversationId。改用 contextKey 从 connection store
+ // 拿状态,但文本只能从 conversation 轮次读。这里用 contextKey 去
+ // 读 conversationId——不,contextKey 不映射到 conversationId。
+ //
+ // 方案:裁判的 conversationId 在 runJudge 里创建后存入 ref。
+ const judgeConvId = judgeConvIdRef.current.get(roundId)
+ if (judgeConvId != null) {
+ try {
+ const detail = await getFolderConversation(judgeConvId)
+ const lastAssistant = [...(detail.turns ?? [])]
+ .reverse()
+ .find((turn) => turn.role === "assistant")
+ const rawText =
+ lastAssistant?.blocks
+ ?.filter(
+ (b): b is { type: "text"; text: string } => b.type === "text"
+ )
+ .map((b) => b.text)
+ .join("\n") ?? ""
+ const result = parseJudgeResult(
+ rawText,
+ round.contestants.filter(
+ (contestant) => contestant.status === "done"
+ )
+ )
+ updateJudge(roundId, {
+ judgeStatus: "done",
+ judgeResult: result ?? {
+ scores: [],
+ summary: "Judge response could not be parsed.",
+ rawText,
+ },
+ })
+ } catch {
+ updateJudge(roundId, {
+ judgeStatus: "error",
+ judgeResult: {
+ scores: [],
+ summary: "Failed to read judge response.",
+ rawText: "",
+ },
+ })
+ }
+ // Judge sessions are system-owned terminal work, not user work waiting
+ // for review. Explicitly settle the linked conversation so the sidebar
+ // does not keep rendering an in-progress spinner after the verdict is
+ // already available.
+ await updateConversationStatus(judgeConvId, "completed").catch(
+ () => undefined
+ )
+ } else {
+ updateJudge(roundId, { judgeStatus: "error" })
+ }
+ for (const [connectionId, entry] of contestantsByConnection.current) {
+ if (entry.isJudge && entry.roundId === roundId) {
+ contestantsByConnection.current.delete(connectionId)
+ }
+ }
+ // 断开裁判连接。
+ const judgeCtxKey = `pk:${roundId}:judge`
+ void disconnect(judgeCtxKey).catch(() => undefined)
+ },
+ [disconnect, updateJudge]
+ )
+
+ const settleJudgeError = useCallback(
+ (roundId: string, message: string) => {
+ updateJudge(roundId, {
+ judgeStatus: "error",
+ judgeResult: {
+ scores: [],
+ summary: message,
+ rawText: "",
+ },
+ })
+ const judgeConvId = judgeConvIdRef.current.get(roundId)
+ if (judgeConvId != null) {
+ void updateConversationStatus(judgeConvId, "cancelled").catch(
+ () => undefined
+ )
+ }
+ for (const [connectionId, entry] of contestantsByConnection.current) {
+ if (entry.isJudge && entry.roundId === roundId) {
+ contestantsByConnection.current.delete(connectionId)
+ }
+ }
+ const judgeCtxKey = `pk:${roundId}:judge`
+ void disconnect(judgeCtxKey).catch(() => undefined)
+ },
+ [disconnect, updateJudge]
+ )
+
+ // Store judge conversationId per round — needed by settleJudge to read the
+ // conversation turns after the judge finishes.
+ const judgeConvIdRef = useRef(new Map())
+
+ const fetchDiff = useCallback(
+ async (round: PkRound, contestant: PkContestant) => {
+ if (!contestant.worktreePath) return
+ try {
+ // 对比基准分支而不是选手自身分支:worktree 里 `git diff <自身分支>`
+ // 在选手提交后为空,而 diff 的意义是"比起跑点改了什么"。先取回合
+ // 仓库当前分支(main 等),再在 worktree 里对它 diff——既含已提交
+ // 也含未提交的工作区改动。
+ const base = (await getGitBranch(round.workingDir)) ?? null
+ const diff =
+ base == null
+ ? // 取不到基准分支名时退回工作区 diff(仅未提交改动)。
+ await gitDiff(contestant.worktreePath)
+ : await gitDiffWithBranch(contestant.worktreePath, base)
+ updateContestant(round.id, contestant.slot, {
+ diff: diff.trim() === "" ? "(无可比较内容:选手未改动工作区)" : diff,
+ })
+ } catch (error) {
+ updateContestant(round.id, contestant.slot, {
+ diff: `diff unavailable: ${String(error)}`,
+ })
+ }
+ },
+ [updateContestant]
+ )
+
+ const runJudge = useCallback(
+ async (round: PkRound) => {
+ if (!round.judgeAgent) return
+ const roundId = round.id
+ updateJudge(roundId, { judgeStatus: "running" })
+
+ // 收集所有选手的 diff(未加载的先加载)。
+ const contestantsWithDiffs = await Promise.all(
+ round.contestants
+ .filter((c) => c.status === "done")
+ .map(async (contestant) => {
+ if (contestant.diff == null && contestant.worktreePath) {
+ await fetchDiff(round, contestant)
+ }
+ const freshRound = usePkArenaStore
+ .getState()
+ .rounds.find((r) => r.id === roundId)
+ const fresh = freshRound?.contestants.find(
+ (c) => c.slot === contestant.slot
+ )
+ return {
+ agentType: contestant.agentType,
+ slot: contestant.slot,
+ label: contestant.label,
+ diff: fresh?.diff ?? "(no diff available)",
+ }
+ })
+ )
+
+ if (contestantsWithDiffs.length === 0) {
+ updateJudge(roundId, {
+ judgeStatus: "skipped",
+ judgeResult: {
+ scores: [],
+ summary: "No completed contestants to judge.",
+ rawText: "",
+ },
+ })
+ return
+ }
+
+ const contextKey = `pk:${roundId}:judge`
+ try {
+ const connectResult = await connect(
+ contextKey,
+ round.judgeAgent,
+ round.workingDir
+ )
+ const connectionId =
+ connectResult ??
+ connectionStore.getConnection(contextKey)?.connectionId ??
+ null
+ if (connectionId) {
+ contestantsByConnection.current.set(connectionId, {
+ roundId,
+ slot: -1,
+ isJudge: true,
+ })
+ }
+
+ // Create a conversation for the judge so its transcript persists.
+ let conversationId: number | null = null
+ try {
+ const taskPreview = round.task.slice(0, 60)
+ conversationId = await createPkConversation(
+ round.folderId,
+ round.judgeAgent as PkContestant["agentType"],
+ Number(roundId),
+ `PK Judge · ${taskPreview}${round.task.length > 60 ? "…" : ""}`
+ )
+ judgeConvIdRef.current.set(roundId, conversationId)
+ } catch {
+ // 裁判没有 conversation 也能跑,只是 transcript 不持久化。
+ }
+
+ await sendPrompt(
+ contextKey,
+ buildJudgePrompt(
+ round.task,
+ contestantsWithDiffs,
+ round.judgeDimensions,
+ locale
+ ),
+ {
+ folderId: round.folderId,
+ conversationId: conversationId ?? undefined,
+ }
+ )
+ } catch (error) {
+ updateJudge(roundId, {
+ judgeStatus: "error",
+ judgeResult: {
+ scores: [],
+ summary: `Judge failed to start: ${String(error)}`,
+ rawText: "",
+ },
+ })
+ }
+ },
+ [connect, connectionStore, sendPrompt, updateJudge, fetchDiff, locale]
+ )
+ // Keep a ref so settleContestant can call it without circular deps.
+ const runJudgeRef = useRef(runJudge)
+ runJudgeRef.current = runJudge
+
+ const settleContestant = useCallback(
+ async (
+ roundId: string,
+ slot: number,
+ outcome: "done" | "error",
+ detail?: string
+ ) => {
+ const endedAt = Date.now()
+ const round = roundsRef.current.find((r) => r.id === roundId)
+ const contestant = round?.contestants.find((c) => c.slot === slot)
+ if (!round || !contestant) return
+
+ const startedAt = contestant.startedAt ?? endedAt
+ updateContestant(roundId, slot, {
+ status: outcome,
+ statusDetail: detail ?? null,
+ endedAt,
+ durationMs: endedAt - startedAt,
+ })
+ if (contestant.conversationId != null) {
+ const usage = await fetchUsage(contestant.conversationId)
+ if (usage) {
+ updateContestant(roundId, slot, { usage })
+ }
+ }
+
+ const fresh = usePkArenaStore
+ .getState()
+ .rounds.find((r) => r.id === roundId)
+ if (
+ fresh &&
+ fresh.contestants.every(
+ (c) =>
+ c.status === "done" ||
+ c.status === "error" ||
+ c.status === "canceled"
+ )
+ ) {
+ markRound(roundId, "finished")
+ // Capture the disposable worktrees as soon as the round settles. This
+ // makes report export independent from the arena staying open and is
+ // retried synchronously before an explicit worktree cleanup.
+ void preparePkReportData(fresh).catch(() => undefined)
+ // 结算即断开:侧边栏的选手会话立刻停止转圈,结果走向持久化
+ // transcript。想继续追一条会话,把它当普通会话打开重连即可。
+ void disconnectFinished(
+ usePkArenaStore.getState().rounds.find((r) => r.id === roundId)
+ )
+ // 裁判自动触发:所有选手 settled 且配置了 judgeAgent 时启动。
+ // 裁判在选手断开后才连(避免连接数叠加),用独立 contextKey。
+ if (fresh.judgeAgent && fresh.judgeStatus === "idle") {
+ void runJudgeRef.current(fresh)
+ }
+ }
+ },
+ [disconnectFinished, markRound, updateContestant]
+ )
+
+ useEffect(() => {
+ const timer = window.setInterval(() => {
+ for (const round of usePkArenaStore.getState().rounds) {
+ if (round.status !== "ready" && round.status !== "running") continue
+ for (const contestant of round.contestants) {
+ if (contestant.contextKey) {
+ try {
+ touchActivity(contestant.contextKey)
+ } catch {
+ // 保活是尽力而为;失败不影响回合。
+ }
+ }
+ }
+ }
+ }, 20000)
+ return () => window.clearInterval(timer)
+ }, [touchActivity])
+
+ useAcpEvent((envelope) => {
+ if (
+ envelope.type !== "status_changed" &&
+ envelope.type !== "error" &&
+ envelope.type !== "turn_complete" &&
+ envelope.type !== "permission_request"
+ ) {
+ return
+ }
+ const entry = contestantsByConnection.current.get(envelope.connection_id)
+ if (!entry) return
+
+ // Judge connections share the event pipeline but settle differently.
+ if (entry.isJudge) {
+ if (envelope.type === "turn_complete") {
+ void settleJudge(entry.roundId)
+ } else if (envelope.type === "error") {
+ settleJudgeError(entry.roundId, envelope.message)
+ } else if (
+ envelope.type === "status_changed" &&
+ envelope.status === "disconnected"
+ ) {
+ settleJudgeError(entry.roundId, "连接中断(空闲回收或进程退出)")
+ }
+ return
+ }
+
+ const round = roundsRef.current.find((r) => r.id === entry.roundId)
+ const contestant = round?.contestants.find((c) => c.slot === entry.slot)
+ if (!round || !contestant) return
+
+ if (envelope.type === "permission_request") {
+ if (
+ round.permissionMode === "bypassPermissions" ||
+ round.permissionMode === "acceptEdits"
+ ) {
+ const opts =
+ (
+ envelope as {
+ options?: Array<{ option_id: string; name?: string }>
+ }
+ ).options ?? []
+ const allowOpt =
+ opts.find(
+ (o) =>
+ /allow|always|proceed|yes|approve|continue/i.test(o.option_id) ||
+ /allow|always|proceed|yes|approve|continue/i.test(o.name ?? "")
+ ) ?? opts[0]
+ if (allowOpt) {
+ const reqId = (envelope as { request_id: string }).request_id
+ const targetKey = contestant.contextKey ?? envelope.connection_id
+ void respondPermission(targetKey, reqId, allowOpt.option_id).catch(
+ () => {
+ void acpRespondPermission(
+ envelope.connection_id,
+ reqId,
+ allowOpt.option_id
+ ).catch(() => {})
+ }
+ )
+ }
+ }
+ return
+ }
+
+ if (envelope.type === "error") {
+ if (
+ contestant.status === "running" ||
+ contestant.status === "connecting"
+ ) {
+ void settleContestant(
+ entry.roundId,
+ entry.slot,
+ "error",
+ envelope.message
+ )
+ }
+ return
+ }
+
+ // `turn_complete` is the REAL settle signal: the backend flips the turn
+ // status at TurnComplete WITHOUT emitting a status_changed envelope
+ // (session_state.rs: "bypassing StatusChanged entirely"), so waiting for
+ // prompting→settled would leave finished contestants stuck on "running".
+ if (envelope.type === "turn_complete") {
+ if (contestant.status === "running") {
+ void settleContestant(entry.roundId, entry.slot, "done")
+ }
+ return
+ }
+
+ // status_changed: only the prompting edge matters for the running flip;
+ // the settle edge does not exist (see turn_complete above). A disconnect
+ // mid-turn means the backend reaped the connection (idle sweep) or the
+ // agent died — that is a failure, not a stuck running state.
+ if (envelope.status === "disconnected") {
+ if (
+ contestant.status === "running" ||
+ contestant.status === "connecting"
+ ) {
+ void settleContestant(
+ entry.roundId,
+ entry.slot,
+ "error",
+ "连接中断(空闲回收或进程退出)"
+ )
+ }
+ return
+ }
+ if (envelope.status === "prompting") {
+ if (contestant.status === "connecting" || contestant.status === "ready") {
+ updateContestant(entry.roundId, entry.slot, {
+ status: "running",
+ startedAt: Date.now(),
+ })
+ }
+ }
+ })
+
+ const startRound = useCallback(
+ async (round: PkRound) => {
+ for (const contestant of round.contestants) {
+ const { slot, agentType } = contestant
+ const contextKey = contestantContextKey(round.id, slot)
+ const branchName = contestantBranchName(round.id, slot)
+ const worktreePath = `${round.workingDir}/.codeg-pk/${round.id}/${slot}`
+ try {
+ await gitWorktreeAdd(
+ round.workingDir,
+ branchName,
+ worktreePath,
+ round.baseCommit
+ )
+ } catch (error) {
+ updateContestant(round.id, slot, {
+ status: "error",
+ statusDetail: `worktree: ${String(error)}`,
+ })
+ continue
+ }
+ updateContestant(round.id, slot, {
+ branchName,
+ worktreePath,
+ })
+
+ let conversationId: number | null = null
+ try {
+ const taskPreview = round.task.slice(0, 60)
+ conversationId = await createPkConversation(
+ round.folderId,
+ agentType,
+ Number(round.id),
+ `PK · ${taskPreview}${round.task.length > 60 ? "…" : ""}`
+ )
+ } catch (error) {
+ updateContestant(round.id, slot, {
+ status: "error",
+ statusDetail: `conversation: ${String(error)}`,
+ })
+ continue
+ }
+ updateContestant(round.id, slot, {
+ conversationId,
+ contextKey,
+ status: "connecting",
+ })
+
+ let initialModeId: string | null = null
+ let initialConfigValues: Record | null =
+ Object.keys(contestant.configValues).length > 0
+ ? { ...contestant.configValues }
+ : null
+ if (agentType === "claude_code") {
+ initialModeId = round.permissionMode
+ } else if (agentType === "codex") {
+ if (round.permissionMode === "bypassPermissions") {
+ initialModeId = "agent-full-access"
+ initialConfigValues = {
+ ...(initialConfigValues ?? {}),
+ mode: "agent-full-access",
+ }
+ } else if (round.permissionMode === "acceptEdits") {
+ initialModeId = "agent"
+ initialConfigValues = {
+ ...(initialConfigValues ?? {}),
+ mode: "agent",
+ }
+ }
+ }
+
+ try {
+ const connectResult = await connect(
+ contextKey,
+ agentType,
+ worktreePath,
+ undefined,
+ undefined,
+ initialModeId,
+ initialConfigValues
+ )
+ const connectionId =
+ connectResult ??
+ connectionStore.getConnection(contextKey)?.connectionId ??
+ null
+ if (connectionId) {
+ contestantsByConnection.current.set(connectionId, {
+ roundId: round.id,
+ slot,
+ })
+ updateContestant(round.id, slot, { connectionId })
+ // LiveTranscriptView resolves its connection via
+ // useConnectionStateById, which looks the store up BY
+ // connectionId — the entry shape only delegation children have
+ // (attach registers contextKey == connectionId). Attach the
+ // contestant the same way so the battle panes mirror the live
+ // stream; done BEFORE the first prompt so the whole turn flows
+ // through the by-id entry (no mid-turn hydrate needed).
+ attachDelegationChild({
+ connectionId,
+ parentConnectionId: connectionId,
+ parentToolUseId: `pk-arena-${round.id}`,
+ agentType,
+ })
+ }
+ await applyPermissionMode(
+ connectionStore,
+ setMode,
+ setConfigOption,
+ contextKey,
+ connectionId,
+ round.permissionMode
+ )
+ const prepared = await applyPreparedOptions(
+ connectionStore,
+ setConfigOption,
+ contextKey,
+ connectionId,
+ round.effort
+ )
+ updateContestant(round.id, slot, {
+ status: "ready",
+ modelOptions: prepared.modelOptions,
+ modelConfigId: prepared.modelConfigId,
+ effortOptions: prepared.effortOptions,
+ effortConfigId: prepared.effortConfigId,
+ selectedModel: prepared.selectedModel,
+ selectedEffort: prepared.selectedEffort,
+ // 诊断:无选择器时把原因写进面板可见的 statusDetail。
+ statusDetail:
+ prepared.modelOptions.length === 0 &&
+ prepared.effortOptions.length === 0
+ ? `no selectors (configOptions ${prepared.diagnostic})`
+ : null,
+ })
+ } catch (error) {
+ updateContestant(round.id, slot, {
+ status: "error",
+ statusDetail: `connect/prompt: ${String(error)}`,
+ })
+ }
+ }
+
+ const fresh = usePkArenaStore
+ .getState()
+ .rounds.find((r) => r.id === round.id)
+ if (fresh && fresh.contestants.every((c) => c.status === "error")) {
+ markRound(round.id, "canceled")
+ }
+ },
+ [
+ connect,
+ connectionStore,
+ markRound,
+ setMode,
+ setConfigOption,
+ updateContestant,
+ attachDelegationChild,
+ ]
+ )
+
+ const cancelRound = useCallback(
+ async (round: PkRound) => {
+ markRound(round.id, "canceled")
+ for (const contestant of round.contestants) {
+ if (
+ contestant.status === "done" ||
+ contestant.status === "error" ||
+ contestant.status === "canceled"
+ ) {
+ continue
+ }
+ if (contestant.connectionId) {
+ detachDelegationChild(contestant.connectionId)
+ }
+ if (contestant.contextKey) {
+ try {
+ await cancel(contestant.contextKey)
+ } catch {
+ // A connection that never came up has nothing to cancel.
+ }
+ void disconnect(contestant.contextKey).catch(() => undefined)
+ }
+ if (contestant.conversationId != null) {
+ await updateConversationStatus(
+ contestant.conversationId,
+ "cancelled"
+ ).catch(() => undefined)
+ }
+ updateContestant(round.id, contestant.slot, {
+ status: "canceled",
+ endedAt: Date.now(),
+ })
+ }
+ // 取消后也触发裁判:已完成的选手(done)仍可参与评分。
+ // 复用 settleContestant 的逻辑——如果配了 judgeAgent 且
+ // judgeStatus === "idle",调 runJudge(修复 issue #1)。
+ const fresh = usePkArenaStore
+ .getState()
+ .rounds.find((r) => r.id === round.id)
+ if (fresh && fresh.judgeAgent && fresh.judgeStatus === "idle") {
+ const hasDone = fresh.contestants.some((c) => c.status === "done")
+ if (hasDone) {
+ void runJudgeRef.current(fresh)
+ }
+ }
+ if (fresh) void preparePkReportData(fresh).catch(() => undefined)
+ },
+ [cancel, detachDelegationChild, disconnect, markRound, updateContestant]
+ )
+
+ const cleanupRound = useCallback(
+ async (round: PkRound, keepBranches: boolean) => {
+ const freshRound =
+ usePkArenaStore
+ .getState()
+ .rounds.find((item) => item.id === round.id) ?? round
+ const reportData = await preparePkReportData(freshRound)
+ if (
+ reportData.source === "empty" &&
+ freshRound.contestants.some((contestant) => contestant.worktreePath)
+ ) {
+ throw new Error(
+ "Could not preserve the PK report; worktrees were not removed"
+ )
+ }
+ // Release the by-id viewer entries before the worktrees go.
+ for (const contestant of round.contestants) {
+ if (contestant.connectionId) {
+ detachDelegationChild(contestant.connectionId)
+ }
+ }
+ await Promise.allSettled(
+ round.contestants
+ .filter((c) => c.worktreePath != null && c.branchName != null)
+ .map((c) =>
+ gitRemoveWorktree(
+ c.worktreePath as string,
+ c.branchName as string,
+ round.folderId,
+ !keepBranches,
+ true
+ )
+ )
+ )
+ for (const contestant of round.contestants) {
+ updateContestant(round.id, contestant.slot, {
+ worktreePath: null,
+ })
+ }
+ },
+ [detachDelegationChild, updateContestant]
+ )
+
+ const startPrompt = useCallback(
+ async (round: PkRound) => {
+ markRound(round.id, "running")
+ await Promise.allSettled(
+ round.contestants
+ .filter((c) => c.status === "ready" && c.contextKey != null)
+ .map(async (contestant) => {
+ const contextKey = contestant.contextKey as string
+ try {
+ await sendPrompt(
+ contextKey,
+ taskPromptBlocks(
+ round.task,
+ contestant.worktreePath ?? round.workingDir,
+ round.bareMode
+ ),
+ {
+ folderId: round.folderId,
+ conversationId: contestant.conversationId,
+ }
+ )
+ // sendPrompt 已下发,主动把选手推进到 running。
+ // 不依赖 status_changed(prompting) 事件——server 模式下该事件
+ // 可能因 attach stream 竞态丢失,导致选手永远停在 ready,
+ // 后续 turn_complete 因 status !== "running" 被忽略,round
+ // 卡死、裁判不触发(问题 #0)。
+ updateContestant(round.id, contestant.slot, {
+ status: "running",
+ startedAt: Date.now(),
+ })
+ } catch (error) {
+ updateContestant(round.id, contestant.slot, {
+ status: "error",
+ statusDetail: `prompt: ${String(error)}`,
+ })
+ }
+ })
+ )
+ const fresh = usePkArenaStore
+ .getState()
+ .rounds.find((r) => r.id === round.id)
+ if (fresh && fresh.contestants.every((c) => c.status === "error")) {
+ markRound(round.id, "canceled")
+ }
+ },
+ [markRound, sendPrompt, updateContestant]
+ )
+
+ /** Send a follow-up message to ONE contestant — the multi-turn / human-
+ * intervention path. Only works when the contestant is done (its previous
+ * turn settled) AND its connection is still alive (contextKey != null).
+ * Pushes the contestant back to running so the scoreboard reflects the
+ * new turn; the round stays "finished" if it was — the follow-up is a
+ * single-contestant side turn, not a new round. */
+ const sendFollowUp = useCallback(
+ async (round: PkRound, contestant: PkContestant, message: string) => {
+ if (!contestant.contextKey || contestant.conversationId == null) return
+ const trimmed = message.trim()
+ if (!trimmed) return
+ const blocks: PromptInputBlock[] = [
+ {
+ type: "text",
+ text: [
+ trimmed,
+ "",
+ `Continue working inside this directory: ${contestant.worktreePath ?? round.workingDir}`,
+ ].join("\n"),
+ },
+ ]
+ try {
+ await sendPrompt(contestant.contextKey, blocks, {
+ folderId: round.folderId,
+ conversationId: contestant.conversationId,
+ })
+ updateContestant(round.id, contestant.slot, {
+ status: "running",
+ endedAt: null,
+ durationMs: null,
+ })
+ } catch (error) {
+ updateContestant(round.id, contestant.slot, {
+ status: "error",
+ statusDetail: `follow-up: ${String(error)}`,
+ })
+ }
+ },
+ [sendPrompt, updateContestant]
+ )
+
+ const applyContestantSelection = useCallback(
+ async (
+ round: PkRound,
+ contestant: PkContestant,
+ configId: string,
+ value: string
+ ) => {
+ if (!contestant.contextKey) return
+ try {
+ await setConfigOption(contestant.contextKey, configId, value)
+ if (configId === contestant.modelConfigId) {
+ const label = contestant.modelOptions.find(
+ (option) => option.value === value
+ )?.name
+ updateContestant(round.id, contestant.slot, {
+ selectedModel: value,
+ ...(label ? { label } : {}),
+ })
+ } else {
+ updateContestant(round.id, contestant.slot, {
+ selectedEffort: value,
+ })
+ }
+ } catch {
+ // 选择被拒(模型临时下架等)不致命。
+ }
+ },
+ [setConfigOption, updateContestant]
+ )
+
+ return {
+ startRound,
+ startPrompt,
+ sendFollowUp,
+ applyContestantSelection,
+ cancelRound,
+ disconnectFinished,
+ cleanupRound,
+ fetchDiff,
+ runJudge,
+ }
+}
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json
index aa5a67fc3..49ea57318 100644
--- a/src/i18n/messages/ar.json
+++ b/src/i18n/messages/ar.json
@@ -1737,8 +1737,17 @@
"sectionFolders": "المجلدات",
"sectionChats": "محادثة",
"sectionRecent": "الأحدث",
+ "sectionPk": "ساحة PK",
"noChats": "لا توجد محادثات",
"noRecent": "لا توجد محادثات حديثة",
+ "noPk": "لا توجد جلسات PK",
+ "pkOpenArena": "Open this PK arena",
+ "pkArchive": "Archive the PK round",
+ "pkArchiveConfirm": "Archive “{task}”? The round and its agent sessions will be hidden.",
+ "pkArchiveSuccess": "PK round archived",
+ "pkArchiveFailed": "Archive failed: {message}",
+ "pkExpand": "Expand agent sessions",
+ "pkCollapse": "Collapse agent sessions",
"showMoreRecent": "عرض المزيد ({count})",
"noFolders": "لا توجد مجلدات مفتوحة",
"newChatAction": "محادثة جديدة",
@@ -1860,7 +1869,8 @@
"search": "بحث",
"openSettings": "فتح الإعدادات",
"backToConversations": "العودة إلى المحادثات",
- "withShortcut": "{label} (اختصار: {shortcut})"
+ "withShortcut": "{label} (اختصار: {shortcut})",
+ "pkArena": "ساحة منافسة الوكلاء"
},
"statusBar": {
"connection": {
@@ -2797,7 +2807,8 @@
"mentionGroupAgent": "الوكلاء",
"mentionGroupSession": "الجلسات",
"mentionGroupCommit": "عمليات الإيداع",
- "mentionGroupSkill": "المهارات"
+ "mentionGroupSkill": "المهارات",
+ "startPk": "منافسة الوكلاء"
},
"messageQueue": {
"addToQueue": "إضافة للقائمة",
@@ -5113,6 +5124,179 @@
"loadFailed": "تعذّر تحميل بيانات الاستهلاك",
"truncatedNotice": "هذا النطاق واسع جدًا — الأرقام تغطي أحدث جزء منه فقط."
},
+ "PkArena": {
+ "launcher": {
+ "title": "منافسة الوكلاء الأذكياء",
+ "description": "أرسل مهمة واحدة إلى عدة وكلاء في آنٍ واحد وقارن النتائج",
+ "contestantsLabel": "المتنافسون ({min}–{max})",
+ "noFolderHint": "تحتاج الساحة إلى مجلد فيه مستودع git — افتح واحدًا أولًا.",
+ "needMore": "اختر {count} وكلاء على الأقل لبدء المنافسة.",
+ "taskLabel": "المهمة",
+ "taskPlaceholder": "المهمة نفسها التي يتلقاها كل متنافس، مثال: \"اكتب لعبة الثعبان في ملف HTML واحد\"",
+ "selectedCount": "تم اختيار {selected}/{max} (الحد الأدنى {min})",
+ "cancel": "إلغاء",
+ "start": "ابدأ المنافسة",
+ "notAGitRepo": "هذا المجلد ليس مستودع git — تحتاج الساحة إلى مستودع لمنح كل متنافس شجرة عمل معزولة.",
+ "initGitRepo": "git init",
+ "initializing": "جارٍ التهيئة…",
+ "permissionLabel": "الأذونات",
+ "permissionOptions": {
+ "default": "اسأل في كل مرة",
+ "acceptEdits": "اقبل التعديلات تلقائيًا",
+ "bypassPermissions": "تشغيل كامل"
+ },
+ "permissionHints": {
+ "default": "كل موافقة توقف المتنافس",
+ "acceptEdits": "تعديلات الملفات دون سؤال",
+ "bypassPermissions": "لا موافقات إطلاقًا"
+ },
+ "permissionNote": "تُطبق على كل متنافس عند بدء الجولة; الوكلاء غير الداعمة يستمرون في السؤال.",
+ "bareModeLabel": "الوضع الأساسي (بدون مهارات)",
+ "bareModeHint": "يُطلب من المتنافسين استخدام قدراتهم الأساسية فقط، دون أي مهارات عامة أو مشروع.",
+ "effortLabel": "مستوى التفكير (موحد لجميع المتنافسين)",
+ "effortOptions": {
+ "default": "افتراضي",
+ "low": "منخفض",
+ "medium": "متوسط",
+ "high": "مرتفع",
+ "max": "أقصى"
+ },
+ "effortNote": "يُطبق أقرب مستوى مُعلن لكل وكيل; الوكلاء غير الداعمين يحتفظون بإعداداتهم.",
+ "agentNotReady": "{agent} غير جاهز (ثبّته من إعدادات الوكلاء أولاً).",
+ "agentCheckFailed": "تعذّر التحقق من {agent}.",
+ "templates": {
+ "pelican": "بجعة",
+ "bouncingBall": "كرة",
+ "jellyBlob": "هلام",
+ "snake": "الثعبان",
+ "flappyBird": "Flappy Bird",
+ "voiceChat": "دردشة صوتية",
+ "blackHole": "ثقب أسود"
+ },
+ "judgeLabel": "حَكَم (اختياري)",
+ "judgeNone": "بدون حَكَم",
+ "judgeHint": "بعد انتهاء جميع المتسابقين، يقرأ هذا الوكيل كل فرق وينتج حكمًا منظماً بالدرجات والترتيب.",
+ "startPoint": "📍 نقطة البداية",
+ "fromHead": "من HEAD الحالي",
+ "startPointHint": "يبدأ المتسابقون من التزام قبل هذا — لا يرون تغييراته، بل رسالته كمهمة. يعيدون نفس الهدف بشكل مستقل.",
+ "loadingCommits": "جارٍ تحميل الالتزامات…",
+ "noCommits": "لم يتم العثور على التزامات.",
+ "creativeTemplates": "قوالب إبداعية",
+ "realEngineering": "هندسة حقيقية",
+ "loadMore": "تحميل المزيد",
+ "judgeDimensionsLabel": "أبعاد التقييم (اختياري)",
+ "judgeDimensionsPlaceholder": "Correctness — does it fulfill the task?\nCode quality — readability, structure, edge cases\nCompleteness — how much of the task is done?\nEfficiency — code-level efficiency (ignore token/time)",
+ "judgeDimensionsHint": "بُعد واحد لكل سطر. اتركه فارغًا لاستخدام الافتراضي.",
+ "addContestant": "إضافة {agent} كمتسابق",
+ "slotLabelPlaceholder": "تسمية (مثل Sonnet، Opus)",
+ "slotNumber": "المتنافس {number}",
+ "modelLoading": "جارٍ تحميل النماذج…",
+ "modelUnavailable": "يستخدم هذا الوكيل نموذجه الافتراضي",
+ "modelLoadFailed": "تعذر تحميل النماذج",
+ "retryModelLoad": "إعادة تحميل النماذج",
+ "removeSlot": "إزالة هذا المتسابق"
+ },
+ "scoreboard": {
+ "status": {
+ "preparing": "قيد التحضير",
+ "connecting": "قيد الاتصال",
+ "running": "قيد التشغيل",
+ "done": "تم",
+ "error": "فشل",
+ "canceled": "ملغى",
+ "ready": "جاهز"
+ },
+ "tokensUnit": "توكن",
+ "tokensUnavailable": "Token not reported",
+ "tokensUnavailableHint": "This agent did not return token usage; this does not mean zero tokens.",
+ "turnsUnit": "أدوار"
+ },
+ "history": {
+ "title": "Round history",
+ "hint": "Search, open, and archive complete rounds.",
+ "trigger": "History {count}",
+ "searchPlaceholder": "Search tasks or agents…",
+ "empty": "No matching rounds",
+ "agents": "{count} agents",
+ "archive": "Archive round",
+ "archiveConfirm": "Archive “{task}”? The round and its agent sessions will be hidden.",
+ "archiveSuccess": "PK round archived",
+ "archiveFailed": "Archive failed: {message}",
+ "status": {
+ "ready": "Ready",
+ "running": "Running",
+ "finished": "Finished",
+ "canceled": "Canceled",
+ "interrupted": "Interrupted"
+ }
+ },
+ "arena": {
+ "title": "ساحة منافسة الوكلاء",
+ "description": "مهمة واحدة، عدة وكلاء، جنبًا إلى جنب",
+ "roundStatus": {
+ "running": "مباشر",
+ "finished": "انتهت",
+ "canceled": "ملغاة",
+ "interrupted": "قُطعت بإعادة التشغيل",
+ "ready": "جاهز"
+ },
+ "roundPicker": "الجولة",
+ "cancelRound": "إلغاء الجولة",
+ "cleanupWorktrees": "تنظيف أشجار العمل",
+ "cleanupHint": "إزالة أشجار عمل المتنافسين (تُحفظ الفروع)",
+ "share": "مشاركة",
+ "sharing": "جارٍ التصدير…",
+ "tabs": {
+ "battle": "المنافسة",
+ "diff": "الفروقات"
+ },
+ "preparing": "جارٍ تحضير المتنافس…",
+ "noRound": "لم تُحدد جولة",
+ "contestantsUnit": "وكلاء",
+ "newRound": "جولة جديدة",
+ "startMatch": "ابدأ المباراة",
+ "readyNote": "اختر نموذج ومستوى تفكير كل متنافس ثم ابدأ.",
+ "modelLabel": "النموذج",
+ "effortLabel": "مستوى التفكير",
+ "effortUnsupported": "هذا الوكيل لا يوفّر إعداد مستويات التفكير؛ ستُستخدم قيمته الافتراضية",
+ "effortOptions": {
+ "off": "إيقاف",
+ "minimal": "الحد الأدنى",
+ "low": "منخفض",
+ "medium": "متوسط",
+ "high": "مرتفع",
+ "max": "الأقصى"
+ },
+ "minimize": "تصغير",
+ "exportReport": "تصدير التقرير",
+ "exporting": "جارٍ الإنشاء…",
+ "shareReport": "Save / share report",
+ "reportSaved": "HTML report saved",
+ "reportFailed": "Report export failed: {message}",
+ "persistenceFailed": "تعذر حفظ بعض تغييرات الجولة. أعد المحاولة قبل إغلاق هذه الصفحة.",
+ "retrySave": "إعادة محاولة الحفظ",
+ "readyTag": "جاهز",
+ "deleteRound": "حذف",
+ "deleteConfirm": "حذف هذه الجولة? تبقى أشجار العمل على القرص — استخدم تنظيف أشجار العمل.",
+ "followUp": "متابعة",
+ "followUpPlaceholder": "إرسال إلى هذا المتسابق فقط (⌘↩ للإرسال)"
+ },
+ "diff": {
+ "loading": "جارٍ تحميل الفروقات…",
+ "empty": "لا تغييرات في شجرة العمل هذه"
+ },
+ "judge": {
+ "title": "حكم الحَكَم",
+ "running": "يقيم…",
+ "error": "فشل الحَكَم",
+ "rerun": "إعادة التقييم"
+ },
+ "minimized": {
+ "restore": "العودة إلى الساحة",
+ "live": "منافسة قيد التشغيل",
+ "dismiss": "إخفاء"
+ }
+ },
"Forge": {
"title": "لوحة المستودع",
"pickFolder": "اختر مجلد مشروع",
diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json
index 36e66b12c..772876886 100644
--- a/src/i18n/messages/de.json
+++ b/src/i18n/messages/de.json
@@ -1737,8 +1737,17 @@
"sectionFolders": "Ordner",
"sectionChats": "Chat",
"sectionRecent": "Zuletzt",
+ "sectionPk": "PK-Arena",
"noChats": "Keine Chats",
"noRecent": "Keine kürzlichen Konversationen",
+ "noPk": "Keine PK-Sitzungen",
+ "pkOpenArena": "Open this PK arena",
+ "pkArchive": "Archive the PK round",
+ "pkArchiveConfirm": "Archive “{task}”? The round and its agent sessions will be hidden.",
+ "pkArchiveSuccess": "PK round archived",
+ "pkArchiveFailed": "Archive failed: {message}",
+ "pkExpand": "Expand agent sessions",
+ "pkCollapse": "Collapse agent sessions",
"showMoreRecent": "Mehr anzeigen ({count})",
"noFolders": "Keine Ordner geöffnet",
"newChatAction": "Neuer Chat",
@@ -1860,7 +1869,8 @@
"search": "Suchen",
"openSettings": "Einstellungen öffnen",
"backToConversations": "Zurück zu Konversationen",
- "withShortcut": "{label} (Tastenkürzel: {shortcut})"
+ "withShortcut": "{label} (Tastenkürzel: {shortcut})",
+ "pkArena": "Agenten-PK-Arena"
},
"statusBar": {
"connection": {
@@ -2797,7 +2807,8 @@
"mentionGroupAgent": "Agenten",
"mentionGroupSession": "Sitzungen",
"mentionGroupCommit": "Commits",
- "mentionGroupSkill": "Fähigkeiten"
+ "mentionGroupSkill": "Fähigkeiten",
+ "startPk": "Agenten-PK"
},
"messageQueue": {
"addToQueue": "Zur Warteschlange",
@@ -5113,6 +5124,179 @@
"loadFailed": "Verbrauch konnte nicht geladen werden",
"truncatedNotice": "Dieser Zeitraum ist sehr groß — die Zahlen decken nur seinen jüngsten Abschnitt ab."
},
+ "PkArena": {
+ "launcher": {
+ "title": "Agenten-PK",
+ "description": "Eine Aufgabe gleichzeitig an mehrere Agenten senden und die Ergebnisse vergleichen",
+ "contestantsLabel": "Teilnehmer ({min}–{max})",
+ "noFolderHint": "Die Arena braucht einen Ordner mit einem Git-Repository — öffne zuerst einen.",
+ "needMore": "Wähle mindestens {count} Agenten für ein Duell.",
+ "taskLabel": "Aufgabe",
+ "taskPlaceholder": "Die Aufgabe, die jeder Teilnehmer erhält, z. B. \"Schreibe ein Snake-Spiel in einer einzigen HTML-Datei\"",
+ "selectedCount": "{selected}/{max} gewählt (min. {min})",
+ "cancel": "Abbrechen",
+ "start": "Kampf starten",
+ "notAGitRepo": "Dieser Ordner ist kein Git-Repository — die Arena braucht eines, um jedem Teilnehmer einen isolierten Worktree zu geben.",
+ "initGitRepo": "git init",
+ "initializing": "Initialisiere…",
+ "permissionLabel": "Berechtigungen",
+ "permissionOptions": {
+ "default": "Jedes Mal fragen",
+ "acceptEdits": "Bearbeitungen automatisch",
+ "bypassPermissions": "Vollautomatik"
+ },
+ "permissionHints": {
+ "default": "jede Freigabe unterbricht den Teilnehmer",
+ "acceptEdits": "Dateibearbeitungen ohne Nachfrage",
+ "bypassPermissions": "gar keine Freigaben"
+ },
+ "permissionNote": "Wird beim Start auf jeden Teilnehmer angewendet; Agenten ohne Unterstützung fragen weiter.",
+ "bareModeLabel": "Bare-Modus (keine Skills)",
+ "bareModeHint": "Teilnehmer dürfen nur ihre Grundfunktionen nutzen — keine globalen oder Projekt-Skills.",
+ "effortLabel": "Denkaufwand (einheitlich)",
+ "effortOptions": {
+ "default": "Standard",
+ "low": "Niedrig",
+ "medium": "Mittel",
+ "high": "Hoch",
+ "max": "Max"
+ },
+ "effortNote": "Wird als nächste verfügbare Stufe je Agent angewendet; ohne Unterstützung bleibt der Standard.",
+ "agentNotReady": "{agent} nicht bereit (zuerst in den Agent-Einstellungen installieren).",
+ "agentCheckFailed": "{agent} konnte nicht geprüft werden.",
+ "templates": {
+ "pelican": "Pelikan",
+ "bouncingBall": "Ball",
+ "jellyBlob": "Gelee",
+ "snake": "Snake",
+ "flappyBird": "Flappy Bird",
+ "voiceChat": "Sprachchat",
+ "blackHole": "Schwarzes Loch"
+ },
+ "judgeLabel": "Schiedsrichter (optional)",
+ "judgeNone": "Kein Schiedsrichter",
+ "judgeHint": "Nachdem alle Teilnehmer fertig sind, liest dieser Agent jeden Diff und erstellt ein strukturiertes Urteil mit Punktzahlen und Rängen.",
+ "startPoint": "📍 Startpunkt",
+ "fromHead": "ab aktuellem HEAD",
+ "startPointHint": "Teilnehmer beginnen einen Commit davor — sie sehen diese Änderungen nicht, nur die Commit-Nachricht als Aufgabe. Sie machen dasselbe Ziel unabhängig.",
+ "loadingCommits": "Commits laden…",
+ "noCommits": "Keine Commits gefunden.",
+ "creativeTemplates": "Kreative Vorlagen",
+ "realEngineering": "Echte Entwicklung",
+ "loadMore": "Mehr laden",
+ "judgeDimensionsLabel": "Bewertungsdimensionen (optional)",
+ "judgeDimensionsPlaceholder": "Correctness — erfüllt es die Aufgabe?\nCode quality — Lesbarkeit, Struktur, Randfälle\nCompleteness — wie viel ist erledigt?\nEfficiency — Code-Effizienz (ohne Token/Zeit)",
+ "judgeDimensionsHint": "Eine Dimension pro Zeile. Leer lassen für Standardwerte.",
+ "addContestant": "{agent} als Teilnehmer hinzufügen",
+ "slotLabelPlaceholder": "Label (z.B. Sonnet, Opus)",
+ "slotNumber": "Teilnehmer {number}",
+ "modelLoading": "Modelle werden geladen…",
+ "modelUnavailable": "Dieser Agent verwendet sein Standardmodell",
+ "modelLoadFailed": "Modelle konnten nicht geladen werden",
+ "retryModelLoad": "Modelle erneut laden",
+ "removeSlot": "Diesen Teilnehmer-Slot entfernen"
+ },
+ "scoreboard": {
+ "status": {
+ "preparing": "Vorbereitung",
+ "connecting": "Verbinde",
+ "running": "Läuft",
+ "done": "Fertig",
+ "error": "Fehlgeschlagen",
+ "canceled": "Abgebrochen",
+ "ready": "Bereit"
+ },
+ "tokensUnit": "Tok",
+ "tokensUnavailable": "Token not reported",
+ "tokensUnavailableHint": "This agent did not return token usage; this does not mean zero tokens.",
+ "turnsUnit": "Runden"
+ },
+ "history": {
+ "title": "Round history",
+ "hint": "Search, open, and archive complete rounds.",
+ "trigger": "History {count}",
+ "searchPlaceholder": "Search tasks or agents…",
+ "empty": "No matching rounds",
+ "agents": "{count} agents",
+ "archive": "Archive round",
+ "archiveConfirm": "Archive “{task}”? The round and its agent sessions will be hidden.",
+ "archiveSuccess": "PK round archived",
+ "archiveFailed": "Archive failed: {message}",
+ "status": {
+ "ready": "Ready",
+ "running": "Running",
+ "finished": "Finished",
+ "canceled": "Canceled",
+ "interrupted": "Interrupted"
+ }
+ },
+ "arena": {
+ "title": "Agenten-PK-Arena",
+ "description": "Eine Aufgabe, mehrere Agenten, Seite an Seite",
+ "roundStatus": {
+ "running": "Live",
+ "finished": "Beendet",
+ "canceled": "Abgebrochen",
+ "interrupted": "Durch Neustart unterbrochen",
+ "ready": "Bereit"
+ },
+ "roundPicker": "Runde",
+ "cancelRound": "Runde abbrechen",
+ "cleanupWorktrees": "Worktrees bereinigen",
+ "cleanupHint": "Worktrees der Teilnehmer entfernen (Branches bleiben)",
+ "share": "Teilen",
+ "sharing": "Exportiere…",
+ "tabs": {
+ "battle": "Kampf",
+ "diff": "Diff"
+ },
+ "preparing": "Teilnehmer wird vorbereitet…",
+ "noRound": "Keine Runde ausgewählt",
+ "contestantsUnit": "Agenten",
+ "newRound": "Neue Runde",
+ "startMatch": "Kampf starten",
+ "readyNote": "Modell und Denkaufwand je Teilnehmer wählen, dann starten.",
+ "modelLabel": "Modell",
+ "effortLabel": "Denkaufwand",
+ "effortUnsupported": "Dieser Agent bietet keine Denkstufen an; sein Standardwert wird verwendet",
+ "effortOptions": {
+ "off": "Aus",
+ "minimal": "Minimal",
+ "low": "Niedrig",
+ "medium": "Mittel",
+ "high": "Hoch",
+ "max": "Maximum"
+ },
+ "minimize": "Minimieren",
+ "exportReport": "Bericht exportieren",
+ "exporting": "Erstelle…",
+ "shareReport": "Save / share report",
+ "reportSaved": "HTML report saved",
+ "reportFailed": "Report export failed: {message}",
+ "persistenceFailed": "Einige Änderungen der Runde konnten nicht gespeichert werden. Wiederhole die Aktion, bevor du diese Seite schließt.",
+ "retrySave": "Speichern wiederholen",
+ "readyTag": "Bereit",
+ "deleteRound": "Löschen",
+ "deleteConfirm": "Diese Runde löschen? Worktrees bleiben auf der Platte — über 'Worktrees bereinigen' entfernen.",
+ "followUp": "Nachfassen",
+ "followUpPlaceholder": "Nur an diesen Teilnehmer senden (⌘↩ zum Senden)"
+ },
+ "diff": {
+ "loading": "Diff wird geladen…",
+ "empty": "Keine Änderungen in diesem Worktree"
+ },
+ "judge": {
+ "title": "Schiedsrichterurteil",
+ "running": "Auswertung…",
+ "error": "Schiedsrichter fehlgeschlagen",
+ "rerun": "Neu bewerten"
+ },
+ "minimized": {
+ "restore": "Zurück zur Arena",
+ "live": "PK läuft",
+ "dismiss": "Ausblenden"
+ }
+ },
"Forge": {
"title": "Repository-Panel",
"pickFolder": "Projektordner wählen",
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index 6e9e380e4..2f7f84920 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -1737,8 +1737,17 @@
"sectionFolders": "Folders",
"sectionChats": "Chat",
"sectionRecent": "Recent",
+ "sectionPk": "PK Arena",
"noChats": "No chats",
"noRecent": "No recent conversations",
+ "noPk": "No PK arena sessions",
+ "pkOpenArena": "Open this PK arena",
+ "pkArchive": "Archive the PK round",
+ "pkArchiveConfirm": "Archive “{task}”? The round and its agent sessions will be hidden.",
+ "pkArchiveSuccess": "PK round archived",
+ "pkArchiveFailed": "Archive failed: {message}",
+ "pkExpand": "Expand agent sessions",
+ "pkCollapse": "Collapse agent sessions",
"showMoreRecent": "Show more ({count})",
"noFolders": "No folders open",
"newChatAction": "New chat",
@@ -1860,7 +1869,8 @@
"search": "Search",
"openSettings": "Open Settings",
"backToConversations": "Back to Conversations",
- "withShortcut": "{label} ({shortcut})"
+ "withShortcut": "{label} ({shortcut})",
+ "pkArena": "Agent PK arena"
},
"statusBar": {
"connection": {
@@ -2797,7 +2807,8 @@
"mentionGroupAgent": "Agents",
"mentionGroupSession": "Sessions",
"mentionGroupCommit": "Commits",
- "mentionGroupSkill": "Skills"
+ "mentionGroupSkill": "Skills",
+ "startPk": "Agent PK"
},
"messageQueue": {
"addToQueue": "Queue message",
@@ -5113,6 +5124,179 @@
"loadFailed": "Could not load usage",
"truncatedNotice": "This range is very large — the numbers cover only the most recent slice of it."
},
+ "PkArena": {
+ "launcher": {
+ "title": "Agent PK",
+ "description": "Send one task to several agents at once and compare the results",
+ "contestantsLabel": "Contestants ({min}–{max})",
+ "noFolderHint": "The arena needs a folder with a git repository — open one first.",
+ "needMore": "Pick at least {count} agents to run a match.",
+ "taskLabel": "Task",
+ "taskPlaceholder": "The task every contestant gets, e.g. \"Write a snake game in a single HTML file\"",
+ "selectedCount": "{selected}/{max} picked (min {min})",
+ "addContestant": "Add {agent} as contestant",
+ "slotLabelPlaceholder": "Label (e.g. Sonnet, Opus)",
+ "slotNumber": "Contestant {number}",
+ "modelLoading": "Loading models…",
+ "modelUnavailable": "This agent uses its default model",
+ "modelLoadFailed": "Could not load models",
+ "retryModelLoad": "Retry loading models",
+ "removeSlot": "Remove this contestant slot",
+ "cancel": "Cancel",
+ "start": "Start match",
+ "notAGitRepo": "This folder is not a git repository — the arena needs one to give each contestant an isolated worktree.",
+ "initGitRepo": "git init",
+ "initializing": "Initializing…",
+ "permissionLabel": "Permissions",
+ "permissionOptions": {
+ "default": "Ask every time",
+ "acceptEdits": "Auto-accept edits",
+ "bypassPermissions": "Full auto"
+ },
+ "permissionHints": {
+ "default": "each approval interrupts the contestant",
+ "acceptEdits": "file edits run without asking",
+ "bypassPermissions": "no approval prompts at all"
+ },
+ "permissionNote": "Applied per contestant when the round starts; agents that don't support a mode keep asking.",
+ "bareModeLabel": "Bare mode (no skills)",
+ "bareModeHint": "Contestants are instructed to use only their built-in capabilities — no global or project skills.",
+ "effortLabel": "Reasoning effort (all contestants)",
+ "effortOptions": {
+ "default": "Default",
+ "low": "Low",
+ "medium": "Medium",
+ "high": "High",
+ "max": "Max"
+ },
+ "effortNote": "Applied as the nearest advertised level per agent; agents without the option keep their default.",
+ "agentNotReady": "{agent} is not ready (install it from Agent Settings first).",
+ "agentCheckFailed": "Could not verify {agent}; check its installation.",
+ "templates": {
+ "pelican": "Pelican",
+ "bouncingBall": "Bouncing Ball",
+ "jellyBlob": "Jelly Blob",
+ "snake": "Snake",
+ "flappyBird": "Flappy Bird",
+ "voiceChat": "Voice Chat",
+ "blackHole": "Black Hole"
+ },
+ "judgeLabel": "Judge (optional)",
+ "judgeNone": "No judge",
+ "judgeHint": "After all contestants finish, this agent reads every diff and produces a structured verdict with scores and rankings.",
+ "judgeDimensionsLabel": "Scoring dimensions (optional)",
+ "judgeDimensionsPlaceholder": "Correctness — does it fulfill the task?\nCode quality — readability, structure, edge cases\nCompleteness — how much of the task is done?\nEfficiency — code-level efficiency (ignore token/time)",
+ "judgeDimensionsHint": "One dimension per line. Leave blank to use the defaults. Each becomes a numbered criterion in the judge prompt.",
+ "startPoint": "📍 Start point",
+ "fromHead": "from current HEAD",
+ "startPointHint": "Contestants branch from one commit BEFORE this — they never see its changes, only its message as the task. So they redo the same goal independently.",
+ "loadingCommits": "Loading commits…",
+ "noCommits": "No commits found.",
+ "creativeTemplates": "Creative templates",
+ "realEngineering": "Real engineering",
+ "loadMore": "Load more"
+ },
+ "scoreboard": {
+ "status": {
+ "preparing": "preparing",
+ "connecting": "connecting",
+ "running": "running",
+ "done": "done",
+ "error": "failed",
+ "canceled": "canceled",
+ "ready": "ready"
+ },
+ "tokensUnit": "tok",
+ "tokensUnavailable": "Token not reported",
+ "tokensUnavailableHint": "This agent did not return token usage; this does not mean zero tokens.",
+ "turnsUnit": "turns"
+ },
+ "history": {
+ "title": "Round history",
+ "hint": "Search, open, and archive complete rounds.",
+ "trigger": "History {count}",
+ "searchPlaceholder": "Search tasks or agents…",
+ "empty": "No matching rounds",
+ "agents": "{count} agents",
+ "archive": "Archive round",
+ "archiveConfirm": "Archive “{task}”? The round and its agent sessions will be hidden.",
+ "archiveSuccess": "PK round archived",
+ "archiveFailed": "Archive failed: {message}",
+ "status": {
+ "ready": "Ready",
+ "running": "Running",
+ "finished": "Finished",
+ "canceled": "Canceled",
+ "interrupted": "Interrupted"
+ }
+ },
+ "arena": {
+ "title": "Agent PK arena",
+ "description": "One task, several agents, side by side",
+ "roundStatus": {
+ "running": "Live",
+ "finished": "Finished",
+ "canceled": "Canceled",
+ "interrupted": "Interrupted by restart",
+ "ready": "Ready"
+ },
+ "roundPicker": "Round",
+ "cancelRound": "Cancel round",
+ "cleanupWorktrees": "Clean worktrees",
+ "cleanupHint": "Remove the contestants' worktrees (branches are kept)",
+ "share": "Share",
+ "sharing": "Exporting…",
+ "tabs": {
+ "battle": "Battle",
+ "diff": "Diff"
+ },
+ "preparing": "Preparing contestant…",
+ "noRound": "No round selected",
+ "contestantsUnit": "agents",
+ "newRound": "New round",
+ "startMatch": "Start match",
+ "readyNote": "Confirm the model and reasoning levels each contestant actually supports, then start the match.",
+ "modelLabel": "Model",
+ "effortLabel": "Reasoning effort",
+ "effortUnsupported": "This agent does not expose reasoning levels; its default will be used",
+ "effortOptions": {
+ "off": "Off",
+ "minimal": "Minimal",
+ "low": "Low",
+ "medium": "Medium",
+ "high": "High",
+ "max": "Max"
+ },
+ "minimize": "Minimize",
+ "exportReport": "Export report",
+ "exporting": "Building…",
+ "shareReport": "Save / share report",
+ "reportSaved": "HTML report saved",
+ "reportFailed": "Report export failed: {message}",
+ "persistenceFailed": "Some round changes could not be saved. Retry the action before closing this page.",
+ "retrySave": "Retry save",
+ "readyTag": "Ready",
+ "deleteRound": "Delete",
+ "deleteConfirm": "Delete this round? (worktrees stay on disk — use Clean worktrees to remove them)",
+ "followUp": "Follow-up",
+ "followUpPlaceholder": "Send a follow-up message to this contestant only (⌘↩ to send)"
+ },
+ "diff": {
+ "loading": "Loading diff…",
+ "empty": "No changes in this worktree"
+ },
+ "judge": {
+ "title": "Judge Verdict",
+ "running": "Evaluating…",
+ "error": "Judge failed",
+ "rerun": "Re-evaluate"
+ },
+ "minimized": {
+ "restore": "Back to the arena",
+ "live": "PK in progress",
+ "dismiss": "Hide pill"
+ }
+ },
"Forge": {
"title": "Repository panel",
"pickFolder": "Pick a project folder",
diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json
index 0c543e2c9..824c2280b 100644
--- a/src/i18n/messages/es.json
+++ b/src/i18n/messages/es.json
@@ -1737,8 +1737,17 @@
"sectionFolders": "Carpetas",
"sectionChats": "Chat",
"sectionRecent": "Recientes",
+ "sectionPk": "Arena PK",
"noChats": "Sin chats",
"noRecent": "Sin conversaciones recientes",
+ "noPk": "Sin sesiones PK",
+ "pkOpenArena": "Open this PK arena",
+ "pkArchive": "Archive the PK round",
+ "pkArchiveConfirm": "Archive “{task}”? The round and its agent sessions will be hidden.",
+ "pkArchiveSuccess": "PK round archived",
+ "pkArchiveFailed": "Archive failed: {message}",
+ "pkExpand": "Expand agent sessions",
+ "pkCollapse": "Collapse agent sessions",
"showMoreRecent": "Mostrar más ({count})",
"noFolders": "No hay carpetas abiertas",
"newChatAction": "Nuevo chat",
@@ -1860,7 +1869,8 @@
"search": "Buscar",
"openSettings": "Abrir configuración",
"backToConversations": "Volver a conversaciones",
- "withShortcut": "{label} (atajo: {shortcut})"
+ "withShortcut": "{label} (atajo: {shortcut})",
+ "pkArena": "Arena PK de agentes"
},
"statusBar": {
"connection": {
@@ -2797,7 +2807,8 @@
"mentionGroupAgent": "Agentes",
"mentionGroupSession": "Sesiones",
"mentionGroupCommit": "Commits",
- "mentionGroupSkill": "Habilidades"
+ "mentionGroupSkill": "Habilidades",
+ "startPk": "PK de agentes"
},
"messageQueue": {
"addToQueue": "Agregar a la cola",
@@ -5113,6 +5124,179 @@
"loadFailed": "No se pudo cargar el uso",
"truncatedNotice": "Este periodo es muy amplio: las cifras cubren solo su tramo más reciente."
},
+ "PkArena": {
+ "launcher": {
+ "title": "PK de agentes",
+ "description": "Envía una tarea a varios agentes a la vez y compara los resultados",
+ "contestantsLabel": "Contendientes ({min}–{max})",
+ "noFolderHint": "La arena necesita una carpeta con un repositorio git: abre una primero.",
+ "needMore": "Elige al menos {count} agentes para iniciar el duelo.",
+ "taskLabel": "Tarea",
+ "taskPlaceholder": "La tarea que recibe cada contendiente, p. ej. \"Escribe un juego de snake en un solo archivo HTML\"",
+ "selectedCount": "{selected}/{max} elegidos (mín. {min})",
+ "cancel": "Cancelar",
+ "start": "Iniciar duelo",
+ "notAGitRepo": "Esta carpeta no es un repositorio git — la arena necesita uno para dar a cada contendiente un worktree aislado.",
+ "initGitRepo": "git init",
+ "initializing": "Inicializando…",
+ "permissionLabel": "Permisos",
+ "permissionOptions": {
+ "default": "Preguntar siempre",
+ "acceptEdits": "Aceptar ediciones",
+ "bypassPermissions": "Todo automático"
+ },
+ "permissionHints": {
+ "default": "cada aprobación interrumpe al contendiente",
+ "acceptEdits": "ediciones de archivos sin preguntar",
+ "bypassPermissions": "sin aprobaciones"
+ },
+ "permissionNote": "Se aplica a cada contendiente al iniciar; los agentes sin soporte siguen preguntando.",
+ "bareModeLabel": "Modo básico (sin habilidades)",
+ "bareModeHint": "Se pide a los contendientes usar solo sus capacidades básicas, sin habilidades globales ni del proyecto.",
+ "effortLabel": "Esfuerzo de razonamiento (uniforme)",
+ "effortOptions": {
+ "default": "Predeterminado",
+ "low": "Bajo",
+ "medium": "Medio",
+ "high": "Alto",
+ "max": "Máximo"
+ },
+ "effortNote": "Se aplica el nivel más cercano anunciado por cada agente; sin soporte, mantiene el suyo.",
+ "agentNotReady": "{agent} no está listo (instálalo en Configuración de agentes).",
+ "agentCheckFailed": "No se pudo verificar {agent}.",
+ "templates": {
+ "pelican": "Pelícano",
+ "bouncingBall": "Pelota",
+ "jellyBlob": "Gelatina",
+ "snake": "Snake",
+ "flappyBird": "Flappy Bird",
+ "voiceChat": "Chat por voz",
+ "blackHole": "Agujero Negro"
+ },
+ "judgeLabel": "Juez (opcional)",
+ "judgeNone": "Sin juez",
+ "judgeHint": "Cuando todos los concursantes terminen, este agente leerá cada diff y producirá un veredicto estructurado con puntuaciones y clasificaciones.",
+ "startPoint": "📍 Punto de partida",
+ "fromHead": "desde el HEAD actual",
+ "startPointHint": "Los concursantes parten un commit antes de este — no ven sus cambios, solo su mensaje como tarea. Repiten el mismo objetivo de forma independiente.",
+ "loadingCommits": "Cargando commits…",
+ "noCommits": "No se encontraron commits.",
+ "creativeTemplates": "Plantillas creativas",
+ "realEngineering": "Ingeniería real",
+ "loadMore": "Cargar más",
+ "judgeDimensionsLabel": "Dimensiones de evaluación (opcional)",
+ "judgeDimensionsPlaceholder": "Correctness — ¿cumple la tarea?\nCode quality — legibilidad, estructura, casos límite\nCompleteness — ¿cuánto está hecho?\nEfficiency — eficiencia del código (ignorar token/tiempo)",
+ "judgeDimensionsHint": "Una dimensión por línea. Déjalo vacío para usar los valores predeterminados.",
+ "addContestant": "Añadir {agent} como concursante",
+ "slotLabelPlaceholder": "Etiqueta (p. ej. Sonnet, Opus)",
+ "slotNumber": "Contendiente {number}",
+ "modelLoading": "Cargando modelos…",
+ "modelUnavailable": "Este agente usa su modelo predeterminado",
+ "modelLoadFailed": "No se pudieron cargar los modelos",
+ "retryModelLoad": "Volver a cargar los modelos",
+ "removeSlot": "Quitar este puesto de concursante"
+ },
+ "scoreboard": {
+ "status": {
+ "preparing": "preparando",
+ "connecting": "conectando",
+ "running": "ejecutando",
+ "done": "listo",
+ "error": "falló",
+ "canceled": "cancelado",
+ "ready": "listo"
+ },
+ "tokensUnit": "tok",
+ "tokensUnavailable": "Token not reported",
+ "tokensUnavailableHint": "This agent did not return token usage; this does not mean zero tokens.",
+ "turnsUnit": "turnos"
+ },
+ "history": {
+ "title": "Round history",
+ "hint": "Search, open, and archive complete rounds.",
+ "trigger": "History {count}",
+ "searchPlaceholder": "Search tasks or agents…",
+ "empty": "No matching rounds",
+ "agents": "{count} agents",
+ "archive": "Archive round",
+ "archiveConfirm": "Archive “{task}”? The round and its agent sessions will be hidden.",
+ "archiveSuccess": "PK round archived",
+ "archiveFailed": "Archive failed: {message}",
+ "status": {
+ "ready": "Ready",
+ "running": "Running",
+ "finished": "Finished",
+ "canceled": "Canceled",
+ "interrupted": "Interrupted"
+ }
+ },
+ "arena": {
+ "title": "Arena PK de agentes",
+ "description": "Una tarea, varios agentes, cara a cara",
+ "roundStatus": {
+ "running": "En vivo",
+ "finished": "Terminada",
+ "canceled": "Cancelada",
+ "interrupted": "Interrumpida por reinicio",
+ "ready": "Lista"
+ },
+ "roundPicker": "Ronda",
+ "cancelRound": "Cancelar ronda",
+ "cleanupWorktrees": "Limpiar worktrees",
+ "cleanupHint": "Elimina los worktrees de los contendientes (las ramas se conservan)",
+ "share": "Compartir",
+ "sharing": "Exportando…",
+ "tabs": {
+ "battle": "Duelo",
+ "diff": "Diff"
+ },
+ "preparing": "Preparando contendiente…",
+ "noRound": "Ninguna ronda seleccionada",
+ "contestantsUnit": "agentes",
+ "newRound": "Nueva ronda",
+ "startMatch": "Iniciar duelo",
+ "readyNote": "Elige modelo y esfuerzo de cada contendiente y luego inicia.",
+ "modelLabel": "Modelo",
+ "effortLabel": "Esfuerzo",
+ "effortUnsupported": "Este agente no permite configurar niveles de razonamiento; se usará su valor predeterminado",
+ "effortOptions": {
+ "off": "Desactivado",
+ "minimal": "Mínimo",
+ "low": "Bajo",
+ "medium": "Medio",
+ "high": "Alto",
+ "max": "Máximo"
+ },
+ "minimize": "Minimizar",
+ "exportReport": "Exportar informe",
+ "exporting": "Generando…",
+ "shareReport": "Save / share report",
+ "reportSaved": "HTML report saved",
+ "reportFailed": "Report export failed: {message}",
+ "persistenceFailed": "No se pudieron guardar algunos cambios de la ronda. Reintenta la acción antes de cerrar esta página.",
+ "retrySave": "Reintentar guardado",
+ "readyTag": "Listo",
+ "deleteRound": "Eliminar",
+ "deleteConfirm": "¿Eliminar esta ronda? Los worktrees permanecen — use Limpiar worktrees.",
+ "followUp": "Continuar",
+ "followUpPlaceholder": "Enviar solo a este concursante (⌘↩ para enviar)"
+ },
+ "diff": {
+ "loading": "Cargando diff…",
+ "empty": "Sin cambios en este worktree"
+ },
+ "judge": {
+ "title": "Veredicto del juez",
+ "running": "Evaluando…",
+ "error": "El juez falló",
+ "rerun": "Reevaluar"
+ },
+ "minimized": {
+ "restore": "Volver a la arena",
+ "live": "PK en curso",
+ "dismiss": "Ocultar"
+ }
+ },
"Forge": {
"title": "Panel del repositorio",
"pickFolder": "Elige una carpeta de proyecto",
diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json
index 36f3499eb..fe40e7e63 100644
--- a/src/i18n/messages/fr.json
+++ b/src/i18n/messages/fr.json
@@ -1737,8 +1737,17 @@
"sectionFolders": "Dossiers",
"sectionChats": "Discussion",
"sectionRecent": "Récents",
+ "sectionPk": "Arène PK",
"noChats": "Aucune discussion",
"noRecent": "Aucune conversation récente",
+ "noPk": "Aucune session PK",
+ "pkOpenArena": "Open this PK arena",
+ "pkArchive": "Archive the PK round",
+ "pkArchiveConfirm": "Archive “{task}”? The round and its agent sessions will be hidden.",
+ "pkArchiveSuccess": "PK round archived",
+ "pkArchiveFailed": "Archive failed: {message}",
+ "pkExpand": "Expand agent sessions",
+ "pkCollapse": "Collapse agent sessions",
"showMoreRecent": "Afficher plus ({count})",
"noFolders": "Aucun dossier ouvert",
"newChatAction": "Nouvelle discussion",
@@ -1860,7 +1869,8 @@
"search": "Rechercher",
"openSettings": "Ouvrir les paramètres",
"backToConversations": "Retour aux conversations",
- "withShortcut": "{label} (raccourci : {shortcut})"
+ "withShortcut": "{label} (raccourci : {shortcut})",
+ "pkArena": "Arène PK d'agents"
},
"statusBar": {
"connection": {
@@ -2797,7 +2807,8 @@
"mentionGroupAgent": "Agents",
"mentionGroupSession": "Sessions",
"mentionGroupCommit": "Commits",
- "mentionGroupSkill": "Compétences"
+ "mentionGroupSkill": "Compétences",
+ "startPk": "PK d'agents"
},
"messageQueue": {
"addToQueue": "Mettre en file",
@@ -5113,6 +5124,179 @@
"loadFailed": "Impossible de charger la consommation",
"truncatedNotice": "Cette période est très large — les chiffres ne couvrent que sa portion la plus récente."
},
+ "PkArena": {
+ "launcher": {
+ "title": "PK d'agents",
+ "description": "Envoyez une même tâche à plusieurs agents et comparez les résultats",
+ "contestantsLabel": "Participants ({min}–{max})",
+ "noFolderHint": "L'arène nécessite un dossier avec un dépôt git — ouvrez-en d'abord un.",
+ "needMore": "Choisissez au moins {count} agents pour lancer le duel.",
+ "taskLabel": "Tâche",
+ "taskPlaceholder": "La tâche reçue par chaque participant, ex. « Écrire un jeu snake dans un seul fichier HTML »",
+ "selectedCount": "{selected}/{max} choisis (min. {min})",
+ "cancel": "Annuler",
+ "start": "Lancer le duel",
+ "notAGitRepo": "Ce dossier n'est pas un dépôt git — l'arène en nécessite un pour donner à chaque participant un worktree isolé.",
+ "initGitRepo": "git init",
+ "initializing": "Initialisation…",
+ "permissionLabel": "Autorisations",
+ "permissionOptions": {
+ "default": "Demander à chaque fois",
+ "acceptEdits": "Accepter les modifications",
+ "bypassPermissions": "Tout automatique"
+ },
+ "permissionHints": {
+ "default": "chaque validation interrompt le participant",
+ "acceptEdits": "modifications sans demander",
+ "bypassPermissions": "aucune validation"
+ },
+ "permissionNote": "Appliqué à chaque participant au départ ; les agents non compatibles continuent de demander.",
+ "bareModeLabel": "Mode nu (sans compétences)",
+ "bareModeHint": "Les participants n'utilisent que leurs capacités de base, sans compétences globales ni de projet.",
+ "effortLabel": "Effort de raisonnement (uniforme)",
+ "effortOptions": {
+ "default": "Défaut",
+ "low": "Faible",
+ "medium": "Moyen",
+ "high": "Élevé",
+ "max": "Max"
+ },
+ "effortNote": "Applique le niveau annoncé le plus proche par agent ; sans support, garde le défaut.",
+ "agentNotReady": "{agent} n'est pas prêt (installez-le dans les paramètres).",
+ "agentCheckFailed": "Impossible de vérifier {agent}.",
+ "templates": {
+ "pelican": "Pélican",
+ "bouncingBall": "Balle",
+ "jellyBlob": "Gelée",
+ "snake": "Snake",
+ "flappyBird": "Flappy Bird",
+ "voiceChat": "Chat vocal",
+ "blackHole": "Trou Noir"
+ },
+ "judgeLabel": "Juge (optionnel)",
+ "judgeNone": "Pas de juge",
+ "judgeHint": "Une fois tous les concurrents terminés, cet agent lit chaque diff et produit un verdict structuré avec scores et classements.",
+ "startPoint": "📍 Point de départ",
+ "fromHead": "depuis le HEAD actuel",
+ "startPointHint": "Les candidats partent un commit avant celui-ci — ils ne voient pas ses changements, seulement son message comme tâche. Ils refont le même objectif indépendamment.",
+ "loadingCommits": "Chargement des commits…",
+ "noCommits": "Aucun commit trouvé.",
+ "creativeTemplates": "Modèles créatifs",
+ "realEngineering": "Ingénierie réelle",
+ "loadMore": "Charger plus",
+ "judgeDimensionsLabel": "Dimensions d'évaluation (optionnel)",
+ "judgeDimensionsPlaceholder": "Correctness — la tâche est-elle remplie ?\nCode quality — lisibilité, structure, cas limites\nCompleteness — quelle proportion est faite ?\nEfficiency — efficacité du code (ignorer token/temps)",
+ "judgeDimensionsHint": "Une dimension par ligne. Laisser vide pour les valeurs par défaut.",
+ "addContestant": "Ajouter {agent} comme concurrent",
+ "slotLabelPlaceholder": "Étiquette (ex. Sonnet, Opus)",
+ "slotNumber": "Participant {number}",
+ "modelLoading": "Chargement des modèles…",
+ "modelUnavailable": "Cet agent utilise son modèle par défaut",
+ "modelLoadFailed": "Impossible de charger les modèles",
+ "retryModelLoad": "Recharger les modèles",
+ "removeSlot": "Retirer ce créneau de concurrent"
+ },
+ "scoreboard": {
+ "status": {
+ "preparing": "préparation",
+ "connecting": "connexion",
+ "running": "en cours",
+ "done": "terminé",
+ "error": "échec",
+ "canceled": "annulé",
+ "ready": "prêt"
+ },
+ "tokensUnit": "tok",
+ "tokensUnavailable": "Token not reported",
+ "tokensUnavailableHint": "This agent did not return token usage; this does not mean zero tokens.",
+ "turnsUnit": "tours"
+ },
+ "history": {
+ "title": "Round history",
+ "hint": "Search, open, and archive complete rounds.",
+ "trigger": "History {count}",
+ "searchPlaceholder": "Search tasks or agents…",
+ "empty": "No matching rounds",
+ "agents": "{count} agents",
+ "archive": "Archive round",
+ "archiveConfirm": "Archive “{task}”? The round and its agent sessions will be hidden.",
+ "archiveSuccess": "PK round archived",
+ "archiveFailed": "Archive failed: {message}",
+ "status": {
+ "ready": "Ready",
+ "running": "Running",
+ "finished": "Finished",
+ "canceled": "Canceled",
+ "interrupted": "Interrupted"
+ }
+ },
+ "arena": {
+ "title": "Arène PK d'agents",
+ "description": "Une tâche, plusieurs agents, côte à côte",
+ "roundStatus": {
+ "running": "En direct",
+ "finished": "Terminée",
+ "canceled": "Annulée",
+ "interrupted": "Interrompue par un redémarrage",
+ "ready": "Prête"
+ },
+ "roundPicker": "Manche",
+ "cancelRound": "Annuler la manche",
+ "cleanupWorktrees": "Nettoyer les worktrees",
+ "cleanupHint": "Supprime les worktrees des participants (les branches sont conservées)",
+ "share": "Partager",
+ "sharing": "Export…",
+ "tabs": {
+ "battle": "Duel",
+ "diff": "Diff"
+ },
+ "preparing": "Préparation du participant…",
+ "noRound": "Aucune manche sélectionnée",
+ "contestantsUnit": "agents",
+ "newRound": "Nouvelle manche",
+ "startMatch": "Lancer le duel",
+ "readyNote": "Choisissez modèle et effort de chaque participant, puis lancez.",
+ "modelLabel": "Modèle",
+ "effortLabel": "Effort",
+ "effortUnsupported": "Cet agent n'expose pas de niveaux de raisonnement; sa valeur par défaut sera utilisée",
+ "effortOptions": {
+ "off": "Désactivé",
+ "minimal": "Minimal",
+ "low": "Faible",
+ "medium": "Moyen",
+ "high": "Élevé",
+ "max": "Maximum"
+ },
+ "minimize": "Réduire",
+ "exportReport": "Exporter le rapport",
+ "exporting": "Génération…",
+ "shareReport": "Save / share report",
+ "reportSaved": "HTML report saved",
+ "reportFailed": "Report export failed: {message}",
+ "persistenceFailed": "Certaines modifications de la manche n’ont pas pu être enregistrées. Réessayez avant de fermer cette page.",
+ "retrySave": "Réessayer l’enregistrement",
+ "readyTag": "Prêt",
+ "deleteRound": "Supprimer",
+ "deleteConfirm": "Supprimer cette manche ? Les worktrees restent — utilisez Nettoyer les worktrees.",
+ "followUp": "Relancer",
+ "followUpPlaceholder": "Envoyer uniquement à ce candidat (⌘↩ pour envoyer)"
+ },
+ "diff": {
+ "loading": "Chargement du diff…",
+ "empty": "Aucune modification dans ce worktree"
+ },
+ "judge": {
+ "title": "Verdict du juge",
+ "running": "Évaluation…",
+ "error": "Le juge a échoué",
+ "rerun": "Réévaluer"
+ },
+ "minimized": {
+ "restore": "Retour à l'arène",
+ "live": "PK en cours",
+ "dismiss": "Masquer"
+ }
+ },
"Forge": {
"title": "Panneau du dépôt",
"pickFolder": "Choisir un dossier de projet",
diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json
index 600ec4974..f2a9d2022 100644
--- a/src/i18n/messages/ja.json
+++ b/src/i18n/messages/ja.json
@@ -1737,8 +1737,17 @@
"sectionFolders": "フォルダ",
"sectionChats": "チャット",
"sectionRecent": "最近",
+ "sectionPk": "PKアリーナ",
"noChats": "チャットがありません",
"noRecent": "最近の会話はありません",
+ "noPk": "PKセッションはありません",
+ "pkOpenArena": "Open this PK arena",
+ "pkArchive": "Archive the PK round",
+ "pkArchiveConfirm": "Archive “{task}”? The round and its agent sessions will be hidden.",
+ "pkArchiveSuccess": "PK round archived",
+ "pkArchiveFailed": "Archive failed: {message}",
+ "pkExpand": "Expand agent sessions",
+ "pkCollapse": "Collapse agent sessions",
"showMoreRecent": "さらに表示({count})",
"noFolders": "開いているフォルダがありません",
"newChatAction": "新しいチャット",
@@ -1860,7 +1869,8 @@
"search": "検索",
"openSettings": "設定を開く",
"backToConversations": "会話に戻る",
- "withShortcut": "{label}({shortcut})"
+ "withShortcut": "{label}({shortcut})",
+ "pkArena": "エージェントPKアリーナ"
},
"statusBar": {
"connection": {
@@ -2797,7 +2807,8 @@
"mentionGroupAgent": "エージェント",
"mentionGroupSession": "セッション",
"mentionGroupCommit": "コミット",
- "mentionGroupSkill": "スキル"
+ "mentionGroupSkill": "スキル",
+ "startPk": "エージェントPK"
},
"messageQueue": {
"addToQueue": "キューに追加",
@@ -5113,6 +5124,179 @@
"loadFailed": "使用量を読み込めませんでした",
"truncatedNotice": "期間が非常に長いため、表示中の数値は直近の一部のみを対象としています。"
},
+ "PkArena": {
+ "launcher": {
+ "title": "エージェントPK",
+ "description": "同じタスクを複数のエージェントに同時に送り、結果を比較します",
+ "contestantsLabel": "出場者({min}~{max}体)",
+ "noFolderHint": "アリーナには git リポジトリのフォルダが必要です。先に開いてください。",
+ "needMore": "対戦を開始するには最低 {count} 体選択してください。",
+ "taskLabel": "タスク",
+ "taskPlaceholder": "全出場者が受け取る同じタスク。例:「HTML ファイル1つでスネークゲームを作って」",
+ "selectedCount": "{selected}/{max} 選択中(最小 {min})",
+ "cancel": "キャンセル",
+ "start": "対戦開始",
+ "notAGitRepo": "このフォルダは git リポジトリではありません。アリーナは各出場者に隔離されたワークツリーを与えるためにリポジトリが必要です。",
+ "initGitRepo": "git init",
+ "initializing": "初期化中…",
+ "permissionLabel": "権限",
+ "permissionOptions": {
+ "default": "毎回確認",
+ "acceptEdits": "編集を自動許可",
+ "bypassPermissions": "完全自動"
+ },
+ "permissionHints": {
+ "default": "承認のたびに出場者が停止",
+ "acceptEdits": "ファイル編集は確認なし",
+ "bypassPermissions": "承認ダイアログなし"
+ },
+ "permissionNote": "ラウンド開始時に各出場者へ適用。未対応のエージェントは引き続き確認します。",
+ "bareModeLabel": "ベアモード(スキル無効)",
+ "bareModeHint": "出場者は基本機能のみ使用。グローバル・プロジェクトのスキルは使用禁止。",
+ "effortLabel": "思考レベル(全出場者統一)",
+ "effortOptions": {
+ "default": "デフォルト",
+ "low": "低",
+ "medium": "中",
+ "high": "高",
+ "max": "最大"
+ },
+ "effortNote": "各エージェントが通告する最寄りのレベルを適用。未対応はデフォルトのまま。",
+ "agentNotReady": "{agent} が未準備です(先に設定でインストール)。",
+ "agentCheckFailed": "{agent} を確認できませんでした。",
+ "templates": {
+ "pelican": "ペリカン",
+ "bouncingBall": "ボール",
+ "jellyBlob": "ゼリー",
+ "snake": "スネーク",
+ "flappyBird": "Flappy Bird",
+ "voiceChat": "音声チャット",
+ "blackHole": "ブラックホール"
+ },
+ "judgeLabel": "審査員(任意)",
+ "judgeNone": "審査員なし",
+ "judgeHint": "全選手の完了後、審査員エージェントが各選手の diff を読み、構造化された評価とランキングを生成します。",
+ "startPoint": "📍 開始点",
+ "fromHead": "現在の HEAD から",
+ "startPointHint": "選手はこのコミットの前から開始し、このコミットの変更を見ず、メッセージをタスクとして独立して再現します。",
+ "loadingCommits": "コミットを読み込み中…",
+ "noCommits": "コミットが見つかりません。",
+ "creativeTemplates": "クリエイティブテンプレート",
+ "realEngineering": "リアルエンジニアリング",
+ "loadMore": "もっと読み込む",
+ "judgeDimensionsLabel": "評価 dimension(任意)",
+ "judgeDimensionsPlaceholder": "正確性 — タスクを満たすか?\nコード品質 — 可読性・構造・エッジケース\n完成度 — どの程度完了したか?\n効率 — コード効率(token/時間は除外)",
+ "judgeDimensionsHint": "1 行 1 dimension。空欄でデフォルト使用。",
+ "addContestant": "{agent} を選手として追加",
+ "slotLabelPlaceholder": "ラベル(例: Sonnet、Opus)",
+ "slotNumber": "出場者 {number}",
+ "modelLoading": "モデルを読み込み中…",
+ "modelUnavailable": "このエージェントはデフォルトモデルを使用します",
+ "modelLoadFailed": "モデルを読み込めませんでした",
+ "retryModelLoad": "モデルを再読み込み",
+ "removeSlot": "この選手スロットを削除"
+ },
+ "scoreboard": {
+ "status": {
+ "preparing": "準備中",
+ "connecting": "接続中",
+ "running": "実行中",
+ "done": "完了",
+ "error": "失敗",
+ "canceled": "キャンセル済み",
+ "ready": "準備完了"
+ },
+ "tokensUnit": "tok",
+ "tokensUnavailable": "Token not reported",
+ "tokensUnavailableHint": "This agent did not return token usage; this does not mean zero tokens.",
+ "turnsUnit": "ターン"
+ },
+ "history": {
+ "title": "Round history",
+ "hint": "Search, open, and archive complete rounds.",
+ "trigger": "History {count}",
+ "searchPlaceholder": "Search tasks or agents…",
+ "empty": "No matching rounds",
+ "agents": "{count} agents",
+ "archive": "Archive round",
+ "archiveConfirm": "Archive “{task}”? The round and its agent sessions will be hidden.",
+ "archiveSuccess": "PK round archived",
+ "archiveFailed": "Archive failed: {message}",
+ "status": {
+ "ready": "Ready",
+ "running": "Running",
+ "finished": "Finished",
+ "canceled": "Canceled",
+ "interrupted": "Interrupted"
+ }
+ },
+ "arena": {
+ "title": "エージェントPKアリーナ",
+ "description": "1つのタスク、複数のエージェント、横並び対決",
+ "roundStatus": {
+ "running": "進行中",
+ "finished": "終了",
+ "canceled": "キャンセル済み",
+ "interrupted": "再起動により中断",
+ "ready": "準備完了"
+ },
+ "roundPicker": "ラウンド",
+ "cancelRound": "ラウンドをキャンセル",
+ "cleanupWorktrees": "ワークツリーを削除",
+ "cleanupHint": "出場者のワークツリーを削除します(ブランチは保持)",
+ "share": "共有",
+ "sharing": "書き出し中…",
+ "tabs": {
+ "battle": "対戦",
+ "diff": "Diff"
+ },
+ "preparing": "出場者を準備中…",
+ "noRound": "ラウンド未選択",
+ "contestantsUnit": "体",
+ "newRound": "新しいラウンド",
+ "startMatch": "対戦開始",
+ "readyNote": "各出場者のモデルと思考レベルを選んでから対戦開始。",
+ "modelLabel": "モデル",
+ "effortLabel": "思考レベル",
+ "effortUnsupported": "このエージェントは思考レベル設定に対応していないため、既定値を使用します",
+ "effortOptions": {
+ "off": "オフ",
+ "minimal": "最小",
+ "low": "低",
+ "medium": "中",
+ "high": "高",
+ "max": "最大"
+ },
+ "minimize": "最小化",
+ "exportReport": "レポートをエクスポート",
+ "exporting": "生成中…",
+ "shareReport": "Save / share report",
+ "reportSaved": "HTML report saved",
+ "reportFailed": "Report export failed: {message}",
+ "persistenceFailed": "一部のラウンド状態を保存できませんでした。ページを閉じる前に操作を再試行してください。",
+ "retrySave": "保存を再試行",
+ "readyTag": "準備完了",
+ "deleteRound": "削除",
+ "deleteConfirm": "このラウンドを削除?ワークツリーは残ります(「ワークツリーを削除」で除去)",
+ "followUp": "追加",
+ "followUpPlaceholder": "この選手にのみ追加メッセージを送信(⌘↩ で送信)"
+ },
+ "diff": {
+ "loading": "diff を読み込み中…",
+ "empty": "このワークツリーに変更はありません"
+ },
+ "judge": {
+ "title": "審査員の評決",
+ "running": "評価中…",
+ "error": "審査員が失敗しました",
+ "rerun": "再評価"
+ },
+ "minimized": {
+ "restore": "アリーナに戻る",
+ "live": "PK 実行中",
+ "dismiss": "非表示"
+ }
+ },
"Forge": {
"title": "リポジトリパネル",
"pickFolder": "プロジェクトフォルダを選択",
diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json
index 96913076c..4c35b6888 100644
--- a/src/i18n/messages/ko.json
+++ b/src/i18n/messages/ko.json
@@ -1737,8 +1737,17 @@
"sectionFolders": "폴더",
"sectionChats": "채팅",
"sectionRecent": "최근",
+ "sectionPk": "PK 아레나",
"noChats": "채팅 없음",
"noRecent": "최근 대화 없음",
+ "noPk": "PK 세션 없음",
+ "pkOpenArena": "Open this PK arena",
+ "pkArchive": "Archive the PK round",
+ "pkArchiveConfirm": "Archive “{task}”? The round and its agent sessions will be hidden.",
+ "pkArchiveSuccess": "PK round archived",
+ "pkArchiveFailed": "Archive failed: {message}",
+ "pkExpand": "Expand agent sessions",
+ "pkCollapse": "Collapse agent sessions",
"showMoreRecent": "더 보기 ({count})",
"noFolders": "열린 폴더 없음",
"newChatAction": "새 채팅",
@@ -1860,7 +1869,8 @@
"search": "검색",
"openSettings": "설정 열기",
"backToConversations": "대화로 돌아가기",
- "withShortcut": "{label} ({shortcut} 단축키)"
+ "withShortcut": "{label} ({shortcut} 단축키)",
+ "pkArena": "에이전트 PK 아레나"
},
"statusBar": {
"connection": {
@@ -2797,7 +2807,8 @@
"mentionGroupAgent": "에이전트",
"mentionGroupSession": "세션",
"mentionGroupCommit": "커밋",
- "mentionGroupSkill": "스킬"
+ "mentionGroupSkill": "스킬",
+ "startPk": "에이전트 PK"
},
"messageQueue": {
"addToQueue": "대기열에 추가",
@@ -5113,6 +5124,179 @@
"loadFailed": "사용량을 불러오지 못했습니다",
"truncatedNotice": "기간이 매우 길어 표시된 수치는 가장 최근 구간만 반영합니다."
},
+ "PkArena": {
+ "launcher": {
+ "title": "에이전트 PK",
+ "description": "하나의 작업을 여러 에이전트에 동시에 보내고 결과를 비교합니다",
+ "contestantsLabel": "참가자({min}–{max}개)",
+ "noFolderHint": "아레나에는 git 저장소 폴더가 필요합니다. 먼저 열어 주세요.",
+ "needMore": "대결을 시작하려면 최소 {count}개를 선택하세요.",
+ "taskLabel": "작업",
+ "taskPlaceholder": "모든 참가자가 받을 동일한 작업. 예: \"HTML 파일 하나로 스네이크 게임 만들기\"",
+ "selectedCount": "{selected}/{max} 선택됨(최소 {min})",
+ "cancel": "취소",
+ "start": "대결 시작",
+ "notAGitRepo": "이 폴더는 git 저장소가 아닙니다. 아레나는 각 참가자에게 격리된 워크트리를 제공하기 위해 저장소가 필요합니다.",
+ "initGitRepo": "git init",
+ "initializing": "초기화 중…",
+ "permissionLabel": "권한",
+ "permissionOptions": {
+ "default": "매번 확인",
+ "acceptEdits": "편집 자동 허용",
+ "bypassPermissions": "완전 자동"
+ },
+ "permissionHints": {
+ "default": "승인마다 참가자가 중단됨",
+ "acceptEdits": "파일 편집 확인 없음",
+ "bypassPermissions": "승인 없음"
+ },
+ "permissionNote": "라운드 시작 시 각 참가자에 적용됩니다. 미지원 에이전트는 계속 확인합니다.",
+ "bareModeLabel": "베어 모드(스킬 비활성화)",
+ "bareModeHint": "참가자는 기본 기능만 사용하며 전역·프로젝트 스킬을 사용할 수 없습니다.",
+ "effortLabel": "추론 수준(모든 참가자 동일)",
+ "effortOptions": {
+ "default": "기본",
+ "low": "낮음",
+ "medium": "중간",
+ "high": "높음",
+ "max": "최대"
+ },
+ "effortNote": "각 에이전트가 공지한 가장 가까운 수준을 적용합니다. 미지원 시 기본 유지.",
+ "agentNotReady": "{agent}이 준비되지 않았습니다(설정에서 설치하세요).",
+ "agentCheckFailed": "{agent} 확인 실패.",
+ "templates": {
+ "pelican": "펠리컨",
+ "bouncingBall": "공",
+ "jellyBlob": "젤리",
+ "snake": "스네이크",
+ "flappyBird": "Flappy Bird",
+ "voiceChat": "음성 채팅",
+ "blackHole": "블랙홀"
+ },
+ "judgeLabel": "심판(선택)",
+ "judgeNone": "심판 없음",
+ "judgeHint": "모든 참가자가 완료되면 심판 에이전트가 각 참가자의 diff를 읽고 구조화된 평가와 순위를 생성합니다.",
+ "startPoint": "📍 시작점",
+ "fromHead": "현재 HEAD에서",
+ "startPointHint": "선수들은 이 커밋 이전부터 시작하여 이 커밋의 변경사항을 보지 못하고, 메시지만 과제로 받아 독립적으로 다시 수행합니다.",
+ "loadingCommits": "커밋 로딩 중…",
+ "noCommits": "커밋이 없습니다.",
+ "creativeTemplates": "크리에이티브 템플릿",
+ "realEngineering": "실제 엔지니어링",
+ "loadMore": "더 로드",
+ "judgeDimensionsLabel": "평가 차원 (선택)",
+ "judgeDimensionsPlaceholder": "정확성 — 작업을 충족하는가?\n코드 품질 — 가독성, 구조, 엣지 케이스\n완성도 — 얼마나 완료했는가?\n효율성 — 코드 수준 효율 (token/시간 제외)",
+ "judgeDimensionsHint": "한 줄에 하나씩. 비워두면 기본값 사용.",
+ "addContestant": "{agent}을(를) 참가자로 추가",
+ "slotLabelPlaceholder": "라벨(예: Sonnet, Opus)",
+ "slotNumber": "참가자 {number}",
+ "modelLoading": "모델 불러오는 중…",
+ "modelUnavailable": "이 에이전트는 기본 모델을 사용합니다",
+ "modelLoadFailed": "모델을 불러오지 못했습니다",
+ "retryModelLoad": "모델 다시 불러오기",
+ "removeSlot": "이 참가자 슬롯 제거"
+ },
+ "scoreboard": {
+ "status": {
+ "preparing": "준비 중",
+ "connecting": "연결 중",
+ "running": "실행 중",
+ "done": "완료",
+ "error": "실패",
+ "canceled": "취소됨",
+ "ready": "준비됨"
+ },
+ "tokensUnit": "tok",
+ "tokensUnavailable": "Token not reported",
+ "tokensUnavailableHint": "This agent did not return token usage; this does not mean zero tokens.",
+ "turnsUnit": "턴"
+ },
+ "history": {
+ "title": "Round history",
+ "hint": "Search, open, and archive complete rounds.",
+ "trigger": "History {count}",
+ "searchPlaceholder": "Search tasks or agents…",
+ "empty": "No matching rounds",
+ "agents": "{count} agents",
+ "archive": "Archive round",
+ "archiveConfirm": "Archive “{task}”? The round and its agent sessions will be hidden.",
+ "archiveSuccess": "PK round archived",
+ "archiveFailed": "Archive failed: {message}",
+ "status": {
+ "ready": "Ready",
+ "running": "Running",
+ "finished": "Finished",
+ "canceled": "Canceled",
+ "interrupted": "Interrupted"
+ }
+ },
+ "arena": {
+ "title": "에이전트 PK 아레나",
+ "description": "하나의 작업, 여러 에이전트, 나란히 대결",
+ "roundStatus": {
+ "running": "진행 중",
+ "finished": "종료",
+ "canceled": "취소됨",
+ "interrupted": "재시작으로 중단됨",
+ "ready": "준비됨"
+ },
+ "roundPicker": "라운드",
+ "cancelRound": "라운드 취소",
+ "cleanupWorktrees": "워크트리 정리",
+ "cleanupHint": "참가자의 워크트리를 제거합니다(브랜치는 유지)",
+ "share": "공유",
+ "sharing": "내보내는 중…",
+ "tabs": {
+ "battle": "대결",
+ "diff": "Diff"
+ },
+ "preparing": "참가자 준비 중…",
+ "noRound": "라운드 미선택",
+ "contestantsUnit": "개",
+ "newRound": "새 라운드",
+ "startMatch": "대결 시작",
+ "readyNote": "각 참가자의 모델과 추론 수준을 선택한 후 시작하세요.",
+ "modelLabel": "모델",
+ "effortLabel": "추론 수준",
+ "effortUnsupported": "이 에이전트는 추론 수준 설정을 지원하지 않아 기본값을 사용합니다",
+ "effortOptions": {
+ "off": "끄기",
+ "minimal": "최소",
+ "low": "낮음",
+ "medium": "중간",
+ "high": "높음",
+ "max": "최대"
+ },
+ "minimize": "최소화",
+ "exportReport": "보고서 내보내기",
+ "exporting": "생성 중…",
+ "shareReport": "Save / share report",
+ "reportSaved": "HTML report saved",
+ "reportFailed": "Report export failed: {message}",
+ "persistenceFailed": "일부 라운드 상태를 저장하지 못했습니다. 페이지를 닫기 전에 작업을 다시 시도하세요.",
+ "retrySave": "저장 다시 시도",
+ "readyTag": "준비됨",
+ "deleteRound": "삭제",
+ "deleteConfirm": "이 라운드를 삭제? 워크트리는 남아 있습니다(정리로 제거)",
+ "followUp": "추가",
+ "followUpPlaceholder": "이 선수에게만 추가 메시지 전송 (⌘↩ 전송)"
+ },
+ "diff": {
+ "loading": "diff 불러오는 중…",
+ "empty": "이 워크트리에는 변경 사항이 없습니다"
+ },
+ "judge": {
+ "title": "심판 평결",
+ "running": "평가 중…",
+ "error": "심판 실패",
+ "rerun": "재평가"
+ },
+ "minimized": {
+ "restore": "아레나로 돌아가기",
+ "live": "PK 진행 중",
+ "dismiss": "숨기기"
+ }
+ },
"Forge": {
"title": "리포지토리 패널",
"pickFolder": "프로젝트 폴더 선택",
diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json
index de8a8fd08..d80d4b30a 100644
--- a/src/i18n/messages/pt.json
+++ b/src/i18n/messages/pt.json
@@ -1737,8 +1737,17 @@
"sectionFolders": "Pastas",
"sectionChats": "Chat",
"sectionRecent": "Recentes",
+ "sectionPk": "Arena PK",
"noChats": "Sem chats",
"noRecent": "Sem conversas recentes",
+ "noPk": "Sem sessões PK",
+ "pkOpenArena": "Open this PK arena",
+ "pkArchive": "Archive the PK round",
+ "pkArchiveConfirm": "Archive “{task}”? The round and its agent sessions will be hidden.",
+ "pkArchiveSuccess": "PK round archived",
+ "pkArchiveFailed": "Archive failed: {message}",
+ "pkExpand": "Expand agent sessions",
+ "pkCollapse": "Collapse agent sessions",
"showMoreRecent": "Mostrar mais ({count})",
"noFolders": "Nenhuma pasta aberta",
"newChatAction": "Novo chat",
@@ -1860,7 +1869,8 @@
"search": "Buscar",
"openSettings": "Abrir configurações",
"backToConversations": "Voltar às conversas",
- "withShortcut": "{label} (atalho: {shortcut})"
+ "withShortcut": "{label} (atalho: {shortcut})",
+ "pkArena": "Arena PK de agentes"
},
"statusBar": {
"connection": {
@@ -2797,7 +2807,8 @@
"mentionGroupAgent": "Agentes",
"mentionGroupSession": "Sessões",
"mentionGroupCommit": "Commits",
- "mentionGroupSkill": "Habilidades"
+ "mentionGroupSkill": "Habilidades",
+ "startPk": "PK de agentes"
},
"messageQueue": {
"addToQueue": "Adicionar à fila",
@@ -5113,6 +5124,179 @@
"loadFailed": "Não foi possível carregar o uso",
"truncatedNotice": "Este período é muito amplo — os números cobrem apenas o trecho mais recente."
},
+ "PkArena": {
+ "launcher": {
+ "title": "PK de agentes",
+ "description": "Envie uma tarefa para vários agentes de uma vez e compare os resultados",
+ "contestantsLabel": "Competidores ({min}–{max})",
+ "noFolderHint": "A arena precisa de uma pasta com um repositório git — abra uma primeiro.",
+ "needMore": "Escolha pelo menos {count} agentes para iniciar o duelo.",
+ "taskLabel": "Tarefa",
+ "taskPlaceholder": "A tarefa que cada competidor recebe, ex.: \"Escreva um jogo da cobrinha em um único arquivo HTML\"",
+ "selectedCount": "{selected}/{max} escolhidos (mín. {min})",
+ "cancel": "Cancelar",
+ "start": "Iniciar duelo",
+ "notAGitRepo": "Esta pasta não é um repositório git — a arena precisa de um para dar a cada competidor um worktree isolado.",
+ "initGitRepo": "git init",
+ "initializing": "Inicializando…",
+ "permissionLabel": "Permissões",
+ "permissionOptions": {
+ "default": "Perguntar sempre",
+ "acceptEdits": "Aceitar edições",
+ "bypassPermissions": "Tudo automático"
+ },
+ "permissionHints": {
+ "default": "cada aprovação interrompe o competidor",
+ "acceptEdits": "edições sem perguntar",
+ "bypassPermissions": "sem aprovações"
+ },
+ "permissionNote": "Aplicado a cada competidor no início; agentes sem suporte continuam perguntando.",
+ "bareModeLabel": "Modo básico (sem habilidades)",
+ "bareModeHint": "Os competidores usam apenas capacidades básicas, sem habilidades globais ou do projeto.",
+ "effortLabel": "Esforço de raciocínio (uniforme)",
+ "effortOptions": {
+ "default": "Padrão",
+ "low": "Baixo",
+ "medium": "Médio",
+ "high": "Alto",
+ "max": "Máximo"
+ },
+ "effortNote": "Aplica o nível anunciado mais próximo por agente; sem suporte, mantém o padrão.",
+ "agentNotReady": "{agent} não está pronto (instale nas configurações de agentes).",
+ "agentCheckFailed": "Não foi possível verificar {agent}.",
+ "templates": {
+ "pelican": "Pelicano",
+ "bouncingBall": "Bola",
+ "jellyBlob": "Geleia",
+ "snake": "Snake",
+ "flappyBird": "Flappy Bird",
+ "voiceChat": "Chat por voz",
+ "blackHole": "Buraco Negro"
+ },
+ "judgeLabel": "Juiz (opcional)",
+ "judgeNone": "Sem juiz",
+ "judgeHint": "Após todos os concorrentes terminarem, este agente lê cada diff e produz um veredito estruturado com pontuações e classificações.",
+ "startPoint": "📍 Ponto de partida",
+ "fromHead": "do HEAD atual",
+ "startPointHint": "Os competidores começam um commit antes deste — sem ver suas mudanças, apenas sua mensagem como tarefa. Refazem o mesmo objetivo de forma independente.",
+ "loadingCommits": "Carregando commits…",
+ "noCommits": "Nenhum commit encontrado.",
+ "creativeTemplates": "Modelos criativos",
+ "realEngineering": "Engenharia real",
+ "loadMore": "Carregar mais",
+ "judgeDimensionsLabel": "Dimensões de avaliação (opcional)",
+ "judgeDimensionsPlaceholder": "Correctness — cumpre a tarefa?\nCode quality — legibilidade, estrutura, casos-limite\nCompleteness — quanto está feito?\nEfficiency — eficiência do código (ignorar token/tempo)",
+ "judgeDimensionsHint": "Uma dimensão por linha. Deixe vazio para usar os padrões.",
+ "addContestant": "Adicionar {agent} como concorrente",
+ "slotLabelPlaceholder": "Rótulo (ex. Sonnet, Opus)",
+ "slotNumber": "Competidor {number}",
+ "modelLoading": "Carregando modelos…",
+ "modelUnavailable": "Este agente usa o modelo padrão",
+ "modelLoadFailed": "Não foi possível carregar os modelos",
+ "retryModelLoad": "Recarregar modelos",
+ "removeSlot": "Remover este slot de concorrente"
+ },
+ "scoreboard": {
+ "status": {
+ "preparing": "preparando",
+ "connecting": "conectando",
+ "running": "executando",
+ "done": "concluído",
+ "error": "falhou",
+ "canceled": "cancelado",
+ "ready": "pronto"
+ },
+ "tokensUnit": "tok",
+ "tokensUnavailable": "Token not reported",
+ "tokensUnavailableHint": "This agent did not return token usage; this does not mean zero tokens.",
+ "turnsUnit": "turnos"
+ },
+ "history": {
+ "title": "Round history",
+ "hint": "Search, open, and archive complete rounds.",
+ "trigger": "History {count}",
+ "searchPlaceholder": "Search tasks or agents…",
+ "empty": "No matching rounds",
+ "agents": "{count} agents",
+ "archive": "Archive round",
+ "archiveConfirm": "Archive “{task}”? The round and its agent sessions will be hidden.",
+ "archiveSuccess": "PK round archived",
+ "archiveFailed": "Archive failed: {message}",
+ "status": {
+ "ready": "Ready",
+ "running": "Running",
+ "finished": "Finished",
+ "canceled": "Canceled",
+ "interrupted": "Interrupted"
+ }
+ },
+ "arena": {
+ "title": "Arena PK de agentes",
+ "description": "Uma tarefa, vários agentes, lado a lado",
+ "roundStatus": {
+ "running": "Ao vivo",
+ "finished": "Encerrada",
+ "canceled": "Cancelada",
+ "interrupted": "Interrompida por reinício",
+ "ready": "Pronta"
+ },
+ "roundPicker": "Rodada",
+ "cancelRound": "Cancelar rodada",
+ "cleanupWorktrees": "Limpar worktrees",
+ "cleanupHint": "Remove os worktrees dos competidores (as branches são mantidas)",
+ "share": "Compartilhar",
+ "sharing": "Exportando…",
+ "tabs": {
+ "battle": "Duelo",
+ "diff": "Diff"
+ },
+ "preparing": "Preparando competidor…",
+ "noRound": "Nenhuma rodada selecionada",
+ "contestantsUnit": "agentes",
+ "newRound": "Nova rodada",
+ "startMatch": "Iniciar duelo",
+ "readyNote": "Escolha modelo e esforço de cada competidor e inicie.",
+ "modelLabel": "Modelo",
+ "effortLabel": "Esforço",
+ "effortUnsupported": "Este agente não oferece níveis de raciocínio; o padrão dele será usado",
+ "effortOptions": {
+ "off": "Desativado",
+ "minimal": "Mínimo",
+ "low": "Baixo",
+ "medium": "Médio",
+ "high": "Alto",
+ "max": "Máximo"
+ },
+ "minimize": "Minimizar",
+ "exportReport": "Exportar relatório",
+ "exporting": "Gerando…",
+ "shareReport": "Save / share report",
+ "reportSaved": "HTML report saved",
+ "reportFailed": "Report export failed: {message}",
+ "persistenceFailed": "Algumas alterações da rodada não puderam ser salvas. Tente novamente antes de fechar esta página.",
+ "retrySave": "Tentar salvar novamente",
+ "readyTag": "Pronto",
+ "deleteRound": "Excluir",
+ "deleteConfirm": "Excluir esta rodada? Os worktrees permanecem — use Limpar worktrees.",
+ "followUp": "Continuar",
+ "followUpPlaceholder": "Enviar apenas a este concorrente (⌘↩ para enviar)"
+ },
+ "diff": {
+ "loading": "Carregando diff…",
+ "empty": "Sem alterações neste worktree"
+ },
+ "judge": {
+ "title": "Veredito do juiz",
+ "running": "Avaliando…",
+ "error": "O juiz falhou",
+ "rerun": "Reavaliar"
+ },
+ "minimized": {
+ "restore": "Voltar à arena",
+ "live": "PK em andamento",
+ "dismiss": "Ocultar"
+ }
+ },
"Forge": {
"title": "Painel do repositório",
"pickFolder": "Escolha uma pasta de projeto",
diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json
index 645644d45..db0cc1ccb 100644
--- a/src/i18n/messages/zh-CN.json
+++ b/src/i18n/messages/zh-CN.json
@@ -1737,8 +1737,17 @@
"sectionFolders": "文件夹",
"sectionChats": "聊天",
"sectionRecent": "最近",
+ "sectionPk": "PK 竞技场",
"noChats": "没有聊天",
"noRecent": "暂无最近会话",
+ "noPk": "暂无 PK 会话",
+ "pkOpenArena": "打开这场 PK 竞技场",
+ "pkArchive": "归档整场 PK",
+ "pkArchiveConfirm": "归档“{task}”? 该局和所属的智能体会话会从列表隐藏。",
+ "pkArchiveSuccess": "PK 对局已归档",
+ "pkArchiveFailed": "归档失败:{message}",
+ "pkExpand": "展开智能体会话",
+ "pkCollapse": "收起智能体会话",
"showMoreRecent": "显示更多({count})",
"noFolders": "没有打开的文件夹",
"newChatAction": "新建聊天",
@@ -1860,7 +1869,8 @@
"search": "搜索",
"openSettings": "打开设置",
"backToConversations": "返回会话",
- "withShortcut": "{label}({shortcut})"
+ "withShortcut": "{label}({shortcut})",
+ "pkArena": "智能体 PK 竞技场"
},
"statusBar": {
"connection": {
@@ -2797,7 +2807,8 @@
"mentionGroupAgent": "智能体",
"mentionGroupSession": "会话",
"mentionGroupCommit": "提交",
- "mentionGroupSkill": "技能"
+ "mentionGroupSkill": "技能",
+ "startPk": "智能体 PK"
},
"messageQueue": {
"addToQueue": "加入队列",
@@ -5113,6 +5124,179 @@
"loadFailed": "用量加载失败",
"truncatedNotice": "该范围过大 —— 下面的数字只覆盖了其中最近的一段。"
},
+ "PkArena": {
+ "launcher": {
+ "title": "智能体 PK",
+ "description": "同一任务同时发给多个智能体,对比结果",
+ "contestantsLabel": "参赛选手({min}–{max} 个)",
+ "noFolderHint": "竞技场需要一个 git 仓库文件夹——请先打开一个。",
+ "needMore": "至少选择 {count} 个智能体才能开赛。",
+ "taskLabel": "任务",
+ "taskPlaceholder": "所有选手收到的同一任务,例如\"用单个 HTML 文件写一个贪吃蛇游戏\"",
+ "selectedCount": "已选 {selected}/{max}(最少 {min})",
+ "addContestant": "添加 {agent} 为选手",
+ "slotLabelPlaceholder": "标签(如 Sonnet、Opus)",
+ "slotNumber": "选手 {number}",
+ "modelLoading": "正在读取模型…",
+ "modelUnavailable": "该智能体使用默认模型",
+ "modelLoadFailed": "模型列表读取失败",
+ "retryModelLoad": "重新读取模型列表",
+ "removeSlot": "移除该选手槽位",
+ "cancel": "取消",
+ "start": "开始比赛",
+ "notAGitRepo": "该文件夹不是 git 仓库——竞技场需要仓库来为每位选手创建隔离的 worktree。",
+ "initGitRepo": "git init",
+ "initializing": "初始化中…",
+ "permissionLabel": "权限",
+ "permissionOptions": {
+ "default": "每步询问",
+ "acceptEdits": "自动接受编辑",
+ "bypassPermissions": "全自动"
+ },
+ "permissionHints": {
+ "default": "每次审批都会打断选手",
+ "acceptEdits": "文件编辑不再询问",
+ "bypassPermissions": "完全不弹审批"
+ },
+ "permissionNote": "开赛时对每位选手生效;不支持的智能体仍会询问。",
+ "bareModeLabel": "裸机模式(禁用技能)",
+ "bareModeHint": "选手被明确要求只使用基础能力,不加载任何全局或项目技能。",
+ "effortLabel": "思考等级(所有选手统一)",
+ "effortOptions": {
+ "default": "默认",
+ "low": "低",
+ "medium": "中",
+ "high": "高",
+ "max": "最高"
+ },
+ "effortNote": "按各选手通告的最近档位应用;不支持的选手保持默认。",
+ "agentNotReady": "{agent} 未就绪(请先在智能体设置里安装)。",
+ "agentCheckFailed": "无法校验 {agent} 的安装状态。",
+ "templates": {
+ "pelican": "鹈鹕骑车",
+ "bouncingBall": "弹球",
+ "jellyBlob": "果冻 Blob",
+ "snake": "贪吃蛇",
+ "flappyBird": "Flappy Bird",
+ "voiceChat": "语音聊天",
+ "blackHole": "黑洞"
+ },
+ "judgeLabel": "裁判(可选)",
+ "judgeNone": "无裁判",
+ "judgeHint": "所有选手完成后,裁判 agent 会读取每个选手的 diff,给出结构化评分和排名。",
+ "judgeDimensionsLabel": "评分维度(可选)",
+ "judgeDimensionsPlaceholder": "正确性 — 是否完成任务?\n代码质量 — 可读性、结构、边界处理\n完整度 — 完成了多少?\n效率 — 代码层面效率(忽略 token 数和耗时)",
+ "judgeDimensionsHint": "每行一个维度。留空使用默认。每个维度会成为裁判提示词中的编号评分标准。",
+ "startPoint": "📍 起点",
+ "fromHead": "从当前 HEAD 开始",
+ "startPointHint": "选手从此提交的上一次提交开始,看不到这次提交的改动,只把它的提交信息当任务——等于独立重做同一个目标。",
+ "loadingCommits": "加载提交中…",
+ "noCommits": "未找到提交。",
+ "creativeTemplates": "创意 PK",
+ "realEngineering": "真实工程 PK",
+ "loadMore": "加载更多"
+ },
+ "scoreboard": {
+ "status": {
+ "preparing": "准备中",
+ "connecting": "连接中",
+ "running": "运行中",
+ "done": "完成",
+ "error": "失败",
+ "canceled": "已取消",
+ "ready": "就绪"
+ },
+ "tokensUnit": "tok",
+ "tokensUnavailable": "Token 未提供",
+ "tokensUnavailableHint": "该智能体没有返回 Token 用量,不是 0 Token。",
+ "turnsUnit": "轮"
+ },
+ "history": {
+ "title": "历史对局",
+ "hint": "按对局管理、查找和归档,不再把历史塞进下拉框。",
+ "trigger": "历史 {count}",
+ "searchPlaceholder": "搜索任务或智能体…",
+ "empty": "没有匹配的对局",
+ "agents": "{count} 个智能体",
+ "archive": "归档整局",
+ "archiveConfirm": "归档“{task}”? 该局和所属的智能体会话会从列表隐藏。",
+ "archiveSuccess": "PK 对局已归档",
+ "archiveFailed": "归档失败:{message}",
+ "status": {
+ "ready": "就绪",
+ "running": "进行中",
+ "finished": "已结束",
+ "canceled": "已取消",
+ "interrupted": "因重启中断"
+ }
+ },
+ "arena": {
+ "title": "智能体 PK 竞技场",
+ "description": "一个任务,多个智能体,同场竞技",
+ "roundStatus": {
+ "running": "进行中",
+ "finished": "已结束",
+ "canceled": "已取消",
+ "interrupted": "因重启中断",
+ "ready": "就绪"
+ },
+ "roundPicker": "回合",
+ "cancelRound": "取消本轮",
+ "cleanupWorktrees": "清理 worktree",
+ "cleanupHint": "移除选手的 worktree(保留分支)",
+ "share": "分享",
+ "sharing": "导出中…",
+ "tabs": {
+ "battle": "对战",
+ "diff": "Diff"
+ },
+ "preparing": "正在准备选手…",
+ "noRound": "未选择回合",
+ "contestantsUnit": "个智能体",
+ "newRound": "新一局",
+ "startMatch": "开始比赛",
+ "readyNote": "按每位选手实际支持的模型与思考等级确认配置,然后开始比赛。",
+ "modelLabel": "模型",
+ "effortLabel": "思考等级",
+ "effortUnsupported": "该智能体不支持思考等级配置,将使用自身默认值",
+ "effortOptions": {
+ "off": "关闭",
+ "minimal": "最低",
+ "low": "低",
+ "medium": "中",
+ "high": "高",
+ "max": "最高"
+ },
+ "minimize": "缩小",
+ "exportReport": "导出报告",
+ "exporting": "生成中…",
+ "shareReport": "保存 / 分享战报",
+ "reportSaved": "HTML 战报已保存",
+ "reportFailed": "战报导出失败:{message}",
+ "persistenceFailed": "部分回合状态未能保存,请在关闭页面前重试对应操作。",
+ "retrySave": "重试保存",
+ "readyTag": "就绪",
+ "deleteRound": "删除",
+ "deleteConfirm": "删除这个回合?worktree 会留在磁盘上,可用「清理 worktree」移除。",
+ "followUp": "追加",
+ "followUpPlaceholder": "只对这名选手发追加消息(⌘↩ 发送)"
+ },
+ "diff": {
+ "loading": "正在加载 diff…",
+ "empty": "该 worktree 没有变更"
+ },
+ "judge": {
+ "title": "裁判裁决",
+ "running": "评审中…",
+ "error": "裁判失败",
+ "rerun": "重新评分"
+ },
+ "minimized": {
+ "restore": "回到竞技场",
+ "live": "PK 进行中",
+ "dismiss": "隐藏"
+ }
+ },
"Forge": {
"title": "仓库面板",
"pickFolder": "选择项目文件夹",
diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json
index fa93ddf2d..2af037bbc 100644
--- a/src/i18n/messages/zh-TW.json
+++ b/src/i18n/messages/zh-TW.json
@@ -1737,8 +1737,17 @@
"sectionFolders": "資料夾",
"sectionChats": "聊天",
"sectionRecent": "最近",
+ "sectionPk": "PK 競技場",
"noChats": "沒有聊天",
"noRecent": "暫無最近對話",
+ "noPk": "暫無 PK 對話",
+ "pkOpenArena": "Open this PK arena",
+ "pkArchive": "Archive the PK round",
+ "pkArchiveConfirm": "Archive “{task}”? The round and its agent sessions will be hidden.",
+ "pkArchiveSuccess": "PK round archived",
+ "pkArchiveFailed": "Archive failed: {message}",
+ "pkExpand": "Expand agent sessions",
+ "pkCollapse": "Collapse agent sessions",
"showMoreRecent": "顯示更多({count})",
"noFolders": "沒有開啟的資料夾",
"newChatAction": "新增聊天",
@@ -1860,7 +1869,8 @@
"search": "搜尋",
"openSettings": "打開設定",
"backToConversations": "返回會話",
- "withShortcut": "{label}({shortcut})"
+ "withShortcut": "{label}({shortcut})",
+ "pkArena": "智慧體 PK 競技場"
},
"statusBar": {
"connection": {
@@ -2797,7 +2807,8 @@
"mentionGroupAgent": "智能體",
"mentionGroupSession": "工作階段",
"mentionGroupCommit": "提交",
- "mentionGroupSkill": "技能"
+ "mentionGroupSkill": "技能",
+ "startPk": "智慧體 PK"
},
"messageQueue": {
"addToQueue": "加入佇列",
@@ -5113,6 +5124,179 @@
"loadFailed": "用量載入失敗",
"truncatedNotice": "此範圍過大 —— 下面的數字只涵蓋其中最近的一段。"
},
+ "PkArena": {
+ "launcher": {
+ "title": "智慧體 PK",
+ "description": "同一任務同時發給多個智慧體,對比結果",
+ "contestantsLabel": "參賽選手({min}–{max} 個)",
+ "noFolderHint": "競技場需要一個 git 儲存庫資料夾——請先開啟一個。",
+ "needMore": "至少選擇 {count} 個智慧體才能開賽。",
+ "taskLabel": "任務",
+ "taskPlaceholder": "所有選手收到的同一任務,例如\"用單一 HTML 檔寫一個貪吃蛇遊戲\"",
+ "selectedCount": "已選 {selected}/{max}(最少 {min})",
+ "cancel": "取消",
+ "start": "開始比賽",
+ "notAGitRepo": "該資料夾不是 git 儲存庫——競技場需要儲存庫來為每位選手建立隔離的 worktree。",
+ "initGitRepo": "git init",
+ "initializing": "初始化中…",
+ "permissionLabel": "權限",
+ "permissionOptions": {
+ "default": "每步詢問",
+ "acceptEdits": "自動接受編輯",
+ "bypassPermissions": "全自動"
+ },
+ "permissionHints": {
+ "default": "每次審批都會打斷選手",
+ "acceptEdits": "檔案編輯不再詢問",
+ "bypassPermissions": "完全不彈審批"
+ },
+ "permissionNote": "開賽時對每位選手生效;不支援的智慧體仍會詢問。",
+ "bareModeLabel": "裸機模式(禁用技能)",
+ "bareModeHint": "選手被明確要求只使用基礎能力,不載入任何全域或專案技能。",
+ "effortLabel": "思考等級(所有選手統一)",
+ "effortOptions": {
+ "default": "預設",
+ "low": "低",
+ "medium": "中",
+ "high": "高",
+ "max": "最高"
+ },
+ "effortNote": "按各選手通告的最近檔位套用;不支援的選手保持預設。",
+ "agentNotReady": "{agent} 未就緒(請先在智慧體設定中安裝)。",
+ "agentCheckFailed": "無法校驗 {agent} 的安裝狀態。",
+ "templates": {
+ "pelican": "鵜鶘騎車",
+ "bouncingBall": "彈球",
+ "jellyBlob": "果凍 Blob",
+ "snake": "貪吃蛇",
+ "flappyBird": "Flappy Bird",
+ "voiceChat": "語音聊天",
+ "blackHole": "黑洞"
+ },
+ "judgeLabel": "裁判(可選)",
+ "judgeNone": "無裁判",
+ "judgeHint": "所有選手完成後,裁判 agent 會讀取每個選手的 diff,給出結構化評分和排名。",
+ "startPoint": "📍 起點",
+ "fromHead": "從目前 HEAD 開始",
+ "startPointHint": "選手從此提交的前一次提交開始,看不到這次提交的改動,只把它的提交訊息當任務——等於獨立重做同一個目標。",
+ "loadingCommits": "載入提交中…",
+ "noCommits": "未找到提交。",
+ "creativeTemplates": "創意 PK",
+ "realEngineering": "真實工程 PK",
+ "loadMore": "加載更多",
+ "judgeDimensionsLabel": "評分維度(可選)",
+ "judgeDimensionsPlaceholder": "正確性 — 是否完成任務?\n程式碼品質 — 可可讀性、結構、邊界處理\n完整度 — 完成了多少?\n效率 — 程式碼層面效率(忽略 token 數和耗時)",
+ "judgeDimensionsHint": "每行一個維度。留空使用預設。每個維度會成為裁判提示詞中的編號評分標準。",
+ "addContestant": "加入 {agent} 為選手",
+ "slotLabelPlaceholder": "標籤(如 Sonnet、Opus)",
+ "slotNumber": "選手 {number}",
+ "modelLoading": "正在讀取模型…",
+ "modelUnavailable": "此智慧體使用預設模型",
+ "modelLoadFailed": "模型清單讀取失敗",
+ "retryModelLoad": "重新讀取模型清單",
+ "removeSlot": "移除該選手位置"
+ },
+ "scoreboard": {
+ "status": {
+ "preparing": "準備中",
+ "connecting": "連線中",
+ "running": "執行中",
+ "done": "完成",
+ "error": "失敗",
+ "canceled": "已取消",
+ "ready": "就緒"
+ },
+ "tokensUnit": "tok",
+ "tokensUnavailable": "Token not reported",
+ "tokensUnavailableHint": "This agent did not return token usage; this does not mean zero tokens.",
+ "turnsUnit": "輪"
+ },
+ "history": {
+ "title": "Round history",
+ "hint": "Search, open, and archive complete rounds.",
+ "trigger": "History {count}",
+ "searchPlaceholder": "Search tasks or agents…",
+ "empty": "No matching rounds",
+ "agents": "{count} agents",
+ "archive": "Archive round",
+ "archiveConfirm": "Archive “{task}”? The round and its agent sessions will be hidden.",
+ "archiveSuccess": "PK round archived",
+ "archiveFailed": "Archive failed: {message}",
+ "status": {
+ "ready": "Ready",
+ "running": "Running",
+ "finished": "Finished",
+ "canceled": "Canceled",
+ "interrupted": "Interrupted"
+ }
+ },
+ "arena": {
+ "title": "智慧體 PK 競技場",
+ "description": "一個任務,多個智慧體,同場競技",
+ "roundStatus": {
+ "running": "進行中",
+ "finished": "已結束",
+ "canceled": "已取消",
+ "interrupted": "因重啟中斷",
+ "ready": "就緒"
+ },
+ "roundPicker": "回合",
+ "cancelRound": "取消本輪",
+ "cleanupWorktrees": "清理 worktree",
+ "cleanupHint": "移除選手的 worktree(保留分支)",
+ "share": "分享",
+ "sharing": "匯出中…",
+ "tabs": {
+ "battle": "對戰",
+ "diff": "Diff"
+ },
+ "preparing": "正在準備選手…",
+ "noRound": "未選擇回合",
+ "contestantsUnit": "個智慧體",
+ "newRound": "新一局",
+ "startMatch": "開始比賽",
+ "readyNote": "選擇每位選手的模型與思考等級,然後開始比賽。",
+ "modelLabel": "模型",
+ "effortLabel": "思考等級",
+ "effortUnsupported": "此智能體不支援思考等級設定,將使用自身預設值",
+ "effortOptions": {
+ "off": "關閉",
+ "minimal": "最低",
+ "low": "低",
+ "medium": "中",
+ "high": "高",
+ "max": "最高"
+ },
+ "minimize": "縮小",
+ "exportReport": "匯出報告",
+ "exporting": "產生中…",
+ "shareReport": "Save / share report",
+ "reportSaved": "HTML report saved",
+ "reportFailed": "Report export failed: {message}",
+ "persistenceFailed": "部分回合狀態未能儲存,請在關閉頁面前重試對應操作。",
+ "retrySave": "重試儲存",
+ "readyTag": "就緒",
+ "deleteRound": "刪除",
+ "deleteConfirm": "刪除這個回合?worktree 會留在磁碟上,可用「清理 worktree」移除。",
+ "followUp": "追加",
+ "followUpPlaceholder": "只對這名選手發追加訊息(⌘↩ 傳送)"
+ },
+ "diff": {
+ "loading": "正在載入 diff…",
+ "empty": "該 worktree 沒有變更"
+ },
+ "judge": {
+ "title": "裁判裁決",
+ "running": "評審中…",
+ "error": "裁判失敗",
+ "rerun": "重新評分"
+ },
+ "minimized": {
+ "restore": "回到競技場",
+ "live": "PK 進行中",
+ "dismiss": "隱藏"
+ }
+ },
"Forge": {
"title": "儲存庫面板",
"pickFolder": "選擇專案資料夾",
diff --git a/src/lib/api-popup-windows.test.ts b/src/lib/api-popup-windows.test.ts
index 60299353f..4c537ae57 100644
--- a/src/lib/api-popup-windows.test.ts
+++ b/src/lib/api-popup-windows.test.ts
@@ -28,7 +28,11 @@ vi.mock("@/lib/transport", () => ({
notifyRemoteDesktopUnauthorized: mocks.notifyRemoteDesktopUnauthorized,
}))
-import { openCommitWindow, openSettingsWindow } from "@/lib/api"
+import {
+ openCommitWindow,
+ openPkRoundWindow,
+ openSettingsWindow,
+} from "@/lib/api"
/** Stand-in for the reserved WindowProxy: only `location.href` and `close`. */
function fakePopup(initialHref = "about:blank") {
@@ -201,4 +205,31 @@ describe("web-mode app popup windows", () => {
)
expect(open).not.toHaveBeenCalled()
})
+
+ it("opens a PK round directly in a reusable web workspace window", async () => {
+ const popup = fakePopup()
+ const open = vi.spyOn(window, "open").mockReturnValue(popup as never)
+
+ await openPkRoundWindow("42", "Build a game")
+
+ expect(open).toHaveBeenCalledWith("", "pk-round-42")
+ expect(popup.location.href).toBe("/workspace?pkRoundId=42")
+ expect(mocks.call).not.toHaveBeenCalled()
+ })
+
+ it("delegates PK windows to the desktop shell with remote scope", async () => {
+ const open = vi.spyOn(window, "open").mockReturnValue(null)
+ mocks.isDesktop.mockReturnValue(true)
+ mocks.getActiveRemoteConnectionId.mockReturnValue("8")
+ mocks.shellCall.mockResolvedValue(undefined)
+
+ await openPkRoundWindow("42", "Build a game")
+
+ expect(mocks.shellCall).toHaveBeenCalledWith("open_pk_round_window", {
+ roundId: "42",
+ title: "Build a game",
+ remoteConnectionId: "8",
+ })
+ expect(open).not.toHaveBeenCalled()
+ })
})
diff --git a/src/lib/api.ts b/src/lib/api.ts
index a26ea3452..871a9db08 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -157,6 +157,10 @@ import type {
TokenUsageReport,
TokenUsageSyncResult,
TokenUsageSyncStatus,
+ PkRoundConfig,
+ PkRoundInfo,
+ PkRoundStatus,
+ PkJudgeResultDto,
} from "./types"
export async function listConversations(params?: {
@@ -2116,12 +2120,14 @@ export async function gitNewBranch(
export async function gitWorktreeAdd(
path: string,
branchName: string,
- worktreePath: string
+ worktreePath: string,
+ base?: string | null
): Promise {
return getTransport().call("git_worktree_add", {
path,
branchName,
worktreePath,
+ base: base ?? undefined,
})
}
@@ -2317,6 +2323,25 @@ async function openAppWindow(
releaseAppWindow(name)
}
+/** Open a PK round in its own workspace window. The target route reuses the
+ * normal workspace shell; `WorkspacePage` turns the query into a local PK tab
+ * once round hydration completes. */
+export async function openPkRoundWindow(
+ roundId: string,
+ title: string
+): Promise {
+ if (isDesktop()) {
+ return getShellTransport().call("open_pk_round_window", {
+ roundId,
+ title,
+ remoteConnectionId: getActiveRemoteConnectionId(),
+ })
+ }
+ return openAppWindow(`pk-round-${roundId}`, async () => ({
+ path: `/workspace?pkRoundId=${encodeURIComponent(roundId)}`,
+ }))
+}
+
export async function openMergeWindow(
folderId: number,
operation: string,
@@ -2850,6 +2875,20 @@ export async function createConversation(
})
}
+export async function createPkConversation(
+ folderId: number,
+ agentType: AgentType,
+ pkRoundId: number,
+ title?: string
+): Promise {
+ return getTransport().call("create_pk_conversation", {
+ folderId,
+ agentType,
+ title: title ?? null,
+ pkRoundId,
+ })
+}
+
/**
* Create a folderless "chat mode" conversation. The backend lazily creates a
* dated per-conversation scratch dir and a dedicated hidden chat folder
@@ -4891,6 +4930,70 @@ export async function scanExternalConflictsWeb(
)
}
+// ─── PK Arena Rounds ────────────────────────────────────────────────────────
+
+export async function pkRoundList(
+ folderId?: number | null
+): Promise {
+ return getTransport().call("pk_round_list", { folderId: folderId ?? null })
+}
+
+export async function pkRoundGet(id: number): Promise {
+ return getTransport().call("pk_round_get", { id })
+}
+
+export async function pkRoundCreate(
+ folderId: number,
+ task: string,
+ config: PkRoundConfig
+): Promise {
+ return getTransport().call("pk_round_create", { folderId, task, config })
+}
+
+export async function pkRoundUpdateStatus(
+ id: number,
+ status: PkRoundStatus
+): Promise {
+ return getTransport().call("pk_round_update_status", { id, status })
+}
+
+export async function pkRoundDelete(id: number): Promise {
+ return getTransport().call("pk_round_delete", { id })
+}
+
+export async function pkRoundUpdateJudge(
+ id: number,
+ judgeResult: PkJudgeResultDto | null,
+ judgeStatus: string
+): Promise {
+ return getTransport().call("pk_round_update_judge", {
+ id,
+ judgeResult: judgeResult != null ? JSON.stringify(judgeResult) : null,
+ judgeStatus,
+ })
+}
+
+export async function pkRoundSaveReportSnapshot(
+ id: number,
+ snapshot: string
+): Promise {
+ return getTransport().call(
+ "pk_round_save_report_snapshot",
+ { id, snapshot },
+ { timeoutMs: 60_000 }
+ )
+}
+
+export async function pkRoundGetReportSnapshot(
+ id: number
+): Promise {
+ return getTransport().call(
+ "pk_round_get_report_snapshot",
+ { id },
+ { timeoutMs: 60_000 }
+ )
+}
+
// ── Forge workbench (Issues/PR) ────────────────────────────────────────────
/** The folder's `origin` remote parsed into forge coordinates, if any. */
diff --git a/src/lib/export-conversation.ts b/src/lib/export-conversation.ts
index ec30d40d1..25208c550 100644
--- a/src/lib/export-conversation.ts
+++ b/src/lib/export-conversation.ts
@@ -71,7 +71,7 @@ export interface ExportConversationData {
* the browser owns the download manager and we have no per-call status
* channel, so this path is always reported as `"saved"`.
*/
-async function saveTextFile(opts: {
+export async function saveTextFile(opts: {
content: string
suggestedName: string
mimeType: string
diff --git a/src/lib/pk-conversation-reconciliation.test.ts b/src/lib/pk-conversation-reconciliation.test.ts
new file mode 100644
index 000000000..665c81b47
--- /dev/null
+++ b/src/lib/pk-conversation-reconciliation.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest"
+import type { DbConversationSummary } from "@/lib/types"
+import type { PkRound } from "@/stores/pk-arena-store"
+import { getPkConversationStatusRepairs } from "./pk-conversation-reconciliation"
+
+describe("getPkConversationStatusRepairs", () => {
+ it("settles stale contestant rows after their PK round was canceled", () => {
+ const round = {
+ id: "4",
+ status: "canceled",
+ judgeStatus: "idle",
+ } as PkRound
+ const conversations = [89, 90, 91, 92, 93, 94].map(
+ (id) =>
+ ({
+ id,
+ pk_round_id: 4,
+ title: "PK · 用单个 HTML 文件写一个俄罗斯方块游戏",
+ status: "in_progress",
+ }) as DbConversationSummary
+ )
+
+ expect(getPkConversationStatusRepairs([round], conversations)).toEqual(
+ conversations.map(({ id }) => ({
+ conversationId: id,
+ status: "cancelled",
+ }))
+ )
+ })
+
+ it("does not cancel a judge that may still run after contestant cancellation", () => {
+ const round = {
+ id: "4",
+ status: "canceled",
+ judgeStatus: "running",
+ } as PkRound
+ const judge = {
+ id: 95,
+ pk_round_id: 4,
+ title: "PK Judge · task",
+ status: "in_progress",
+ } as DbConversationSummary
+
+ expect(getPkConversationStatusRepairs([round], [judge])).toEqual([])
+ })
+})
diff --git a/src/lib/pk-conversation-reconciliation.ts b/src/lib/pk-conversation-reconciliation.ts
new file mode 100644
index 000000000..1782fa520
--- /dev/null
+++ b/src/lib/pk-conversation-reconciliation.ts
@@ -0,0 +1,55 @@
+import type { PkRound } from "@/stores/pk-arena-store"
+import type { DbConversationSummary } from "@/lib/types"
+
+export interface PkConversationStatusRepair {
+ conversationId: number
+ status: "completed" | "cancelled"
+}
+
+/**
+ * Derive repairs for PK-owned conversations whose persisted lifecycle no
+ * longer agrees with their authoritative round state.
+ *
+ * Contestant conversations must be terminal once their round is canceled or
+ * interrupted. Judge conversations have their own lifecycle because a judge
+ * may legitimately continue after contestants are canceled.
+ */
+export function getPkConversationStatusRepairs(
+ rounds: readonly PkRound[],
+ conversations: readonly DbConversationSummary[]
+): PkConversationStatusRepair[] {
+ const roundsById = new Map(rounds.map((round) => [Number(round.id), round]))
+ const repairs: PkConversationStatusRepair[] = []
+
+ for (const conversation of conversations) {
+ if (conversation.pk_round_id == null) continue
+ const round = roundsById.get(conversation.pk_round_id)
+ if (!round) continue
+
+ const isJudge = conversation.title?.startsWith("PK Judge ·") ?? false
+ if (isJudge) {
+ const status =
+ round.judgeStatus === "done"
+ ? "completed"
+ : round.judgeStatus === "error"
+ ? "cancelled"
+ : null
+ if (status && conversation.status !== status) {
+ repairs.push({ conversationId: conversation.id, status })
+ }
+ continue
+ }
+
+ if (
+ conversation.status === "in_progress" &&
+ (round.status === "canceled" || round.status === "interrupted")
+ ) {
+ repairs.push({
+ conversationId: conversation.id,
+ status: "cancelled",
+ })
+ }
+ }
+
+ return repairs
+}
diff --git a/src/lib/pk-judge.ts b/src/lib/pk-judge.ts
new file mode 100644
index 000000000..a3eb9bbd3
--- /dev/null
+++ b/src/lib/pk-judge.ts
@@ -0,0 +1,55 @@
+export interface PkJudgeContestantRef {
+ slot: number
+ agentType: string
+ label?: string | null
+}
+
+export interface PkJudgeScoreRef {
+ slot?: number
+ agentType: string
+}
+
+/**
+ * Give legacy judge scores a stable contestant identity.
+ *
+ * New verdicts carry `slot`. Older verdicts only carried `agentType`, so two
+ * models from the same agent were indistinguishable. For those rows, consume
+ * matching contestant slots in their original arena order.
+ */
+export function assignJudgeScoreSlots(
+ scores: readonly T[],
+ contestants: readonly PkJudgeContestantRef[]
+): Array {
+ const usedSlots = new Set()
+
+ return scores.map((score) => {
+ const explicit =
+ score.slot == null
+ ? undefined
+ : contestants.find(
+ (contestant) =>
+ contestant.slot === score.slot &&
+ contestant.agentType === score.agentType &&
+ !usedSlots.has(contestant.slot)
+ )
+ const matched =
+ explicit ??
+ contestants.find(
+ (contestant) =>
+ contestant.agentType === score.agentType &&
+ !usedSlots.has(contestant.slot)
+ )
+
+ if (!matched) return { ...score }
+ usedSlots.add(matched.slot)
+ return { ...score, slot: matched.slot }
+ })
+}
+
+export function contestantForJudgeScore(
+ score: PkJudgeScoreRef,
+ contestants: readonly PkJudgeContestantRef[]
+): PkJudgeContestantRef | undefined {
+ if (score.slot == null) return undefined
+ return contestants.find((contestant) => contestant.slot === score.slot)
+}
diff --git a/src/lib/pk-report-artifact.ts b/src/lib/pk-report-artifact.ts
new file mode 100644
index 000000000..68eb55bbb
--- /dev/null
+++ b/src/lib/pk-report-artifact.ts
@@ -0,0 +1,53 @@
+const STORAGE_COMPATIBILITY_SCRIPT = ``
+
+/**
+ * Keep standalone contestant HTML runnable inside the report's opaque-origin
+ * iframe. Web Storage access throws in that sandbox, so install a document-
+ * local in-memory implementation before any contestant script can execute.
+ */
+export function preparePkReportArtifactHtml(html: string): string {
+ if (html.includes("data-codeg-storage-compat")) return html
+
+ const doctype = /^\s*]*>/i.exec(html)
+ if (!doctype) return `${STORAGE_COMPATIBILITY_SCRIPT}${html}`
+
+ return `${html.slice(0, doctype[0].length)}${STORAGE_COMPATIBILITY_SCRIPT}${html.slice(doctype[0].length)}`
+}
+
+export function decodePkReportArtifact(contentBase64: string): string {
+ const binary = atob(contentBase64)
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0))
+ return new TextDecoder().decode(bytes)
+}
+
+export function encodePkReportArtifact(html: string): string {
+ const bytes = new TextEncoder().encode(html)
+ const chunks: string[] = []
+ for (let offset = 0; offset < bytes.length; offset += 0x8000) {
+ chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)))
+ }
+ return btoa(chunks.join(""))
+}
diff --git a/src/lib/pk-report-data.test.ts b/src/lib/pk-report-data.test.ts
new file mode 100644
index 000000000..aac55b1c3
--- /dev/null
+++ b/src/lib/pk-report-data.test.ts
@@ -0,0 +1,176 @@
+import { describe, expect, it, vi } from "vitest"
+import { decodePkReportArtifact } from "@/lib/pk-report-artifact"
+import {
+ preparePkReportData,
+ type PkReportDataDependencies,
+ type PkReportSnapshot,
+} from "@/lib/pk-report-data"
+import type { PkRound } from "@/stores/pk-arena-store"
+import type { FileTreeNode } from "@/lib/types"
+
+const round = {
+ id: "7",
+ task: "build a game",
+ workingDir: "/repo",
+ contestants: [
+ {
+ slot: 0,
+ agentType: "qoder",
+ worktreePath: null,
+ status: "done",
+ diff: null,
+ },
+ ],
+} as PkRound
+
+function dependencies(
+ overrides: Partial = {}
+): PkReportDataDependencies {
+ return {
+ getFileTree: vi.fn(async () => {
+ throw new Error("missing")
+ }),
+ readWorkspaceFileBase64: vi.fn(async () => {
+ throw new Error("missing")
+ }),
+ listChangedPaths: vi.fn(async () => []),
+ readDiff: vi.fn(async () => null),
+ loadSnapshot: vi.fn(async () => null),
+ saveSnapshot: vi.fn(async () => undefined),
+ ...overrides,
+ }
+}
+
+describe("preparePkReportData", () => {
+ it("recovers a legacy agent-named worktree and inlines its web dependencies", async () => {
+ const files: Record = {
+ "index.html": 'Game
',
+ "assets/game.js": 'document.querySelector("h1").textContent = "Ready"',
+ }
+ const deps = dependencies({
+ getFileTree: vi.fn(async (root): Promise => {
+ if (root !== "/repo/.codeg-pk/7/qoder") throw new Error("missing")
+ return [
+ { kind: "file", name: "index.html", path: `${root}/index.html` },
+ {
+ kind: "dir",
+ name: "assets",
+ path: `${root}/assets`,
+ children: [
+ {
+ kind: "file",
+ name: "game.js",
+ path: `${root}/assets/game.js`,
+ },
+ ],
+ },
+ ]
+ }),
+ readWorkspaceFileBase64: vi.fn(async (_root, path) => btoa(files[path])),
+ listChangedPaths: vi.fn(async () => ["index.html", "assets/game.js"]),
+ readDiff: vi.fn(
+ async () =>
+ "diff --git a/index.html b/index.html\n+++ b/index.html\ndiff --git a/assets/game.js b/assets/game.js\n+++ b/assets/game.js"
+ ),
+ })
+
+ const result = await preparePkReportData(round, deps)
+ const artifact = result.artifactsBySlot["0"][0]
+
+ expect(result.source).toBe("fresh")
+ expect(artifact.path).toBe("index.html")
+ expect(decodePkReportArtifact(artifact.contentBase64 ?? "")).toContain(
+ 'document.querySelector("h1")'
+ )
+ expect(deps.saveSnapshot).toHaveBeenCalledOnce()
+ })
+
+ it("exports a historical round from its saved snapshot after worktrees are gone", async () => {
+ const snapshot: PkReportSnapshot = {
+ version: 1,
+ roundId: "7",
+ capturedAt: 1,
+ contestants: [{ slot: 0, diff: "+historical", usage: null }],
+ artifactsBySlot: {
+ "0": [{ path: "index.html", contentBase64: btoa("Saved
") }],
+ },
+ }
+ const deps = dependencies({ loadSnapshot: vi.fn(async () => snapshot) })
+
+ const result = await preparePkReportData(round, deps)
+
+ expect(result.source).toBe("snapshot")
+ expect(result.round.contestants[0].diff).toBe("+historical")
+ expect(result.artifactsBySlot["0"][0].path).toBe("index.html")
+ })
+
+ it("does not present an unchanged repository page as contestant output", async () => {
+ const root = "/repo/.codeg-pk/7/0"
+ const deps = dependencies({
+ getFileTree: vi.fn(
+ async (): Promise => [
+ { kind: "file", name: "index.html", path: `${root}/index.html` },
+ { kind: "file", name: "README.md", path: `${root}/README.md` },
+ ]
+ ),
+ readDiff: vi.fn(
+ async () =>
+ "diff --git a/README.md b/README.md\n+++ b/README.md\n+updated"
+ ),
+ listChangedPaths: vi.fn(async () => ["README.md"]),
+ })
+
+ const result = await preparePkReportData(round, deps)
+
+ expect(result.artifactsBySlot["0"]).toEqual([{ path: "README.md" }])
+ })
+
+ it("recognizes a newly created untracked HTML entry when git diff is empty", async () => {
+ const root = "/repo/.codeg-pk/7/0"
+ const deps = dependencies({
+ getFileTree: vi.fn(
+ async (): Promise => [
+ { kind: "file", name: "index.html", path: `${root}/index.html` },
+ { kind: "file", name: "README.md", path: `${root}/README.md` },
+ ]
+ ),
+ listChangedPaths: vi.fn(async () => ["index.html"]),
+ readDiff: vi.fn(async () => ""),
+ readWorkspaceFileBase64: vi.fn(async () => btoa("New game
")),
+ })
+
+ const result = await preparePkReportData(round, deps)
+
+ expect(result.artifactsBySlot["0"]).toEqual([
+ expect.objectContaining({
+ path: "index.html",
+ contentBase64: expect.any(String),
+ }),
+ ])
+ })
+
+ it("recognizes an HTML entry already committed on the contestant branch", async () => {
+ const root = "/repo/.codeg-pk/7/0"
+ const deps = dependencies({
+ getFileTree: vi.fn(
+ async (): Promise => [
+ { kind: "file", name: "index.html", path: `${root}/index.html` },
+ ]
+ ),
+ listChangedPaths: vi.fn(async () => []),
+ readDiff: vi.fn(
+ async () =>
+ "diff --git a/index.html b/index.html\n+++ b/index.html\n+committed"
+ ),
+ readWorkspaceFileBase64: vi.fn(async () =>
+ btoa("Committed game
")
+ ),
+ })
+
+ const result = await preparePkReportData(round, deps)
+
+ expect(result.artifactsBySlot["0"][0]).toEqual(
+ expect.objectContaining({ path: "index.html" })
+ )
+ })
+})
diff --git a/src/lib/pk-report-data.ts b/src/lib/pk-report-data.ts
new file mode 100644
index 000000000..d1c8d2246
--- /dev/null
+++ b/src/lib/pk-report-data.ts
@@ -0,0 +1,307 @@
+import {
+ getFileTree,
+ getGitBranch,
+ gitDiff,
+ gitDiffWithBranch,
+ gitStatus,
+ pkRoundGetReportSnapshot,
+ pkRoundSaveReportSnapshot,
+ readWorkspaceFileBase64,
+} from "@/lib/api"
+import { inlineHtmlResources } from "@/lib/html-preview-inline"
+import {
+ decodePkReportArtifact,
+ encodePkReportArtifact,
+} from "@/lib/pk-report-artifact"
+import {
+ pickRunnableHtmlPath,
+ reportableArtifactPaths,
+ type PkReportArtifact,
+} from "@/lib/pk-report"
+import type { FileTreeNode } from "@/lib/types"
+import type {
+ PkContestant,
+ PkContestantUsage,
+ PkRound,
+} from "@/stores/pk-arena-store"
+
+const MAX_HTML_BYTES = 2_000_000
+const MAX_RESOURCE_BYTES = 8 * 1024 * 1024
+const MAX_INLINE_BYTES = 4 * 1024 * 1024
+
+export interface PkReportSnapshotContestant {
+ slot: number
+ status?: PkContestant["status"]
+ statusDetail?: string | null
+ startedAt?: number | null
+ endedAt?: number | null
+ durationMs?: number | null
+ usage?: PkContestantUsage | null
+ diff?: string | null
+}
+
+export interface PkReportSnapshot {
+ version: 1
+ roundId: string
+ capturedAt: number
+ contestants: PkReportSnapshotContestant[]
+ artifactsBySlot: Record
+}
+
+export interface PkReportData {
+ round: PkRound
+ artifactsBySlot: Record
+ source: "fresh" | "snapshot" | "empty"
+}
+
+export interface PkReportDataDependencies {
+ getFileTree: (root: string, maxDepth: number) => Promise
+ readWorkspaceFileBase64: (
+ root: string,
+ path: string,
+ maxBytes: number
+ ) => Promise
+ listChangedPaths: (root: string) => Promise
+ readDiff: (root: string, round: PkRound) => Promise
+ loadSnapshot: (roundId: string) => Promise
+ saveSnapshot: (snapshot: PkReportSnapshot) => Promise
+}
+
+function normalizePath(path: string): string {
+ return path.replace(/\\/g, "/").replace(/\/$/, "")
+}
+
+function joinPath(root: string, relative: string): string {
+ return `${normalizePath(root)}/${relative.replace(/^\.\//, "")}`
+}
+
+function dirname(path: string): string {
+ const normalized = normalizePath(path)
+ const slash = normalized.lastIndexOf("/")
+ return slash <= 0 ? normalized : normalized.slice(0, slash)
+}
+
+function relativeToRoot(root: string, absolutePath: string): string {
+ const normalizedRoot = normalizePath(root)
+ const normalizedPath = normalizePath(absolutePath)
+ if (normalizedPath === normalizedRoot) return ""
+ const prefix = `${normalizedRoot}/`
+ if (!normalizedPath.startsWith(prefix)) {
+ throw new Error("report resource escaped contestant worktree")
+ }
+ return normalizedPath.slice(prefix.length)
+}
+
+function flattenTree(nodes: readonly FileTreeNode[], prefix = ""): string[] {
+ const files: string[] = []
+ for (const node of nodes) {
+ const relative = prefix ? `${prefix}/${node.name}` : node.name
+ if (node.kind === "file") files.push(relative)
+ else files.push(...flattenTree(node.children, relative))
+ }
+ return files
+}
+
+/** Files present in a git diff, including changes already committed on a PK
+ * branch. Status contributes working-tree and untracked files separately. */
+export function changedPathsFromDiff(diff: string | null): string[] {
+ if (!diff) return []
+ const paths = new Set()
+ for (const line of diff.split("\n")) {
+ if (!line.startsWith("+++ b/")) continue
+ const path = line.slice("+++ b/".length).trim()
+ if (path && path !== "/dev/null") paths.add(path)
+ }
+ return [...paths]
+}
+
+function artifactRoots(round: PkRound, contestant: PkContestant): string[] {
+ const roots = [
+ contestant.worktreePath,
+ `${round.workingDir}/.codeg-pk/${round.id}/${contestant.slot}`,
+ // Rounds created before slot identity used the agent wire name here.
+ `${round.workingDir}/.codeg-pk/${round.id}/${contestant.agentType}`,
+ ].filter((root): root is string => Boolean(root))
+ return [...new Set(roots.map(normalizePath))]
+}
+
+function mergeSnapshot(round: PkRound, snapshot: PkReportSnapshot | null) {
+ if (!snapshot || snapshot.roundId !== round.id) return round
+ const bySlot = new Map(snapshot.contestants.map((item) => [item.slot, item]))
+ return {
+ ...round,
+ contestants: round.contestants.map((contestant) => {
+ const saved = bySlot.get(contestant.slot)
+ if (!saved) return contestant
+ const patch = Object.fromEntries(
+ Object.entries(saved).filter(
+ ([key, value]) => key !== "slot" && value !== undefined
+ )
+ )
+ return { ...contestant, ...patch }
+ }),
+ } as PkRound
+}
+
+function snapshotOf(
+ round: PkRound,
+ artifactsBySlot: Record
+): PkReportSnapshot {
+ return {
+ version: 1,
+ roundId: round.id,
+ capturedAt: Date.now(),
+ contestants: round.contestants.map((contestant) => ({
+ slot: contestant.slot,
+ status: contestant.status,
+ statusDetail: contestant.statusDetail,
+ startedAt: contestant.startedAt,
+ endedAt: contestant.endedAt,
+ durationMs: contestant.durationMs,
+ usage: contestant.usage,
+ diff: contestant.diff,
+ })),
+ artifactsBySlot,
+ }
+}
+
+function parseSnapshot(raw: string | null, roundId: string) {
+ if (!raw) return null
+ try {
+ const parsed = JSON.parse(raw) as PkReportSnapshot
+ if (
+ parsed.version !== 1 ||
+ parsed.roundId !== roundId ||
+ !Array.isArray(parsed.contestants) ||
+ !parsed.artifactsBySlot
+ ) {
+ return null
+ }
+ return parsed
+ } catch {
+ return null
+ }
+}
+
+const defaultDependencies: PkReportDataDependencies = {
+ getFileTree,
+ readWorkspaceFileBase64,
+ listChangedPaths: async (root) => {
+ const entries = await gitStatus(root, true)
+ return entries.map((entry) => {
+ // Porcelain v1 renders renames as `old -> new`; only the destination can
+ // exist in the current worktree and participate in a runnable report.
+ const separator = entry.file.lastIndexOf(" -> ")
+ return separator >= 0 ? entry.file.slice(separator + 4) : entry.file
+ })
+ },
+ readDiff: async (root, round) => {
+ const base = round.baseCommit ?? (await getGitBranch(round.workingDir))
+ const diff = base
+ ? await gitDiffWithBranch(root, base)
+ : await gitDiff(root)
+ return diff
+ },
+ loadSnapshot: async (roundId) =>
+ parseSnapshot(await pkRoundGetReportSnapshot(Number(roundId)), roundId),
+ saveSnapshot: async (snapshot) =>
+ pkRoundSaveReportSnapshot(
+ Number(snapshot.roundId),
+ JSON.stringify(snapshot)
+ ),
+}
+
+async function collectContestant(
+ root: string,
+ round: PkRound,
+ contestant: PkContestant,
+ dependencies: PkReportDataDependencies
+): Promise<{ artifacts: PkReportArtifact[]; diff: string | null }> {
+ const tree = await dependencies.getFileTree(root, 8)
+ const allPaths = reportableArtifactPaths(flattenTree(tree))
+ const diff = contestant.diff ?? (await dependencies.readDiff(root, round))
+ const changed = new Set([
+ ...(await dependencies.listChangedPaths(root)),
+ ...changedPathsFromDiff(diff),
+ ])
+ const artifactPaths = allPaths.filter((path) => changed.has(path))
+ const runnablePath = pickRunnableHtmlPath(artifactPaths)
+ let runnableBase64: string | undefined
+
+ if (runnablePath) {
+ const encoded = await dependencies.readWorkspaceFileBase64(
+ root,
+ runnablePath,
+ MAX_HTML_BYTES
+ )
+ const html = decodePkReportArtifact(encoded)
+ const inlined = await inlineHtmlResources(html, {
+ fileDir: dirname(joinPath(root, runnablePath)),
+ folderPath: root,
+ maxInlineBytes: MAX_INLINE_BYTES,
+ readFileBase64: (absolutePath) =>
+ dependencies.readWorkspaceFileBase64(
+ root,
+ relativeToRoot(root, absolutePath),
+ MAX_RESOURCE_BYTES
+ ),
+ })
+ runnableBase64 = encodePkReportArtifact(inlined)
+ }
+
+ return {
+ diff,
+ artifacts: artifactPaths.map((path) => ({
+ path,
+ ...(path === runnablePath && runnableBase64
+ ? { contentBase64: runnableBase64 }
+ : {}),
+ })),
+ }
+}
+
+export async function preparePkReportData(
+ inputRound: PkRound,
+ dependencies: PkReportDataDependencies = defaultDependencies
+): Promise {
+ const saved = await dependencies.loadSnapshot(inputRound.id).catch(() => null)
+ let round = mergeSnapshot(inputRound, saved)
+ const artifactsBySlot: Record = {
+ ...(saved?.artifactsBySlot ?? {}),
+ }
+ let fresh = false
+
+ for (const contestant of round.contestants) {
+ for (const root of artifactRoots(round, contestant)) {
+ try {
+ const collected = await collectContestant(
+ root,
+ round,
+ contestant,
+ dependencies
+ )
+ artifactsBySlot[String(contestant.slot)] = collected.artifacts
+ round = {
+ ...round,
+ contestants: round.contestants.map((item) =>
+ item.slot === contestant.slot
+ ? { ...item, diff: collected.diff }
+ : item
+ ),
+ }
+ fresh = true
+ break
+ } catch {
+ // Try the slot path, then the legacy agent-named path, then the saved
+ // snapshot. One missing contestant must not block the whole report.
+ }
+ }
+ }
+
+ if (fresh) {
+ await dependencies.saveSnapshot(snapshotOf(round, artifactsBySlot))
+ return { round, artifactsBySlot, source: "fresh" }
+ }
+ if (saved) return { round, artifactsBySlot, source: "snapshot" }
+ return { round, artifactsBySlot, source: "empty" }
+}
diff --git a/src/lib/pk-report-export.ts b/src/lib/pk-report-export.ts
new file mode 100644
index 000000000..26811d99f
--- /dev/null
+++ b/src/lib/pk-report-export.ts
@@ -0,0 +1,14 @@
+import { saveTextFile, type ExportResult } from "@/lib/export-conversation"
+
+export function savePkReportHtml(
+ html: string,
+ roundId: string
+): Promise {
+ return saveTextFile({
+ content: html,
+ suggestedName: `codeg-pk-${roundId}.html`,
+ mimeType: "text/html;charset=utf-8",
+ filterName: "HTML battle report",
+ ext: "html",
+ })
+}
diff --git a/src/lib/pk-report-locales.ts b/src/lib/pk-report-locales.ts
new file mode 100644
index 000000000..34cf4c822
--- /dev/null
+++ b/src/lib/pk-report-locales.ts
@@ -0,0 +1,409 @@
+export interface PkReportLabels {
+ title: string
+ status: string
+ finished: string
+ running: string
+ ready: string
+ contestants: string
+ duration: string
+ generated: string
+ ranking: string
+ round: string
+ agent: string
+ score: string
+ time: string
+ tokens: string
+ turns: string
+ changes: string
+ files: string
+ unavailable: string
+ verdict: string
+ details: string
+ outputFiles: string
+ diff: string
+ noFiles: string
+ noDiff: string
+ showcase: string
+ showcaseHint: string
+ shareHint: string
+ preview: string
+ openRun: string
+ runHere: string
+ viewSource: string
+ sandboxHint: string
+ generatedBy: string
+}
+
+const LABELS = {
+ en: {
+ title: "Agent PK Battle Report",
+ status: "Status",
+ finished: "Finished",
+ running: "Running",
+ ready: "Ready",
+ contestants: "Contestants",
+ duration: "Duration",
+ generated: "Generated",
+ ranking: "Results",
+ round: "Round",
+ agent: "Agent",
+ score: "Score",
+ time: "Time",
+ tokens: "Output tokens",
+ turns: "Turns",
+ changes: "Code changes",
+ files: "Files",
+ unavailable: "Not reported",
+ verdict: "Judge verdict",
+ details: "Contestant output",
+ outputFiles: "Output files",
+ diff: "Code diff",
+ noFiles: "No output files",
+ noDiff: "No comparable code changes",
+ showcase: "Play the entries",
+ showcaseHint: "Choose a contestant to run the final HTML entry",
+ shareHint:
+ "Every entry is embedded in this report—share this HTML file as-is",
+ preview: "Live preview",
+ openRun: "Open and run",
+ runHere: "Run above",
+ viewSource: "View HTML source",
+ sandboxHint: "Preview runs in an isolated sandbox",
+ generatedBy: "Generated by Codeg Agent PK Arena",
+ },
+ "zh-CN": {
+ title: "智能体 PK 战报",
+ status: "状态",
+ finished: "已结束",
+ running: "进行中",
+ ready: "就绪",
+ contestants: "参赛智能体",
+ duration: "总用时",
+ generated: "生成时间",
+ ranking: "比赛结果",
+ round: "回合",
+ agent: "智能体",
+ score: "得分",
+ time: "用时",
+ tokens: "输出 Token",
+ turns: "轮次",
+ changes: "代码变更",
+ files: "文件",
+ unavailable: "未提供",
+ verdict: "裁判结论",
+ details: "选手产出",
+ outputFiles: "产出文件",
+ diff: "代码 Diff",
+ noFiles: "没有产出文件",
+ noDiff: "没有可比较的代码变更",
+ showcase: "作品试玩",
+ showcaseHint: "选择选手,直接运行其最终 HTML 作品",
+ shareHint: "报告已完整内嵌作品,直接发送这个 HTML 文件即可分享",
+ preview: "可运行预览",
+ openRun: "新窗口运行",
+ runHere: "在上方运行",
+ viewSource: "查看 HTML 源码",
+ sandboxHint: "预览在隔离环境中运行",
+ generatedBy: "由 Codeg 智能体 PK 竞技场生成",
+ },
+ "zh-TW": {
+ title: "智能體 PK 戰報",
+ status: "狀態",
+ finished: "已結束",
+ running: "進行中",
+ ready: "就緒",
+ contestants: "參賽智能體",
+ duration: "總用時",
+ generated: "產生時間",
+ ranking: "比賽結果",
+ round: "回合",
+ agent: "智能體",
+ score: "得分",
+ time: "用時",
+ tokens: "輸出 Token",
+ turns: "輪次",
+ changes: "程式碼變更",
+ files: "檔案",
+ unavailable: "未提供",
+ verdict: "裁判結論",
+ details: "選手產出",
+ outputFiles: "產出檔案",
+ diff: "程式碼 Diff",
+ noFiles: "沒有產出檔案",
+ noDiff: "沒有可比較的程式碼變更",
+ showcase: "作品試玩",
+ showcaseHint: "選擇選手,直接執行其最終 HTML 作品",
+ shareHint: "報告已完整內嵌作品,直接傳送此 HTML 檔案即可分享",
+ preview: "可執行預覽",
+ openRun: "在新視窗執行",
+ runHere: "在上方執行",
+ viewSource: "查看 HTML 原始碼",
+ sandboxHint: "預覽在隔離環境中執行",
+ generatedBy: "由 Codeg 智能體 PK 競技場產生",
+ },
+ ja: {
+ title: "エージェントPK戦報",
+ status: "状態",
+ finished: "終了",
+ running: "実行中",
+ ready: "準備完了",
+ contestants: "参加エージェント",
+ duration: "合計時間",
+ generated: "生成日時",
+ ranking: "対戦結果",
+ round: "ラウンド",
+ agent: "エージェント",
+ score: "スコア",
+ time: "時間",
+ tokens: "出力トークン",
+ turns: "ターン",
+ changes: "コード変更",
+ files: "ファイル",
+ unavailable: "未報告",
+ verdict: "審査結果",
+ details: "参加者の成果",
+ outputFiles: "成果物",
+ diff: "コード差分",
+ noFiles: "成果物はありません",
+ noDiff: "比較可能なコード変更はありません",
+ showcase: "作品を試す",
+ showcaseHint: "参加者を選んで最終HTML作品を実行できます",
+ shareHint:
+ "全作品が埋め込まれています。このHTMLファイルをそのまま共有できます",
+ preview: "ライブプレビュー",
+ openRun: "新しいウィンドウで実行",
+ runHere: "上で実行",
+ viewSource: "HTMLソースを表示",
+ sandboxHint: "プレビューは隔離環境で実行されます",
+ generatedBy: "Codeg エージェントPKアリーナで生成",
+ },
+ ko: {
+ title: "에이전트 PK 결과 보고서",
+ status: "상태",
+ finished: "종료됨",
+ running: "진행 중",
+ ready: "준비됨",
+ contestants: "참가 에이전트",
+ duration: "총 소요 시간",
+ generated: "생성 시간",
+ ranking: "경기 결과",
+ round: "라운드",
+ agent: "에이전트",
+ score: "점수",
+ time: "시간",
+ tokens: "출력 토큰",
+ turns: "턴",
+ changes: "코드 변경",
+ files: "파일",
+ unavailable: "보고되지 않음",
+ verdict: "심판 판정",
+ details: "참가자 결과물",
+ outputFiles: "결과 파일",
+ diff: "코드 Diff",
+ noFiles: "결과 파일 없음",
+ noDiff: "비교 가능한 코드 변경 없음",
+ showcase: "작품 실행",
+ showcaseHint: "참가자를 선택해 최종 HTML 작품을 실행하세요",
+ shareHint:
+ "모든 작품이 포함되어 있으므로 이 HTML 파일을 그대로 공유할 수 있습니다",
+ preview: "실행 미리보기",
+ openRun: "새 창에서 실행",
+ runHere: "위에서 실행",
+ viewSource: "HTML 소스 보기",
+ sandboxHint: "미리보기는 격리된 환경에서 실행됩니다",
+ generatedBy: "Codeg 에이전트 PK 아레나에서 생성",
+ },
+ es: {
+ title: "Informe de batalla PK de agentes",
+ status: "Estado",
+ finished: "Finalizada",
+ running: "En curso",
+ ready: "Lista",
+ contestants: "Participantes",
+ duration: "Duración",
+ generated: "Generado",
+ ranking: "Resultados",
+ round: "Ronda",
+ agent: "Agente",
+ score: "Puntuación",
+ time: "Tiempo",
+ tokens: "Tokens de salida",
+ turns: "Turnos",
+ changes: "Cambios de código",
+ files: "Archivos",
+ unavailable: "No informado",
+ verdict: "Veredicto del juez",
+ details: "Resultados de los participantes",
+ outputFiles: "Archivos generados",
+ diff: "Diff del código",
+ noFiles: "No hay archivos generados",
+ noDiff: "No hay cambios de código comparables",
+ showcase: "Probar los trabajos",
+ showcaseHint: "Elige un participante para ejecutar su HTML final",
+ shareHint:
+ "Todos los trabajos están incluidos; comparte este archivo HTML tal cual",
+ preview: "Vista previa ejecutable",
+ openRun: "Abrir y ejecutar",
+ runHere: "Ejecutar arriba",
+ viewSource: "Ver fuente HTML",
+ sandboxHint: "La vista previa se ejecuta en un entorno aislado",
+ generatedBy: "Generado por la arena PK de agentes de Codeg",
+ },
+ de: {
+ title: "Agenten-PK-Kampfbericht",
+ status: "Status",
+ finished: "Beendet",
+ running: "Läuft",
+ ready: "Bereit",
+ contestants: "Teilnehmer",
+ duration: "Dauer",
+ generated: "Erstellt",
+ ranking: "Ergebnisse",
+ round: "Runde",
+ agent: "Agent",
+ score: "Punktzahl",
+ time: "Zeit",
+ tokens: "Ausgabe-Token",
+ turns: "Runden",
+ changes: "Codeänderungen",
+ files: "Dateien",
+ unavailable: "Nicht gemeldet",
+ verdict: "Juryurteil",
+ details: "Ergebnisse der Teilnehmer",
+ outputFiles: "Ausgabedateien",
+ diff: "Code-Diff",
+ noFiles: "Keine Ausgabedateien",
+ noDiff: "Keine vergleichbaren Codeänderungen",
+ showcase: "Beiträge ausprobieren",
+ showcaseHint:
+ "Wähle einen Teilnehmer, um dessen finale HTML-Datei auszuführen",
+ shareHint:
+ "Alle Beiträge sind eingebettet – teile diese HTML-Datei unverändert",
+ preview: "Live-Vorschau",
+ openRun: "Öffnen und ausführen",
+ runHere: "Oben ausführen",
+ viewSource: "HTML-Quelltext anzeigen",
+ sandboxHint: "Die Vorschau läuft in einer isolierten Umgebung",
+ generatedBy: "Erstellt mit der Codeg Agenten-PK-Arena",
+ },
+ fr: {
+ title: "Rapport de bataille PK des agents",
+ status: "État",
+ finished: "Terminée",
+ running: "En cours",
+ ready: "Prête",
+ contestants: "Participants",
+ duration: "Durée",
+ generated: "Généré",
+ ranking: "Résultats",
+ round: "Manche",
+ agent: "Agent",
+ score: "Score",
+ time: "Temps",
+ tokens: "Tokens de sortie",
+ turns: "Tours",
+ changes: "Modifications du code",
+ files: "Fichiers",
+ unavailable: "Non communiqué",
+ verdict: "Verdict du juge",
+ details: "Résultats des participants",
+ outputFiles: "Fichiers produits",
+ diff: "Diff du code",
+ noFiles: "Aucun fichier produit",
+ noDiff: "Aucune modification de code comparable",
+ showcase: "Essayer les créations",
+ showcaseHint: "Choisissez un participant pour exécuter son HTML final",
+ shareHint:
+ "Toutes les créations sont intégrées : partagez ce fichier HTML tel quel",
+ preview: "Aperçu exécutable",
+ openRun: "Ouvrir et exécuter",
+ runHere: "Exécuter ci-dessus",
+ viewSource: "Voir la source HTML",
+ sandboxHint: "L’aperçu s’exécute dans un environnement isolé",
+ generatedBy: "Généré par l’arène PK des agents Codeg",
+ },
+ pt: {
+ title: "Relatório de batalha PK de agentes",
+ status: "Status",
+ finished: "Finalizada",
+ running: "Em andamento",
+ ready: "Pronta",
+ contestants: "Participantes",
+ duration: "Duração",
+ generated: "Gerado",
+ ranking: "Resultados",
+ round: "Rodada",
+ agent: "Agente",
+ score: "Pontuação",
+ time: "Tempo",
+ tokens: "Tokens de saída",
+ turns: "Turnos",
+ changes: "Alterações de código",
+ files: "Arquivos",
+ unavailable: "Não informado",
+ verdict: "Veredito do juiz",
+ details: "Resultados dos participantes",
+ outputFiles: "Arquivos gerados",
+ diff: "Diff do código",
+ noFiles: "Nenhum arquivo gerado",
+ noDiff: "Nenhuma alteração de código comparável",
+ showcase: "Experimentar os trabalhos",
+ showcaseHint: "Escolha um participante para executar o HTML final",
+ shareHint:
+ "Todos os trabalhos estão incorporados; compartilhe este HTML como está",
+ preview: "Prévia executável",
+ openRun: "Abrir e executar",
+ runHere: "Executar acima",
+ viewSource: "Ver código-fonte HTML",
+ sandboxHint: "A prévia é executada em um ambiente isolado",
+ generatedBy: "Gerado pela arena PK de agentes do Codeg",
+ },
+ ar: {
+ title: "تقرير منافسة الوكلاء",
+ status: "الحالة",
+ finished: "انتهت",
+ running: "قيد التشغيل",
+ ready: "جاهزة",
+ contestants: "المتسابقون",
+ duration: "المدة",
+ generated: "وقت الإنشاء",
+ ranking: "النتائج",
+ round: "الجولة",
+ agent: "الوكيل",
+ score: "النتيجة",
+ time: "الوقت",
+ tokens: "رموز الإخراج",
+ turns: "الجولات",
+ changes: "تغييرات الشفرة",
+ files: "الملفات",
+ unavailable: "غير مُبلّغ",
+ verdict: "حكم المُحكّم",
+ details: "مخرجات المتسابقين",
+ outputFiles: "ملفات المخرجات",
+ diff: "فروق الشفرة",
+ noFiles: "لا توجد ملفات مخرجات",
+ noDiff: "لا توجد تغييرات قابلة للمقارنة",
+ showcase: "تشغيل الأعمال",
+ showcaseHint: "اختر متسابقًا لتشغيل عمل HTML النهائي",
+ shareHint: "جميع الأعمال مضمّنة؛ شارك ملف HTML هذا كما هو",
+ preview: "معاينة قابلة للتشغيل",
+ openRun: "فتح وتشغيل",
+ runHere: "تشغيل في الأعلى",
+ viewSource: "عرض مصدر HTML",
+ sandboxHint: "تعمل المعاينة في بيئة معزولة",
+ generatedBy: "تم الإنشاء بواسطة ساحة منافسة الوكلاء في Codeg",
+ },
+} satisfies Record
+
+export function getPkReportLabels(locale: string): PkReportLabels {
+ const normalized = locale.toLowerCase()
+ if (normalized.startsWith("zh")) {
+ return normalized.includes("tw") || normalized.includes("hant")
+ ? LABELS["zh-TW"]
+ : LABELS["zh-CN"]
+ }
+ for (const language of ["ja", "ko", "es", "de", "fr", "pt", "ar"] as const) {
+ if (normalized.startsWith(language)) return LABELS[language]
+ }
+ return LABELS.en
+}
diff --git a/src/lib/pk-report.test.ts b/src/lib/pk-report.test.ts
new file mode 100644
index 000000000..2956500e9
--- /dev/null
+++ b/src/lib/pk-report.test.ts
@@ -0,0 +1,274 @@
+import { describe, expect, it } from "vitest"
+import { JSDOM } from "jsdom"
+import {
+ buildPkReportHtml,
+ pickRunnableHtmlPath,
+ reportableArtifactPaths,
+} from "@/lib/pk-report"
+import {
+ decodePkReportArtifact,
+ encodePkReportArtifact,
+ preparePkReportArtifactHtml,
+} from "@/lib/pk-report-artifact"
+import type { PkRound } from "@/stores/pk-arena-store"
+
+const round = {
+ id: "7",
+ task: "实现一个中文贪吃蛇",
+ createdAt: Date.parse("2026-08-20T08:00:00Z"),
+ status: "finished",
+ judgeResult: {
+ scores: [
+ {
+ slot: 0,
+ agentType: "qoder",
+ score: 88,
+ rank: 1,
+ comment: "结构清晰",
+ },
+ ],
+ summary: "Qoder 获胜",
+ rawText: "",
+ },
+ contestants: [
+ {
+ slot: 0,
+ agentType: "qoder",
+ label: null,
+ status: "done",
+ durationMs: 1200,
+ usage: {
+ inputTokens: 0,
+ outputTokens: 0,
+ turnCount: 3,
+ tokensReported: false,
+ },
+ diff: "+const snake = true",
+ },
+ ],
+} as PkRound
+
+describe("buildPkReportHtml", () => {
+ it("ignores the worktree .git control file when selecting a runnable HTML artifact", () => {
+ expect(pickRunnableHtmlPath([".git", "index.html"])).toBe("index.html")
+ expect(pickRunnableHtmlPath([".git\\config", "index.html"])).toBe(
+ "index.html"
+ )
+ expect(reportableArtifactPaths([".git", "index.html"])).toEqual([
+ "index.html",
+ ])
+ })
+
+ it("recognizes a conventional HTML entrypoint in a multi-file web project", () => {
+ expect(
+ pickRunnableHtmlPath([
+ ".git",
+ "README.md",
+ "index.html",
+ "assets/game.js",
+ "assets/game.css",
+ ])
+ ).toBe("index.html")
+ expect(
+ pickRunnableHtmlPath([
+ "package.json",
+ "src/main.ts",
+ "dist/index.html",
+ "dist/assets/app.js",
+ ])
+ ).toBe("dist/index.html")
+ })
+
+ it("builds a self-contained localized battle report without inventing tokens", () => {
+ const html = buildPkReportHtml(
+ round,
+ { "0": [{ path: "index.html" }] },
+ "zh-CN"
+ )
+
+ expect(html).toContain("智能体 PK 战报")
+ expect(html).toContain("Qoder 获胜")
+ expect(html).toContain("index.html")
+ expect(html).toContain("未提供")
+ expect(html).not.toContain(">0")
+ expect(html).not.toContain("https://")
+ })
+
+ it("localizes reports for every supported UI language", () => {
+ const expectations = [
+ ["zh-TW", "智能體 PK 戰報"],
+ ["ja", "エージェントPK戦報"],
+ ["ko", "에이전트 PK 결과 보고서"],
+ ["es", "Informe de batalla PK de agentes"],
+ ["de", "Agenten-PK-Kampfbericht"],
+ ["fr", "Rapport de bataille PK des agents"],
+ ["pt", "Relatório de batalha PK de agentes"],
+ ["ar", "تقرير منافسة الوكلاء"],
+ ] as const
+
+ for (const [locale, title] of expectations) {
+ expect(buildPkReportHtml(round, {}, locale)).toContain(title)
+ }
+ expect(buildPkReportHtml(round, {}, "ar")).toContain(
+ ''
+ )
+ })
+
+ it("embeds a single HTML artifact as a sandboxed runnable preview", () => {
+ const contentBase64 = btoa("Playable
")
+ const html = buildPkReportHtml(
+ round,
+ { "0": [{ path: "index.html", contentBase64 }] },
+ "en"
+ )
+
+ expect(html).toContain("data-showcase")
+ expect(html).toContain("data-artifact-trigger")
+ expect(html).toContain('sandbox="allow-scripts allow-pointer-lock"')
+ expect(html).toContain("data-open-artifact")
+ expect(html).toContain("Open and run")
+ expect(html.indexOf("data-showcase")).toBeLessThan(html.indexOf("Results"))
+ expect(html).not.toContain("+const snake = true")
+ expect(html).not.toContain("Playable
")
+
+ const dom = new JSDOM(html)
+ const embedded = dom.window.document
+ .querySelector("[data-artifact-html]")
+ ?.getAttribute("data-artifact-html")
+ expect(decodePkReportArtifact(embedded ?? "")).toContain(
+ "data-codeg-storage-compat"
+ )
+ })
+
+ it("injects compatibility after the doctype and only once", () => {
+ const artifact = ""
+ const prepared = preparePkReportArtifactHtml(artifact)
+
+ expect(prepared).toMatch(
+ /^