feat(desktop): add session automation controls
(cherry picked from commit 8a61c2bcba8e4c7b3adcc172d4e5457721f76990)
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { $composerActionsBySession } from '@/store/composer-actions'
|
||||
import { $previewStatusBySession } from '@/store/preview-status'
|
||||
import { $sessionControlBySession, type SessionControlEntry } from '@/store/session-control'
|
||||
import { $todosBySession } from '@/store/todos'
|
||||
|
||||
import { useSessionStatusPresence } from './use-status-presence'
|
||||
|
||||
const SID = 'presence-session-1'
|
||||
|
||||
const mockEntry = (overrides?: Partial<SessionControlEntry>): SessionControlEntry => ({
|
||||
capability: 'supported',
|
||||
error: null,
|
||||
loading: false,
|
||||
pendingAction: null,
|
||||
snapshot: {
|
||||
goal: null,
|
||||
heartbeat: null,
|
||||
loop: null,
|
||||
revision: 'rev-1',
|
||||
updated_at: 1000
|
||||
},
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('useSessionStatusPresence', () => {
|
||||
beforeEach(() => {
|
||||
$todosBySession.set({})
|
||||
$composerActionsBySession.set({})
|
||||
$previewStatusBySession.set({})
|
||||
$sessionControlBySession.set({})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$todosBySession.set({})
|
||||
$composerActionsBySession.set({})
|
||||
$previewStatusBySession.set({})
|
||||
$sessionControlBySession.set({})
|
||||
})
|
||||
|
||||
it('returns false when session is null or empty', () => {
|
||||
const { result } = renderHook(() => useSessionStatusPresence(null))
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
const { result: emptyResult } = renderHook(() => useSessionStatusPresence(SID))
|
||||
expect(emptyResult.current).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when legacy status items exist', () => {
|
||||
const { result } = renderHook(() => useSessionStatusPresence(SID))
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
act(() => {
|
||||
$todosBySession.set({
|
||||
[SID]: [{ content: 'task 1', id: '1', status: 'in_progress' }]
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when structured goal exists in session control', () => {
|
||||
const { result } = renderHook(() => useSessionStatusPresence(SID))
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
act(() => {
|
||||
$sessionControlBySession.set({
|
||||
[SID]: mockEntry({
|
||||
snapshot: {
|
||||
goal: {
|
||||
contract: {
|
||||
boundaries: '',
|
||||
constraints: '',
|
||||
outcome: 'test outcome',
|
||||
stop_when: '',
|
||||
verification: ''
|
||||
},
|
||||
gates: [],
|
||||
max_turns: 10,
|
||||
status: 'active',
|
||||
subgoals: [],
|
||||
title: 'Structured Goal',
|
||||
turns_used: 1
|
||||
},
|
||||
heartbeat: null,
|
||||
loop: null,
|
||||
revision: 'rev-2',
|
||||
updated_at: 2000
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when structured loop exists in session control', () => {
|
||||
const { result } = renderHook(() => useSessionStatusPresence(SID))
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
act(() => {
|
||||
$sessionControlBySession.set({
|
||||
[SID]: mockEntry({
|
||||
snapshot: {
|
||||
goal: null,
|
||||
heartbeat: null,
|
||||
loop: {
|
||||
awaiting_response: false,
|
||||
created_at: 1000,
|
||||
current_delay: 60,
|
||||
deferred_by_goal: false,
|
||||
interval_seconds: 60,
|
||||
last_fired_at: 1000,
|
||||
max_ticks: 10,
|
||||
mode: 'interval',
|
||||
next_due_at: 2000,
|
||||
prompt: 'Run loop',
|
||||
status: 'active',
|
||||
ticks_fired: 0,
|
||||
times: 5,
|
||||
until: ''
|
||||
},
|
||||
revision: 'rev-3',
|
||||
updated_at: 2000
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when structured heartbeat exists in session control', () => {
|
||||
const { result } = renderHook(() => useSessionStatusPresence(SID))
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
act(() => {
|
||||
$sessionControlBySession.set({
|
||||
[SID]: mockEntry({
|
||||
snapshot: {
|
||||
goal: null,
|
||||
heartbeat: {
|
||||
created_at: 1000,
|
||||
fire_count: 1,
|
||||
interval_seconds: 300,
|
||||
last_fired_at: 1000,
|
||||
prompt: 'Heartbeat check',
|
||||
status: 'active'
|
||||
},
|
||||
loop: null,
|
||||
revision: 'rev-4',
|
||||
updated_at: 2000
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when session control entry has empty snapshot (null goal, loop, heartbeat)', () => {
|
||||
const { result } = renderHook(() => useSessionStatusPresence(SID))
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
act(() => {
|
||||
$sessionControlBySession.set({
|
||||
[SID]: mockEntry({
|
||||
snapshot: {
|
||||
goal: null,
|
||||
heartbeat: null,
|
||||
loop: null,
|
||||
revision: 'rev-empty',
|
||||
updated_at: 2000
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when session control entry has only an error', () => {
|
||||
const { result } = renderHook(() => useSessionStatusPresence(SID))
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
act(() => {
|
||||
$sessionControlBySession.set({
|
||||
[SID]: mockEntry({
|
||||
error: 'Gateway connection failed',
|
||||
snapshot: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -3,8 +3,9 @@ import { useSyncExternalStore } from 'react'
|
||||
import { $composerActionsBySession } from '@/store/composer-actions'
|
||||
import { $statusItemsBySession } from '@/store/composer-status'
|
||||
import { $previewStatusBySession } from '@/store/preview-status'
|
||||
import { $sessionControlBySession } from '@/store/session-control'
|
||||
|
||||
/** Structural view of the three per-session feeds — they hold different item
|
||||
/** Structural view of the per-session feeds — they hold different item
|
||||
* types, and all this hook needs from each is "does this key have rows". */
|
||||
interface PresenceFeed {
|
||||
get(): Record<string, undefined | unknown[]>
|
||||
@@ -14,7 +15,7 @@ interface PresenceFeed {
|
||||
const FEEDS: PresenceFeed[] = [$statusItemsBySession, $composerActionsBySession, $previewStatusBySession]
|
||||
|
||||
const subscribe = (onChange: () => void) => {
|
||||
const offs = FEEDS.map(feed => feed.listen(onChange))
|
||||
const offs = [...FEEDS.map(feed => feed.listen(onChange)), $sessionControlBySession.listen(onChange)]
|
||||
|
||||
return () => {
|
||||
for (const off of offs) {
|
||||
@@ -24,8 +25,9 @@ const subscribe = (onChange: () => void) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a session has any status items, micro actions, or previews, as a
|
||||
* coarse *edge*: the boolean only flips when the stack appears/disappears.
|
||||
* Whether a session has any status items, micro actions, previews, or
|
||||
* structured session controls (goal, loop, heartbeat), as a coarse *edge*:
|
||||
* the boolean only flips when the stack appears/disappears.
|
||||
* ChatBar uses it to toggle a styling data-attr — subscribing to the whole
|
||||
* `$statusItemsBySession` (a `computed` that rebuilds the entire map) /
|
||||
* `$previewStatusBySession` maps re-rendered the ~1.4k ChatBar on every
|
||||
@@ -39,6 +41,16 @@ export function useSessionStatusPresence(sessionId: string | null): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
return FEEDS.some(feed => (feed.get()[sessionId]?.length ?? 0) > 0)
|
||||
if (FEEDS.some(feed => (feed.get()[sessionId]?.length ?? 0) > 0)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const control = $sessionControlBySession.get()[sessionId]
|
||||
|
||||
return Boolean(
|
||||
control?.error ||
|
||||
(control?.snapshot &&
|
||||
(control.snapshot.goal !== null || control.snapshot.loop !== null || control.snapshot.heartbeat !== null))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1195,6 +1195,7 @@ export function ChatBar({
|
||||
grows upward over the thread and the dock's own measurement covers
|
||||
it. Collapses to nothing when every status is empty. */}
|
||||
<ComposerStatusStack
|
||||
onSubmit={onSubmit}
|
||||
queue={
|
||||
activeQueueSessionKey && queuedPrompts.length > 0 ? (
|
||||
<QueuePanel
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router'
|
||||
|
||||
import { blurComposerInput } from '@/app/chat/composer/focus'
|
||||
import { AGENTS_ROUTE } from '@/app/routes'
|
||||
import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'
|
||||
import { BillingBanner } from '@/components/billing-banner'
|
||||
import { composerDockCard } from '@/components/chat/composer-dock'
|
||||
import { StatusSection } from '@/components/chat/status-section'
|
||||
@@ -24,12 +25,14 @@ import {
|
||||
type StatusGroup,
|
||||
stopBackgroundProcess
|
||||
} from '@/store/composer-status'
|
||||
import { refreshSessionGoal } from '@/store/goals'
|
||||
import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview-status'
|
||||
import { $sessionControlBySession, refreshSessionControl } from '@/store/session-control'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
import { PreviewStatusRow } from './preview-row'
|
||||
import { SessionControlSections } from './session-control'
|
||||
import { useSessionValue } from './session-control-utils'
|
||||
import { StatusItemRow } from './status-row'
|
||||
|
||||
// Slow safety-net poll for silent exits (processes without notify_on_complete
|
||||
@@ -74,6 +77,7 @@ const hasRunningTodo = (group: StatusGroup) =>
|
||||
group.type === 'todo' && group.items.some(item => item.todoStatus === 'in_progress' && item.state === 'running')
|
||||
|
||||
interface ComposerStatusStackProps {
|
||||
onSubmit?: (value: string, options?: SubmitTextOptions) => Promise<boolean> | boolean
|
||||
/** The queue, built by the composer (it owns the queue's callbacks). Rendered
|
||||
* as the last group so it stays fused to the composer like before. */
|
||||
queue: ReactNode
|
||||
@@ -85,7 +89,7 @@ interface ComposerStatusStackProps {
|
||||
* every session-scoped status — subagents, background tasks, queue — grouped by
|
||||
* type and separated by light dividers. Collapses to nothing when empty.
|
||||
*/
|
||||
export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackProps) {
|
||||
export function ComposerStatusStack({ onSubmit, queue, sessionId }: ComposerStatusStackProps) {
|
||||
const { t } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
// Subscribe to THIS session's slice only. Both maps churn on other
|
||||
@@ -96,10 +100,22 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
// items actually changed.
|
||||
const items = useSessionSlice($statusItemsBySession, sessionId)
|
||||
const previews = useSessionSlice($previewStatusBySession, sessionId)
|
||||
const controlEntry = useSessionValue($sessionControlBySession, sessionId)
|
||||
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const billing = useStore($billingBlock)
|
||||
|
||||
const groups = useMemo(() => groupStatusItems(items), [items])
|
||||
const isStructuredSupported = controlEntry?.capability === 'supported'
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const raw = groupStatusItems(items)
|
||||
|
||||
if (isStructuredSupported) {
|
||||
return raw.filter(g => g.type !== 'goal')
|
||||
}
|
||||
|
||||
return raw
|
||||
}, [items, isStructuredSupported])
|
||||
|
||||
// Seed from the registry on session open; event-driven refreshes (terminal /
|
||||
// process tool completions) live in use-message-stream. This must NOT reset
|
||||
@@ -112,7 +128,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
useEffect(() => {
|
||||
if (sessionId) {
|
||||
void refreshBackgroundProcesses(sessionId)
|
||||
void refreshSessionGoal(sessionId)
|
||||
void refreshSessionControl(sessionId)
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
@@ -160,6 +176,21 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
sections.push({ key: 'billing', node: <BillingBanner sessionId={sessionId} /> })
|
||||
}
|
||||
|
||||
const hasControlContent = Boolean(
|
||||
controlEntry &&
|
||||
(controlEntry.error ||
|
||||
controlEntry.snapshot?.goal ||
|
||||
controlEntry.snapshot?.loop ||
|
||||
controlEntry.snapshot?.heartbeat)
|
||||
)
|
||||
|
||||
if (sessionId && controlEntry && hasControlContent) {
|
||||
sections.push({
|
||||
key: 'session-control',
|
||||
node: <SessionControlSections entry={controlEntry} onSubmit={onSubmit} sessionId={sessionId} />
|
||||
})
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
sections.push({
|
||||
key: group.type,
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
|
||||
import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'
|
||||
import { StatusSection } from '@/components/chat/status-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import {
|
||||
runSessionControlAction,
|
||||
type SessionControlAction,
|
||||
type SessionControlActionArgs,
|
||||
type SessionControlGoal
|
||||
} from '@/store/session-control'
|
||||
|
||||
import type { ConfirmState } from './session-control-utils'
|
||||
|
||||
interface GoalSectionProps {
|
||||
goal: SessionControlGoal
|
||||
sessionId: string
|
||||
pendingAction: SessionControlAction | null
|
||||
onSubmit?: (value: string, options?: SubmitTextOptions) => Promise<boolean> | boolean
|
||||
onFeedback: (error: string | null, success: string | null) => void
|
||||
}
|
||||
|
||||
export const SessionControlGoalSection = memo(function SessionControlGoalSection({
|
||||
goal,
|
||||
sessionId,
|
||||
pendingAction,
|
||||
onSubmit,
|
||||
onFeedback
|
||||
}: GoalSectionProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.statusStack
|
||||
const ctrl = s.control
|
||||
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
const [addCriterionOpen, setAddCriterionOpen] = useState(false)
|
||||
const [addCriterionError, setAddCriterionError] = useState<string | null>(null)
|
||||
const [newCriterionText, setNewCriterionText] = useState('')
|
||||
const [confirmState, setConfirmState] = useState<ConfirmState | null>(null)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const isBusy = Boolean(pendingAction)
|
||||
|
||||
const handleAction = useCallback(
|
||||
async (
|
||||
action: SessionControlAction,
|
||||
args?: SessionControlActionArgs,
|
||||
onFailure?: (message: string) => void
|
||||
): Promise<boolean> => {
|
||||
onFeedback(null, null)
|
||||
|
||||
try {
|
||||
const dispatch = await runSessionControlAction(sessionId, action, args)
|
||||
|
||||
if (dispatch.type === 'send') {
|
||||
if (!dispatch.message || !onSubmit) {
|
||||
onFeedback(ctrl.continuationFailed, null)
|
||||
onFailure?.(ctrl.continuationFailed)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const submitted = await onSubmit(dispatch.message, {
|
||||
displayKind: 'hidden',
|
||||
sessionId
|
||||
})
|
||||
|
||||
if (!submitted) {
|
||||
onFeedback(ctrl.continuationFailed, null)
|
||||
onFailure?.(ctrl.continuationFailed)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
onFeedback(null, ctrl.actionSucceeded)
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
const failure = ctrl.actionFailed(msg)
|
||||
onFeedback(failure, null)
|
||||
onFailure?.(failure)
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
[sessionId, onSubmit, onFeedback, ctrl]
|
||||
)
|
||||
|
||||
const copyCriterionText = useCallback(
|
||||
async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
onFeedback(null, ctrl.copySuccess)
|
||||
} catch {
|
||||
onFeedback(ctrl.copyFailure, null)
|
||||
}
|
||||
},
|
||||
[onFeedback, ctrl]
|
||||
)
|
||||
|
||||
const visibleState: 'waiting' | 'active' | 'paused' | 'done' = goal.wait_barrier
|
||||
? 'waiting'
|
||||
: goal.status === 'paused'
|
||||
? 'paused'
|
||||
: goal.status === 'done'
|
||||
? 'done'
|
||||
: 'active'
|
||||
|
||||
const stateLabel =
|
||||
visibleState === 'waiting'
|
||||
? s.goalWaiting
|
||||
: visibleState === 'paused'
|
||||
? s.goalPaused
|
||||
: visibleState === 'done'
|
||||
? s.goalDone
|
||||
: s.goalActive
|
||||
|
||||
const headerLabel =
|
||||
visibleState === 'done'
|
||||
? `${stateLabel} · ${ctrl.goalDoneTurns(goal.turns_used)}`
|
||||
: goal.max_turns > 0
|
||||
? `${stateLabel} · ${ctrl.goalActiveTurns(goal.turns_used, goal.max_turns)}`
|
||||
: goal.turns_used > 0
|
||||
? `${stateLabel} · ${ctrl.goalTurn(goal.turns_used)}`
|
||||
: stateLabel
|
||||
|
||||
const confirmClearGoal = () => {
|
||||
setConfirmState({
|
||||
title: ctrl.clearGoalConfirmTitle,
|
||||
description: ctrl.clearGoalConfirmBody,
|
||||
destructive: true,
|
||||
confirmLabel: ctrl.clearGoal,
|
||||
onConfirm: async () => {
|
||||
await handleAction('goal.clear')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const confirmRemoveCriterion = (index: number) => {
|
||||
setConfirmState({
|
||||
title: ctrl.removeCriterionConfirmTitle(index),
|
||||
description: ctrl.removeCriterionConfirmBody(index),
|
||||
destructive: true,
|
||||
confirmLabel: ctrl.removeCriterion(index),
|
||||
onConfirm: async () => {
|
||||
await handleAction('subgoal.remove', { index })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const confirmClearCriteria = () => {
|
||||
setConfirmState({
|
||||
title: ctrl.clearCriteriaConfirmTitle,
|
||||
description: ctrl.clearCriteriaConfirmBody,
|
||||
destructive: true,
|
||||
confirmLabel: ctrl.clearCriteria,
|
||||
onConfirm: async () => {
|
||||
await handleAction('subgoal.clear')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const openAddCriterion = () => {
|
||||
setAddCriterionError(null)
|
||||
setAddCriterionOpen(true)
|
||||
}
|
||||
|
||||
const renderMenuItems = (isContext = false) => {
|
||||
const Item = isContext ? ContextMenuItem : DropdownMenuItem
|
||||
const Sep = isContext ? ContextMenuSeparator : DropdownMenuSeparator
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasDetails && (
|
||||
<Item disabled={isBusy} onSelect={() => setDetailsOpen(true)}>
|
||||
<Codicon name="eye" size="0.8rem" />
|
||||
<span>{ctrl.viewDetails}</span>
|
||||
</Item>
|
||||
)}
|
||||
{visibleState !== 'done' && (
|
||||
<Item disabled={isBusy} onSelect={openAddCriterion}>
|
||||
<Codicon name="add" size="0.8rem" />
|
||||
<span>{ctrl.addCriterion}</span>
|
||||
</Item>
|
||||
)}
|
||||
{(hasDetails || visibleState !== 'done') && <Sep />}
|
||||
{visibleState === 'active' && (
|
||||
<Item disabled={isBusy} onSelect={() => void handleAction('goal.pause')}>
|
||||
<Codicon name="debug-pause" size="0.8rem" />
|
||||
<span>{ctrl.pauseGoal}</span>
|
||||
</Item>
|
||||
)}
|
||||
{visibleState === 'paused' && (
|
||||
<Item disabled={isBusy} onSelect={() => void handleAction('goal.resume')}>
|
||||
<Codicon name="play" size="0.8rem" />
|
||||
<span>{ctrl.resumeGoal}</span>
|
||||
</Item>
|
||||
)}
|
||||
{visibleState === 'waiting' && (
|
||||
<>
|
||||
<Item disabled={isBusy} onSelect={() => void handleAction('goal.unwait')}>
|
||||
<Codicon name="play" size="0.8rem" />
|
||||
<span>{ctrl.resumeNow}</span>
|
||||
</Item>
|
||||
<Item disabled={isBusy} onSelect={() => void handleAction('goal.pause')}>
|
||||
<Codicon name="debug-pause" size="0.8rem" />
|
||||
<span>{ctrl.pauseGoal}</span>
|
||||
</Item>
|
||||
</>
|
||||
)}
|
||||
<Item disabled={isBusy} onSelect={confirmClearGoal} variant="destructive">
|
||||
<Codicon name="trash" size="0.8rem" />
|
||||
<span>{ctrl.clearGoal}</span>
|
||||
</Item>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const hasDetails = Boolean(
|
||||
goal.contract.outcome ||
|
||||
goal.contract.verification ||
|
||||
goal.contract.constraints ||
|
||||
goal.contract.boundaries ||
|
||||
goal.contract.stop_when ||
|
||||
goal.wait_barrier ||
|
||||
goal.gates.length > 0
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div data-slot="session-control-goal">
|
||||
<StatusSection
|
||||
accessory={
|
||||
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
|
||||
<Tip label={ctrl.goalActions}>
|
||||
<span className="inline-flex">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-haspopup="menu"
|
||||
aria-label={ctrl.goalActions}
|
||||
className="size-6 rounded-md text-muted-foreground/70 hover:text-foreground/90"
|
||||
disabled={isBusy}
|
||||
onClick={event => {
|
||||
// Radix opens pointer interactions from pointerdown. Keyboard,
|
||||
// assistive-tech, and programmatic clicks have no pointer sequence.
|
||||
if (event.detail === 0) {
|
||||
setMenuOpen(true)
|
||||
}
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'F10' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
setMenuOpen(true)
|
||||
}
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="ellipsis" size="0.8rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</span>
|
||||
</Tip>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
{renderMenuItems(false)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
defaultCollapsed={false}
|
||||
icon={<Codicon className="text-muted-foreground/70" name="target" size="0.8rem" />}
|
||||
label={headerLabel}
|
||||
>
|
||||
<div className="space-y-1.5 px-1 py-1">
|
||||
{/* Full goal title */}
|
||||
<div className="text-xs font-normal leading-relaxed text-foreground/92 break-words">{goal.title}</div>
|
||||
|
||||
{/* Optional reasons */}
|
||||
{!detailsOpen && goal.wait_barrier && (
|
||||
<div className="text-[0.7rem] italic text-muted-foreground/80">
|
||||
{goal.wait_barrier.reason
|
||||
? `${ctrl.waitBarrierTitle}: ${goal.wait_barrier.reason}`
|
||||
: ctrl.waitBarrierTitle}
|
||||
</div>
|
||||
)}
|
||||
{!goal.wait_barrier && goal.paused_reason && (
|
||||
<div className="text-[0.7rem] italic text-muted-foreground/80">{goal.paused_reason}</div>
|
||||
)}
|
||||
{!goal.wait_barrier && !goal.paused_reason && goal.last_reason && (
|
||||
<div className="text-[0.7rem] italic text-muted-foreground/80">{goal.last_reason}</div>
|
||||
)}
|
||||
|
||||
{/* View details button */}
|
||||
{hasDetails && (
|
||||
<div>
|
||||
<Button
|
||||
className="text-[0.7rem] text-muted-foreground/75 hover:text-foreground/90"
|
||||
onClick={() => setDetailsOpen(true)}
|
||||
size="micro"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{ctrl.viewDetails}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Criteria subsection */}
|
||||
<div className="mt-2 border-t border-(--ui-stroke-tertiary)/40 pt-1.5">
|
||||
<div className="flex items-center justify-between pb-1 text-[0.68rem] font-medium text-muted-foreground/75">
|
||||
<span>{ctrl.criteriaHeader(goal.subgoals.length)}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
className="text-[0.68rem] text-muted-foreground/75 hover:text-foreground/90"
|
||||
disabled={isBusy}
|
||||
onClick={openAddCriterion}
|
||||
size="micro"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{ctrl.addCriterion}
|
||||
</Button>
|
||||
{goal.subgoals.length > 0 && (
|
||||
<Button
|
||||
className="text-[0.68rem] text-muted-foreground/60 hover:text-destructive"
|
||||
disabled={isBusy}
|
||||
onClick={confirmClearCriteria}
|
||||
size="micro"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{ctrl.clearCriteria}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{goal.subgoals.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{goal.subgoals.map((subgoal, idx) => {
|
||||
const index = idx + 1
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group/criterion flex items-start justify-between gap-1.5 rounded px-1 py-0.5 text-xs text-foreground/85 hover:bg-(--ui-control-active-background)/40"
|
||||
key={`${index}-${subgoal}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-start gap-1.5 leading-relaxed">
|
||||
<span className="shrink-0 text-muted-foreground/60 tabular-nums">{index}.</span>
|
||||
<span className="break-words">{subgoal}</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-0.5 opacity-80 group-hover/criterion:opacity-100">
|
||||
<Tip label={ctrl.copyCriterion(index)}>
|
||||
<Button
|
||||
aria-label={ctrl.copyCriterion(index)}
|
||||
className="size-6 rounded text-muted-foreground/60 hover:text-foreground/90"
|
||||
onClick={() => void copyCriterionText(subgoal)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="copy" size="0.7rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={ctrl.removeCriterion(index)}>
|
||||
<Button
|
||||
aria-label={ctrl.removeCriterion(index)}
|
||||
className="size-6 rounded text-muted-foreground/60 hover:text-destructive"
|
||||
disabled={isBusy}
|
||||
onClick={() => confirmRemoveCriterion(index)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="0.7rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</StatusSection>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-44">{renderMenuItems(true)}</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
{/* Add Criterion Dialog */}
|
||||
<Dialog
|
||||
onOpenChange={open => {
|
||||
if (!open) {
|
||||
setAddCriterionOpen(false)
|
||||
setAddCriterionError(null)
|
||||
}
|
||||
}}
|
||||
open={addCriterionOpen}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<form
|
||||
onSubmit={async e => {
|
||||
e.preventDefault()
|
||||
const text = newCriterionText.trim()
|
||||
|
||||
if (!text || isBusy) {
|
||||
return
|
||||
}
|
||||
|
||||
setAddCriterionError(null)
|
||||
const ok = await handleAction('subgoal.add', { text }, setAddCriterionError)
|
||||
|
||||
if (ok) {
|
||||
setNewCriterionText('')
|
||||
setAddCriterionOpen(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{ctrl.addCriterionDialogTitle}</DialogTitle>
|
||||
<DialogDescription>{ctrl.addCriterionPlaceholder}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<textarea
|
||||
aria-label={ctrl.criterionLabel}
|
||||
autoFocus
|
||||
className="w-full rounded-md border border-(--ui-stroke-secondary) bg-transparent p-2 text-xs text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
disabled={isBusy}
|
||||
onChange={e => setNewCriterionText(e.target.value)}
|
||||
placeholder={ctrl.addCriterionPlaceholder}
|
||||
rows={3}
|
||||
value={newCriterionText}
|
||||
/>
|
||||
</div>
|
||||
{addCriterionError && (
|
||||
<div className="text-xs text-destructive" role="alert">
|
||||
{addCriterionError}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button disabled={isBusy} onClick={() => setAddCriterionOpen(false)} type="button" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={isBusy || !newCriterionText.trim()} type="submit">
|
||||
{ctrl.add}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Goal Details Dialog */}
|
||||
<Dialog onOpenChange={open => !open && setDetailsOpen(false)} open={detailsOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{ctrl.goalDetailsTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2 text-xs">
|
||||
<div>
|
||||
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
|
||||
{ctrl.objectiveLabel}
|
||||
</div>
|
||||
<div className="mt-0.5 text-foreground leading-relaxed break-words">{goal.title}</div>
|
||||
</div>
|
||||
|
||||
{/* Contract fields */}
|
||||
{goal.contract.outcome && (
|
||||
<div>
|
||||
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
|
||||
{ctrl.contractOutcome}
|
||||
</div>
|
||||
<div className="mt-0.5 text-foreground/90 leading-relaxed">{goal.contract.outcome}</div>
|
||||
</div>
|
||||
)}
|
||||
{goal.contract.verification && (
|
||||
<div>
|
||||
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
|
||||
{ctrl.contractVerification}
|
||||
</div>
|
||||
<div className="mt-0.5 text-foreground/90 leading-relaxed">{goal.contract.verification}</div>
|
||||
</div>
|
||||
)}
|
||||
{goal.contract.constraints && (
|
||||
<div>
|
||||
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
|
||||
{ctrl.contractConstraints}
|
||||
</div>
|
||||
<div className="mt-0.5 text-foreground/90 leading-relaxed">{goal.contract.constraints}</div>
|
||||
</div>
|
||||
)}
|
||||
{goal.contract.boundaries && (
|
||||
<div>
|
||||
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
|
||||
{ctrl.contractBoundaries}
|
||||
</div>
|
||||
<div className="mt-0.5 text-foreground/90 leading-relaxed">{goal.contract.boundaries}</div>
|
||||
</div>
|
||||
)}
|
||||
{goal.contract.stop_when && (
|
||||
<div>
|
||||
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
|
||||
{ctrl.contractStopWhen}
|
||||
</div>
|
||||
<div className="mt-0.5 text-foreground/90 leading-relaxed">{goal.contract.stop_when}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wait Barrier */}
|
||||
{goal.wait_barrier && (
|
||||
<div>
|
||||
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
|
||||
{ctrl.waitBarrierTitle}
|
||||
</div>
|
||||
<div className="mt-0.5 text-foreground/90 leading-relaxed">
|
||||
{goal.wait_barrier.type === 'until' &&
|
||||
ctrl.waitUntil(new Date(goal.wait_barrier.until_at * 1000).toLocaleTimeString())}
|
||||
{goal.wait_barrier.type === 'session' && ctrl.waitSession(goal.wait_barrier.target)}
|
||||
{goal.wait_barrier.type === 'pid' && ctrl.waitPid(goal.wait_barrier.target)}
|
||||
{goal.wait_barrier.reason && (
|
||||
<div className="italic text-muted-foreground/80 mt-0.5">{goal.wait_barrier.reason}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quality Gates */}
|
||||
{goal.gates.length > 0 && (
|
||||
<div>
|
||||
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
|
||||
{ctrl.qualityGatesTitle}
|
||||
</div>
|
||||
<div className="mt-1 space-y-1.5">
|
||||
{goal.gates.map((gate, i) => (
|
||||
<div
|
||||
className="rounded border border-(--ui-stroke-tertiary)/60 bg-(--ui-control-active-background)/20 p-2"
|
||||
key={`${i}-${gate.command}`}
|
||||
>
|
||||
<div className="font-mono text-[0.7rem] text-foreground/95">{gate.command}</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[0.68rem] text-muted-foreground/80">
|
||||
<span>{ctrl.gateAttempts(gate.attempts, gate.max_retries)}</span>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<span>{ctrl.gateTimeout(gate.timeout_seconds)}</span>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<span>{ctrl.gateLastExit(gate.last_exit_code)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setDetailsOpen(false)} type="button">
|
||||
{t.common.close}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{confirmState && (
|
||||
<ConfirmDialog
|
||||
cancelLabel={t.common.cancel}
|
||||
confirmLabel={confirmState.confirmLabel}
|
||||
description={confirmState.description}
|
||||
destructive={confirmState.destructive}
|
||||
onClose={() => setConfirmState(null)}
|
||||
onConfirm={confirmState.onConfirm}
|
||||
open={Boolean(confirmState)}
|
||||
title={confirmState.title}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
|
||||
import { StatusSection } from '@/components/chat/status-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import {
|
||||
runSessionControlAction,
|
||||
type SessionControlAction,
|
||||
type SessionControlActionArgs,
|
||||
type SessionControlHeartbeat
|
||||
} from '@/store/session-control'
|
||||
|
||||
import { type ConfirmState, formatApproximateTime, formatHeartbeatInterval } from './session-control-utils'
|
||||
|
||||
interface HeartbeatSectionProps {
|
||||
heartbeat: SessionControlHeartbeat
|
||||
sessionId: string
|
||||
pendingAction: SessionControlAction | null
|
||||
onFeedback: (error: string | null, success: string | null) => void
|
||||
}
|
||||
|
||||
export const SessionControlHeartbeatSection = memo(function SessionControlHeartbeatSection({
|
||||
heartbeat,
|
||||
sessionId,
|
||||
pendingAction,
|
||||
onFeedback
|
||||
}: HeartbeatSectionProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.statusStack
|
||||
const ctrl = s.control
|
||||
|
||||
const [confirmState, setConfirmState] = useState<ConfirmState | null>(null)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const isBusy = Boolean(pendingAction)
|
||||
|
||||
const handleAction = useCallback(
|
||||
async (action: SessionControlAction, args?: SessionControlActionArgs): Promise<boolean> => {
|
||||
onFeedback(null, null)
|
||||
|
||||
try {
|
||||
await runSessionControlAction(sessionId, action, args)
|
||||
onFeedback(null, ctrl.actionSucceeded)
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
onFeedback(ctrl.actionFailed(msg), null)
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
[sessionId, onFeedback, ctrl]
|
||||
)
|
||||
|
||||
const stateLabel = heartbeat.status === 'paused' ? ctrl.heartbeatPaused : ctrl.heartbeatActive
|
||||
|
||||
const intervalLabel = formatHeartbeatInterval(heartbeat.interval_seconds, t)
|
||||
|
||||
const nextDueTimestamp = (heartbeat.last_fired_at || heartbeat.created_at) + heartbeat.interval_seconds
|
||||
|
||||
const nextRunLabel =
|
||||
heartbeat.status === 'active' ? ` · ${ctrl.heartbeatNext(formatApproximateTime(nextDueTimestamp))}` : ''
|
||||
|
||||
const headerLabel = `${stateLabel} · ${intervalLabel}${nextRunLabel}`
|
||||
|
||||
const confirmClearHeartbeat = () => {
|
||||
setConfirmState({
|
||||
title: ctrl.clearHeartbeatConfirmTitle,
|
||||
description: ctrl.clearHeartbeatConfirmBody,
|
||||
destructive: true,
|
||||
confirmLabel: ctrl.clearHeartbeat,
|
||||
onConfirm: async () => {
|
||||
await handleAction('heartbeat.clear')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const renderMenuItems = (isContext = false) => {
|
||||
const Item = isContext ? ContextMenuItem : DropdownMenuItem
|
||||
const Sep = isContext ? ContextMenuSeparator : DropdownMenuSeparator
|
||||
|
||||
return (
|
||||
<>
|
||||
{heartbeat.status === 'active' && (
|
||||
<Item disabled={isBusy} onSelect={() => void handleAction('heartbeat.pause')}>
|
||||
<Codicon name="debug-pause" size="0.8rem" />
|
||||
<span>{ctrl.pauseHeartbeat}</span>
|
||||
</Item>
|
||||
)}
|
||||
{heartbeat.status === 'paused' && (
|
||||
<Item disabled={isBusy} onSelect={() => void handleAction('heartbeat.resume')}>
|
||||
<Codicon name="play" size="0.8rem" />
|
||||
<span>{ctrl.resumeHeartbeat}</span>
|
||||
</Item>
|
||||
)}
|
||||
<Sep />
|
||||
<Item disabled={isBusy} onSelect={confirmClearHeartbeat} variant="destructive">
|
||||
<Codicon name="trash" size="0.8rem" />
|
||||
<span>{ctrl.clearHeartbeat}</span>
|
||||
</Item>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div data-slot="session-control-heartbeat">
|
||||
<StatusSection
|
||||
accessory={
|
||||
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
|
||||
<Tip label={ctrl.heartbeatActions}>
|
||||
<span className="inline-flex">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-haspopup="menu"
|
||||
aria-label={ctrl.heartbeatActions}
|
||||
className="size-6 rounded-md text-muted-foreground/70 hover:text-foreground/90"
|
||||
disabled={isBusy}
|
||||
onClick={event => {
|
||||
// Radix opens pointer interactions from pointerdown. Keyboard,
|
||||
// assistive-tech, and programmatic clicks have no pointer sequence.
|
||||
if (event.detail === 0) {
|
||||
setMenuOpen(true)
|
||||
}
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'F10' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
setMenuOpen(true)
|
||||
}
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="ellipsis" size="0.8rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</span>
|
||||
</Tip>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
{renderMenuItems(false)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
defaultCollapsed={true}
|
||||
icon={<Codicon className="text-muted-foreground/70" name="pulse" size="0.8rem" />}
|
||||
label={headerLabel}
|
||||
>
|
||||
<div className="space-y-1 px-1 py-1 text-xs">
|
||||
<div className="text-foreground/92 leading-relaxed break-words">{heartbeat.prompt}</div>
|
||||
<div className="text-[0.7rem] text-muted-foreground/80">
|
||||
<span>{ctrl.heartbeatFiredCount(heartbeat.fire_count)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</StatusSection>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-44">{renderMenuItems(true)}</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
{confirmState && (
|
||||
<ConfirmDialog
|
||||
cancelLabel={t.common.cancel}
|
||||
confirmLabel={confirmState.confirmLabel}
|
||||
description={confirmState.description}
|
||||
destructive={confirmState.destructive}
|
||||
onClose={() => setConfirmState(null)}
|
||||
onConfirm={confirmState.onConfirm}
|
||||
open={Boolean(confirmState)}
|
||||
title={confirmState.title}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
|
||||
import { StatusSection } from '@/components/chat/status-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import {
|
||||
runSessionControlAction,
|
||||
type SessionControlAction,
|
||||
type SessionControlActionArgs,
|
||||
type SessionControlLoop
|
||||
} from '@/store/session-control'
|
||||
|
||||
import { type ConfirmState, formatApproximateTime, formatInterval } from './session-control-utils'
|
||||
|
||||
interface LoopSectionProps {
|
||||
loop: SessionControlLoop
|
||||
sessionId: string
|
||||
pendingAction: SessionControlAction | null
|
||||
onFeedback: (error: string | null, success: string | null) => void
|
||||
}
|
||||
|
||||
export const SessionControlLoopSection = memo(function SessionControlLoopSection({
|
||||
loop,
|
||||
sessionId,
|
||||
pendingAction,
|
||||
onFeedback
|
||||
}: LoopSectionProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.statusStack
|
||||
const ctrl = s.control
|
||||
|
||||
const [confirmState, setConfirmState] = useState<ConfirmState | null>(null)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const isBusy = Boolean(pendingAction)
|
||||
|
||||
const handleAction = useCallback(
|
||||
async (action: SessionControlAction, args?: SessionControlActionArgs): Promise<boolean> => {
|
||||
onFeedback(null, null)
|
||||
|
||||
try {
|
||||
await runSessionControlAction(sessionId, action, args)
|
||||
onFeedback(null, ctrl.actionSucceeded)
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
onFeedback(ctrl.actionFailed(msg), null)
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
[sessionId, onFeedback, ctrl]
|
||||
)
|
||||
|
||||
const stateLabel = loop.deferred_by_goal
|
||||
? ctrl.loopDeferred
|
||||
: loop.status === 'paused'
|
||||
? ctrl.loopPaused
|
||||
: loop.status === 'done'
|
||||
? ctrl.loopFinished
|
||||
: loop.awaiting_response
|
||||
? `${ctrl.loopActive} · ${ctrl.loopAwaitingResponse}`
|
||||
: ctrl.loopActive
|
||||
|
||||
const runCountLabel =
|
||||
loop.times > 0 ? ctrl.loopRunCount(loop.ticks_fired, loop.times) : ctrl.loopRuns(loop.ticks_fired)
|
||||
|
||||
const nextRunLabel =
|
||||
loop.status === 'active' && !loop.deferred_by_goal && loop.next_due_at
|
||||
? ` · ${ctrl.loopNext(formatApproximateTime(loop.next_due_at))}`
|
||||
: ''
|
||||
|
||||
const headerLabel = `${stateLabel} · ${runCountLabel}${nextRunLabel}`
|
||||
|
||||
const confirmStopLoop = () => {
|
||||
if (loop.status === 'done') {
|
||||
void handleAction('loop.stop')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setConfirmState({
|
||||
title: ctrl.stopLoopConfirmTitle,
|
||||
description: ctrl.stopLoopConfirmBody,
|
||||
destructive: true,
|
||||
confirmLabel: ctrl.stopLoop,
|
||||
onConfirm: async () => {
|
||||
await handleAction('loop.stop')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const renderMenuItems = (isContext = false) => {
|
||||
const Item = isContext ? ContextMenuItem : DropdownMenuItem
|
||||
const Sep = isContext ? ContextMenuSeparator : DropdownMenuSeparator
|
||||
|
||||
return (
|
||||
<>
|
||||
{loop.status === 'active' && (
|
||||
<Item disabled={isBusy} onSelect={() => void handleAction('loop.pause')}>
|
||||
<Codicon name="debug-pause" size="0.8rem" />
|
||||
<span>{ctrl.pauseLoop}</span>
|
||||
</Item>
|
||||
)}
|
||||
{loop.status === 'paused' && (
|
||||
<Item disabled={isBusy} onSelect={() => void handleAction('loop.resume')}>
|
||||
<Codicon name="play" size="0.8rem" />
|
||||
<span>{ctrl.resumeLoop}</span>
|
||||
</Item>
|
||||
)}
|
||||
{loop.status !== 'done' && <Sep />}
|
||||
{loop.status === 'done' ? (
|
||||
<Item disabled={isBusy} onSelect={() => void handleAction('loop.stop')}>
|
||||
<Codicon name="close" size="0.8rem" />
|
||||
<span>{ctrl.dismissLoop}</span>
|
||||
</Item>
|
||||
) : (
|
||||
<Item disabled={isBusy} onSelect={confirmStopLoop} variant="destructive">
|
||||
<Codicon name="stop-circle" size="0.8rem" />
|
||||
<span>{ctrl.stopLoop}</span>
|
||||
</Item>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div data-slot="session-control-loop">
|
||||
<StatusSection
|
||||
accessory={
|
||||
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
|
||||
<Tip label={ctrl.loopActions}>
|
||||
<span className="inline-flex">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-haspopup="menu"
|
||||
aria-label={ctrl.loopActions}
|
||||
className="size-6 rounded-md text-muted-foreground/70 hover:text-foreground/90"
|
||||
disabled={isBusy}
|
||||
onClick={event => {
|
||||
// Radix opens pointer interactions from pointerdown. Keyboard,
|
||||
// assistive-tech, and programmatic clicks have no pointer sequence.
|
||||
if (event.detail === 0) {
|
||||
setMenuOpen(true)
|
||||
}
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'F10' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
setMenuOpen(true)
|
||||
}
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="ellipsis" size="0.8rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</span>
|
||||
</Tip>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
{renderMenuItems(false)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
defaultCollapsed={true}
|
||||
icon={<Codicon className="text-muted-foreground/70" name="sync" size="0.8rem" />}
|
||||
label={headerLabel}
|
||||
>
|
||||
<div className="space-y-1 px-1 py-1 text-xs">
|
||||
<div className="text-foreground/92 leading-relaxed break-words">{loop.prompt}</div>
|
||||
<div className="text-[0.7rem] text-muted-foreground/80">
|
||||
<span>
|
||||
{ctrl.loopCadenceLabel}:{' '}
|
||||
{loop.mode === 'self_paced' ? ctrl.loopSelfPaced : formatInterval(loop.interval_seconds, t)}
|
||||
</span>
|
||||
</div>
|
||||
{loop.until && (
|
||||
<div className="text-[0.7rem] text-muted-foreground/80">
|
||||
<span>
|
||||
{ctrl.loopUntilLabel}: {loop.until}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{loop.deferred_by_goal && (
|
||||
<div className="text-[0.7rem] italic text-muted-foreground/80">{ctrl.loopDeferredNotice}</div>
|
||||
)}
|
||||
{loop.awaiting_response && (
|
||||
<div className="text-[0.7rem] italic text-muted-foreground/80">{ctrl.loopAwaitingResponse}</div>
|
||||
)}
|
||||
{loop.paused_reason && (
|
||||
<div className="text-[0.7rem] italic text-muted-foreground/80">{loop.paused_reason}</div>
|
||||
)}
|
||||
{loop.last_stop_reason && (
|
||||
<div className="text-[0.7rem] italic text-muted-foreground/80">{loop.last_stop_reason}</div>
|
||||
)}
|
||||
</div>
|
||||
</StatusSection>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-40">{renderMenuItems(true)}</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
{confirmState && (
|
||||
<ConfirmDialog
|
||||
cancelLabel={t.common.cancel}
|
||||
confirmLabel={confirmState.confirmLabel}
|
||||
description={confirmState.description}
|
||||
destructive={confirmState.destructive}
|
||||
onClose={() => setConfirmState(null)}
|
||||
onConfirm={confirmState.onConfirm}
|
||||
open={Boolean(confirmState)}
|
||||
title={confirmState.title}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react'
|
||||
|
||||
import type { Translations } from '@/i18n'
|
||||
|
||||
interface SessionSliceStore<T> {
|
||||
get(): Record<string, T | undefined>
|
||||
listen(listener: () => void): () => void
|
||||
}
|
||||
|
||||
/** Subscribe to one stable per-session store entry without reacting to other sessions' writes. */
|
||||
export function useSessionValue<T>(store: SessionSliceStore<T>, sessionId: string | null): T | undefined {
|
||||
const subscribe = useCallback((onChange: () => void) => store.listen(onChange), [store])
|
||||
|
||||
return useSyncExternalStore(subscribe, () => (sessionId ? store.get()[sessionId] : undefined))
|
||||
}
|
||||
|
||||
export interface ConfirmState {
|
||||
title: string
|
||||
description: string
|
||||
destructive?: boolean
|
||||
confirmLabel?: string
|
||||
onConfirm: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export function formatApproximateTime(targetTimestamp: number): string {
|
||||
if (!targetTimestamp) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const targetMs = targetTimestamp > 1e11 ? targetTimestamp : targetTimestamp * 1000
|
||||
const diffMs = targetMs - Date.now()
|
||||
|
||||
if (diffMs <= 0) {
|
||||
return '~0s'
|
||||
}
|
||||
|
||||
const seconds = Math.round(diffMs / 1000)
|
||||
|
||||
if (seconds < 60) {
|
||||
return `~${seconds}s`
|
||||
}
|
||||
|
||||
const minutes = Math.round(seconds / 60)
|
||||
|
||||
if (minutes < 60) {
|
||||
return `~${minutes}m`
|
||||
}
|
||||
|
||||
const hours = Math.round(minutes / 60)
|
||||
|
||||
if (hours < 24) {
|
||||
return `~${hours}h`
|
||||
}
|
||||
|
||||
const days = Math.round(hours / 24)
|
||||
|
||||
return `~${days}d`
|
||||
}
|
||||
|
||||
export function formatInterval(seconds: number, t: Translations): string {
|
||||
if (seconds % 3600 === 0) {
|
||||
return t.statusStack.control.loopEveryHours(seconds / 3600)
|
||||
}
|
||||
|
||||
if (seconds % 60 === 0) {
|
||||
return t.statusStack.control.loopEveryMinutes(seconds / 60)
|
||||
}
|
||||
|
||||
return t.statusStack.control.loopEverySeconds(seconds)
|
||||
}
|
||||
|
||||
export function formatHeartbeatInterval(seconds: number, t: Translations): string {
|
||||
if (seconds % 3600 === 0) {
|
||||
return t.statusStack.control.heartbeatEveryHours(seconds / 3600)
|
||||
}
|
||||
|
||||
if (seconds % 60 === 0) {
|
||||
return t.statusStack.control.heartbeatEveryMinutes(seconds / 60)
|
||||
}
|
||||
|
||||
return t.statusStack.control.heartbeatEverySeconds(seconds)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
|
||||
import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { SessionControlEntry } from '@/store/session-control'
|
||||
|
||||
import { SessionControlGoalSection } from './session-control-goal'
|
||||
import { SessionControlHeartbeatSection } from './session-control-heartbeat'
|
||||
import { SessionControlLoopSection } from './session-control-loop'
|
||||
|
||||
export interface SessionControlSectionsProps {
|
||||
entry: SessionControlEntry
|
||||
onSubmit?: (value: string, options?: SubmitTextOptions) => Promise<boolean> | boolean
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export const SessionControlSections = memo(function SessionControlSections({
|
||||
entry,
|
||||
onSubmit,
|
||||
sessionId
|
||||
}: SessionControlSectionsProps) {
|
||||
const { t } = useI18n()
|
||||
const ctrl = t.statusStack.control
|
||||
|
||||
const [feedbackError, setFeedbackError] = useState<string | null>(null)
|
||||
const [feedbackSuccess, setFeedbackSuccess] = useState<string | null>(null)
|
||||
const [dismissedStoreError, setDismissedStoreError] = useState<string | null>(null)
|
||||
|
||||
const handleFeedback = useCallback((error: string | null, success: string | null) => {
|
||||
setFeedbackError(error)
|
||||
setFeedbackSuccess(success)
|
||||
}, [])
|
||||
|
||||
const storeError = entry.error ? ctrl.actionFailed(entry.error) : null
|
||||
const displayError = feedbackError ?? (entry.error === dismissedStoreError ? null : storeError)
|
||||
|
||||
const handleDismissError = useCallback(() => {
|
||||
if (feedbackError) {
|
||||
setFeedbackError(null)
|
||||
} else {
|
||||
setDismissedStoreError(entry.error)
|
||||
}
|
||||
}, [entry.error, feedbackError])
|
||||
|
||||
const snapshot = entry.snapshot
|
||||
|
||||
if (!snapshot && !displayError) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{displayError && (
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 border-b border-destructive/20 bg-destructive/10 px-3 py-1.5 text-xs text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 truncate">
|
||||
<Codicon className="shrink-0" name="error" size="0.85rem" />
|
||||
<span className="truncate">{displayError}</span>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={ctrl.dismissError}
|
||||
className="size-6 shrink-0 p-0 text-destructive hover:bg-destructive/20"
|
||||
onClick={handleDismissError}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{feedbackSuccess && (
|
||||
<div aria-live="polite" className="sr-only">
|
||||
{feedbackSuccess}
|
||||
</div>
|
||||
)}
|
||||
{snapshot?.goal && (
|
||||
<SessionControlGoalSection
|
||||
goal={snapshot.goal}
|
||||
onFeedback={handleFeedback}
|
||||
onSubmit={onSubmit}
|
||||
pendingAction={entry.pendingAction}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
)}
|
||||
{snapshot?.loop && (
|
||||
<SessionControlLoopSection
|
||||
loop={snapshot.loop}
|
||||
onFeedback={handleFeedback}
|
||||
pendingAction={entry.pendingAction}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
)}
|
||||
{snapshot?.heartbeat && (
|
||||
<SessionControlHeartbeatSection
|
||||
heartbeat={snapshot.heartbeat}
|
||||
onFeedback={handleFeedback}
|
||||
pendingAction={entry.pendingAction}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -1991,6 +1991,91 @@ export const ar = defineLocale({
|
||||
stop: 'إيقاف',
|
||||
dismiss: 'تجاهل',
|
||||
exit: code => `خروج ${code}`,
|
||||
control: {
|
||||
goalActiveTurns: (turn, maxTurns) => `الجولة ${turn}/${maxTurns}`,
|
||||
goalDoneTurns: turns => `${turns} جولات`,
|
||||
goalTurn: turn => `الجولة ${turn}`,
|
||||
goalActions: 'إجراءات الهدف',
|
||||
viewDetails: 'عرض التفاصيل',
|
||||
addCriterion: 'إضافة معيار',
|
||||
addCriterionDialogTitle: 'إضافة معيار',
|
||||
addCriterionPlaceholder: 'أدخل نص المعيار...',
|
||||
criterionLabel: 'المعيار',
|
||||
pauseGoal: 'إيقاف الهدف مؤقتاً',
|
||||
resumeGoal: 'استئناف الهدف',
|
||||
resumeNow: 'استئناف الآن',
|
||||
clearGoal: 'مسح الهدف',
|
||||
clearGoalConfirmTitle: 'مسح الهدف؟',
|
||||
clearGoalConfirmBody: 'هل أنت متأكد من مسح الهدف النشط؟ لا يمكن التراجع عن هذا الإجراء.',
|
||||
copyCriterion: index => `نسخ المعيار ${index}`,
|
||||
removeCriterion: index => `إزالة المعيار ${index}`,
|
||||
removeCriterionConfirmTitle: index => `إزالة المعيار ${index}؟`,
|
||||
removeCriterionConfirmBody: index => `هل أنت متأكد من إزالة المعيار ${index}؟`,
|
||||
clearCriteria: 'مسح جميع المعايير',
|
||||
clearCriteriaConfirmTitle: 'مسح جميع المعايير؟',
|
||||
clearCriteriaConfirmBody: 'هل أنت متأكد من رغبتك في إزالة جميع المعايير من هذا الهدف؟',
|
||||
criteriaHeader: count => `المعايير · ${count}`,
|
||||
noCriteria: 'لا توجد معايير',
|
||||
goalDetailsTitle: 'تفاصيل الهدف',
|
||||
objectiveLabel: 'الهدف',
|
||||
contractOutcome: 'النتيجة',
|
||||
contractVerification: 'التحقق',
|
||||
contractConstraints: 'القيود',
|
||||
contractBoundaries: 'الحدود',
|
||||
contractStopWhen: 'التوقف عند',
|
||||
waitBarrierTitle: 'شرط الانتظار',
|
||||
waitUntil: target => `في الانتظار حتى ${target}`,
|
||||
waitSession: target => `في انتظار الجلسة ${target}`,
|
||||
waitPid: pid => `في انتظار العملية ${pid}`,
|
||||
qualityGatesTitle: 'بوابات الجودة',
|
||||
gateCommand: 'الأمر',
|
||||
gateAttempts: (attempts, max) => `${attempts}/${max} محاولات`,
|
||||
gateTimeout: seconds => `مهلة ${seconds} ثانية`,
|
||||
gateLastExit: code => (code === null ? 'قيد الانتظار' : `رمز الخروج: ${code}`),
|
||||
loopActive: 'التكرار نشط',
|
||||
loopPaused: 'التكرار متوقف مؤقتاً',
|
||||
loopDeferred: 'التكرار مؤجل',
|
||||
loopFinished: 'التكرار منتهي',
|
||||
loopRuns: runs => `${runs} مرات تشغيل`,
|
||||
loopRunCount: (current, total) => `تشغيل ${current}/${total}`,
|
||||
loopNext: time => `التالي ${time}`,
|
||||
loopEverySeconds: seconds => `كل ${seconds} ث`,
|
||||
loopEveryMinutes: minutes => `كل ${minutes} د`,
|
||||
loopEveryHours: hours => `كل ${hours} س`,
|
||||
loopSelfPaced: 'ذاتي الإيقاع',
|
||||
loopActions: 'إجراءات التكرار',
|
||||
pauseLoop: 'إيقاف التكرار مؤقتاً',
|
||||
resumeLoop: 'استئناف التكرار',
|
||||
stopLoop: 'إيقاف التكرار',
|
||||
stopLoopConfirmTitle: 'إيقاف التكرار؟',
|
||||
stopLoopConfirmBody: 'هل أنت متأكد من رغبتك في إيقاف هذا التكرار؟',
|
||||
dismissLoop: 'تجاهل التكرار',
|
||||
loopPromptLabel: 'الموجه',
|
||||
loopCadenceLabel: 'الإيقاع',
|
||||
loopUntilLabel: 'شرط الانتهاء',
|
||||
loopDeferredNotice: 'الهدف النشط يتحكم بالجلسة حالياً.',
|
||||
loopAwaitingResponse: 'في انتظار الاستجابة',
|
||||
heartbeatActive: 'النبض نشط',
|
||||
heartbeatPaused: 'النبض متوقف مؤقتاً',
|
||||
heartbeatEveryMinutes: minutes => `كل ${minutes} د`,
|
||||
heartbeatEveryHours: hours => `كل ${hours} س`,
|
||||
heartbeatEverySeconds: seconds => `كل ${seconds} ث`,
|
||||
heartbeatNext: time => `التالي ${time}`,
|
||||
heartbeatActions: 'إجراءات النبض',
|
||||
pauseHeartbeat: 'إيقاف النبض مؤقتاً',
|
||||
resumeHeartbeat: 'استئناف النبض',
|
||||
clearHeartbeat: 'مسح النبض',
|
||||
clearHeartbeatConfirmTitle: 'مسح النبض؟',
|
||||
clearHeartbeatConfirmBody: 'هل أنت متأكد من رغبتك في مسح هذا النبض؟',
|
||||
heartbeatFiredCount: count => `انطلق ${count} مرة`,
|
||||
actionFailed: msg => `فشل الإجراء: ${msg}`,
|
||||
actionSucceeded: 'تم الإجراء بنجاح',
|
||||
copySuccess: 'تم نسخ المعيار إلى الحافظة',
|
||||
copyFailure: 'فشل نسخ المعيار إلى الحافظة',
|
||||
continuationFailed: 'فشل إرسال متابعة الهدف',
|
||||
dismissError: 'تجاهل الخطأ',
|
||||
add: 'إضافة'
|
||||
},
|
||||
coding: {
|
||||
title: 'شجرة العمل',
|
||||
noBranch: 'لا يوجد فرع',
|
||||
|
||||
@@ -2709,6 +2709,91 @@ export const en: Translations = {
|
||||
stop: 'Stop',
|
||||
dismiss: 'Dismiss',
|
||||
exit: code => `exit ${code}`,
|
||||
control: {
|
||||
goalActiveTurns: (turn, maxTurns) => `Turn ${turn}/${maxTurns}`,
|
||||
goalDoneTurns: turns => `${turns} turn${turns === 1 ? '' : 's'}`,
|
||||
goalTurn: turn => `Turn ${turn}`,
|
||||
goalActions: 'Goal actions',
|
||||
viewDetails: 'View details',
|
||||
addCriterion: 'Add criterion',
|
||||
addCriterionDialogTitle: 'Add criterion',
|
||||
addCriterionPlaceholder: 'Enter criterion text...',
|
||||
criterionLabel: 'Criterion',
|
||||
pauseGoal: 'Pause goal',
|
||||
resumeGoal: 'Resume goal',
|
||||
resumeNow: 'Resume now',
|
||||
clearGoal: 'Clear goal',
|
||||
clearGoalConfirmTitle: 'Clear goal?',
|
||||
clearGoalConfirmBody: 'Are you sure you want to clear the active goal? This cannot be undone.',
|
||||
copyCriterion: index => `Copy criterion ${index}`,
|
||||
removeCriterion: index => `Remove criterion ${index}`,
|
||||
removeCriterionConfirmTitle: index => `Remove criterion ${index}?`,
|
||||
removeCriterionConfirmBody: index => `Are you sure you want to remove criterion ${index}?`,
|
||||
clearCriteria: 'Clear all criteria',
|
||||
clearCriteriaConfirmTitle: 'Clear all criteria?',
|
||||
clearCriteriaConfirmBody: 'Are you sure you want to remove all criteria from this goal?',
|
||||
criteriaHeader: count => `Criteria · ${count}`,
|
||||
noCriteria: 'No criteria',
|
||||
goalDetailsTitle: 'Goal details',
|
||||
objectiveLabel: 'Objective',
|
||||
contractOutcome: 'Outcome',
|
||||
contractVerification: 'Verification',
|
||||
contractConstraints: 'Constraints',
|
||||
contractBoundaries: 'Boundaries',
|
||||
contractStopWhen: 'Stop when',
|
||||
waitBarrierTitle: 'Wait condition',
|
||||
waitUntil: target => `Waiting until ${target}`,
|
||||
waitSession: target => `Waiting on session ${target}`,
|
||||
waitPid: pid => `Waiting on process ${pid}`,
|
||||
qualityGatesTitle: 'Quality gates',
|
||||
gateCommand: 'Command',
|
||||
gateAttempts: (attempts, max) => `${attempts}/${max} attempts`,
|
||||
gateTimeout: seconds => `${seconds}s timeout`,
|
||||
gateLastExit: code => (code === null ? 'Pending' : `Exit code: ${code}`),
|
||||
loopActive: 'Loop active',
|
||||
loopPaused: 'Loop paused',
|
||||
loopDeferred: 'Loop deferred',
|
||||
loopFinished: 'Loop finished',
|
||||
loopRuns: runs => `${runs} run${runs === 1 ? '' : 's'}`,
|
||||
loopRunCount: (current, total) => `Run ${current}/${total}`,
|
||||
loopNext: time => `next ${time}`,
|
||||
loopEverySeconds: seconds => `every ${seconds}s`,
|
||||
loopEveryMinutes: minutes => `every ${minutes}m`,
|
||||
loopEveryHours: hours => `every ${hours}h`,
|
||||
loopSelfPaced: 'self-paced',
|
||||
loopActions: 'Loop actions',
|
||||
pauseLoop: 'Pause loop',
|
||||
resumeLoop: 'Resume loop',
|
||||
stopLoop: 'Stop loop',
|
||||
stopLoopConfirmTitle: 'Stop loop?',
|
||||
stopLoopConfirmBody: 'Are you sure you want to stop this loop?',
|
||||
dismissLoop: 'Dismiss loop',
|
||||
loopPromptLabel: 'Prompt',
|
||||
loopCadenceLabel: 'Cadence',
|
||||
loopUntilLabel: 'Until condition',
|
||||
loopDeferredNotice: 'An active goal currently controls the session.',
|
||||
loopAwaitingResponse: 'Awaiting response',
|
||||
heartbeatActive: 'Heartbeat active',
|
||||
heartbeatPaused: 'Heartbeat paused',
|
||||
heartbeatEveryMinutes: minutes => `every ${minutes}m`,
|
||||
heartbeatEveryHours: hours => `every ${hours}h`,
|
||||
heartbeatEverySeconds: seconds => `every ${seconds}s`,
|
||||
heartbeatNext: time => `next ${time}`,
|
||||
heartbeatActions: 'Heartbeat actions',
|
||||
pauseHeartbeat: 'Pause heartbeat',
|
||||
resumeHeartbeat: 'Resume heartbeat',
|
||||
clearHeartbeat: 'Clear heartbeat',
|
||||
clearHeartbeatConfirmTitle: 'Clear heartbeat?',
|
||||
clearHeartbeatConfirmBody: 'Are you sure you want to clear this heartbeat?',
|
||||
heartbeatFiredCount: count => `Fired ${count} time${count === 1 ? '' : 's'}`,
|
||||
actionFailed: msg => `Action failed: ${msg}`,
|
||||
actionSucceeded: 'Action succeeded',
|
||||
copySuccess: 'Criterion copied to clipboard',
|
||||
copyFailure: 'Failed to copy criterion to clipboard',
|
||||
continuationFailed: 'Failed to submit goal continuation',
|
||||
dismissError: 'Dismiss error',
|
||||
add: 'Add'
|
||||
},
|
||||
coding: {
|
||||
title: 'Working tree',
|
||||
noBranch: 'No branch',
|
||||
|
||||
@@ -2334,6 +2334,91 @@ export const ja = defineLocale({
|
||||
stop: '停止',
|
||||
dismiss: '閉じる',
|
||||
exit: code => `終了コード ${code}`,
|
||||
control: {
|
||||
goalActiveTurns: (turn, maxTurns) => `ターン ${turn}/${maxTurns}`,
|
||||
goalDoneTurns: turns => `${turns} ターン`,
|
||||
goalTurn: turn => `ターン ${turn}`,
|
||||
goalActions: 'ゴールのアクション',
|
||||
viewDetails: '詳細を表示',
|
||||
addCriterion: '基準を追加',
|
||||
addCriterionDialogTitle: '基準の追加',
|
||||
addCriterionPlaceholder: '基準を入力...',
|
||||
criterionLabel: '基準',
|
||||
pauseGoal: 'ゴールを一時停止',
|
||||
resumeGoal: 'ゴールを再開',
|
||||
resumeNow: '今すぐ再開',
|
||||
clearGoal: 'ゴールをクリア',
|
||||
clearGoalConfirmTitle: 'ゴールをクリアしますか?',
|
||||
clearGoalConfirmBody: 'アクティブなゴールをクリアしてもよろしいですか?この操作は取り消せません。',
|
||||
copyCriterion: index => `基準 ${index} をコピー`,
|
||||
removeCriterion: index => `基準 ${index} を削除`,
|
||||
removeCriterionConfirmTitle: index => `基準 ${index} を削除しますか?`,
|
||||
removeCriterionConfirmBody: index => `基準 ${index} を削除してもよろしいですか?`,
|
||||
clearCriteria: 'すべての基準をクリア',
|
||||
clearCriteriaConfirmTitle: 'すべての基準をクリアしますか?',
|
||||
clearCriteriaConfirmBody: 'このゴールからすべての基準を削除してもよろしいですか?',
|
||||
criteriaHeader: count => `基準 · ${count}`,
|
||||
noCriteria: '基準なし',
|
||||
goalDetailsTitle: 'ゴールの詳細',
|
||||
objectiveLabel: '目標',
|
||||
contractOutcome: '成果',
|
||||
contractVerification: '検証',
|
||||
contractConstraints: '制約',
|
||||
contractBoundaries: '境界',
|
||||
contractStopWhen: '終了条件',
|
||||
waitBarrierTitle: '待機条件',
|
||||
waitUntil: target => `${target} まで待機`,
|
||||
waitSession: target => `セッション ${target} を待機中`,
|
||||
waitPid: pid => `プロセス ${pid} を待機中`,
|
||||
qualityGatesTitle: '品質ゲート',
|
||||
gateCommand: 'コマンド',
|
||||
gateAttempts: (attempts, max) => `試行 ${attempts}/${max}`,
|
||||
gateTimeout: seconds => `タイムアウト ${seconds}秒`,
|
||||
gateLastExit: code => (code === null ? '保留中' : `終了コード: ${code}`),
|
||||
loopActive: 'ループ実行中',
|
||||
loopPaused: 'ループ一時停止',
|
||||
loopDeferred: 'ループ保留中',
|
||||
loopFinished: 'ループ終了',
|
||||
loopRuns: runs => `${runs} 回実行`,
|
||||
loopRunCount: (current, total) => `実行 ${current}/${total}`,
|
||||
loopNext: time => `次回 ${time}`,
|
||||
loopEverySeconds: seconds => `${seconds}秒ごと`,
|
||||
loopEveryMinutes: minutes => `${minutes}分ごと`,
|
||||
loopEveryHours: hours => `${hours}時間ごと`,
|
||||
loopSelfPaced: 'セルフペース',
|
||||
loopActions: 'ループのアクション',
|
||||
pauseLoop: 'ループを一時停止',
|
||||
resumeLoop: 'ループを再開',
|
||||
stopLoop: 'ループを停止',
|
||||
stopLoopConfirmTitle: 'ループを停止しますか?',
|
||||
stopLoopConfirmBody: 'このループを停止してもよろしいですか?',
|
||||
dismissLoop: 'ループを閉じる',
|
||||
loopPromptLabel: 'プロンプト',
|
||||
loopCadenceLabel: 'ペース',
|
||||
loopUntilLabel: '終了条件',
|
||||
loopDeferredNotice: '現在アクティブなゴールがセッションを制御しています。',
|
||||
loopAwaitingResponse: '応答待ち',
|
||||
heartbeatActive: 'ハートビート実行中',
|
||||
heartbeatPaused: 'ハートビート一時停止',
|
||||
heartbeatEveryMinutes: minutes => `${minutes}分ごと`,
|
||||
heartbeatEveryHours: hours => `${hours}時間ごと`,
|
||||
heartbeatEverySeconds: seconds => `${seconds}秒ごと`,
|
||||
heartbeatNext: time => `次回 ${time}`,
|
||||
heartbeatActions: 'ハートビートのアクション',
|
||||
pauseHeartbeat: 'ハートビートを一時停止',
|
||||
resumeHeartbeat: 'ハートビートを再開',
|
||||
clearHeartbeat: 'ハートビートをクリア',
|
||||
clearHeartbeatConfirmTitle: 'ハートビートをクリアしますか?',
|
||||
clearHeartbeatConfirmBody: 'このハートビートをクリアしてもよろしいですか?',
|
||||
heartbeatFiredCount: count => `${count} 回発火`,
|
||||
actionFailed: msg => `アクションに失敗しました: ${msg}`,
|
||||
actionSucceeded: 'アクションが完了しました',
|
||||
copySuccess: '基準をクリップボードにコピーしました',
|
||||
copyFailure: '基準のコピーに失敗しました',
|
||||
continuationFailed: 'ゴールの継続送信に失敗しました',
|
||||
dismissError: 'エラーを閉じる',
|
||||
add: '追加'
|
||||
},
|
||||
coding: {
|
||||
title: 'ワークツリー',
|
||||
noBranch: 'ブランチなし',
|
||||
|
||||
@@ -2745,6 +2745,91 @@ export const ru = defineLocale({
|
||||
stop: 'Стоп',
|
||||
dismiss: 'Скрыть',
|
||||
exit: code => `exit ${code}`,
|
||||
control: {
|
||||
goalActiveTurns: (turn, maxTurns) => `Ход ${turn}/${maxTurns}`,
|
||||
goalDoneTurns: turns => `${turns} ходов`,
|
||||
goalTurn: turn => `Ход ${turn}`,
|
||||
goalActions: 'Действия с целью',
|
||||
viewDetails: 'Подробнее',
|
||||
addCriterion: 'Добавить критерий',
|
||||
addCriterionDialogTitle: 'Добавить критерий',
|
||||
addCriterionPlaceholder: 'Введите текст критерия...',
|
||||
criterionLabel: 'Критерий',
|
||||
pauseGoal: 'Приостановить цель',
|
||||
resumeGoal: 'Возобновить цель',
|
||||
resumeNow: 'Возобновить сейчас',
|
||||
clearGoal: 'Очистить цель',
|
||||
clearGoalConfirmTitle: 'Очистить цель?',
|
||||
clearGoalConfirmBody: 'Вы уверены, что хотите очистить активную цель? Это действие необратимо.',
|
||||
copyCriterion: index => `Скопировать критерий ${index}`,
|
||||
removeCriterion: index => `Удалить критерий ${index}`,
|
||||
removeCriterionConfirmTitle: index => `Удалить критерий ${index}?`,
|
||||
removeCriterionConfirmBody: index => `Вы уверены, что хотите удалить критерий ${index}?`,
|
||||
clearCriteria: 'Очистить все критерии',
|
||||
clearCriteriaConfirmTitle: 'Очистить все критерии?',
|
||||
clearCriteriaConfirmBody: 'Вы уверены, что хотите удалить все критерии этой цели?',
|
||||
criteriaHeader: count => `Критерии · ${count}`,
|
||||
noCriteria: 'Нет критериев',
|
||||
goalDetailsTitle: 'Детали цели',
|
||||
objectiveLabel: 'Задача',
|
||||
contractOutcome: 'Результат',
|
||||
contractVerification: 'Проверка',
|
||||
contractConstraints: 'Ограничения',
|
||||
contractBoundaries: 'Границы',
|
||||
contractStopWhen: 'Условие остановки',
|
||||
waitBarrierTitle: 'Условие ожидания',
|
||||
waitUntil: target => `Ожидание до ${target}`,
|
||||
waitSession: target => `Ожидание сессии ${target}`,
|
||||
waitPid: pid => `Ожидание процесса ${pid}`,
|
||||
qualityGatesTitle: 'Контроли качества',
|
||||
gateCommand: 'Команда',
|
||||
gateAttempts: (attempts, max) => `${attempts}/${max} попыток`,
|
||||
gateTimeout: seconds => `таймаут ${seconds}с`,
|
||||
gateLastExit: code => (code === null ? 'В ожидании' : `Код возврата: ${code}`),
|
||||
loopActive: 'Цикл активен',
|
||||
loopPaused: 'Цикл приостановлен',
|
||||
loopDeferred: 'Цикл отложен',
|
||||
loopFinished: 'Цикл завершен',
|
||||
loopRuns: runs => `${runs} запусков`,
|
||||
loopRunCount: (current, total) => `Запуск ${current}/${total}`,
|
||||
loopNext: time => `следующий ${time}`,
|
||||
loopEverySeconds: seconds => `каждые ${seconds}с`,
|
||||
loopEveryMinutes: minutes => `каждые ${minutes}м`,
|
||||
loopEveryHours: hours => `каждые ${hours}ч`,
|
||||
loopSelfPaced: 'автономный',
|
||||
loopActions: 'Действия цикла',
|
||||
pauseLoop: 'Приостановить цикл',
|
||||
resumeLoop: 'Возобновить цикл',
|
||||
stopLoop: 'Остановить цикл',
|
||||
stopLoopConfirmTitle: 'Остановить цикл?',
|
||||
stopLoopConfirmBody: 'Вы уверены, что хотите остановить этот цикл?',
|
||||
dismissLoop: 'Закрыть цикл',
|
||||
loopPromptLabel: 'Промпт',
|
||||
loopCadenceLabel: 'Интервал',
|
||||
loopUntilLabel: 'Условие окончания',
|
||||
loopDeferredNotice: 'Активная цель в данный момент управляет сессией.',
|
||||
loopAwaitingResponse: 'Ожидание ответа',
|
||||
heartbeatActive: 'Пульс активен',
|
||||
heartbeatPaused: 'Пульс приостановлен',
|
||||
heartbeatEveryMinutes: minutes => `каждые ${minutes}м`,
|
||||
heartbeatEveryHours: hours => `каждые ${hours}ч`,
|
||||
heartbeatEverySeconds: seconds => `каждые ${seconds}с`,
|
||||
heartbeatNext: time => `следующий ${time}`,
|
||||
heartbeatActions: 'Действия пульса',
|
||||
pauseHeartbeat: 'Приостановить пульс',
|
||||
resumeHeartbeat: 'Возобновить пульс',
|
||||
clearHeartbeat: 'Очистить пульс',
|
||||
clearHeartbeatConfirmTitle: 'Очистить пульс?',
|
||||
clearHeartbeatConfirmBody: 'Вы уверены, что хотите очистить этот пульс?',
|
||||
heartbeatFiredCount: count => `Сработал ${count} раз`,
|
||||
actionFailed: msg => `Ошибка действия: ${msg}`,
|
||||
actionSucceeded: 'Действие выполнено успешно',
|
||||
copySuccess: 'Критерий скопирован в буфер обмена',
|
||||
copyFailure: 'Не удалось скопировать критерий в буфер обмена',
|
||||
continuationFailed: 'Не удалось отправить продолжение цели',
|
||||
dismissError: 'Скрыть ошибку',
|
||||
add: 'Добавить'
|
||||
},
|
||||
coding: {
|
||||
title: 'Рабочее дерево',
|
||||
noBranch: 'Без ветки',
|
||||
|
||||
@@ -2303,6 +2303,91 @@ export interface Translations {
|
||||
stop: string
|
||||
dismiss: string
|
||||
exit: (code: number) => string
|
||||
control: {
|
||||
goalActiveTurns: (turn: number, maxTurns: number) => string
|
||||
goalDoneTurns: (turns: number) => string
|
||||
goalTurn: (turn: number) => string
|
||||
goalActions: string
|
||||
viewDetails: string
|
||||
addCriterion: string
|
||||
addCriterionDialogTitle: string
|
||||
addCriterionPlaceholder: string
|
||||
criterionLabel: string
|
||||
pauseGoal: string
|
||||
resumeGoal: string
|
||||
resumeNow: string
|
||||
clearGoal: string
|
||||
clearGoalConfirmTitle: string
|
||||
clearGoalConfirmBody: string
|
||||
copyCriterion: (index: number) => string
|
||||
removeCriterion: (index: number) => string
|
||||
removeCriterionConfirmTitle: (index: number) => string
|
||||
removeCriterionConfirmBody: (index: number) => string
|
||||
clearCriteria: string
|
||||
clearCriteriaConfirmTitle: string
|
||||
clearCriteriaConfirmBody: string
|
||||
criteriaHeader: (count: number) => string
|
||||
noCriteria: string
|
||||
goalDetailsTitle: string
|
||||
objectiveLabel: string
|
||||
contractOutcome: string
|
||||
contractVerification: string
|
||||
contractConstraints: string
|
||||
contractBoundaries: string
|
||||
contractStopWhen: string
|
||||
waitBarrierTitle: string
|
||||
waitUntil: (target: string) => string
|
||||
waitSession: (target: string) => string
|
||||
waitPid: (pid: number) => string
|
||||
qualityGatesTitle: string
|
||||
gateCommand: string
|
||||
gateAttempts: (attempts: number, max: number) => string
|
||||
gateTimeout: (seconds: number) => string
|
||||
gateLastExit: (code: number | null) => string
|
||||
loopActive: string
|
||||
loopPaused: string
|
||||
loopDeferred: string
|
||||
loopFinished: string
|
||||
loopRuns: (runs: number) => string
|
||||
loopRunCount: (current: number, total: number) => string
|
||||
loopNext: (time: string) => string
|
||||
loopEverySeconds: (seconds: number) => string
|
||||
loopEveryMinutes: (minutes: number) => string
|
||||
loopEveryHours: (hours: number) => string
|
||||
loopSelfPaced: string
|
||||
loopActions: string
|
||||
pauseLoop: string
|
||||
resumeLoop: string
|
||||
stopLoop: string
|
||||
stopLoopConfirmTitle: string
|
||||
stopLoopConfirmBody: string
|
||||
dismissLoop: string
|
||||
loopPromptLabel: string
|
||||
loopCadenceLabel: string
|
||||
loopUntilLabel: string
|
||||
loopDeferredNotice: string
|
||||
loopAwaitingResponse: string
|
||||
heartbeatActive: string
|
||||
heartbeatPaused: string
|
||||
heartbeatEveryMinutes: (minutes: number) => string
|
||||
heartbeatEveryHours: (hours: number) => string
|
||||
heartbeatEverySeconds: (seconds: number) => string
|
||||
heartbeatNext: (time: string) => string
|
||||
heartbeatActions: string
|
||||
pauseHeartbeat: string
|
||||
resumeHeartbeat: string
|
||||
clearHeartbeat: string
|
||||
clearHeartbeatConfirmTitle: string
|
||||
clearHeartbeatConfirmBody: string
|
||||
heartbeatFiredCount: (count: number) => string
|
||||
actionFailed: (msg: string) => string
|
||||
actionSucceeded: string
|
||||
copySuccess: string
|
||||
copyFailure: string
|
||||
continuationFailed: string
|
||||
dismissError: string
|
||||
add: string
|
||||
}
|
||||
coding: {
|
||||
title: string
|
||||
noBranch: string
|
||||
|
||||
@@ -2254,6 +2254,91 @@ export const zhHant = defineLocale({
|
||||
stop: '停止',
|
||||
dismiss: '關閉',
|
||||
exit: code => `結束碼 ${code}`,
|
||||
control: {
|
||||
goalActiveTurns: (turn, maxTurns) => `第 ${turn}/${maxTurns} 輪`,
|
||||
goalDoneTurns: turns => `共 ${turns} 輪`,
|
||||
goalTurn: turn => `第 ${turn} 輪`,
|
||||
goalActions: '目標操作',
|
||||
viewDetails: '檢視詳細資訊',
|
||||
addCriterion: '新增條件',
|
||||
addCriterionDialogTitle: '新增條件',
|
||||
addCriterionPlaceholder: '輸入條件內容...',
|
||||
criterionLabel: '條件',
|
||||
pauseGoal: '暫停目標',
|
||||
resumeGoal: '繼續目標',
|
||||
resumeNow: '立即繼續',
|
||||
clearGoal: '清除目標',
|
||||
clearGoalConfirmTitle: '清除目標?',
|
||||
clearGoalConfirmBody: '確定要清除目前活躍的目標嗎?此動作無法復原。',
|
||||
copyCriterion: index => `複製條件 ${index}`,
|
||||
removeCriterion: index => `移除條件 ${index}`,
|
||||
removeCriterionConfirmTitle: index => `移除條件 ${index}?`,
|
||||
removeCriterionConfirmBody: index => `確定要移除條件 ${index} 嗎?`,
|
||||
clearCriteria: '清除全部條件',
|
||||
clearCriteriaConfirmTitle: '清除全部條件?',
|
||||
clearCriteriaConfirmBody: '確定要移除此目標下的所有條件嗎?',
|
||||
criteriaHeader: count => `條件 · ${count}`,
|
||||
noCriteria: '無條件',
|
||||
goalDetailsTitle: '目標詳細資訊',
|
||||
objectiveLabel: '目標內容',
|
||||
contractOutcome: '預期成果',
|
||||
contractVerification: '驗證方式',
|
||||
contractConstraints: '限制條件',
|
||||
contractBoundaries: '邊界',
|
||||
contractStopWhen: '停止條件',
|
||||
waitBarrierTitle: '等待條件',
|
||||
waitUntil: target => `等待至 ${target}`,
|
||||
waitSession: target => `等待工作階段 ${target}`,
|
||||
waitPid: pid => `等待處理程序 ${pid}`,
|
||||
qualityGatesTitle: '品質關卡',
|
||||
gateCommand: '命令',
|
||||
gateAttempts: (attempts, max) => `嘗試 ${attempts}/${max}`,
|
||||
gateTimeout: seconds => `逾時 ${seconds} 秒`,
|
||||
gateLastExit: code => (code === null ? '擱置中' : `結束代碼: ${code}`),
|
||||
loopActive: '循環進行中',
|
||||
loopPaused: '循環已暫停',
|
||||
loopDeferred: '循環已延後',
|
||||
loopFinished: '循環已完成',
|
||||
loopRuns: runs => `執行 ${runs} 次`,
|
||||
loopRunCount: (current, total) => `執行 ${current}/${total}`,
|
||||
loopNext: time => `下次 ${time}`,
|
||||
loopEverySeconds: seconds => `每 ${seconds} 秒`,
|
||||
loopEveryMinutes: minutes => `每 ${minutes} 分鐘`,
|
||||
loopEveryHours: hours => `每 ${hours} 小時`,
|
||||
loopSelfPaced: '自訂步調',
|
||||
loopActions: '循環操作',
|
||||
pauseLoop: '暫停循環',
|
||||
resumeLoop: '繼續循環',
|
||||
stopLoop: '停止循環',
|
||||
stopLoopConfirmTitle: '停止循環?',
|
||||
stopLoopConfirmBody: '確定要停止此循環嗎?',
|
||||
dismissLoop: '關閉循環',
|
||||
loopPromptLabel: '提示詞',
|
||||
loopCadenceLabel: '頻率',
|
||||
loopUntilLabel: '截止條件',
|
||||
loopDeferredNotice: '目前由進行中的目標控制工作階段。',
|
||||
loopAwaitingResponse: '等待回應中',
|
||||
heartbeatActive: '活動訊號進行中',
|
||||
heartbeatPaused: '活動訊號已暫停',
|
||||
heartbeatEveryMinutes: minutes => `每 ${minutes} 分鐘`,
|
||||
heartbeatEveryHours: hours => `每 ${hours} 小時`,
|
||||
heartbeatEverySeconds: seconds => `每 ${seconds} 秒`,
|
||||
heartbeatNext: time => `下次 ${time}`,
|
||||
heartbeatActions: '活動訊號操作',
|
||||
pauseHeartbeat: '暫停活動訊號',
|
||||
resumeHeartbeat: '繼續活動訊號',
|
||||
clearHeartbeat: '清除活動訊號',
|
||||
clearHeartbeatConfirmTitle: '清除活動訊號?',
|
||||
clearHeartbeatConfirmBody: '確定要清除此活動訊號嗎?',
|
||||
heartbeatFiredCount: count => `已觸發 ${count} 次`,
|
||||
actionFailed: msg => `操作失敗: ${msg}`,
|
||||
actionSucceeded: '操作成功',
|
||||
copySuccess: '已將條件複製到剪貼板',
|
||||
copyFailure: '複製條件到剪貼板失敗',
|
||||
continuationFailed: '提交目標延續內容失敗',
|
||||
dismissError: '關閉錯誤',
|
||||
add: '新增'
|
||||
},
|
||||
coding: {
|
||||
title: '工作區',
|
||||
noBranch: '無分支',
|
||||
|
||||
@@ -2873,6 +2873,91 @@ export const zh: Translations = {
|
||||
stop: '停止',
|
||||
dismiss: '关闭',
|
||||
exit: code => `退出码 ${code}`,
|
||||
control: {
|
||||
goalActiveTurns: (turn, maxTurns) => `第 ${turn}/${maxTurns} 轮`,
|
||||
goalDoneTurns: turns => `共 ${turns} 轮`,
|
||||
goalTurn: turn => `第 ${turn} 轮`,
|
||||
goalActions: '目标操作',
|
||||
viewDetails: '查看详情',
|
||||
addCriterion: '添加条件',
|
||||
addCriterionDialogTitle: '添加条件',
|
||||
addCriterionPlaceholder: '输入条件内容...',
|
||||
criterionLabel: '条件',
|
||||
pauseGoal: '暂停目标',
|
||||
resumeGoal: '恢复目标',
|
||||
resumeNow: '立即恢复',
|
||||
clearGoal: '清除目标',
|
||||
clearGoalConfirmTitle: '清除目标?',
|
||||
clearGoalConfirmBody: '确定要清除当前活跃的目标吗?此操作无法撤销。',
|
||||
copyCriterion: index => `复制条件 ${index}`,
|
||||
removeCriterion: index => `移除条件 ${index}`,
|
||||
removeCriterionConfirmTitle: index => `移除条件 ${index}?`,
|
||||
removeCriterionConfirmBody: index => `确定要移除条件 ${index} 吗?`,
|
||||
clearCriteria: '清除全部条件',
|
||||
clearCriteriaConfirmTitle: '清除全部条件?',
|
||||
clearCriteriaConfirmBody: '确定要移除此目标下的所有条件吗?',
|
||||
criteriaHeader: count => `标准 · ${count}`,
|
||||
noCriteria: '无条件',
|
||||
goalDetailsTitle: '目标详情',
|
||||
objectiveLabel: '目标内容',
|
||||
contractOutcome: '预期结果',
|
||||
contractVerification: '验证方式',
|
||||
contractConstraints: '约束条件',
|
||||
contractBoundaries: '边界条件',
|
||||
contractStopWhen: '停止条件',
|
||||
waitBarrierTitle: '等待状态',
|
||||
waitUntil: target => `等待至 ${target}`,
|
||||
waitSession: target => `等待会话 ${target}`,
|
||||
waitPid: pid => `等待进程 ${pid}`,
|
||||
qualityGatesTitle: '质量关卡',
|
||||
gateCommand: '命令',
|
||||
gateAttempts: (attempts, max) => `尝试 ${attempts}/${max}`,
|
||||
gateTimeout: seconds => `超时 ${seconds} 秒`,
|
||||
gateLastExit: code => (code === null ? '等待中' : `退出代码: ${code}`),
|
||||
loopActive: '循环活跃',
|
||||
loopPaused: '循环暂停',
|
||||
loopDeferred: '循环已推迟',
|
||||
loopFinished: '循环已完成',
|
||||
loopRuns: runs => `运行 ${runs} 次`,
|
||||
loopRunCount: (current, total) => `运行 ${current}/${total}`,
|
||||
loopNext: time => `下次 ${time}`,
|
||||
loopEverySeconds: seconds => `每 ${seconds} 秒`,
|
||||
loopEveryMinutes: minutes => `每 ${minutes} 分钟`,
|
||||
loopEveryHours: hours => `每 ${hours} 小时`,
|
||||
loopSelfPaced: '自节奏',
|
||||
loopActions: '循环操作',
|
||||
pauseLoop: '暂停循环',
|
||||
resumeLoop: '恢复循环',
|
||||
stopLoop: '停止循环',
|
||||
stopLoopConfirmTitle: '停止循环?',
|
||||
stopLoopConfirmBody: '确定要停止此循环吗?',
|
||||
dismissLoop: '关闭循环',
|
||||
loopPromptLabel: '提示词',
|
||||
loopCadenceLabel: '节奏',
|
||||
loopUntilLabel: '终止条件',
|
||||
loopDeferredNotice: '活跃目标当前正在控制会话。',
|
||||
loopAwaitingResponse: '等待响应中',
|
||||
heartbeatActive: '心跳活跃',
|
||||
heartbeatPaused: '心跳暂停',
|
||||
heartbeatEveryMinutes: minutes => `每 ${minutes} 分钟`,
|
||||
heartbeatEveryHours: hours => `每 ${hours} 小时`,
|
||||
heartbeatEverySeconds: seconds => `每 ${seconds} 秒`,
|
||||
heartbeatNext: time => `下次 ${time}`,
|
||||
heartbeatActions: '心跳操作',
|
||||
pauseHeartbeat: '暂停心跳',
|
||||
resumeHeartbeat: '恢复心跳',
|
||||
clearHeartbeat: '清除心跳',
|
||||
clearHeartbeatConfirmTitle: '清除心跳?',
|
||||
clearHeartbeatConfirmBody: '确定要清除该心跳吗?',
|
||||
heartbeatFiredCount: count => `已触发 ${count} 次`,
|
||||
actionFailed: msg => `操作失败: ${msg}`,
|
||||
actionSucceeded: '操作成功',
|
||||
copySuccess: '已将条件复制到剪贴板',
|
||||
copyFailure: '复制条件到剪贴板失败',
|
||||
continuationFailed: '提交目标后续内容失败',
|
||||
dismissError: '关闭错误',
|
||||
add: '添加'
|
||||
},
|
||||
coding: {
|
||||
title: '工作区',
|
||||
noBranch: '无分支',
|
||||
|
||||
Reference in New Issue
Block a user