`: 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"
@@ -263,15 +257,8 @@ export const ReasoningContent = memo(
plugins={plugins}
remarkPlugins={remarkPlugins}
{...props}
- // 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"],
- }}
+ // Enforce the link icon + safety override after spreading props.
+ components={markdownLinkComponents}
>
{normalized}
diff --git a/src/components/chat/composer/composer-add-menu.tsx b/src/components/chat/composer/composer-add-menu.tsx
index 8c5110a75..4c51a8a5a 100644
--- a/src/components/chat/composer/composer-add-menu.tsx
+++ b/src/components/chat/composer/composer-add-menu.tsx
@@ -14,7 +14,6 @@ import {
Plus,
Search,
Sparkles,
- Swords,
Upload,
} from "lucide-react"
@@ -32,8 +31,6 @@ 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"
@@ -399,29 +396,6 @@ 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 469a8c9ac..2c0b5c84a 100644
--- a/src/components/chat/conversation-context-bar.test.tsx
+++ b/src/components/chat/conversation-context-bar.test.tsx
@@ -28,7 +28,6 @@ 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
@@ -94,14 +93,7 @@ describe("ConversationHeaderFolderPicker", () => {
folders: [repo, other],
allFolders: [repo, other],
})
- tabs = [
- {
- id: "tab-draft",
- kind: "conversation",
- folderId: 1,
- conversationId: null,
- },
- ]
+ tabs = [{ id: "tab-draft", folderId: 1, conversationId: null }]
activeTabId = "tab-draft"
const user = userEvent.setup()
@@ -119,14 +111,7 @@ describe("ConversationHeaderFolderPicker", () => {
folders: [repo, other],
allFolders: [repo, other],
})
- tabs = [
- {
- id: "tab-1",
- kind: "conversation",
- folderId: 1,
- conversationId: 42,
- },
- ]
+ tabs = [{ id: "tab-1", folderId: 1, conversationId: 42 }]
activeTabId = "tab-1"
const user = userEvent.setup()
@@ -140,13 +125,7 @@ describe("ConversationHeaderFolderPicker", () => {
it("shows the chat-mode label for a folderless chat tab", () => {
tabs = [
- {
- id: "tab-chat",
- kind: "conversation",
- folderId: 999,
- conversationId: null,
- isChat: true,
- },
+ { id: "tab-chat", 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 98a8dbf20..e9d11424b 100644
--- a/src/components/chat/conversation-context-bar.tsx
+++ b/src/components/chat/conversation-context-bar.tsx
@@ -7,7 +7,6 @@ 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,
@@ -126,8 +125,7 @@ export const ConversationHeaderFolderPicker = memo(
const ownTab = useMemo(() => {
const lookupId = tabId ?? activeTabId
- const tab = tabs.find((x) => x.id === lookupId)
- return tab && isConversationWorkspaceTab(tab) ? tab : null
+ return tabs.find((x) => x.id === lookupId) ?? null
}, [tabs, tabId, activeTabId])
const ownFolder = useMemo(
@@ -252,8 +250,7 @@ export const ConversationFolderBranchPicker = memo(
const ownTab = useMemo(() => {
const lookupId = tabId ?? activeTabId
- const tab = tabs.find((x) => x.id === lookupId)
- return tab && isConversationWorkspaceTab(tab) ? tab : null
+ return tabs.find((x) => x.id === lookupId) ?? null
}, [tabs, tabId, activeTabId])
const ownFolder = useMemo(
@@ -372,9 +369,7 @@ export function useConversationFolderBranchPickerVisible(
const activeTabId = useTabStore((s) => s.activeTabId)
const allFolders = useAppWorkspaceStore((s) => s.allFolders)
const lookupId = tabId ?? activeTabId
- const matchedTab = tabs.find((x) => x.id === lookupId)
- const ownTab =
- matchedTab && isConversationWorkspaceTab(matchedTab) ? matchedTab : null
+ const ownTab = tabs.find((x) => x.id === lookupId) ?? 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
deleted file mode 100644
index 5bf1e437f..000000000
--- a/src/components/chunk-load-recovery.test.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-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
deleted file mode 100644
index a5721ed73..000000000
--- a/src/components/chunk-load-recovery.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-"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 6bb7748bf..f55ee8700 100644
--- a/src/components/conversations/conversation-detail-panel-layout.test.ts
+++ b/src/components/conversations/conversation-detail-panel-layout.test.ts
@@ -284,12 +284,10 @@ 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 && selConversationTab && (")
+ expect(shellBody).toContain("{isSplit && selTab && (")
expect(shellBody).toContain(" {
- const tab = s.tabs.find((item) => item.id === tabId)
- return tab && isConversationWorkspaceTab(tab) ? tab : null
- })
+ const ownTab = useTabStore(
+ (s) => s.tabs.find((tab) => tab.id === tabId) ?? 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
@@ -2231,8 +2228,7 @@ export function ConversationDetailPanel() {
} = useTabActions()
const newConversation = useMemo(() => {
const activeTab = tabs.find((tab) => tab.id === activeTabId)
- if (!activeTab || !isConversationWorkspaceTab(activeTab)) return null
- if (activeTab.conversationId != null) return null
+ if (!activeTab || activeTab.conversationId != null) return null
const workingDir = activeTab.workingDir ?? folder?.path
if (!workingDir) return null
return { workingDir, folderId: activeTab.folderId }
@@ -2298,10 +2294,9 @@ export function ConversationDetailPanel() {
const dbId2 = summary?.id
const isOpenInTabs = tabs.some(
(tab) =>
- isConversationWorkspaceTab(tab) &&
- (tab.conversationId === matchedConversationId ||
- tab.runtimeConversationId === matchedConversationId ||
- (dbId2 != null && tab.conversationId === dbId2))
+ tab.conversationId === matchedConversationId ||
+ tab.runtimeConversationId === matchedConversationId ||
+ (dbId2 != null && tab.conversationId === dbId2)
)
if (isOpenInTabs) return
@@ -2320,12 +2315,13 @@ export function ConversationDetailPanel() {
)
const hasNoTabs = tabs.length === 0 && !activeTabId
- const activeConversationTab = useMemo(() => {
- const tab = tabs.find((item) => item.id === activeTabId)
- return tab && isConversationWorkspaceTab(tab) && tab.conversationId != null
- ? tab
- : null
- }, [tabs, activeTabId])
+ const activeConversationTab = useMemo(
+ () =>
+ tabs.find(
+ (tab) => tab.id === activeTabId && tab.conversationId != null
+ ) ?? null,
+ [tabs, activeTabId]
+ )
const canReloadActiveConversation = activeConversationTab != null
const handleReloadActiveConversation = useCallback(() => {
if (!activeConversationTab) return
@@ -2551,21 +2547,18 @@ 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 =
- tab.kind === "pk" ? (
-
- ) : (
-
- )
+ const view = (
+
+ )
return (
tab.id === groupSelection[groupId]) ??
groupTabs[0] ??
null
- const selConversationTab =
- selTab && isConversationWorkspaceTab(selTab) ? selTab : null
- const selTabFolder = selConversationTab
- ? allFolders.find((f) => f.id === selConversationTab.folderId)
+ const selTabFolder = selTab
+ ? allFolders.find((f) => f.id === selTab.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
@@ -2659,7 +2650,7 @@ export function ConversationDetailPanel() {
{touchesRight && }
)}
- {isSplit && selConversationTab && (
+ {isSplit && selTab && (
)}
@@ -2723,7 +2710,7 @@ export function ConversationDetailPanel() {
return (
<>
- {!isSplit && activeTab && isConversationWorkspaceTab(activeTab) && (
+ {!isSplit && activeTab && (
{
expect(shellStart).toBeGreaterThan(-1)
const shellBody = source.slice(shellStart, shellStart + 6000)
const stripIdx = shellBody.indexOf("{isSplit && (")
- const headerIdx = shellBody.indexOf("{isSplit && selConversationTab && (")
+ const headerIdx = shellBody.indexOf("{isSplit && selTab && (")
const contentIdx = shellBody.indexOf(
''
)
diff --git a/src/components/conversations/sidebar-conversation-grouping.ts b/src/components/conversations/sidebar-conversation-grouping.ts
index 78dc8650f..18882b924 100644
--- a/src/components/conversations/sidebar-conversation-grouping.ts
+++ b/src/components/conversations/sidebar-conversation-grouping.ts
@@ -262,54 +262,6 @@ 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
@@ -345,7 +297,6 @@ 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)
}
@@ -543,33 +494,13 @@ export interface RecentMoreRow {
}
/**
- * 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.
+ * 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.
*/
export interface SectionHeaderRow {
kind: "section"
@@ -607,8 +538,6 @@ export type SidebarRow =
| RecentEmptyRow
| RecentMoreRow
| SubsessionLoadingRow
- | PkRoundHeaderRow
- | PkEmptyRow
const MAX_RENDER_DEPTH = 32
@@ -626,9 +555,6 @@ 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
@@ -758,16 +684,6 @@ 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
@@ -824,10 +740,6 @@ export function buildRows(args: {
const {
pinned,
pinnedExpanded,
- pkConversations = EMPTY_PK_MAP,
- pkExpanded = true,
- pkRoundTasks = EMPTY_ROUND_TASKS,
- pkRoundCollapsed = EMPTY_EXPANDED,
orderedFolderIds,
byFolder,
folderExpanded,
@@ -900,41 +812,6 @@ 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
@@ -1039,11 +916,6 @@ 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 ad041db3c..8b4ede4ef 100644
--- a/src/components/conversations/sidebar-conversation-list.test.tsx
+++ b/src/components/conversations/sidebar-conversation-list.test.tsx
@@ -36,7 +36,6 @@ const store = vi.hoisted(() => ({
activeTabId: null as string | null,
tabSpec: [] as Array<{
id: string
- kind?: string
conversationId: number | null
agentType: string
folderId: number
@@ -736,7 +735,6 @@ 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 de0d784f2..8aa06f888 100644
--- a/src/components/conversations/sidebar-conversation-list.tsx
+++ b/src/components/conversations/sidebar-conversation-list.tsx
@@ -21,7 +21,6 @@ import {
ChevronDown,
ChevronRight,
Download,
- Archive,
ExternalLink,
FolderClosed,
FolderGit2,
@@ -42,9 +41,7 @@ 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"
@@ -114,7 +111,6 @@ import {
reuseSet,
selectChatConversationsWithReuse,
selectPinnedWithReuse,
- selectPkConversationsWithReuse,
selectRecentConversationsWithReuse,
worktreeChildrenByParent,
worktreeHeaderAlias,
@@ -801,8 +797,6 @@ export function SidebarConversationList({
const tabs = useTabStore((s) => s.tabs)
const {
openTab,
- openPkRoundTab,
- closePkRoundTab,
closeConversationTab,
closeTabsByFolder,
openNewConversationTab,
@@ -843,9 +837,7 @@ export function SidebarConversationList({
const selectedConversation = useMemo(() => {
const activeTab = tabs.find((tab) => tab.id === activeTabId)
const next =
- !activeTab ||
- !isConversationWorkspaceTab(activeTab) ||
- activeTab.conversationId == null
+ !activeTab || activeTab.conversationId == null
? null
: { id: activeTab.conversationId, agentType: activeTab.agentType }
const reused = reuseSelected(selectedConvRef.current, next)
@@ -857,7 +849,7 @@ export function SidebarConversationList({
const openTabKeys = useMemo(() => {
const next = new Set()
for (const tab of tabs) {
- if (isConversationWorkspaceTab(tab) && tab.conversationId != null) {
+ if (tab.conversationId != null) {
next.add(`${tab.agentType}:${tab.conversationId}`)
}
}
@@ -884,7 +876,6 @@ 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
@@ -1109,7 +1100,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.kind !== "pk"
+ (c) => c.pinned_at == null && c.kind !== "chat"
)
if (showCompleted) return base
return base.filter((c) => c.status !== "completed")
@@ -1129,84 +1120,6 @@ 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
@@ -2661,8 +2510,6 @@ 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 e0ef0b0a3..2556f8ac4 100644
--- a/src/components/conversations/sidebar-section-header.tsx
+++ b/src/components/conversations/sidebar-section-header.tsx
@@ -82,13 +82,11 @@ export const SidebarSectionHeader = memo(function SidebarSectionHeader({
const label =
section === "pinned"
? t("sectionPinned")
- : section === "pk"
- ? t("sectionPk")
- : section === "chats"
- ? t("sectionChats")
- : section === "recent"
- ? t("sectionRecent")
- : t("sectionFolders")
+ : 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 ec75b4afd..7d3333c46 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 { isLocalDesktop, revealItemInDir, subscribe } from "@/lib/platform"
+import { revealItemInDir, subscribe } from "@/lib/platform"
import ignore from "ignore"
import { Check, ChevronRight, Link2 } from "lucide-react"
import { useTranslations } from "next-intl"
@@ -822,13 +822,11 @@ function RenderNode({
{t("openIn")}
- {isLocalDesktop() && (
- void handleOpenInSystemExplorer()}
- >
- {systemExplorerLabel}
-
- )}
+ void handleOpenInSystemExplorer()}
+ >
+ {systemExplorerLabel}
+
void onOpenDirInTerminal(dirPath, node.name)}
>
@@ -1060,13 +1058,11 @@ function RenderNode({
{t("openIn")}
- {isLocalDesktop() && (
- void handleOpenDirInSystemExplorer()}
- >
- {systemExplorerLabel}
-
- )}
+ void handleOpenDirInSystemExplorer()}
+ >
+ {systemExplorerLabel}
+
void onOpenDirInTerminal(absolutePath, node.name)}
>
@@ -3006,15 +3002,13 @@ export function FileTreeTab() {
{t("openIn")}
- {isLocalDesktop() && (
- {
- void revealItemInDir(folder.path)
- }}
- >
- {systemExplorerLabel}
-
- )}
+ {
+ 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 763f51de0..70a540a13 100644
--- a/src/components/layout/aux-panel-session-details-tab.test.tsx
+++ b/src/components/layout/aux-panel-session-details-tab.test.tsx
@@ -44,7 +44,6 @@ const mockWorkspace = useAppWorkspaceStore as unknown as Mock
type TabSlice = {
tabs: Array<{
id: number
- kind: "conversation"
conversationId: number | null
runtimeConversationId?: number
}>
@@ -80,9 +79,7 @@ function setupScene(opts: { hasActiveConversation: boolean }) {
mockAux.mockReturnValue({ isOpen: true, activeTab: "session_details" })
const tabState: TabSlice = {
- tabs: opts.hasActiveConversation
- ? [{ id: 1, kind: "conversation", conversationId: 7 }]
- : [],
+ tabs: opts.hasActiveConversation ? [{ id: 1, 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 0c75bb372..1335f014a 100644
--- a/src/components/layout/aux-panel-session-details-tab.tsx
+++ b/src/components/layout/aux-panel-session-details-tab.tsx
@@ -11,7 +11,6 @@ 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
@@ -35,12 +34,13 @@ export function SessionDetailsTab() {
const tabs = useTabStore((s) => s.tabs)
const activeTabId = useTabStore((s) => s.activeTabId)
- const activeConversationTab = useMemo(() => {
- const tab = tabs.find((item) => item.id === activeTabId)
- return tab && isConversationWorkspaceTab(tab) && tab.conversationId != null
- ? tab
- : null
- }, [tabs, activeTabId])
+ const activeConversationTab = useMemo(
+ () =>
+ tabs.find(
+ (tab) => tab.id === activeTabId && tab.conversationId != null
+ ) ?? 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
deleted file mode 100644
index 8f7183612..000000000
--- a/src/components/pk/pk-arena-host.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-"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
deleted file mode 100644
index 7d97bb4c8..000000000
--- a/src/components/pk/pk-arena-policy.test.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-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
deleted file mode 100644
index 734cc49c3..000000000
--- a/src/components/pk/pk-arena-policy.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-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
deleted file mode 100644
index c7877664f..000000000
--- a/src/components/pk/pk-arena-view.tsx
+++ /dev/null
@@ -1,564 +0,0 @@
-"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
deleted file mode 100644
index 465656ff1..000000000
--- a/src/components/pk/pk-diff-view.test.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-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
deleted file mode 100644
index 8c7a668d6..000000000
--- a/src/components/pk/pk-diff-view.tsx
+++ /dev/null
@@ -1,120 +0,0 @@
-"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
deleted file mode 100644
index 3482e3594..000000000
--- a/src/components/pk/pk-history-picker.tsx
+++ /dev/null
@@ -1,201 +0,0 @@
-"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
deleted file mode 100644
index 01e1cff70..000000000
--- a/src/components/pk/pk-judge-panel.test.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-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
deleted file mode 100644
index f3ff90560..000000000
--- a/src/components/pk/pk-judge-panel.tsx
+++ /dev/null
@@ -1,148 +0,0 @@
-"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
deleted file mode 100644
index 7c6283ecb..000000000
--- a/src/components/pk/pk-launcher-dialog.test.tsx
+++ /dev/null
@@ -1,172 +0,0 @@
-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
deleted file mode 100644
index 98264ee50..000000000
--- a/src/components/pk/pk-launcher-dialog.tsx
+++ /dev/null
@@ -1,965 +0,0 @@
-"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
deleted file mode 100644
index 4a59cb65a..000000000
--- a/src/components/pk/pk-minimized-pill.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-"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
deleted file mode 100644
index 1448e2596..000000000
--- a/src/components/pk/pk-scoreboard.tsx
+++ /dev/null
@@ -1,160 +0,0 @@
-"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 0a9264970..fbdae3482 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 { WorkspaceTabItem as TabItemData } from "@/contexts/tab-context"
+import type { TabItem as TabItemData } from "@/contexts/tab-context"
import { groupOfTab } from "@/stores/tab-store"
import {
firstLeafId,
@@ -210,18 +210,14 @@ export function TabBar({ groupId }: TabBarProps) {
const selFolder = selTab
? allFolders.find((f) => f.id === selTab.folderId)
: undefined
- if (
- (selTab?.kind === "conversation" && selTab.isChat === true) ||
- selFolder?.kind === "chat"
- ) {
+ if (selTab?.isChat === true || selFolder?.kind === "chat") {
openChatModeTab(groupOptions)
return
}
if (selTab && selFolder) {
openNewConversationTab(
selFolder.id,
- (selTab.kind === "conversation" ? selTab.workingDir : undefined) ??
- selFolder.path,
+ selTab.workingDir ?? selFolder.path,
groupOptions
)
return
@@ -324,8 +320,7 @@ 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.kind === "conversation" && tab.conversationId == null
+ const isDraft = 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 107f18f5f..88a9f63b3 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 { WorkspaceTabItem as TabItemData } from "@/contexts/tab-context"
+import type { TabItem 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,15 +326,9 @@ export const TabItem = memo(function TabItem({
]
)}
>
- {tab.kind === "pk" ? (
-
- PK
-
- ) : (
-
- )}
+
{
it("keeps drafts out of every cross-group affordance", () => {
- expect(tabBar).toContain(
- 'tab.kind === "conversation" && tab.conversationId == null'
- )
+ expect(tabBar).toContain("const isDraft = 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 98f4db0a7..409b9b814 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 d1bf91d35..c6614cba4 100644
--- a/src/contexts/acp-connections-context.tsx
+++ b/src/contexts/acp-connections-context.tsx
@@ -2493,10 +2493,8 @@ export interface AcpActionsValue {
agentType: AgentType,
workingDir?: string,
sessionId?: string,
- conversationId?: number,
- modeIdOverride?: string | null,
- configValuesOverride?: Record | null
- ): Promise
+ conversationId?: number
+ ): Promise
/**
* Release the connection for `contextKey`. The LOCAL entry always goes away
* — a stranded one would make the next `connect()` take its "already
@@ -4843,9 +4841,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
agentType: AgentType,
workingDir?: string,
sessionId?: string,
- conversationId?: number,
- modeIdOverride?: string | null,
- configValuesOverride?: Record | null
+ conversationId?: number
) => {
const request: ConnectRequest = {
agentType,
@@ -4976,7 +4972,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
existing.status !== "disconnected" &&
existing.status !== "error"
) {
- return existing.connectionId
+ return
}
if (
existing.status !== "disconnected" &&
@@ -5066,7 +5062,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
orphanConn.connectionId,
orphanCursor
)
- return orphanConn.connectionId
+ return
}
}
@@ -5126,7 +5122,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 discovered.connection_id
+ if (attached) return
}
}
@@ -5149,18 +5145,12 @@ 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,
- initialModeId,
- initialConfigValues
+ savedPrefs.modeId,
+ savedPrefs.configValues
)
// If disconnect was requested while connect was in flight, tear down
@@ -5270,7 +5260,6 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
}
}
}
- return connectionId
} catch (err) {
const pendingRequest = pendingConnectRequestsRef.current.get(contextKey)
const superseded =
@@ -5692,11 +5681,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
)
const setMode = useCallback(async (contextKey: string, modeId: string) => {
- const conn =
- storeRef.current.connections.get(contextKey) ??
- Array.from(storeRef.current.connections.values()).find(
- (c) => c.connectionId === contextKey
- )
+ const conn = storeRef.current.connections.get(contextKey)
if (!conn) return
// Persist user's mode selection to localStorage
const modes =
@@ -5713,11 +5698,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
const setConfigOption = useCallback(
async (contextKey: string, configId: string, valueId: string) => {
- const conn =
- storeRef.current.connections.get(contextKey) ??
- Array.from(storeRef.current.connections.values()).find(
- (c) => c.connectionId === contextKey
- )
+ const conn = storeRef.current.connections.get(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 effa941a9..6bab472c2 100644
--- a/src/contexts/tab-context.test.tsx
+++ b/src/contexts/tab-context.test.tsx
@@ -25,10 +25,6 @@ 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,
@@ -567,12 +563,8 @@ 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 is ConversationWorkspaceTab =>
- t.kind === "conversation" && t.id === target?.tabId
- )?.agentTypeProvisional
+ useTabStore.getState().rawTabs.find((t) => t.id === target?.tabId)
+ ?.agentTypeProvisional
).toBe(false)
})
@@ -638,12 +630,8 @@ describe("TabProvider tab state transitions", () => {
expect(draft?.agentType).toBe("codex")
expect(draft?.isChat).toBe(true)
expect(
- useTabStore
- .getState()
- .rawTabs.find(
- (t): t is ConversationWorkspaceTab =>
- t.kind === "conversation" && t.id === chatDraftId
- )?.agentTypeProvisional
+ useTabStore.getState().rawTabs.find((t) => t.id === chatDraftId)
+ ?.agentTypeProvisional
).toBe(false)
})
@@ -1996,7 +1984,7 @@ describe("TabProvider tab groups", () => {
expect(leaves()).toHaveLength(2)
const g1 = newLeafBeside(home)
- const draft = store().rawTabs.find(isConversationDraft)
+ const draft = store().rawTabs.find((t) => t.conversationId == null)
expect(draft).toBeDefined()
expect(draft?.folderId).toBe(1)
expect(draft?.agentType).toBe("codex")
@@ -2020,7 +2008,7 @@ describe("TabProvider tab groups", () => {
store().splitTab(chatDraftId, "right", { move: false })
})
- const drafts = store().rawTabs.filter(isConversationDraft)
+ const drafts = store().rawTabs.filter((t) => t.conversationId == null)
expect(drafts).toHaveLength(2)
const newDraft = drafts.find((t) => t.id !== chatDraftId)
expect(newDraft?.isChat).toBe(true)
@@ -2231,61 +2219,26 @@ describe("TabProvider tab groups", () => {
store().splitTab("conv-1-codex-1", "right", { move: false })
})
const g1 = newLeafBeside(home)
- const draftInG1 = store().rawTabs.find(isConversationDraft)!
+ const draftInG1 = store().rawTabs.find((t) => t.conversationId == null)!
// Focused group is g1 (the draft) — reuses that draft, no new tab.
act(() => {
store().openNewConversationTab(1, "/repo")
})
- expect(store().rawTabs.filter(isConversationDraft)).toHaveLength(1)
+ expect(
+ store().rawTabs.filter((t) => t.conversationId == null)
+ ).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(isConversationDraft)
+ const drafts = store().rawTabs.filter((t) => t.conversationId == null)
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)])
@@ -2328,7 +2281,7 @@ describe("TabProvider tab groups", () => {
store().splitTab("conv-1-codex-1", "right", { move: false })
})
const g1 = newLeafBeside(home)
- const draft = store().rawTabs.find(isConversationDraft)!
+ const draft = store().rawTabs.find((t) => t.conversationId == null)!
expect(groupOfId(draft.id)).toBe(g1)
const blob = JSON.parse(localStorage.getItem("workspace:tab-groups:v1")!)
@@ -2343,7 +2296,7 @@ describe("TabProvider tab groups", () => {
await renderWithTabs([tabItem(1, 1, true)])
expect(selectIsSplit(store())).toBe(true)
- const restoredDraft = store().rawTabs.find(isConversationDraft)
+ const restoredDraft = store().rawTabs.find((t) => t.conversationId == null)
expect(restoredDraft?.id).toBe(draft.id)
expect(restoredDraft?.folderId).toBe(1)
expect(groupOfId(draft.id)).toBe(g1)
@@ -2363,7 +2316,7 @@ describe("TabProvider tab groups", () => {
store().splitTab("conv-1-codex-1", "right", { move: false })
})
const g1 = newLeafBeside(home)
- const draft = store().rawTabs.find(isConversationDraft)!
+ const draft = store().rawTabs.find((t) => t.conversationId == null)!
const goodBlob = localStorage.getItem("workspace:tab-groups:v1")!
// Unsent text in BOTH drafts' composers (per-tab keys).
saveMessageInputDraftV2(buildNewConversationDraftStorageKey(draft.id), {
@@ -2416,7 +2369,7 @@ describe("TabProvider tab groups", () => {
store().splitTab("conv-1-codex-1", "right", { move: false })
})
const g1 = newLeafBeside(home)
- const draft = store().rawTabs.find(isConversationDraft)!
+ const draft = store().rawTabs.find((t) => t.conversationId == null)!
first.unmount()
act(() => {
@@ -2474,7 +2427,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(isConversationDraft)!
+ const draft = store().rawTabs.find((t) => t.conversationId == null)!
act(() => {
store().splitTab("conv-1-codex-1", "right", { move: true })
})
@@ -2513,7 +2466,7 @@ describe("TabProvider tab groups", () => {
expect(selectIsSplit(store())).toBe(true)
expect(groupOfId("conv-1-codex-2")).toBe(movedGroup)
- expect(store().rawTabs.some(isConversationDraft)).toBe(false)
+ expect(store().rawTabs.some((t) => t.conversationId == null)).toBe(false)
})
it("prunes a restored group whose tabs no longer exist after hydration", async () => {
@@ -2551,7 +2504,7 @@ describe("TabProvider tab groups", () => {
store().splitTab("conv-1-codex-1", "right", { move: false })
})
const g1 = newLeafBeside(home)
- const draft = store().rawTabs.find(isConversationDraft)!
+ const draft = store().rawTabs.find((t) => t.conversationId == null)!
expect(groupOfId(draft.id)).toBe(g1)
act(() => {
@@ -2574,7 +2527,7 @@ describe("TabProvider tab groups", () => {
store().splitTab("conv-1-codex-1", "right", { move: false })
})
const g1 = newLeafBeside(home)
- const draft = store().rawTabs.find(isConversationDraft)!
+ const draft = store().rawTabs.find((t) => t.conversationId == null)!
act(() => {
tabsChangedHandler?.({
@@ -2711,8 +2664,7 @@ describe("TabProvider tab groups", () => {
})
const g1 = newLeafBeside(home)
const g1Draft = store().rawTabs.find(
- (t): t is ConversationWorkspaceTab & { conversationId: null } =>
- isConversationDraft(t) && groupOfId(t.id) === g1
+ (t) => t.conversationId == null && groupOfId(t.id) === g1
)!
expect(g1Draft).toBeTruthy()
@@ -2742,7 +2694,7 @@ describe("TabProvider tab groups", () => {
act(() => {
store().openNewConversationTab(1, "/w1", { targetGroup: home })
})
- const draft = store().rawTabs.find(isConversationDraft)!
+ const draft = store().rawTabs.find((t) => t.conversationId == null)!
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 bf9c28393..b66f2af15 100644
--- a/src/contexts/tab-context.tsx
+++ b/src/contexts/tab-context.tsx
@@ -15,17 +15,15 @@ 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, WorkspaceTabItem }
+export type { OpenedDraftTarget, TabItem }
export { useTabStore, useTabActions } from "@/stores/tab-store"
interface TabProviderProps {
@@ -217,22 +215,15 @@ export interface TabContextValue {
openTab: (
folderId: number,
conversationId: number,
- agentType: AgentType,
+ agentType: TabItem["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: AgentType
+ agentType: TabItem["agentType"]
) => void
closeOtherTabs: (tabId: string) => void
closeAllTabs: () => void
@@ -246,7 +237,7 @@ export interface TabContextValue {
workingDir: string,
options?: {
inheritFromActive?: boolean
- folderDefaultAgent?: AgentType | null
+ folderDefaultAgent?: TabItem["agentType"] | null
targetGroup?: string
forceAgent?: TabItem["agentType"]
}
@@ -256,12 +247,15 @@ export interface TabContextValue {
forceAgent?: TabItem["agentType"]
}) => OpenedDraftTarget
setChatDraftWorkingDir: (tabId: string, workingDir: string) => void
- confirmDraftAgent: (tabId: string, agentType: AgentType) => void
- setDraftAgentFromFallback: (tabId: string, agentType: AgentType) => void
+ confirmDraftAgent: (tabId: string, agentType: TabItem["agentType"]) => void
+ setDraftAgentFromFallback: (
+ tabId: string,
+ agentType: TabItem["agentType"]
+ ) => void
bindConversationTab: (
tabId: string,
conversationId: number,
- agentType: AgentType,
+ agentType: TabItem["agentType"],
title: string,
runtimeConversationId?: number,
folderId?: number,
@@ -286,16 +280,11 @@ export interface TabContextValue {
export function useTabContext(): TabContextValue {
return useTabStore(
useShallow((s) => ({
- // 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[],
+ tabs: s.tabs,
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 497832761..2fe092296 100644
--- a/src/hooks/use-connection.ts
+++ b/src/hooks/use-connection.ts
@@ -94,10 +94,8 @@ export interface UseConnectionReturn {
agentType: AgentType,
workingDir?: string,
sessionId?: string,
- conversationId?: number,
- modeIdOverride?: string | null,
- configValuesOverride?: Record | null
- ) => Promise
+ conversationId?: number
+ ) => Promise
disconnect: () => Promise
/** Restart the session (disconnect + resume same sessionId) so it picks up
* current agent/model settings. Returns `true` if it actually restarted,
@@ -241,18 +239,14 @@ export function useConnection(contextKey: string): UseConnectionReturn {
agentType: AgentType,
workingDir?: string,
sessionId?: string,
- conversationId?: number,
- modeIdOverride?: string | null,
- configValuesOverride?: Record | null
+ conversationId?: number
) =>
actions.connect(
contextKey,
agentType,
workingDir,
sessionId,
- conversationId,
- modeIdOverride,
- configValuesOverride
+ conversationId
),
[actions, contextKey]
)
diff --git a/src/hooks/use-is-active-chat-mode.ts b/src/hooks/use-is-active-chat-mode.ts
index 062fbf300..22c6865ed 100644
--- a/src/hooks/use-is-active-chat-mode.ts
+++ b/src/hooks/use-is-active-chat-mode.ts
@@ -2,7 +2,6 @@
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
@@ -19,9 +18,5 @@ 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 Boolean(
- activeTab &&
- isConversationWorkspaceTab(activeTab) &&
- activeTab.isChat === true
- )
+ return activeTab?.isChat === true
}
diff --git a/src/hooks/use-pk-round-judge.test.tsx b/src/hooks/use-pk-round-judge.test.tsx
deleted file mode 100644
index d8b1b467d..000000000
--- a/src/hooks/use-pk-round-judge.test.tsx
+++ /dev/null
@@ -1,166 +0,0 @@
-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
deleted file mode 100644
index f5767e76c..000000000
--- a/src/hooks/use-pk-round.test.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-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
deleted file mode 100644
index 8def5743f..000000000
--- a/src/hooks/use-pk-round.ts
+++ /dev/null
@@ -1,1421 +0,0 @@
-"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 dd91a499e..e6899b81f 100644
--- a/src/i18n/messages/ar.json
+++ b/src/i18n/messages/ar.json
@@ -1737,17 +1737,8 @@
"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": "محادثة جديدة",
@@ -1869,8 +1860,7 @@
"search": "بحث",
"openSettings": "فتح الإعدادات",
"backToConversations": "العودة إلى المحادثات",
- "withShortcut": "{label} (اختصار: {shortcut})",
- "pkArena": "ساحة منافسة الوكلاء"
+ "withShortcut": "{label} (اختصار: {shortcut})"
},
"statusBar": {
"connection": {
@@ -2807,8 +2797,7 @@
"mentionGroupAgent": "الوكلاء",
"mentionGroupSession": "الجلسات",
"mentionGroupCommit": "عمليات الإيداع",
- "mentionGroupSkill": "المهارات",
- "startPk": "منافسة الوكلاء"
+ "mentionGroupSkill": "المهارات"
},
"messageQueue": {
"addToQueue": "إضافة للقائمة",
@@ -5130,179 +5119,6 @@
"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 d50c5f695..578eaa22f 100644
--- a/src/i18n/messages/de.json
+++ b/src/i18n/messages/de.json
@@ -1737,17 +1737,8 @@
"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",
@@ -1869,8 +1860,7 @@
"search": "Suchen",
"openSettings": "Einstellungen öffnen",
"backToConversations": "Zurück zu Konversationen",
- "withShortcut": "{label} (Tastenkürzel: {shortcut})",
- "pkArena": "Agenten-PK-Arena"
+ "withShortcut": "{label} (Tastenkürzel: {shortcut})"
},
"statusBar": {
"connection": {
@@ -2807,8 +2797,7 @@
"mentionGroupAgent": "Agenten",
"mentionGroupSession": "Sitzungen",
"mentionGroupCommit": "Commits",
- "mentionGroupSkill": "Fähigkeiten",
- "startPk": "Agenten-PK"
+ "mentionGroupSkill": "Fähigkeiten"
},
"messageQueue": {
"addToQueue": "Zur Warteschlange",
@@ -5130,179 +5119,6 @@
"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 073f075e0..3cfdc782d 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -1737,17 +1737,8 @@
"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",
@@ -1869,8 +1860,7 @@
"search": "Search",
"openSettings": "Open Settings",
"backToConversations": "Back to Conversations",
- "withShortcut": "{label} ({shortcut})",
- "pkArena": "Agent PK arena"
+ "withShortcut": "{label} ({shortcut})"
},
"statusBar": {
"connection": {
@@ -2807,8 +2797,7 @@
"mentionGroupAgent": "Agents",
"mentionGroupSession": "Sessions",
"mentionGroupCommit": "Commits",
- "mentionGroupSkill": "Skills",
- "startPk": "Agent PK"
+ "mentionGroupSkill": "Skills"
},
"messageQueue": {
"addToQueue": "Queue message",
@@ -5130,179 +5119,6 @@
"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 354f2471a..31a5c6c84 100644
--- a/src/i18n/messages/es.json
+++ b/src/i18n/messages/es.json
@@ -1737,17 +1737,8 @@
"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",
@@ -1869,8 +1860,7 @@
"search": "Buscar",
"openSettings": "Abrir configuración",
"backToConversations": "Volver a conversaciones",
- "withShortcut": "{label} (atajo: {shortcut})",
- "pkArena": "Arena PK de agentes"
+ "withShortcut": "{label} (atajo: {shortcut})"
},
"statusBar": {
"connection": {
@@ -2807,8 +2797,7 @@
"mentionGroupAgent": "Agentes",
"mentionGroupSession": "Sesiones",
"mentionGroupCommit": "Commits",
- "mentionGroupSkill": "Habilidades",
- "startPk": "PK de agentes"
+ "mentionGroupSkill": "Habilidades"
},
"messageQueue": {
"addToQueue": "Agregar a la cola",
@@ -5130,179 +5119,6 @@
"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 4bea26f0e..95799d47f 100644
--- a/src/i18n/messages/fr.json
+++ b/src/i18n/messages/fr.json
@@ -1737,17 +1737,8 @@
"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",
@@ -1869,8 +1860,7 @@
"search": "Rechercher",
"openSettings": "Ouvrir les paramètres",
"backToConversations": "Retour aux conversations",
- "withShortcut": "{label} (raccourci : {shortcut})",
- "pkArena": "Arène PK d'agents"
+ "withShortcut": "{label} (raccourci : {shortcut})"
},
"statusBar": {
"connection": {
@@ -2807,8 +2797,7 @@
"mentionGroupAgent": "Agents",
"mentionGroupSession": "Sessions",
"mentionGroupCommit": "Commits",
- "mentionGroupSkill": "Compétences",
- "startPk": "PK d'agents"
+ "mentionGroupSkill": "Compétences"
},
"messageQueue": {
"addToQueue": "Mettre en file",
@@ -5130,179 +5119,6 @@
"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 0aeee44d5..faabaa6ef 100644
--- a/src/i18n/messages/ja.json
+++ b/src/i18n/messages/ja.json
@@ -1737,17 +1737,8 @@
"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": "新しいチャット",
@@ -1869,8 +1860,7 @@
"search": "検索",
"openSettings": "設定を開く",
"backToConversations": "会話に戻る",
- "withShortcut": "{label}({shortcut})",
- "pkArena": "エージェントPKアリーナ"
+ "withShortcut": "{label}({shortcut})"
},
"statusBar": {
"connection": {
@@ -2807,8 +2797,7 @@
"mentionGroupAgent": "エージェント",
"mentionGroupSession": "セッション",
"mentionGroupCommit": "コミット",
- "mentionGroupSkill": "スキル",
- "startPk": "エージェントPK"
+ "mentionGroupSkill": "スキル"
},
"messageQueue": {
"addToQueue": "キューに追加",
@@ -5130,179 +5119,6 @@
"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 3c4f72a62..7af67d2cf 100644
--- a/src/i18n/messages/ko.json
+++ b/src/i18n/messages/ko.json
@@ -1737,17 +1737,8 @@
"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": "새 채팅",
@@ -1869,8 +1860,7 @@
"search": "검색",
"openSettings": "설정 열기",
"backToConversations": "대화로 돌아가기",
- "withShortcut": "{label} ({shortcut} 단축키)",
- "pkArena": "에이전트 PK 아레나"
+ "withShortcut": "{label} ({shortcut} 단축키)"
},
"statusBar": {
"connection": {
@@ -2807,8 +2797,7 @@
"mentionGroupAgent": "에이전트",
"mentionGroupSession": "세션",
"mentionGroupCommit": "커밋",
- "mentionGroupSkill": "스킬",
- "startPk": "에이전트 PK"
+ "mentionGroupSkill": "스킬"
},
"messageQueue": {
"addToQueue": "대기열에 추가",
@@ -5130,179 +5119,6 @@
"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 2827fd253..bf3af5798 100644
--- a/src/i18n/messages/pt.json
+++ b/src/i18n/messages/pt.json
@@ -1737,17 +1737,8 @@
"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",
@@ -1869,8 +1860,7 @@
"search": "Buscar",
"openSettings": "Abrir configurações",
"backToConversations": "Voltar às conversas",
- "withShortcut": "{label} (atalho: {shortcut})",
- "pkArena": "Arena PK de agentes"
+ "withShortcut": "{label} (atalho: {shortcut})"
},
"statusBar": {
"connection": {
@@ -2807,8 +2797,7 @@
"mentionGroupAgent": "Agentes",
"mentionGroupSession": "Sessões",
"mentionGroupCommit": "Commits",
- "mentionGroupSkill": "Habilidades",
- "startPk": "PK de agentes"
+ "mentionGroupSkill": "Habilidades"
},
"messageQueue": {
"addToQueue": "Adicionar à fila",
@@ -5130,179 +5119,6 @@
"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 4894d1885..953d691d7 100644
--- a/src/i18n/messages/zh-CN.json
+++ b/src/i18n/messages/zh-CN.json
@@ -1737,17 +1737,8 @@
"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": "新建聊天",
@@ -1869,8 +1860,7 @@
"search": "搜索",
"openSettings": "打开设置",
"backToConversations": "返回会话",
- "withShortcut": "{label}({shortcut})",
- "pkArena": "智能体 PK 竞技场"
+ "withShortcut": "{label}({shortcut})"
},
"statusBar": {
"connection": {
@@ -2807,8 +2797,7 @@
"mentionGroupAgent": "智能体",
"mentionGroupSession": "会话",
"mentionGroupCommit": "提交",
- "mentionGroupSkill": "技能",
- "startPk": "智能体 PK"
+ "mentionGroupSkill": "技能"
},
"messageQueue": {
"addToQueue": "加入队列",
@@ -5130,179 +5119,6 @@
"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 1dee2b469..8ad5c4600 100644
--- a/src/i18n/messages/zh-TW.json
+++ b/src/i18n/messages/zh-TW.json
@@ -1737,17 +1737,8 @@
"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": "新增聊天",
@@ -1869,8 +1860,7 @@
"search": "搜尋",
"openSettings": "打開設定",
"backToConversations": "返回會話",
- "withShortcut": "{label}({shortcut})",
- "pkArena": "智慧體 PK 競技場"
+ "withShortcut": "{label}({shortcut})"
},
"statusBar": {
"connection": {
@@ -2807,8 +2797,7 @@
"mentionGroupAgent": "智能體",
"mentionGroupSession": "工作階段",
"mentionGroupCommit": "提交",
- "mentionGroupSkill": "技能",
- "startPk": "智慧體 PK"
+ "mentionGroupSkill": "技能"
},
"messageQueue": {
"addToQueue": "加入佇列",
@@ -5130,179 +5119,6 @@
"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 4c537ae57..60299353f 100644
--- a/src/lib/api-popup-windows.test.ts
+++ b/src/lib/api-popup-windows.test.ts
@@ -28,11 +28,7 @@ vi.mock("@/lib/transport", () => ({
notifyRemoteDesktopUnauthorized: mocks.notifyRemoteDesktopUnauthorized,
}))
-import {
- openCommitWindow,
- openPkRoundWindow,
- openSettingsWindow,
-} from "@/lib/api"
+import { openCommitWindow, openSettingsWindow } from "@/lib/api"
/** Stand-in for the reserved WindowProxy: only `location.href` and `close`. */
function fakePopup(initialHref = "about:blank") {
@@ -205,31 +201,4 @@ 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 1246f8891..6d4fc1e21 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -157,10 +157,6 @@ import type {
TokenUsageReport,
TokenUsageSyncResult,
TokenUsageSyncStatus,
- PkRoundConfig,
- PkRoundInfo,
- PkRoundStatus,
- PkJudgeResultDto,
} from "./types"
export async function listConversations(params?: {
@@ -2120,14 +2116,12 @@ export async function gitNewBranch(
export async function gitWorktreeAdd(
path: string,
branchName: string,
- worktreePath: string,
- base?: string | null
+ worktreePath: string
): Promise {
return getTransport().call("git_worktree_add", {
path,
branchName,
worktreePath,
- base: base ?? undefined,
})
}
@@ -2323,25 +2317,6 @@ 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,
@@ -2875,20 +2850,6 @@ 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
@@ -4941,70 +4902,6 @@ 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 25208c550..ec30d40d1 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"`.
*/
-export async function saveTextFile(opts: {
+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
deleted file mode 100644
index 665c81b47..000000000
--- a/src/lib/pk-conversation-reconciliation.test.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-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
deleted file mode 100644
index 1782fa520..000000000
--- a/src/lib/pk-conversation-reconciliation.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-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
deleted file mode 100644
index a3eb9bbd3..000000000
--- a/src/lib/pk-judge.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-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
deleted file mode 100644
index 68eb55bbb..000000000
--- a/src/lib/pk-report-artifact.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-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
deleted file mode 100644
index aac55b1c3..000000000
--- a/src/lib/pk-report-data.test.ts
+++ /dev/null
@@ -1,176 +0,0 @@
-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
deleted file mode 100644
index d1c8d2246..000000000
--- a/src/lib/pk-report-data.ts
+++ /dev/null
@@ -1,307 +0,0 @@
-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
deleted file mode 100644
index 26811d99f..000000000
--- a/src/lib/pk-report-export.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-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
deleted file mode 100644
index 34cf4c822..000000000
--- a/src/lib/pk-report-locales.ts
+++ /dev/null
@@ -1,409 +0,0 @@
-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
deleted file mode 100644
index 2956500e9..000000000
--- a/src/lib/pk-report.test.ts
+++ /dev/null
@@ -1,274 +0,0 @@
-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(
- /^