mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
feat(remote-control): 优化 Web 展示、状态同步与桥接控制流程 (#288)
Co-authored-by: chengzifeng <chengzifeng@meituan.com>
This commit is contained in:
30
src/utils/__tests__/messageQueueManager.test.ts
Normal file
30
src/utils/__tests__/messageQueueManager.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { isSlashCommand } from '../messageQueueManager.js'
|
||||
|
||||
describe('messageQueueManager.isSlashCommand', () => {
|
||||
test('treats normal slash commands as slash commands', () => {
|
||||
expect(isSlashCommand({ value: '/help', mode: 'prompt' } as any)).toBe(true)
|
||||
})
|
||||
|
||||
test('keeps remote bridge slash commands slash-routed when bridgeOrigin is set', () => {
|
||||
expect(
|
||||
isSlashCommand({
|
||||
value: '/proactive',
|
||||
mode: 'prompt',
|
||||
skipSlashCommands: true,
|
||||
bridgeOrigin: true,
|
||||
} as any),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('keeps skipSlashCommands text-only when bridgeOrigin is absent', () => {
|
||||
expect(
|
||||
isSlashCommand({
|
||||
value: '/proactive',
|
||||
mode: 'prompt',
|
||||
skipSlashCommands: true,
|
||||
} as any),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
174
src/utils/__tests__/sessionState.test.ts
Normal file
174
src/utils/__tests__/sessionState.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
notifyAutomationStateChanged,
|
||||
notifySessionStateChanged,
|
||||
notifySessionMetadataChanged,
|
||||
resetSessionStateForTests,
|
||||
setSessionMetadataChangedListener,
|
||||
} from '../sessionState'
|
||||
|
||||
describe('sessionState metadata replay', () => {
|
||||
beforeEach(() => {
|
||||
resetSessionStateForTests()
|
||||
})
|
||||
|
||||
test('replays cached automation state to listeners that attach later', () => {
|
||||
const seen: Array<Record<string, unknown>> = []
|
||||
|
||||
notifyAutomationStateChanged({
|
||||
enabled: true,
|
||||
phase: 'standby',
|
||||
next_tick_at: 123,
|
||||
sleep_until: null,
|
||||
})
|
||||
|
||||
setSessionMetadataChangedListener(
|
||||
metadata => {
|
||||
seen.push(metadata as Record<string, unknown>)
|
||||
},
|
||||
{ replayCurrent: true },
|
||||
)
|
||||
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
automation_state: {
|
||||
enabled: true,
|
||||
phase: 'standby',
|
||||
next_tick_at: 123,
|
||||
sleep_until: null,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('dedupes identical automation states after replay but forwards changes', () => {
|
||||
const seen: Array<Record<string, unknown>> = []
|
||||
|
||||
notifyAutomationStateChanged({
|
||||
enabled: true,
|
||||
phase: 'standby',
|
||||
next_tick_at: 123,
|
||||
sleep_until: null,
|
||||
})
|
||||
setSessionMetadataChangedListener(
|
||||
metadata => {
|
||||
seen.push(metadata as Record<string, unknown>)
|
||||
},
|
||||
{ replayCurrent: true },
|
||||
)
|
||||
|
||||
notifyAutomationStateChanged({
|
||||
enabled: true,
|
||||
phase: 'standby',
|
||||
next_tick_at: 123,
|
||||
sleep_until: null,
|
||||
})
|
||||
notifyAutomationStateChanged({
|
||||
enabled: true,
|
||||
phase: 'sleeping',
|
||||
next_tick_at: null,
|
||||
sleep_until: 456,
|
||||
})
|
||||
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
automation_state: {
|
||||
enabled: true,
|
||||
phase: 'standby',
|
||||
next_tick_at: 123,
|
||||
sleep_until: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
automation_state: {
|
||||
enabled: true,
|
||||
phase: 'sleeping',
|
||||
next_tick_at: null,
|
||||
sleep_until: 456,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('replays merged metadata snapshots instead of only the latest delta', () => {
|
||||
const seen: Array<Record<string, unknown>> = []
|
||||
|
||||
notifySessionMetadataChanged({ model: 'claude-sonnet-4-6' })
|
||||
notifyAutomationStateChanged({
|
||||
enabled: true,
|
||||
phase: 'sleeping',
|
||||
next_tick_at: null,
|
||||
sleep_until: 456,
|
||||
})
|
||||
|
||||
setSessionMetadataChangedListener(
|
||||
metadata => {
|
||||
seen.push(metadata as Record<string, unknown>)
|
||||
},
|
||||
{ replayCurrent: true },
|
||||
)
|
||||
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
model: 'claude-sonnet-4-6',
|
||||
automation_state: {
|
||||
enabled: true,
|
||||
phase: 'sleeping',
|
||||
next_tick_at: null,
|
||||
sleep_until: 456,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('replays pending_action metadata cached through session-state transitions', () => {
|
||||
const seen: Array<Record<string, unknown>> = []
|
||||
|
||||
notifySessionStateChanged('requires_action', {
|
||||
tool_name: 'Edit',
|
||||
action_description: 'Edit src/utils/sessionState.ts',
|
||||
tool_use_id: 'toolu_123',
|
||||
request_id: 'req_123',
|
||||
input: { path: 'src/utils/sessionState.ts' },
|
||||
})
|
||||
|
||||
setSessionMetadataChangedListener(
|
||||
metadata => {
|
||||
seen.push(metadata as Record<string, unknown>)
|
||||
},
|
||||
{ replayCurrent: true },
|
||||
)
|
||||
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
pending_action: {
|
||||
tool_name: 'Edit',
|
||||
action_description: 'Edit src/utils/sessionState.ts',
|
||||
tool_use_id: 'toolu_123',
|
||||
request_id: 'req_123',
|
||||
input: { path: 'src/utils/sessionState.ts' },
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('replays cleared task_summary metadata after returning to idle', () => {
|
||||
const seen: Array<Record<string, unknown>> = []
|
||||
|
||||
notifySessionMetadataChanged({ task_summary: 'Running regression suite' })
|
||||
notifySessionStateChanged('idle')
|
||||
|
||||
setSessionMetadataChangedListener(
|
||||
metadata => {
|
||||
seen.push(metadata as Record<string, unknown>)
|
||||
},
|
||||
{ replayCurrent: true },
|
||||
)
|
||||
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
task_summary: null,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
84
src/utils/__tests__/taskStateMessage.test.ts
Normal file
84
src/utils/__tests__/taskStateMessage.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
buildTaskStateMessage,
|
||||
getTaskStateSnapshotKey,
|
||||
} from "../taskStateMessage";
|
||||
|
||||
describe("buildTaskStateMessage", () => {
|
||||
test("filters internal tasks and preserves public task fields", () => {
|
||||
const message = buildTaskStateMessage("tasklist", [
|
||||
{
|
||||
id: "1",
|
||||
subject: "Visible task",
|
||||
description: "Shown in web UI",
|
||||
activeForm: "Doing visible task",
|
||||
status: "in_progress",
|
||||
owner: "agent-1",
|
||||
blocks: ["2"],
|
||||
blockedBy: [],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
subject: "Internal task",
|
||||
description: "Hidden from web UI",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
metadata: { _internal: true },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(message.type).toBe("task_state");
|
||||
expect(message.task_list_id).toBe("tasklist");
|
||||
expect(message.uuid).toEqual(expect.any(String));
|
||||
expect(message.tasks).toEqual([
|
||||
{
|
||||
id: "1",
|
||||
subject: "Visible task",
|
||||
description: "Shown in web UI",
|
||||
activeForm: "Doing visible task",
|
||||
status: "in_progress",
|
||||
owner: "agent-1",
|
||||
blocks: ["2"],
|
||||
blockedBy: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds a stable snapshot key for equivalent public tasks", () => {
|
||||
const tasks = [
|
||||
{
|
||||
id: "2",
|
||||
subject: "Second",
|
||||
description: "Second task",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
},
|
||||
{
|
||||
id: "1",
|
||||
subject: "First",
|
||||
description: "First task",
|
||||
status: "in_progress",
|
||||
blocks: ["2"],
|
||||
blockedBy: [],
|
||||
},
|
||||
{
|
||||
id: "internal",
|
||||
subject: "Internal task",
|
||||
description: "Hidden",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
metadata: { _internal: true },
|
||||
},
|
||||
];
|
||||
|
||||
const firstKey = getTaskStateSnapshotKey("tasklist", tasks as any);
|
||||
const secondKey = getTaskStateSnapshotKey("tasklist", [...tasks].reverse() as any);
|
||||
const message = buildTaskStateMessage("tasklist", tasks as any);
|
||||
|
||||
expect(firstKey).toBe(secondKey);
|
||||
expect(message.tasks.map(task => task.id)).toEqual(["1", "2"]);
|
||||
});
|
||||
});
|
||||
@@ -121,6 +121,8 @@ export type HandlePromptSubmitParams = BaseExecutionParams & {
|
||||
* trigger local slash commands or skills.
|
||||
*/
|
||||
skipSlashCommands?: boolean
|
||||
/** Preserves that the input originated from Remote Control when queued. */
|
||||
bridgeOrigin?: boolean
|
||||
}
|
||||
|
||||
export async function handlePromptSubmit(
|
||||
@@ -147,6 +149,7 @@ export async function handlePromptSubmit(
|
||||
queuedCommands,
|
||||
uuid,
|
||||
skipSlashCommands,
|
||||
bridgeOrigin,
|
||||
} = params
|
||||
|
||||
const { setCursorOffset, clearBuffer, resetHistory } = helpers
|
||||
@@ -345,6 +348,7 @@ export async function handlePromptSubmit(
|
||||
mode,
|
||||
pastedContents: hasImages ? pastedContents : undefined,
|
||||
skipSlashCommands,
|
||||
bridgeOrigin,
|
||||
uuid,
|
||||
})
|
||||
|
||||
@@ -368,6 +372,7 @@ export async function handlePromptSubmit(
|
||||
mode,
|
||||
pastedContents: hasImages ? pastedContents : undefined,
|
||||
skipSlashCommands,
|
||||
bridgeOrigin,
|
||||
uuid,
|
||||
}
|
||||
|
||||
|
||||
@@ -535,13 +535,14 @@ export function getCommandsByMaxPriority(
|
||||
* Returns true if the command is a slash command that should be routed through
|
||||
* processSlashCommand rather than sent to the model as text.
|
||||
*
|
||||
* Commands with `skipSlashCommands` (e.g. bridge/CCR messages) are NOT treated
|
||||
* as slash commands — their text is meant for the model.
|
||||
* Commands with `skipSlashCommands` are usually treated as plain text, except
|
||||
* Remote Control bridge messages (`bridgeOrigin`) that are re-validated later
|
||||
* through isBridgeSafeCommand().
|
||||
*/
|
||||
export function isSlashCommand(cmd: QueuedCommand): boolean {
|
||||
return (
|
||||
typeof cmd.value === 'string' &&
|
||||
cmd.value.trim().startsWith('/') &&
|
||||
!cmd.skipSlashCommands
|
||||
(!cmd.skipSlashCommands || cmd.bridgeOrigin === true)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import { logEvent } from 'src/services/analytics/index.js'
|
||||
import { getContentText } from 'src/utils/messages.js'
|
||||
import {
|
||||
findCommand,
|
||||
getBridgeCommandSafety,
|
||||
getCommandName,
|
||||
isBridgeSafeCommand,
|
||||
type LocalJSXCommandContext,
|
||||
} from '../../commands.js'
|
||||
import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
|
||||
@@ -432,10 +432,13 @@ async function processUserInputBase(
|
||||
? findCommand(parsed.commandName, context.options.commands)
|
||||
: undefined
|
||||
if (cmd) {
|
||||
if (isBridgeSafeCommand(cmd)) {
|
||||
const safety = getBridgeCommandSafety(cmd, parsed?.args ?? '')
|
||||
if (safety.ok) {
|
||||
effectiveSkipSlash = false
|
||||
} else {
|
||||
const msg = `/${getCommandName(cmd)} isn't available over Remote Control.`
|
||||
const msg =
|
||||
safety.reason ??
|
||||
`/${getCommandName(cmd)} isn't available over Remote Control.`
|
||||
return {
|
||||
messages: [
|
||||
createUserMessage({ content: inputString, uuid }),
|
||||
|
||||
@@ -19,12 +19,15 @@ type ProcessQueueResult = {
|
||||
*/
|
||||
function isSlashCommand(cmd: QueuedCommand): boolean {
|
||||
if (typeof cmd.value === 'string') {
|
||||
return cmd.value.trim().startsWith('/')
|
||||
return cmd.value.trim().startsWith('/') && (!cmd.skipSlashCommands || cmd.bridgeOrigin === true)
|
||||
}
|
||||
// For ContentBlockParam[], check the first text block
|
||||
for (const block of cmd.value) {
|
||||
if (block.type === 'text') {
|
||||
return block.text.trim().startsWith('/')
|
||||
return (
|
||||
block.text.trim().startsWith('/') &&
|
||||
(!cmd.skipSlashCommands || cmd.bridgeOrigin === true)
|
||||
)
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export type SessionState = 'idle' | 'running' | 'requires_action'
|
||||
|
||||
import { isProactiveActive } from '../proactive/index.js'
|
||||
|
||||
/**
|
||||
* Context carried with requires_action transitions so downstream
|
||||
* surfaces (CCR sidebar, push notifications) can show what the
|
||||
@@ -23,6 +25,15 @@ export type RequiresActionDetails = {
|
||||
input?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type AutomationStatePhase = 'standby' | 'sleeping'
|
||||
|
||||
export type AutomationStateMetadata = {
|
||||
enabled: boolean
|
||||
phase: AutomationStatePhase | null
|
||||
next_tick_at: number | null
|
||||
sleep_until: number | null
|
||||
}
|
||||
|
||||
import { isEnvTruthy } from './envUtils.js'
|
||||
import type { PermissionMode } from './permissions/PermissionMode.js'
|
||||
import { enqueueSdkEvent } from './sdkEventQueue.js'
|
||||
@@ -34,6 +45,7 @@ export type SessionExternalMetadata = {
|
||||
is_ultraplan_mode?: boolean | null
|
||||
model?: string | null
|
||||
pending_action?: RequiresActionDetails | null
|
||||
automation_state?: AutomationStateMetadata | null
|
||||
// Opaque — typed at the emit site. Importing PostTurnSummaryOutput here
|
||||
// would leak the import path string into sdk.d.ts via agentSdkBridge's
|
||||
// re-export of SessionState.
|
||||
@@ -52,6 +64,9 @@ type SessionMetadataChangedListener = (
|
||||
metadata: SessionExternalMetadata,
|
||||
) => void
|
||||
type PermissionModeChangedListener = (mode: PermissionMode) => void
|
||||
type SessionMetadataListenerOptions = {
|
||||
replayCurrent?: boolean
|
||||
}
|
||||
|
||||
let stateListener: SessionStateChangedListener | null = null
|
||||
let metadataListener: SessionMetadataChangedListener | null = null
|
||||
@@ -65,8 +80,19 @@ export function setSessionStateChangedListener(
|
||||
|
||||
export function setSessionMetadataChangedListener(
|
||||
cb: SessionMetadataChangedListener | null,
|
||||
options?: SessionMetadataListenerOptions,
|
||||
): void {
|
||||
metadataListener = cb
|
||||
if (!cb || !options?.replayCurrent) {
|
||||
return
|
||||
}
|
||||
|
||||
const snapshot = getSessionMetadataSnapshot()
|
||||
if (Object.keys(snapshot).length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
cb(snapshot)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,6 +110,61 @@ export function setPermissionModeChangedListener(
|
||||
|
||||
let hasPendingAction = false
|
||||
let currentState: SessionState = 'idle'
|
||||
let currentAutomationState: AutomationStateMetadata | null = null
|
||||
let currentMetadata: SessionExternalMetadata = {}
|
||||
|
||||
function normalizeAutomationState(
|
||||
state: AutomationStateMetadata | null | undefined,
|
||||
): AutomationStateMetadata | null {
|
||||
if (!state || state.enabled !== true) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
phase:
|
||||
state.phase === 'standby' || state.phase === 'sleeping'
|
||||
? state.phase
|
||||
: null,
|
||||
next_tick_at:
|
||||
typeof state.next_tick_at === 'number' ? state.next_tick_at : null,
|
||||
sleep_until:
|
||||
typeof state.sleep_until === 'number' ? state.sleep_until : null,
|
||||
}
|
||||
}
|
||||
|
||||
function automationStateKey(
|
||||
state: AutomationStateMetadata | null,
|
||||
): string {
|
||||
return JSON.stringify(state)
|
||||
}
|
||||
|
||||
function applyMetadataUpdate(
|
||||
metadata: SessionExternalMetadata,
|
||||
): void {
|
||||
const nextMetadata = { ...currentMetadata }
|
||||
for (const key of Object.keys(metadata) as Array<
|
||||
keyof SessionExternalMetadata
|
||||
>) {
|
||||
const value = metadata[key]
|
||||
if (value === undefined) {
|
||||
delete nextMetadata[key]
|
||||
continue
|
||||
}
|
||||
;(nextMetadata as Record<string, unknown>)[key] = value
|
||||
}
|
||||
currentMetadata = nextMetadata
|
||||
}
|
||||
|
||||
export function getSessionMetadataSnapshot(): SessionExternalMetadata {
|
||||
const snapshot: SessionExternalMetadata = { ...currentMetadata }
|
||||
if (currentAutomationState) {
|
||||
snapshot.automation_state = { ...currentAutomationState }
|
||||
} else if ('automation_state' in currentMetadata) {
|
||||
snapshot.automation_state = currentMetadata.automation_state ?? null
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
export function getSessionState(): SessionState {
|
||||
return currentState
|
||||
@@ -101,18 +182,31 @@ export function notifySessionStateChanged(
|
||||
// null on the next non-blocked transition.
|
||||
if (state === 'requires_action' && details) {
|
||||
hasPendingAction = true
|
||||
metadataListener?.({
|
||||
notifySessionMetadataChanged({
|
||||
pending_action: details,
|
||||
})
|
||||
} else if (hasPendingAction) {
|
||||
hasPendingAction = false
|
||||
metadataListener?.({ pending_action: null })
|
||||
notifySessionMetadataChanged({ pending_action: null })
|
||||
}
|
||||
|
||||
// task_summary is written mid-turn by the forked summarizer; clear it at
|
||||
// idle so the next turn doesn't briefly show the previous turn's progress.
|
||||
if (state === 'idle') {
|
||||
metadataListener?.({ task_summary: null })
|
||||
notifySessionMetadataChanged({ task_summary: null })
|
||||
}
|
||||
|
||||
if (state !== 'idle') {
|
||||
notifyAutomationStateChanged(
|
||||
isProactiveActive()
|
||||
? {
|
||||
enabled: true,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
}
|
||||
: null,
|
||||
)
|
||||
}
|
||||
|
||||
// Mirror to the SDK event stream so non-CCR consumers (scmuxd, VS Code)
|
||||
@@ -136,9 +230,25 @@ export function notifySessionStateChanged(
|
||||
export function notifySessionMetadataChanged(
|
||||
metadata: SessionExternalMetadata,
|
||||
): void {
|
||||
applyMetadataUpdate(metadata)
|
||||
metadataListener?.(metadata)
|
||||
}
|
||||
|
||||
export function notifyAutomationStateChanged(
|
||||
state: AutomationStateMetadata | null | undefined,
|
||||
): void {
|
||||
const nextState = normalizeAutomationState(state)
|
||||
if (
|
||||
automationStateKey(nextState) === automationStateKey(currentAutomationState)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
currentAutomationState = nextState
|
||||
applyMetadataUpdate({ automation_state: nextState })
|
||||
metadataListener?.({ automation_state: nextState })
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired by onChangeAppState when toolPermissionContext.mode changes.
|
||||
* Downstream listeners (CCR external_metadata PUT, SDK status stream) are
|
||||
@@ -148,3 +258,13 @@ export function notifySessionMetadataChanged(
|
||||
export function notifyPermissionModeChanged(mode: PermissionMode): void {
|
||||
permissionModeListener?.(mode)
|
||||
}
|
||||
|
||||
export function resetSessionStateForTests(): void {
|
||||
stateListener = null
|
||||
metadataListener = null
|
||||
permissionModeListener = null
|
||||
hasPendingAction = false
|
||||
currentState = 'idle'
|
||||
currentAutomationState = null
|
||||
currentMetadata = {}
|
||||
}
|
||||
|
||||
76
src/utils/taskStateMessage.ts
Normal file
76
src/utils/taskStateMessage.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { SDKMessage } from 'src/entrypoints/agentSdkTypes.js'
|
||||
import type { Task } from './tasks.js'
|
||||
|
||||
export type TaskStateItem = Pick<
|
||||
Task,
|
||||
| 'id'
|
||||
| 'subject'
|
||||
| 'description'
|
||||
| 'activeForm'
|
||||
| 'status'
|
||||
| 'owner'
|
||||
| 'blocks'
|
||||
| 'blockedBy'
|
||||
>
|
||||
|
||||
export type TaskStateMessage = SDKMessage & {
|
||||
type: 'task_state'
|
||||
uuid: string
|
||||
task_list_id: string
|
||||
tasks: TaskStateItem[]
|
||||
}
|
||||
|
||||
export type TaskStateSnapshot = Pick<
|
||||
TaskStateMessage,
|
||||
'task_list_id' | 'tasks'
|
||||
>
|
||||
|
||||
function toTaskStateItem(task: Task): TaskStateItem {
|
||||
return {
|
||||
id: task.id,
|
||||
subject: task.subject,
|
||||
description: task.description,
|
||||
activeForm: task.activeForm,
|
||||
status: task.status,
|
||||
owner: task.owner,
|
||||
blocks: [...task.blocks],
|
||||
blockedBy: [...task.blockedBy],
|
||||
}
|
||||
}
|
||||
|
||||
function compareTaskStateItems(a: TaskStateItem, b: TaskStateItem): number {
|
||||
return a.id.localeCompare(b.id)
|
||||
}
|
||||
|
||||
export function buildTaskStateSnapshot(
|
||||
taskListId: string,
|
||||
tasks: Task[],
|
||||
): TaskStateSnapshot {
|
||||
return {
|
||||
task_list_id: taskListId,
|
||||
tasks: tasks
|
||||
.filter(task => !task.metadata?._internal)
|
||||
.map(toTaskStateItem)
|
||||
.sort(compareTaskStateItems),
|
||||
}
|
||||
}
|
||||
|
||||
export function getTaskStateSnapshotKey(
|
||||
taskListId: string,
|
||||
tasks: Task[],
|
||||
): string {
|
||||
return JSON.stringify(buildTaskStateSnapshot(taskListId, tasks))
|
||||
}
|
||||
|
||||
export function buildTaskStateMessage(
|
||||
taskListId: string,
|
||||
tasks: Task[],
|
||||
): TaskStateMessage {
|
||||
const snapshot = buildTaskStateSnapshot(taskListId, tasks)
|
||||
return {
|
||||
type: 'task_state',
|
||||
uuid: randomUUID(),
|
||||
...snapshot,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user